diff --git a/app/(auth)/casting-player.tsx b/app/(auth)/casting-player.tsx index 32ad99590..8c35a867b 100644 --- a/app/(auth)/casting-player.tsx +++ b/app/(auth)/casting-player.tsx @@ -5,7 +5,7 @@ import { router, Stack } from "expo-router"; import { useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, ScrollView, View } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; @@ -189,23 +189,37 @@ export default function CastingPlayerScreen() { loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id; // Expose this player to the app-wide remote-control surface while a cast - // session is connected. `castingControls` is the live useCasting result. + // session is connected. The individual useCasting methods are each + // useCallback-wrapped and stable, so depend on them directly rather than on + // the whole `castingControls` object literal (rebuilt every render). + const { + togglePlayPause: castTogglePlayPause, + pause: castPause, + play: castPlay, + stop: castStop, + seek: castSeek, + setVolume: castSetVolume, + } = castingControls; + // toggleMute reads the latest volume without making `volume` a useMemo dep. + const volumeRef = useRef(volume); + volumeRef.current = volume; + const castController = useMemo( () => ({ playPause: () => { - castingControls.togglePlayPause(); + castTogglePlayPause(); }, pause: () => { - castingControls.pause(); + castPause(); }, unpause: () => { - castingControls.play(); + castPlay(); }, stop: () => { - castingControls.stop(); + castStop(); }, seek: (positionMs) => { - castingControls.seek(positionMs); + castSeek(positionMs); }, next: () => { if (nextEpisode) loadEpisode(nextEpisode); @@ -215,13 +229,24 @@ export default function CastingPlayerScreen() { if (idx > 0) loadEpisode(episodes[idx - 1]); }, setVolume: (level) => { - castingControls.setVolume(level); + castSetVolume(level); }, toggleMute: () => { - castingControls.setVolume(castingControls.volume > 0 ? 0 : 1); + castSetVolume(volumeRef.current > 0 ? 0 : 1); }, }), - [castingControls, episodes, nextEpisode, loadEpisode, currentItem?.Id], + [ + castTogglePlayPause, + castPause, + castPlay, + castStop, + castSeek, + castSetVolume, + episodes, + nextEpisode, + loadEpisode, + currentItem?.Id, + ], ); useRegisterPlaybackController( diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 1683a2d9c..26c996aaa 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -343,26 +343,6 @@ export default function page() { reportPlaybackStart(); }, [stream, api, offline]); - const togglePlay = async () => { - lightHapticFeedback(); - setIsPlaying(!isPlaying); - if (isPlaying) { - await videoRef.current?.pause(); - const progressInfo = currentPlayStateInfo(); - if (progressInfo) { - playbackManager.reportPlaybackProgress(progressInfo); - } - } else { - videoRef.current?.play(); - const progressInfo = currentPlayStateInfo(); - if (!offline && api) { - await getPlaystateApi(api).reportPlaybackStart({ - playbackStartInfo: progressInfo, - }); - } - } - }; - const reportPlaybackStopped = useCallback(async () => { if (!item?.Id || !stream?.sessionId || offline || !api) return; @@ -431,6 +411,35 @@ export default function page() { isMuted, ]); + // Declared after currentPlayStateInfo so the dependency array can reference + // it without hitting the temporal dead zone. + const togglePlay = useCallback(async () => { + lightHapticFeedback(); + setIsPlaying(!isPlaying); + if (isPlaying) { + await videoRef.current?.pause(); + const progressInfo = currentPlayStateInfo(); + if (progressInfo) { + playbackManager.reportPlaybackProgress(progressInfo); + } + } else { + videoRef.current?.play(); + const progressInfo = currentPlayStateInfo(); + if (!offline && api) { + await getPlaystateApi(api).reportPlaybackStart({ + playbackStartInfo: progressInfo, + }); + } + } + }, [ + lightHapticFeedback, + isPlaying, + currentPlayStateInfo, + playbackManager, + offline, + api, + ]); + const lastUrlUpdateTime = useSharedValue(0); const wasJustSeeking = useSharedValue(false); const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second diff --git a/hooks/useRemoteControl.ts b/hooks/useRemoteControl.ts index ff1598f5e..1eb29bcd5 100644 --- a/hooks/useRemoteControl.ts +++ b/hooks/useRemoteControl.ts @@ -5,7 +5,7 @@ */ import { useAtomValue } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { toast } from "sonner-native"; import { activePlaybackControllerAtom } from "@/utils/playback/playbackController"; import { @@ -16,9 +16,11 @@ import { /** Handle one remote-control message (call it whenever a new WS message arrives). */ export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => { const controller = useAtomValue(activePlaybackControllerAtom); + const handledRef = useRef(null); useEffect(() => { - if (!lastMessage) return; + if (!lastMessage || lastMessage === handledRef.current) return; + handledRef.current = lastMessage; const action = mapRemoteCommand(lastMessage); if (!action) return;