import { Feather } from "@expo/vector-icons"; import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; import { router } from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import GoogleCast, { CastButton, CastContext, CastState, useCastDevice, useCastState, useDevices, useMediaStatus, useRemoteMediaClient, } from "react-native-google-cast"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu"; import { RoundButton } from "./RoundButton"; export function Chromecast({ width = 48, height = 48, background = "transparent", ...props }) { // Hooks called for their side effects (keep Chromecast session active) useRemoteMediaClient(); useCastDevice(); const castState = useCastState(); useDevices(); const discoveryManager = GoogleCast.getDiscoveryManager(); const mediaStatus = useMediaStatus(); const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); // Connection menu state const [showConnectionMenu, setShowConnectionMenu] = useState(false); const isConnected = castState === CastState.CONNECTED; const lastReportedProgressRef = useRef(0); const lastReportedPlayerStateRef = useRef(null); const playSessionIdRef = useRef(null); const lastContentIdRef = useRef(null); const discoveryAttempts = useRef(0); const maxDiscoveryAttempts = 3; // Enhanced discovery with retry mechanism - runs once on mount useEffect(() => { let isSubscribed = true; let retryTimeout: NodeJS.Timeout; const startDiscoveryWithRetry = async () => { if (!discoveryManager) { return; } try { // Stop any existing discovery first try { await discoveryManager.stopDiscovery(); } catch { // Ignore errors when stopping } // Start fresh discovery await discoveryManager.startDiscovery(); discoveryAttempts.current = 0; // Reset on success } catch (error) { console.error("[Chromecast Discovery] Failed:", error); // Retry on error if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) { discoveryAttempts.current++; retryTimeout = setTimeout(() => { if (isSubscribed) { startDiscoveryWithRetry(); } }, 2000); } } }; startDiscoveryWithRetry(); return () => { isSubscribed = false; if (retryTimeout) { clearTimeout(retryTimeout); } }; }, [discoveryManager]); // Only re-run if discoveryManager changes // Report video progress to Jellyfin server useEffect(() => { if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) { return; } const streamPosition = mediaStatus.streamPosition || 0; const playerState = mediaStatus.playerState || null; // Report every 10 seconds OR immediately when playerState changes (pause/resume) const positionChanged = Math.abs(streamPosition - lastReportedProgressRef.current) >= 10; const stateChanged = playerState !== lastReportedPlayerStateRef.current; if (!positionChanged && !stateChanged) { return; } const contentId = mediaStatus.mediaInfo.contentId; // Generate a new PlaySessionId when the content changes if (contentId !== lastContentIdRef.current) { const randomBytes = new Uint8Array(7); crypto.getRandomValues(randomBytes); const randomSuffix = Array.from(randomBytes, (b) => b.toString(36).padStart(2, "0"), ) .join("") .substring(0, 9); playSessionIdRef.current = `${contentId}-${Date.now()}-${randomSuffix}`; lastContentIdRef.current = contentId; } const positionTicks = Math.floor(streamPosition * 10000000); const isPaused = mediaStatus.playerState === "paused"; const streamUrl = mediaStatus.mediaInfo.contentUrl || ""; const isTranscoding = streamUrl.includes("m3u8"); const progressInfo: PlaybackProgressInfo = { ItemId: contentId, PositionTicks: positionTicks, IsPaused: isPaused, PlayMethod: isTranscoding ? "Transcode" : "DirectStream", PlaySessionId: playSessionIdRef.current || contentId, }; getPlaystateApi(api) .reportPlaybackProgress({ playbackProgressInfo: progressInfo }) .then(() => { lastReportedProgressRef.current = streamPosition; lastReportedPlayerStateRef.current = playerState; }) .catch((error) => { console.error("Failed to report Chromecast progress:", error); }); }, [ api, user?.Id, mediaStatus?.streamPosition, mediaStatus?.mediaInfo?.contentId, mediaStatus?.playerState, mediaStatus?.mediaInfo?.contentUrl, ]); // Android requires the cast button to be present for startDiscovery to work const AndroidCastButton = useCallback( () => Platform.OS === "android" ? : null, [Platform.OS], ); // Handle press - show connection menu when connected, otherwise show cast dialog const handlePress = useCallback(() => { if (isConnected) { if (mediaStatus?.currentItemId) { // Media is playing - navigate to full player router.push("/casting-player"); } else { // Connected but no media - show connection menu setShowConnectionMenu(true); } } else { // Not connected - show cast dialog CastContext.showCastDialog(); } }, [isConnected, mediaStatus?.currentItemId]); // Handle disconnect from Chromecast const handleDisconnect = useCallback(async () => { try { const sessionManager = GoogleCast.getSessionManager(); await sessionManager.endCurrentSession(true); } catch (error) { console.error("[Chromecast] Disconnect error:", error); } }, []); if (Platform.OS === "ios") { return ( <> setShowConnectionMenu(false)} onDisconnect={handleDisconnect} /> ); } if (background === "transparent") return ( <> setShowConnectionMenu(false)} onDisconnect={handleDisconnect} /> ); return ( <> setShowConnectionMenu(false)} onDisconnect={handleDisconnect} /> ); }