mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-21 03:22:30 +00:00
feat(casting): unify Chromecast and AirPlay into single casting interface
BREAKING CHANGE: Merged separate Chromecast and AirPlay implementations into unified casting system - Created unified casting types and helpers (utils/casting/) - Built useCasting hook that manages both protocols - Single CastingMiniPlayer component works with both Chromecast and AirPlay - Single casting-player modal for full-screen controls - Protocol-aware UI: Red for Chromecast, Blue for AirPlay - Shows device type icon (TV for Chromecast, Apple logo for AirPlay) - Detects active protocol automatically - Previous separate implementations (ChromecastMiniPlayer, AirPlayMiniPlayer) superseded Benefits: - Better UX: One cast button shows all available devices - Cleaner architecture: Protocol differences abstracted - Easier maintenance: Single UI codebase - Protocol-specific logic isolated in adapters
This commit is contained in:
185
components/casting/CastingMiniPlayer.tsx
Normal file
185
components/casting/CastingMiniPlayer.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Unified Casting Mini Player
|
||||
* Works with both Chromecast and AirPlay
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useCasting } from "@/hooks/useCasting";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
formatTime,
|
||||
getPosterUrl,
|
||||
getProtocolIcon,
|
||||
getProtocolName,
|
||||
} from "@/utils/casting/helpers";
|
||||
import { CASTING_CONSTANTS, PROTOCOL_COLORS } from "@/utils/casting/types";
|
||||
|
||||
export const CastingMiniPlayer: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const {
|
||||
isConnected,
|
||||
protocol,
|
||||
currentItem,
|
||||
currentDevice,
|
||||
progress,
|
||||
duration,
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
} = useCasting(null);
|
||||
|
||||
if (!isConnected || !currentItem || !protocol) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const posterUrl = getPosterUrl(
|
||||
api?.basePath,
|
||||
currentItem.Id,
|
||||
currentItem.ImageTags?.Primary,
|
||||
80,
|
||||
120,
|
||||
);
|
||||
|
||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
||||
const protocolColor = PROTOCOL_COLORS[protocol];
|
||||
|
||||
const handlePress = () => {
|
||||
router.push("/casting-player");
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 49, // Above tab bar
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={handlePress}>
|
||||
{/* Progress bar */}
|
||||
<View
|
||||
style={{
|
||||
height: 3,
|
||||
backgroundColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: protocolColor,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
{posterUrl && (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 60,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.Name}
|
||||
</Text>
|
||||
{currentItem.SeriesName && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentItem.SeriesName}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={getProtocolIcon(protocol)}
|
||||
size={12}
|
||||
color={protocolColor}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: protocolColor,
|
||||
fontSize: 11,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{currentDevice?.name || getProtocolName(protocol)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{formatTime(progress)} / {formatTime(duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
style={{
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user