mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 09:38:25 +01: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:
@@ -11,8 +11,7 @@ import { withLayoutContext } from "expo-router";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { AirPlayMiniPlayer } from "@/components/airplay/AirPlayMiniPlayer";
|
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||||
import { ChromecastMiniPlayer } from "@/components/chromecast/ChromecastMiniPlayer";
|
|
||||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -120,8 +119,7 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<AirPlayMiniPlayer />
|
<CastingMiniPlayer />
|
||||||
<ChromecastMiniPlayer />
|
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
390
app/(auth)/casting-player.tsx
Normal file
390
app/(auth)/casting-player.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Player Modal
|
||||||
|
* Full-screen player for 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 { useCallback } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useCasting } from "@/hooks/useCasting";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
calculateEndingTime,
|
||||||
|
formatTime,
|
||||||
|
getPosterUrl,
|
||||||
|
getProtocolIcon,
|
||||||
|
getProtocolName,
|
||||||
|
truncateTitle,
|
||||||
|
} from "@/utils/casting/helpers";
|
||||||
|
import { PROTOCOL_COLORS } from "@/utils/casting/types";
|
||||||
|
|
||||||
|
export default function CastingPlayerScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
protocol,
|
||||||
|
currentItem,
|
||||||
|
currentDevice,
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
|
togglePlayPause,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
stop,
|
||||||
|
} = useCasting(null);
|
||||||
|
|
||||||
|
// Swipe down to dismiss gesture
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const context = useSharedValue({ y: 0 });
|
||||||
|
|
||||||
|
const dismissModal = useCallback(() => {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const panGesture = Gesture.Pan()
|
||||||
|
.onStart(() => {
|
||||||
|
context.value = { y: translateY.value };
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
if (event.translationY > 0) {
|
||||||
|
translateY.value = event.translationY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
if (event.translationY > 100) {
|
||||||
|
translateY.value = withSpring(500, {}, () => {
|
||||||
|
runOnJS(dismissModal)();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
translateY.value = withSpring(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Redirect if not connected
|
||||||
|
if (!isConnected || !currentItem || !protocol) {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posterUrl = getPosterUrl(
|
||||||
|
api?.basePath,
|
||||||
|
currentItem.Id,
|
||||||
|
currentItem.ImageTags?.Primary,
|
||||||
|
300,
|
||||||
|
450,
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
||||||
|
const protocolColor = PROTOCOL_COLORS[protocol];
|
||||||
|
const protocolIcon = getProtocolIcon(protocol);
|
||||||
|
const protocolName = getProtocolName(protocol);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={panGesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={dismissModal}
|
||||||
|
style={{ padding: 8, marginLeft: -8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-down' size={32} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: protocolColor,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{protocolName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ width: 48 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title and episode info */}
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncateTitle(currentItem.Name || "Unknown", 50)}
|
||||||
|
</Text>
|
||||||
|
{currentItem.SeriesName && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#999",
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentItem.SeriesName}
|
||||||
|
{currentItem.ParentIndexNumber &&
|
||||||
|
currentItem.IndexNumber &&
|
||||||
|
` • S${currentItem.ParentIndexNumber}:E${currentItem.IndexNumber}`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Poster with buffering overlay */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 300,
|
||||||
|
height: 450,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={64} color='#333' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buffering overlay */}
|
||||||
|
{isBuffering && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='large' color={protocolColor} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Buffering...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Device info */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={protocolIcon} size={20} color={protocolColor} />
|
||||||
|
<Text style={{ color: protocolColor, fontSize: 15 }}>
|
||||||
|
{currentDevice?.name || protocolName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress slider */}
|
||||||
|
<View style={{ marginBottom: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "#333",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Time display */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||||
|
{formatTime(progress)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||||
|
Ending at {calculateEndingTime(progress, duration)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13 }}>
|
||||||
|
{formatTime(duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playback controls */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 32,
|
||||||
|
marginBottom: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Rewind 10s */}
|
||||||
|
<Pressable onPress={() => skipBackward(10)} style={{ padding: 16 }}>
|
||||||
|
<Ionicons name='play-back' size={32} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<Pressable
|
||||||
|
onPress={togglePlayPause}
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
backgroundColor: protocolColor,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={36}
|
||||||
|
color='white'
|
||||||
|
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Forward 10s */}
|
||||||
|
<Pressable onPress={() => skipForward(10)} style={{ padding: 16 }}>
|
||||||
|
<Ionicons name='play-forward' size={32} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stop casting button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={stop}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop-circle-outline' size={20} color='#FF3B30' />
|
||||||
|
<Text style={{ color: "#FF3B30", fontSize: 16, fontWeight: "600" }}>
|
||||||
|
Stop Casting
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
272
hooks/useCasting.ts
Normal file
272
hooks/useCasting.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Hook
|
||||||
|
* Manages both Chromecast and AirPlay through a common interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import {
|
||||||
|
useCastDevice,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
||||||
|
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified hook for managing casting (Chromecast + AirPlay)
|
||||||
|
*/
|
||||||
|
export const useCasting = (item: BaseItemDto | null) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
// Chromecast hooks
|
||||||
|
const client = useRemoteMediaClient();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
||||||
|
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Detect which protocol is active
|
||||||
|
const chromecastConnected = castDevice !== null;
|
||||||
|
const airplayConnected = false; // TODO: Detect AirPlay connection from video player
|
||||||
|
|
||||||
|
const activeProtocol: CastProtocol | null = chromecastConnected
|
||||||
|
? "chromecast"
|
||||||
|
: airplayConnected
|
||||||
|
? "airplay"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isConnected = chromecastConnected || airplayConnected;
|
||||||
|
|
||||||
|
// Update current device
|
||||||
|
useEffect(() => {
|
||||||
|
if (chromecastConnected && castDevice) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isConnected: true,
|
||||||
|
protocol: "chromecast",
|
||||||
|
currentDevice: {
|
||||||
|
id: castDevice.deviceId,
|
||||||
|
name: castDevice.friendlyName || castDevice.deviceId,
|
||||||
|
protocol: "chromecast",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else if (airplayConnected) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isConnected: true,
|
||||||
|
protocol: "airplay",
|
||||||
|
currentDevice: {
|
||||||
|
id: "airplay-device",
|
||||||
|
name: "AirPlay Device", // TODO: Get real device name
|
||||||
|
protocol: "airplay",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isConnected: false,
|
||||||
|
protocol: null,
|
||||||
|
currentDevice: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [chromecastConnected, airplayConnected, castDevice]);
|
||||||
|
|
||||||
|
// Chromecast: Update playback state
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProtocol === "chromecast" && mediaStatus) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isPlaying: !mediaStatus.isPaused && !mediaStatus.isBuffering,
|
||||||
|
progress: (mediaStatus.streamPosition || 0) * 1000,
|
||||||
|
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
||||||
|
isBuffering: mediaStatus.isBuffering || false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [mediaStatus, activeProtocol]);
|
||||||
|
|
||||||
|
// Progress reporting to Jellyfin
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected || !item?.Id || !user?.Id || state.progress <= 0) return;
|
||||||
|
|
||||||
|
const reportProgress = () => {
|
||||||
|
const progressSeconds = Math.floor(state.progress / 1000);
|
||||||
|
api?.playStateApi
|
||||||
|
.reportPlaybackProgress({
|
||||||
|
playbackProgressInfo: {
|
||||||
|
ItemId: item.Id,
|
||||||
|
PositionTicks: progressSeconds * 10000000,
|
||||||
|
IsPaused: !state.isPlaying,
|
||||||
|
PlayMethod:
|
||||||
|
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(reportProgress, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
item?.Id,
|
||||||
|
user?.Id,
|
||||||
|
state.progress,
|
||||||
|
state.isPlaying,
|
||||||
|
isConnected,
|
||||||
|
activeProtocol,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Play/Pause controls
|
||||||
|
const play = useCallback(async () => {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
await client?.play();
|
||||||
|
}
|
||||||
|
// TODO: AirPlay play control
|
||||||
|
}, [client, activeProtocol]);
|
||||||
|
|
||||||
|
const pause = useCallback(async () => {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
await client?.pause();
|
||||||
|
}
|
||||||
|
// TODO: AirPlay pause control
|
||||||
|
}, [client, activeProtocol]);
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(async () => {
|
||||||
|
if (state.isPlaying) {
|
||||||
|
await pause();
|
||||||
|
} else {
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
}, [state.isPlaying, play, pause]);
|
||||||
|
|
||||||
|
// Seek controls
|
||||||
|
const seek = useCallback(
|
||||||
|
async (positionMs: number) => {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
await client?.seek({ position: positionMs / 1000 });
|
||||||
|
}
|
||||||
|
// TODO: AirPlay seek control
|
||||||
|
},
|
||||||
|
[client, activeProtocol],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipForward = useCallback(
|
||||||
|
async (seconds = 10) => {
|
||||||
|
const newPosition = state.progress + seconds * 1000;
|
||||||
|
await seek(Math.min(newPosition, state.duration));
|
||||||
|
},
|
||||||
|
[state.progress, state.duration, seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipBackward = useCallback(
|
||||||
|
async (seconds = 10) => {
|
||||||
|
const newPosition = state.progress - seconds * 1000;
|
||||||
|
await seek(Math.max(newPosition, 0));
|
||||||
|
},
|
||||||
|
[state.progress, seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop and disconnect
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
await client?.stop();
|
||||||
|
}
|
||||||
|
// TODO: AirPlay stop control
|
||||||
|
|
||||||
|
// Report stop to Jellyfin
|
||||||
|
if (api && item?.Id && user?.Id) {
|
||||||
|
await api.playStateApi.reportPlaybackStopped({
|
||||||
|
playbackStopInfo: {
|
||||||
|
ItemId: item.Id,
|
||||||
|
PositionTicks: state.progress * 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(DEFAULT_CAST_STATE);
|
||||||
|
}, [client, api, item?.Id, user?.Id, state.progress, activeProtocol]);
|
||||||
|
|
||||||
|
// Volume control
|
||||||
|
const setVolume = useCallback(
|
||||||
|
async (volume: number) => {
|
||||||
|
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
if (activeProtocol === "chromecast") {
|
||||||
|
await client?.setVolume(clampedVolume);
|
||||||
|
}
|
||||||
|
// TODO: AirPlay volume control
|
||||||
|
setState((prev) => ({ ...prev, volume: clampedVolume }));
|
||||||
|
},
|
||||||
|
[client, activeProtocol],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Controls visibility
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, showControls: true }));
|
||||||
|
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
controlsTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (state.isPlaying) {
|
||||||
|
setState((prev) => ({ ...prev, showControls: false }));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}, [state.isPlaying]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, showControls: false }));
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current);
|
||||||
|
}
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isConnected,
|
||||||
|
protocol: activeProtocol,
|
||||||
|
isPlaying: state.isPlaying,
|
||||||
|
isBuffering: state.isBuffering,
|
||||||
|
currentItem: item,
|
||||||
|
currentDevice: state.currentDevice,
|
||||||
|
progress: state.progress,
|
||||||
|
duration: state.duration,
|
||||||
|
volume: state.volume,
|
||||||
|
|
||||||
|
// Availability
|
||||||
|
isChromecastAvailable: true, // Always available via react-native-google-cast
|
||||||
|
isAirPlayAvailable: Platform.OS === "ios",
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlayPause,
|
||||||
|
seek,
|
||||||
|
skipForward,
|
||||||
|
skipBackward,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
showControls,
|
||||||
|
hideControls,
|
||||||
|
};
|
||||||
|
};
|
||||||
130
utils/casting/helpers.ts
Normal file
130
utils/casting/helpers.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Helper Functions
|
||||||
|
* Common utilities for both Chromecast and AirPlay
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CastProtocol, ConnectionQuality } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format milliseconds to HH:MM:SS or MM:SS
|
||||||
|
*/
|
||||||
|
export const formatTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate ending time based on current progress and duration
|
||||||
|
*/
|
||||||
|
export const calculateEndingTime = (
|
||||||
|
currentMs: number,
|
||||||
|
durationMs: number,
|
||||||
|
): string => {
|
||||||
|
const remainingMs = durationMs - currentMs;
|
||||||
|
const endTime = new Date(Date.now() + remainingMs);
|
||||||
|
const hours = endTime.getHours();
|
||||||
|
const minutes = endTime.getMinutes();
|
||||||
|
const ampm = hours >= 12 ? "PM" : "AM";
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
|
||||||
|
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine connection quality based on bitrate
|
||||||
|
*/
|
||||||
|
export const getConnectionQuality = (bitrate?: number): ConnectionQuality => {
|
||||||
|
if (!bitrate) return "good";
|
||||||
|
const mbps = bitrate / 1000000;
|
||||||
|
|
||||||
|
if (mbps >= 15) return "excellent";
|
||||||
|
if (mbps >= 8) return "good";
|
||||||
|
if (mbps >= 4) return "fair";
|
||||||
|
return "poor";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get poster URL for item with specified dimensions
|
||||||
|
*/
|
||||||
|
export const getPosterUrl = (
|
||||||
|
baseUrl: string | undefined,
|
||||||
|
itemId: string | undefined,
|
||||||
|
tag: string | undefined,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): string | null => {
|
||||||
|
if (!baseUrl || !itemId) return null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
maxWidth: width.toString(),
|
||||||
|
maxHeight: height.toString(),
|
||||||
|
quality: "90",
|
||||||
|
...(tag && { tag }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate title to max length with ellipsis
|
||||||
|
*/
|
||||||
|
export const truncateTitle = (title: string, maxLength: number): string => {
|
||||||
|
if (title.length <= maxLength) return title;
|
||||||
|
return `${title.substring(0, maxLength - 3)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current time is within a segment
|
||||||
|
*/
|
||||||
|
export const isWithinSegment = (
|
||||||
|
currentMs: number,
|
||||||
|
segment: { start: number; end: number } | null,
|
||||||
|
): boolean => {
|
||||||
|
if (!segment) return false;
|
||||||
|
const currentSeconds = currentMs / 1000;
|
||||||
|
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bitrate to human-readable string
|
||||||
|
*/
|
||||||
|
export const formatBitrate = (bitrate: number): string => {
|
||||||
|
const mbps = bitrate / 1000000;
|
||||||
|
if (mbps >= 1) {
|
||||||
|
return `${mbps.toFixed(1)} Mbps`;
|
||||||
|
}
|
||||||
|
return `${(bitrate / 1000).toFixed(0)} Kbps`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get protocol display name
|
||||||
|
*/
|
||||||
|
export const getProtocolName = (protocol: CastProtocol): string => {
|
||||||
|
switch (protocol) {
|
||||||
|
case "chromecast":
|
||||||
|
return "Chromecast";
|
||||||
|
case "airplay":
|
||||||
|
return "AirPlay";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get protocol icon name
|
||||||
|
*/
|
||||||
|
export const getProtocolIcon = (
|
||||||
|
protocol: CastProtocol,
|
||||||
|
): "tv" | "logo-apple" => {
|
||||||
|
switch (protocol) {
|
||||||
|
case "chromecast":
|
||||||
|
return "tv";
|
||||||
|
case "airplay":
|
||||||
|
return "logo-apple";
|
||||||
|
}
|
||||||
|
};
|
||||||
87
utils/casting/types.ts
Normal file
87
utils/casting/types.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Unified Casting Types and Options
|
||||||
|
* Abstracts Chromecast and AirPlay into a common interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CastProtocol = "chromecast" | "airplay";
|
||||||
|
|
||||||
|
export interface CastDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
protocol: CastProtocol;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastPlayerState {
|
||||||
|
isConnected: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentItem: any | null;
|
||||||
|
currentDevice: CastDevice | null;
|
||||||
|
protocol: CastProtocol | null;
|
||||||
|
progress: number;
|
||||||
|
duration: number;
|
||||||
|
volume: number;
|
||||||
|
showControls: boolean;
|
||||||
|
isBuffering: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastSegmentData {
|
||||||
|
intro: { start: number; end: number } | null;
|
||||||
|
credits: { start: number; end: number } | null;
|
||||||
|
recap: { start: number; end: number } | null;
|
||||||
|
commercial: Array<{ start: number; end: number }>;
|
||||||
|
preview: Array<{ start: number; end: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioTrack {
|
||||||
|
index: number;
|
||||||
|
language: string;
|
||||||
|
codec: string;
|
||||||
|
displayTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
index: number;
|
||||||
|
language: string;
|
||||||
|
codec: string;
|
||||||
|
displayTitle: string;
|
||||||
|
isForced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bitrate?: number;
|
||||||
|
container: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CASTING_CONSTANTS = {
|
||||||
|
POSTER_WIDTH: 300,
|
||||||
|
POSTER_HEIGHT: 450,
|
||||||
|
ANIMATION_DURATION: 300,
|
||||||
|
CONTROL_HIDE_DELAY: 5000,
|
||||||
|
PROGRESS_UPDATE_INTERVAL: 1000,
|
||||||
|
SEEK_FORWARD_SECONDS: 10,
|
||||||
|
SEEK_BACKWARD_SECONDS: 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_CAST_STATE: CastPlayerState = {
|
||||||
|
isConnected: false,
|
||||||
|
isPlaying: false,
|
||||||
|
currentItem: null,
|
||||||
|
currentDevice: null,
|
||||||
|
protocol: null,
|
||||||
|
progress: 0,
|
||||||
|
duration: 0,
|
||||||
|
volume: 0.5,
|
||||||
|
showControls: true,
|
||||||
|
isBuffering: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionQuality = "excellent" | "good" | "fair" | "poor";
|
||||||
|
|
||||||
|
// Protocol-specific colors for UI differentiation
|
||||||
|
export const PROTOCOL_COLORS = {
|
||||||
|
chromecast: "#e50914", // Red (Google Cast)
|
||||||
|
airplay: "#007AFF", // Blue (Apple)
|
||||||
|
} as const;
|
||||||
Reference in New Issue
Block a user