/** * Full Chromecast Player Modal * Displays when user taps mini player or cast button during playback */ import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; import { useAtomValue } from "jotai"; import React, { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Modal, Pressable, useWindowDimensions, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { FadeIn, FadeOut, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useChromecastPlayer } from "@/components/chromecast/hooks/useChromecastPlayer"; import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { Text } from "@/components/common/Text"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; import { formatEpisodeInfo, getPosterUrl, truncateTitle, } from "@/utils/chromecast/helpers"; import { CHROMECAST_CONSTANTS } from "@/utils/chromecast/options"; interface ChromecastPlayerProps { visible: boolean; onClose: () => void; } export const ChromecastPlayer: React.FC = ({ visible, onClose, }) => { const insets = useSafeAreaInsets(); const { height: screenHeight } = useWindowDimensions(); const api = useAtomValue(apiAtom); const { playerState, showControls, currentItem, nextItem, stop, togglePlay, seek, skipForward, skipBackward, disconnect, setShowControls, currentTime, remainingTime, endingTime, showNextEpisodeCountdown, settings, } = useChromecastPlayer(); const { currentSegment, skipSegment } = useChromecastSegments( currentItem, playerState.progress, ); const { calculateTrickplayUrl, trickplayInfo } = useTrickplay(currentItem!); const [_showMenu, setShowMenu] = useState(false); const [_showDeviceSheet, setShowDeviceSheet] = useState(false); const [_showEpisodeList, setShowEpisodeList] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); // Slider values const progress = useSharedValue(playerState.progress); const min = useSharedValue(0); const max = useSharedValue(playerState.duration); const isSeeking = useSharedValue(false); // Update slider when player state changes React.useEffect(() => { if (!isSeeking.value) { progress.value = playerState.progress; } max.value = playerState.duration; }, [playerState.progress, playerState.duration, isSeeking]); // Swipe down to dismiss gesture const translateY = useSharedValue(0); const context = useSharedValue({ y: 0 }); const gesture = Gesture.Pan() .onStart(() => { context.value = { y: translateY.value }; }) .onUpdate((event) => { if (event.translationY > 0) { translateY.value = context.value.y + event.translationY; } }) .onEnd((event) => { if (event.translationY > 100) { translateY.value = withTiming(screenHeight, {}, () => { runOnJS(onClose)(); }); } else { translateY.value = withSpring(0); } }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const posterUrl = useMemo(() => { if (!currentItem || !api) return null; return getPosterUrl(currentItem, api); }, [currentItem, api]); const handleSliderChange = useCallback( (value: number) => { progress.value = value; if (trickplayInfo && currentItem) { calculateTrickplayUrl(value); } }, [calculateTrickplayUrl, trickplayInfo, currentItem], ); const handleSliderComplete = useCallback( (value: number) => { isSeeking.value = false; seek(value); }, [seek, isSeeking], ); if (!playerState.isConnected || !visible) { return null; } return ( {/* Header - Collapsible */} {/* Collapse arrow */} setIsCollapsed(!isCollapsed)} style={{ padding: 4 }} > {/* Title and episode info */} {currentItem && ( <> {truncateTitle( currentItem.Name || "Unknown", isCollapsed ? 50 : 35, )} {!isCollapsed && ( {formatEpisodeInfo( currentItem.ParentIndexNumber, currentItem.IndexNumber, )} {currentItem.SeriesName && ` • ${truncateTitle(currentItem.SeriesName, 25)}`} )} )} {/* Connection quality indicator */} {/* Main content area */} {/* Poster */} {posterUrl ? ( ) : ( )} {/* Buffering indicator */} {playerState.isBuffering && ( )} {/* Current segment indicator */} {currentSegment && ( {currentSegment.type.toUpperCase()} DETECTED )} {/* Bottom controls */} {showControls && ( {/* Time display */} {currentTime} {remainingTime} {/* Progress slider */} { isSeeking.value = true; }} onValueChange={handleSliderChange} onSlidingComplete={handleSliderComplete} /> {/* Ending time */} Ending at {endingTime} {/* Control buttons row */} {/* Skip segment button */} {currentSegment && ( skipSegment(seek)} style={{ paddingHorizontal: 16, paddingVertical: 8, backgroundColor: "#e50914", borderRadius: 4, }} > Skip {currentSegment.type} )} {/* Episode list button */} {currentItem?.Type === "Episode" && ( setShowEpisodeList(true)} style={{ padding: 8 }} > )} {/* Settings menu */} setShowMenu(true)} style={{ padding: 8 }} > {/* Chromecast device info */} setShowDeviceSheet(true)} style={{ padding: 8 }} > {/* Playback controls */} {/* Rewind */} {settings?.rewindSkipTime || 15} {/* Play/Pause */} {/* Forward */} {settings?.forwardSkipTime || 15} {/* Stop */} )} {/* Next episode countdown */} {showNextEpisodeCountdown && nextItem && ( Next: {truncateTitle(nextItem.Name || "Unknown", 40)} Starting in{" "} {Math.ceil( (playerState.duration - playerState.progress) / 1000, )} s )} {/* TODO: Add settings menu modal */} {/* TODO: Add device info sheet modal */} {/* TODO: Add episode list modal */} ); };