diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 717667f5..4a87c52b 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -15,7 +15,7 @@ import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, Platform, View } from "react-native"; -import { useSharedValue } from "react-native-reanimated"; +import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; @@ -98,7 +98,7 @@ export default function page() { /** Playback position in ticks. */ playbackPosition?: string; }>(); - const [settings] = useSettings(); + const [_settings] = useSettings(); const offline = offlineStr === "true"; const playbackManager = usePlaybackManager(); @@ -280,11 +280,15 @@ export default function page() { ]); const stop = useCallback(() => { + // Update URL with final playback position before stopping + router.setParams({ + playbackPosition: msToTicks(progress.get()).toString(), + }); reportPlaybackStopped(); setIsPlaybackStopped(true); videoRef.current?.stop(); revalidateProgressCache(); - }, [videoRef, reportPlaybackStopped]); + }, [videoRef, reportPlaybackStopped, progress]); useEffect(() => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop); @@ -293,7 +297,7 @@ export default function page() { }; }, [navigation, stop]); - const currentPlayStateInfo = () => { + const currentPlayStateInfo = useCallback(() => { if (!stream) return; return { itemId: item?.Id!, @@ -309,7 +313,32 @@ export default function page() { repeatMode: RepeatMode.RepeatNone, playbackOrder: PlaybackOrder.Default, }; - }; + }, [ + stream, + item?.Id, + audioIndex, + subtitleIndex, + mediaSourceId, + progress, + isPlaying, + isMuted, + ]); + + const lastUrlUpdateTime = useSharedValue(0); + const wasJustSeeking = useSharedValue(false); + const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second + + // Track when seeking ends to update URL immediately + useAnimatedReaction( + () => isSeeking.get(), + (currentSeeking, previousSeeking) => { + if (previousSeeking && !currentSeeking) { + // Seeking just ended + wasJustSeeking.value = true; + } + }, + [], + ); const onProgress = useCallback( async (data: ProgressUpdatePayload) => { @@ -322,10 +351,20 @@ export default function page() { progress.set(currentTime); - // Update the playback position in the URL. - router.setParams({ - playbackPosition: msToTicks(currentTime).toString(), - }); + // Update URL immediately after seeking, or every 30 seconds during normal playback + const now = Date.now(); + const shouldUpdateUrl = wasJustSeeking.get(); + wasJustSeeking.value = false; + + if ( + shouldUpdateUrl || + now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL + ) { + router.setParams({ + playbackPosition: msToTicks(currentTime).toString(), + }); + lastUrlUpdateTime.value = now; + } if (!item?.Id) return; @@ -398,6 +437,7 @@ export default function page() { console.error("Error toggling mute:", error); } }, [previousVolume]); + const volumeDownCb = useCallback(async () => { if (Platform.isTV) return; @@ -512,7 +552,7 @@ export default function page() { /** Whether the stream we're playing is not transcoding*/ const notTranscoding = !stream?.mediaSource.TranscodingUrl; /** The initial options to pass to the VLC Player */ - const initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; + const initOptions = [``]; if ( chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) @@ -537,6 +577,54 @@ export default function page() { return () => setIsMounted(false); }, []); + // Memoize video ref functions to prevent unnecessary re-renders + const startPictureInPicture = useMemo( + () => videoRef.current?.startPictureInPicture, + [isVideoLoaded], + ); + const play = useMemo( + () => videoRef.current?.play || (() => {}), + [isVideoLoaded], + ); + const pause = useMemo( + () => videoRef.current?.pause || (() => {}), + [isVideoLoaded], + ); + const seek = useMemo( + () => videoRef.current?.seekTo || (() => {}), + [isVideoLoaded], + ); + const getAudioTracks = useMemo( + () => videoRef.current?.getAudioTracks, + [isVideoLoaded], + ); + const getSubtitleTracks = useMemo( + () => videoRef.current?.getSubtitleTracks, + [isVideoLoaded], + ); + const setSubtitleTrack = useMemo( + () => videoRef.current?.setSubtitleTrack, + [isVideoLoaded], + ); + const setSubtitleURL = useMemo( + () => videoRef.current?.setSubtitleURL, + [isVideoLoaded], + ); + const setAudioTrack = useMemo( + () => videoRef.current?.setAudioTrack, + [isVideoLoaded], + ); + const setVideoAspectRatio = useMemo( + () => videoRef.current?.setVideoAspectRatio, + [isVideoLoaded], + ); + const setVideoScaleFactor = useMemo( + () => videoRef.current?.setVideoScaleFactor, + [isVideoLoaded], + ); + + console.log("Debug: component render"); // Uncomment to debug re-renders + // Show error UI first, before checking loading/missing‐data if (itemStatus.isError || streamStatus.isError) { return ( @@ -567,7 +655,7 @@ export default function page() { {})} - pause={videoRef.current?.pause || (() => {})} - seek={videoRef.current?.seekTo || (() => {})} + startPictureInPicture={startPictureInPicture} + play={play} + pause={pause} + seek={seek} enableTrickplay={true} - getAudioTracks={videoRef.current?.getAudioTracks} - getSubtitleTracks={videoRef.current?.getSubtitleTracks} + getAudioTracks={getAudioTracks} + getSubtitleTracks={getSubtitleTracks} offline={offline} - setSubtitleTrack={videoRef.current?.setSubtitleTrack} - setSubtitleURL={videoRef.current?.setSubtitleURL} - setAudioTrack={videoRef.current?.setAudioTrack} - setVideoAspectRatio={videoRef.current?.setVideoAspectRatio} - setVideoScaleFactor={videoRef.current?.setVideoScaleFactor} + setSubtitleTrack={setSubtitleTrack} + setSubtitleURL={setSubtitleURL} + setAudioTrack={setAudioTrack} + setVideoAspectRatio={setVideoAspectRatio} + setVideoScaleFactor={setVideoScaleFactor} aspectRatio={aspectRatio} scaleFactor={scaleFactor} setAspectRatio={setAspectRatio} diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index dc7e90d1..7ffdcaeb 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -282,18 +282,31 @@ export const Controls: FC = ({ const effectiveProgress = useSharedValue(0); - // Recompute progress whenever remote scrubbing is active + // Recompute progress whenever remote scrubbing is active or when progress significantly changes useAnimatedReaction( () => ({ isScrubbing: isRemoteScrubbing.value, scrub: remoteScrubProgress.value, actual: progress.value, }), - (current) => { - effectiveProgress.value = - current.isScrubbing && current.scrub != null - ? current.scrub - : current.actual; + (current, previous) => { + // Always update if scrubbing state changed or we're currently scrubbing + if ( + current.isScrubbing !== previous?.isScrubbing || + current.isScrubbing + ) { + effectiveProgress.value = + current.isScrubbing && current.scrub != null + ? current.scrub + : current.actual; + } else { + // When not scrubbing, only update if progress changed significantly (1 second) + const progressUnit = isVlc ? 1000 : 10000000; // 1 second in ms or ticks + const progressDiff = Math.abs(current.actual - effectiveProgress.value); + if (progressDiff >= progressUnit) { + effectiveProgress.value = current.actual; + } + } }, [], ); @@ -450,6 +463,9 @@ export const Controls: FC = ({ [goToNextItem], ); + const lastCurrentTimeRef = useRef(0); + const lastRemainingTimeRef = useRef(0); + const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); @@ -457,8 +473,25 @@ export const Controls: FC = ({ ? maxValue - currentProgress : ticksToSeconds(maxValue - currentProgress); - setCurrentTime(current); - setRemainingTime(remaining); + // Only update state if the displayed time actually changed (avoid sub-second updates) + const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1)); + const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1)); + const lastCurrentSeconds = Math.floor( + lastCurrentTimeRef.current / (isVlc ? 1000 : 1), + ); + const lastRemainingSeconds = Math.floor( + lastRemainingTimeRef.current / (isVlc ? 1000 : 1), + ); + + if ( + currentSeconds !== lastCurrentSeconds || + remainingSeconds !== lastRemainingSeconds + ) { + setCurrentTime(current); + setRemainingTime(remaining); + lastCurrentTimeRef.current = current; + lastRemainingTimeRef.current = remaining; + } }, [goToNextItem, isVlc], ); diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index 855311b1..1ed1b5b7 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -70,7 +70,7 @@ export const usePlaybackManager = ({ useDownload(); /** Whether the device is online. actually it's connected to the internet. */ - const isOnline = netInfo.isConnected; + const isOnline = useMemo(() => netInfo.isConnected, [netInfo.isConnected]); // Adjacent episodes logic const { data: adjacentItems } = useQuery({