/** * Chromecast Device Info Sheet * Shows device details, volume control, and disconnect option */ import { Ionicons } from "@expo/vector-icons"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Modal, Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { useCastSession } from "react-native-google-cast"; import { useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; interface ChromecastDeviceSheetProps { visible: boolean; onClose: () => void; device: { friendlyName?: string } | null; onDisconnect: () => Promise; volume?: number; onVolumeChange?: (volume: number) => Promise; } export const ChromecastDeviceSheet: React.FC = ({ visible, onClose, device, onDisconnect, volume = 0.5, onVolumeChange, }) => { const insets = useSafeAreaInsets(); const { t } = useTranslation(); const [isDisconnecting, setIsDisconnecting] = useState(false); const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100)); const volumeValue = useSharedValue(volume * 100); const minimumValue = useSharedValue(0); const maximumValue = useSharedValue(100); const castSession = useCastSession(); const volumeDebounceRef = useRef(null); const [isMuted, setIsMuted] = useState(false); const isSliding = useRef(false); const lastSetVolume = useRef(Math.round(volume * 100)); // Sync volume slider with prop changes (updates from physical buttons) useEffect(() => { volumeValue.value = volume * 100; setDisplayVolume(Math.round(volume * 100)); }, [volume, volumeValue]); // Poll for volume and mute updates when sheet is visible to catch physical button changes useEffect(() => { if (!visible || !castSession) return; // Get initial mute state castSession .isMute() .then(setIsMuted) .catch(() => {}); // Poll CastSession for device volume and mute state (only when not sliding) const interval = setInterval(async () => { if (isSliding.current) return; try { const deviceVolume = await castSession.getVolume(); if (deviceVolume !== undefined) { const volumePercent = Math.round(deviceVolume * 100); // Only update if external change (physical buttons) if (Math.abs(volumePercent - lastSetVolume.current) > 2) { setDisplayVolume(volumePercent); volumeValue.value = volumePercent; lastSetVolume.current = volumePercent; } } // Check mute state const muteState = await castSession.isMute(); setIsMuted(muteState); } catch { // Ignore errors - device might be disconnected } }, 1000); return () => clearInterval(interval); }, [visible, castSession, volumeValue]); const handleDisconnect = async () => { setIsDisconnecting(true); try { await onDisconnect(); onClose(); } catch (error) { console.error("Failed to disconnect:", error); } finally { setIsDisconnecting(false); } }; const handleVolumeComplete = async (value: number) => { const newVolume = value / 100; setDisplayVolume(Math.round(value)); try { // Use CastSession.setVolume for DEVICE volume control // This works even when no media is playing, unlike setStreamVolume if (castSession) { await castSession.setVolume(newVolume); } else if (onVolumeChange) { // Fallback to prop method if session not available await onVolumeChange(newVolume); } } catch (error) { console.error("[Volume] Error setting volume:", error); } }; // Debounced volume update during sliding for smooth live feedback const handleVolumeChange = useCallback( (value: number) => { setDisplayVolume(Math.round(value)); // Debounce the API call to avoid too many requests if (volumeDebounceRef.current) { clearTimeout(volumeDebounceRef.current); } volumeDebounceRef.current = setTimeout(async () => { const newVolume = value / 100; try { if (castSession) { await castSession.setVolume(newVolume); } } catch { // Ignore errors during sliding } }, 150); // 150ms debounce }, [castSession], ); // Toggle mute state const handleToggleMute = useCallback(async () => { if (!castSession) return; try { const newMuteState = !isMuted; await castSession.setMute(newMuteState); setIsMuted(newMuteState); } catch (error) { console.error("[Volume] Error toggling mute:", error); } }, [castSession, isMuted]); // Cleanup debounce timer on unmount useEffect(() => { return () => { if (volumeDebounceRef.current) { clearTimeout(volumeDebounceRef.current); } }; }, []); return ( e.stopPropagation()} > {/* Header */} {t("casting_player.chromecast")} {/* Device info */} {t("casting_player.device_name")} {device?.friendlyName || t("casting_player.unknown_device")} {/* Volume control */} {t("casting_player.volume")} {isMuted ? t("casting_player.muted") : `${displayVolume}%`} {/* Mute button */} { isSliding.current = true; }} onValueChange={async (value) => { volumeValue.value = value; handleVolumeChange(value); // Unmute when adjusting volume if (isMuted && castSession) { setIsMuted(false); try { await castSession.setMute(false); } catch (error) { console.error("[Volume] Failed to unmute:", error); setIsMuted(true); // Rollback on failure } } }} onSlidingComplete={(value) => { isSliding.current = false; lastSetVolume.current = Math.round(value); handleVolumeComplete(value); }} panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }} /> {/* Disconnect button */} {isDisconnecting ? t("casting_player.disconnecting") : t("casting_player.stop_casting")} ); };