mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-02 16:38:08 +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:
@@ -11,8 +11,7 @@ import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { AirPlayMiniPlayer } from "@/components/airplay/AirPlayMiniPlayer";
|
||||
import { ChromecastMiniPlayer } from "@/components/chromecast/ChromecastMiniPlayer";
|
||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -120,8 +119,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<AirPlayMiniPlayer />
|
||||
<ChromecastMiniPlayer />
|
||||
<CastingMiniPlayer />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</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