/** * Unified Casting Player Modal * Protocol-agnostic full-screen player for all supported casting protocols */ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; import { router, Stack } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Dimensions, Pressable, ScrollView, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import GoogleCast, { CastState, MediaPlayerState, useCastDevice, useCastState, useMediaStatus, useRemoteMediaClient, } from "react-native-google-cast"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet"; import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList"; import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu"; import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments"; import { Text } from "@/components/common/Text"; import { useCasting } from "@/hooks/useCasting"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { calculateEndingTime, formatTime, formatTrickplayTime, getPosterUrl, truncateTitle, } from "@/utils/casting/helpers"; import { buildCastMediaInfo } from "@/utils/casting/mediaInfo"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { chromecast } from "@/utils/profiles/chromecast"; import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { msToTicks, ticksToSeconds } from "@/utils/time"; export default function CastingPlayerScreen() { const insets = useSafeAreaInsets(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); const { t } = useTranslation(); // Get raw Chromecast state directly - same as old implementation const castState = useCastState(); const mediaStatus = useMediaStatus(); const castDevice = useCastDevice(); // Keep hook active for connection - used by remoteMediaClient from useCasting useRemoteMediaClient(); // Shared values for progress slider (must be initialized before any early returns) const sliderProgress = useSharedValue(0); const sliderMin = useSharedValue(0); const sliderMax = useSharedValue(100); const isScrubbing = useRef(false); // Trickplay time display const [trickplayTime, setTrickplayTime] = useState({ hours: 0, minutes: 0, seconds: 0, }); // Track scrub percentage for trickplay bubble positioning const [scrubPercentage, setScrubPercentage] = useState(0); // Live progress tracking - update every second const [liveProgress, setLiveProgress] = useState(0); const lastSyncPositionRef = useRef(0); const lastSyncTimestampRef = useRef(Date.now()); // Fetch full item data from Jellyfin by ID const [fetchedItem, setFetchedItem] = useState(null); useEffect(() => { const controller = new AbortController(); const fetchItemData = async () => { const itemId = mediaStatus?.mediaInfo?.contentId; if (!itemId || !api || !user?.Id) return; try { const res = await getUserLibraryApi(api).getItem( { itemId, userId: user.Id }, { signal: controller.signal }, ); if (!controller.signal.aborted) { setFetchedItem(res.data); } } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; console.error("[Casting Player] Failed to fetch item:", error); } }; fetchItemData(); return () => controller.abort(); }, [mediaStatus?.mediaInfo?.contentId, api, user?.Id]); useEffect(() => { // Sync refs whenever mediaStatus provides a new position if (mediaStatus?.streamPosition !== undefined) { lastSyncPositionRef.current = mediaStatus.streamPosition; lastSyncTimestampRef.current = Date.now(); setLiveProgress(mediaStatus.streamPosition); } // Update every second when playing, deriving from last sync point const interval = setInterval(() => { if ( mediaStatus?.playerState === MediaPlayerState.PLAYING && mediaStatus?.streamPosition !== undefined ) { const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000; setLiveProgress(lastSyncPositionRef.current + elapsed); } else if (mediaStatus?.streamPosition !== undefined) { // Sync with actual position when paused/buffering setLiveProgress(mediaStatus.streamPosition); } }, 1000); return () => clearInterval(interval); }, [mediaStatus?.playerState, mediaStatus?.streamPosition]); // Extract item from customData, or use fetched item, or create a minimal fallback const currentItem = useMemo(() => { // Priority 1: Use fetched item from API (most reliable) if (fetchedItem) { return fetchedItem; } // Priority 2: Try customData from mediaStatus const customData = mediaStatus?.mediaInfo?.customData as BaseItemDto | null; if ( customData?.Type && (customData.ImageTags || customData.MediaSources || customData.Id) ) { // Use customData if it has a real Type AND meaningful metadata // (rules out placeholder objects that lack image tags, media sources, or an ID) return customData; } // Priority 3: Create minimal fallback while loading if (mediaStatus?.mediaInfo) { const { contentId, metadata } = mediaStatus.mediaInfo; // Derive type from metadata if available, otherwise omit to avoid // misrepresenting episodes as movies let metadataType: string | undefined; if (metadata?.type === "movie") { metadataType = "Movie"; } else if (metadata?.type === "tvShow") { metadataType = "Episode"; } return { Id: contentId, Name: metadata?.title || "Unknown", ...(metadataType ? { Type: metadataType } : {}), ServerId: "", } as BaseItemDto; } return null; }, [fetchedItem, mediaStatus?.mediaInfo]); // Derive state from raw Chromecast hooks const progress = liveProgress; // Use live-updating progress const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0; const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING; const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING; const currentDevice = castDevice?.friendlyName ?? null; // Trickplay for seeking preview - use fetched item with full data const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( fetchedItem ?? null, ); // Update slider max when duration changes useEffect(() => { if (duration > 0) { sliderMax.value = duration * 1000; // Convert to milliseconds } }, [duration, sliderMax]); // Update slider progress when not scrubbing useEffect(() => { if (!isScrubbing.current && progress > 0) { sliderProgress.value = progress * 1000; // Convert to milliseconds } }, [progress, sliderProgress]); // Only use casting controls if we have a current item to avoid "No session" errors const castingControls = useCasting(currentItem); const { togglePlayPause, skipForward, skipBackward, setVolume, volume, remoteMediaClient, } = currentItem ? castingControls : { togglePlayPause: async () => {}, skipForward: async () => {}, skipBackward: async () => {}, setVolume: () => {}, volume: 1, remoteMediaClient: null, }; // Modal states const [showEpisodeList, setShowEpisodeList] = useState(false); const [showDeviceSheet, setShowDeviceSheet] = useState(false); const [showSettings, setShowSettings] = useState(false); const [episodes, setEpisodes] = useState([]); const [nextEpisode, setNextEpisode] = useState(null); const [seasonData, setSeasonData] = useState(null); // Track selection states const [selectedAudioTrackIndex, setSelectedAudioTrackIndex] = useState< number | null >(null); const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState< number | null >(null); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1); // Function to reload media with new audio/subtitle/quality settings const reloadWithSettings = useCallback( async (options: { audioIndex?: number; subtitleIndex?: number | null; bitrateValue?: number; }) => { if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) { console.warn("[Casting Player] Cannot reload - missing required data"); return; } try { // Save current playback position const currentPosition = mediaStatus?.streamPosition ?? 0; // Get new stream URL with updated settings const enableH265 = settings.enableH265ForChromecast; const data = await getStreamUrl({ api, item: currentItem, deviceProfile: enableH265 ? chromecasth265 : chromecast, startTimeTicks: Math.floor(currentPosition * 10000000), // Convert seconds to ticks userId: user.Id, audioStreamIndex: options.audioIndex ?? selectedAudioTrackIndex ?? undefined, // null = subtitles off (omit from request), number = specific track subtitleStreamIndex: options.subtitleIndex === null ? undefined : options.subtitleIndex, maxStreamingBitrate: options.bitrateValue, }); if (!data?.url) { console.error("[Casting Player] Failed to get stream URL"); return; } // Reload media with new URL await remoteMediaClient.loadMedia({ mediaInfo: buildCastMediaInfo({ item: currentItem, streamUrl: data.url, api, }), startTime: currentPosition, // Resume at same position }); } catch (error) { console.error("[Casting Player] Failed to reload stream:", error); } }, [ api, user?.Id, currentItem, remoteMediaClient, mediaStatus?.streamPosition, settings.enableH265ForChromecast, selectedAudioTrackIndex, ], ); // Load a different episode on the Chromecast const loadEpisode = useCallback( async (episode: BaseItemDto) => { if (!api || !user?.Id || !episode.Id || !remoteMediaClient) return; try { const enableH265 = settings.enableH265ForChromecast; const data = await getStreamUrl({ api, item: episode, deviceProfile: enableH265 ? chromecasth265 : chromecast, startTimeTicks: episode.UserData?.PlaybackPositionTicks ?? 0, userId: user.Id, }); if (!data?.url) { console.error( "[Casting Player] Failed to get stream URL for episode", ); return; } await remoteMediaClient.loadMedia({ mediaInfo: buildCastMediaInfo({ item: episode, streamUrl: data.url, api, }), startTime: (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000000, }); // Reset track selections for new episode setSelectedAudioTrackIndex(null); setSelectedSubtitleTrackIndex(null); } catch (error) { console.error("[Casting Player] Failed to load episode:", error); } }, [api, user?.Id, remoteMediaClient, settings.enableH265ForChromecast], ); // Fetch season data for season poster useEffect(() => { if ( currentItem?.Type !== "Episode" || !currentItem.SeasonId || !api || !user?.Id ) return; const fetchSeasonData = async () => { try { const userLibraryApi = getUserLibraryApi(api); const response = await userLibraryApi.getItem({ itemId: currentItem.SeasonId!, userId: user.Id!, }); setSeasonData(response.data); } catch (error) { console.error("[Casting Player] Failed to fetch season data:", error); setSeasonData(null); } }; fetchSeasonData(); }, [currentItem?.Type, currentItem?.SeasonId, api, user?.Id]); const availableAudioTracks = useMemo(() => { if (!currentItem?.MediaStreams) return []; return currentItem.MediaStreams.filter( (stream) => stream.Type === "Audio", ).map((stream) => ({ index: stream.Index ?? 0, language: stream.Language || "Unknown", displayTitle: stream.DisplayTitle || `${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(), codec: stream.Codec || "Unknown", channels: stream.Channels, bitrate: stream.BitRate, })); }, [currentItem?.MediaStreams]); const availableSubtitleTracks = useMemo(() => { if (!currentItem?.MediaStreams) return []; return currentItem.MediaStreams.filter( (stream) => stream.Type === "Subtitle", ).map((stream) => ({ index: stream.Index ?? 0, language: stream.Language || "Unknown", displayTitle: stream.DisplayTitle || [ stream.Language || "Unknown", stream.IsForced ? " (Forced)" : "", stream.Title ? ` - ${stream.Title}` : "", ].join(""), codec: stream.Codec || "Unknown", isForced: stream.IsForced || false, isExternal: stream.IsExternal || false, })); }, [currentItem?.MediaStreams]); const availableMediaSources = useMemo(() => { // Get the original source bitrate const originalBitrate = currentItem?.MediaSources?.[0]?.Bitrate || currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate || 20000000; // Default to 20Mbps if unknown // Generate bitrate variants const variants = [ { id: `${currentItem?.Id}-max`, name: "Max", bitrate: originalBitrate, container: currentItem?.MediaSources?.[0]?.Container || "mp4", }, { id: `${currentItem?.Id}-8mbps`, name: "8 Mb/s", bitrate: 8000000, container: currentItem?.MediaSources?.[0]?.Container || "mp4", }, { id: `${currentItem?.Id}-4mbps`, name: "4 Mb/s", bitrate: 4000000, container: currentItem?.MediaSources?.[0]?.Container || "mp4", }, { id: `${currentItem?.Id}-2mbps`, name: "2 Mb/s", bitrate: 2000000, container: currentItem?.MediaSources?.[0]?.Container || "mp4", }, { id: `${currentItem?.Id}-1mbps`, name: "1 Mb/s", bitrate: 1000000, container: currentItem?.MediaSources?.[0]?.Container || "mp4", }, ]; return variants; }, [currentItem?.MediaSources, currentItem?.MediaStreams, currentItem?.Id]); // Fetch episodes for TV shows useEffect(() => { if (currentItem?.Type !== "Episode" || !currentItem.SeriesId || !api) return; const fetchEpisodes = async () => { try { const tvShowsApi = getTvShowsApi(api); // Fetch ALL episodes from ALL seasons by removing seasonId filter const response = await tvShowsApi.getEpisodes({ seriesId: currentItem.SeriesId!, // Don't filter by seasonId - get all seasons userId: api.accessToken ? undefined : "", }); const episodeList = response.data.Items || []; setEpisodes(episodeList); // Find next episode const currentIndex = episodeList.findIndex( (ep) => ep.Id === currentItem.Id, ); if (currentIndex >= 0 && currentIndex < episodeList.length - 1) { setNextEpisode(episodeList[currentIndex + 1]); } else { setNextEpisode(null); } } catch (error) { console.error("Failed to fetch episodes:", error); } }; fetchEpisodes(); }, [ currentItem?.Type, currentItem?.SeriesId, currentItem?.SeasonId, currentItem?.Id, api, ]); // NOTE: Auto-navigation to casting-player is handled by higher-level // components (e.g., CastingMiniPlayer or Chromecast button). We intentionally // do NOT call router.replace("/casting-player") here because this component // IS the casting-player screen — doing so would cause redundant navigation loops. // Segment detection (skip intro/credits) - use progress in seconds for accurate detection const { currentSegment, skipIntro, skipCredits, skipSegment } = useChromecastSegments(currentItem, progress * 1000, false); // Swipe down to dismiss gesture const translateY = useSharedValue(0); const context = useSharedValue({ y: 0 }); const dismissModal = useCallback(() => { // Navigate immediately without animation to prevent crashes if (router.canGoBack()) { router.back(); } else { router.replace("/(auth)/(tabs)/(home)/"); } }, [router]); const panGesture = Gesture.Pan() .onStart(() => { context.value = { y: translateY.value }; }) .onUpdate((event) => { // Only allow downward swipes from top of screen if (event.translationY > 0) { translateY.value = context.value.y + event.translationY; } }) .onEnd((event) => { // Dismiss if swiped down more than 150px or fast swipe if (event.translationY > 150 || event.velocityY > 600) { // Animate down and dismiss translateY.value = withSpring( 1000, { damping: 20, stiffness: 90, }, () => { runOnJS(dismissModal)(); }, ); } else { // Spring back to position translateY.value = withSpring(0); } }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); // Memoize expensive calculations (before early return) const posterUrl = useMemo(() => { if (!api?.basePath || !currentItem?.Id) return null; // For episodes, use SEASON poster instead of episode poster if (currentItem.Type === "Episode" && seasonData?.Id) { // Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id; const seasonImageTag = seasonData.ImageTags?.Primary; return seasonImageTag ? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}` : `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`; } // Fallback to item poster for non-episodes or if season data not loaded return getPosterUrl( api.basePath, currentItem.Id, currentItem.ImageTags?.Primary, 260, 390, ); }, [ api?.basePath, currentItem?.Id, currentItem?.Type, seasonData?.Id, seasonData?.ImageTags?.Primary, currentItem?.ImageTags?.Primary, ]); const protocolColor = "#a855f7"; // Streamyfin purple // Redirect if not connected - check CastState like old implementation useEffect(() => { // Redirect immediately when disconnected or no devices if ( castState === CastState.NOT_CONNECTED || castState === CastState.NO_DEVICES_AVAILABLE ) { // Use setTimeout to avoid state update during render const timer = setTimeout(() => { if (router.canGoBack()) { router.back(); } else { router.replace("/(auth)/(tabs)/(home)/"); } }, 100); return () => clearTimeout(timer); } }, [castState, router]); // Also redirect if mediaStatus disappears (media ended or stopped) useEffect(() => { if (castState === CastState.CONNECTED && !mediaStatus) { const timer = setTimeout(() => { if (router.canGoBack()) { router.back(); } else { router.replace("/(auth)/(tabs)/(home)/"); } }, 500); // Small delay to allow for media transitions return () => clearTimeout(timer); } }, [castState, mediaStatus, router]); // Show loading while connecting if (castState === CastState.CONNECTING) { return ( {t("casting_player.connecting")} ); } // Don't render if not connected or no media playing if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) { return null; } return ( <> {/* Header - Fixed at top */} {/* Connection indicator */} setShowDeviceSheet(true)} style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: "#1a1a1a", borderRadius: 16, }} > {currentDevice || t("casting_player.unknown_device")} setShowSettings(true)} style={{ padding: 8, marginRight: -8 }} > {/* Title Area */} {/* Title */} {truncateTitle( currentItem.Name || t("casting_player.unknown"), 50, )} {/* Grey episode/season info */} {currentItem.Type === "Episode" && currentItem.ParentIndexNumber !== undefined && currentItem.IndexNumber !== undefined && ( {t("casting_player.season_episode_format", { season: currentItem.ParentIndexNumber, episode: currentItem.IndexNumber, })} )} {/* Scrollable content area */} {/* Poster with buffering overlay */} {posterUrl ? ( ) : ( )} {/* Skip intro/credits bar at bottom of poster */} {currentSegment && ( { if (!remoteMediaClient) return; try { const seekFn = async (positionMs: number) => { if ( mediaStatus?.playerState === MediaPlayerState.PLAYING || mediaStatus?.playerState === MediaPlayerState.PAUSED ) { await remoteMediaClient.seek({ position: positionMs / 1000, }); } }; if (currentSegment.type === "intro") { await skipIntro(seekFn); } else if (currentSegment.type === "credits") { await skipCredits(seekFn); } else { await skipSegment(seekFn); } } catch (error) { console.error("[Casting Player] Skip error:", error); } }} style={{ position: "absolute", bottom: 0, left: 0, right: 0, backgroundColor: protocolColor, paddingVertical: 12, paddingHorizontal: 16, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, }} > {t( `player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`, )} )} {/* Buffering overlay */} {isBuffering && ( {t("casting_player.buffering")} )} {/* Fixed 4-button control row for episodes - positioned independently */} {currentItem.Type === "Episode" && ( {/* Episodes button */} setShowEpisodeList(true)} style={{ flex: 1, backgroundColor: "#1a1a1a", padding: 12, borderRadius: 12, flexDirection: "row", justifyContent: "center", alignItems: "center", }} > {/* Previous episode button */} { const currentIndex = episodes.findIndex( (ep) => ep.Id === currentItem.Id, ); if (currentIndex > 0) { await loadEpisode(episodes[currentIndex - 1]); } }} disabled={ episodes.findIndex((ep) => ep.Id === currentItem.Id) <= 0 } style={{ flex: 1, backgroundColor: "#1a1a1a", padding: 12, borderRadius: 12, flexDirection: "row", justifyContent: "center", alignItems: "center", opacity: episodes.findIndex((ep) => ep.Id === currentItem.Id) <= 0 ? 0.4 : 1, }} > {/* Next episode button */} { if (nextEpisode) { await loadEpisode(nextEpisode); } }} disabled={!nextEpisode} style={{ flex: 1, backgroundColor: "#1a1a1a", padding: 12, borderRadius: 12, flexDirection: "row", justifyContent: "center", alignItems: "center", opacity: nextEpisode ? 1 : 0.4, }} > {/* Stop playback button - stops media but stays connected to Chromecast */} { try { // Stop the current media playback (don't disconnect from Chromecast) if (remoteMediaClient) { await remoteMediaClient.stop(); } // Navigate back/close the player (mini player will disappear since no media is playing) if (router.canGoBack()) { router.back(); } else { router.replace("/(auth)/(tabs)/(home)/"); } } catch (error) { console.error( "[Casting Player] Error stopping playback:", error, ); // Navigate anyway if (router.canGoBack()) { router.back(); } else { router.replace("/(auth)/(tabs)/(home)/"); } } }} style={{ flex: 1, backgroundColor: "#1a1a1a", padding: 12, borderRadius: 12, flexDirection: "row", justifyContent: "center", alignItems: "center", }} > )} {/* Fixed bottom controls area */} {/* Progress slider with trickplay preview */} { isScrubbing.current = true; }} onValueChange={(value) => { // Calculate trickplay preview const progressInTicks = msToTicks(value); calculateTrickplayUrl(progressInTicks); // Update time display for trickplay bubble const progressInSeconds = Math.floor( ticksToSeconds(progressInTicks), ); const hours = Math.floor(progressInSeconds / 3600); const minutes = Math.floor((progressInSeconds % 3600) / 60); const seconds = progressInSeconds % 60; setTrickplayTime({ hours, minutes, seconds }); // Track scrub percentage for bubble positioning const durationMs = duration * 1000; if (durationMs > 0) { setScrubPercentage(value / durationMs); } }} onSlidingComplete={(value) => { isScrubbing.current = false; // Seek to the position (value is in milliseconds, convert to seconds) const positionSeconds = value / 1000; if (remoteMediaClient && duration > 0) { remoteMediaClient .seek({ position: positionSeconds }) .catch((error) => { console.error("[Casting Player] Seek error:", error); }); } }} renderBubble={() => { // Calculate bubble position with edge clamping const screenWidth = Dimensions.get("window").width; const containerPadding = 20; // left/right padding of slider container (matches style) const thumbWidth = 16; // matches thumbWidth prop on Slider const sliderWidth = screenWidth - containerPadding * 2; // Adjust thumb position to account for thumb width affecting travel range const effectiveTrackWidth = sliderWidth - thumbWidth; const thumbPosition = thumbWidth / 2 + scrubPercentage * effectiveTrackWidth; if (!trickPlayUrl || !trickplayInfo) { // Show simple time bubble when no trickplay const timeBubbleWidth = 80; // Clamp position so bubble stays on screen // minLeft prevents going off left edge, maxLeft prevents going off right edge const minLeft = -thumbPosition; const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth; const centeredLeft = -timeBubbleWidth / 2; const clampedLeft = Math.max( minLeft, Math.min(maxLeft, centeredLeft), ); return ( {formatTrickplayTime(trickplayTime)} ); } const { x, y, url } = trickPlayUrl; const tileWidth = 220; // Larger preview for casting player const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78); // Calculate clamped position for trickplay preview // minLeft: furthest left (when thumb is at left edge) // maxLeft: furthest right (when thumb is at right edge) const minLeft = -thumbPosition; const maxLeft = sliderWidth - thumbPosition - tileWidth; const centeredLeft = -tileWidth / 2; const clampedLeft = Math.max( minLeft, Math.min(maxLeft, centeredLeft), ); return ( {/* Trickplay image preview */} {/* Time overlay */} {formatTrickplayTime(trickplayTime)} ); }} sliderHeight={6} thumbWidth={16} panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }} /> {/* Time display */} {formatTime(progress * 1000)} {t("casting_player.ending_at", { time: calculateEndingTime(progress * 1000, duration * 1000), })} {formatTime(duration * 1000)} {/* Playback controls */} {/* Rewind (use settings) */} skipBackward(settings?.rewindSkipTime ?? 10)} style={{ position: "relative", justifyContent: "center", alignItems: "center", }} > {settings?.rewindSkipTime != null && ( {settings.rewindSkipTime} )} {/* Play/Pause */} {/* Forward (use settings) */} skipForward(settings?.forwardSkipTime ?? 10)} style={{ position: "relative", justifyContent: "center", alignItems: "center", }} > {settings?.forwardSkipTime != null && ( {settings.forwardSkipTime} )} {/* Modals */} setShowDeviceSheet(false)} device={ currentDevice && castDevice ? { friendlyName: currentDevice } : null } onDisconnect={async () => { try { // End the casting session and disconnect completely const sessionManager = GoogleCast.getSessionManager(); await sessionManager.endCurrentSession(true); setShowDeviceSheet(false); // Close player immediately after disconnecting setTimeout(() => { if (router.canGoBack()) { router.back(); } else { router.replace("/(auth)/(tabs)/(home)/"); } }, 100); } catch (error) { console.error( "[Casting Player] Error disconnecting from Chromecast:", error, ); } }} volume={volume} onVolumeChange={async (vol) => { try { setVolume(vol); } catch (error) { console.error("[Casting Player] Failed to set volume:", error); } }} /> setShowEpisodeList(false)} currentItem={currentItem} episodes={episodes} api={api} onSelectEpisode={async (episode) => { setShowEpisodeList(false); await loadEpisode(episode); }} /> setShowSettings(false)} item={currentItem} mediaSources={availableMediaSources.filter((source) => { const currentBitrate = availableMediaSources[0]?.bitrate || Number.POSITIVE_INFINITY; return (source.bitrate || 0) <= currentBitrate; })} selectedMediaSource={availableMediaSources[0] || null} onMediaSourceChange={(source) => { reloadWithSettings({ bitrateValue: source.bitrate }); }} audioTracks={availableAudioTracks} selectedAudioTrack={ selectedAudioTrackIndex === null ? availableAudioTracks[0] || null : availableAudioTracks.find( (t) => t.index === selectedAudioTrackIndex, ) || null } onAudioTrackChange={(track) => { setSelectedAudioTrackIndex(track.index); // Reload stream with new audio track reloadWithSettings({ audioIndex: track.index }); }} subtitleTracks={availableSubtitleTracks} selectedSubtitleTrack={ selectedSubtitleTrackIndex === null ? null : availableSubtitleTracks.find( (t) => t.index === selectedSubtitleTrackIndex, ) || null } onSubtitleTrackChange={(track) => { setSelectedSubtitleTrackIndex(track?.index ?? null); // Reload stream with new subtitle track reloadWithSettings({ subtitleIndex: track?.index ?? null }); }} playbackSpeed={currentPlaybackSpeed} onPlaybackSpeedChange={(speed) => { setCurrentPlaybackSpeed(speed); remoteMediaClient?.setPlaybackRate(speed).catch(console.error); }} /> ); }