From 5bc6494ba67bf59e0c2304a7068bb42b4ca63851 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 20 Aug 2025 21:43:00 +0200 Subject: [PATCH] fix: refactor controls into smaller parts (#963) --- app/(auth)/player/direct-player.tsx | 92 +- .../video-player/controls/BottomControls.tsx | 224 ++++ .../video-player/controls/CenterControls.tsx | 158 +++ components/video-player/controls/Controls.tsx | 962 +++--------------- .../video-player/controls/EpisodeList.tsx | 3 +- .../video-player/controls/HeaderControls.tsx | 196 ++++ .../video-player/controls/TimeDisplay.tsx | 43 + .../video-player/controls/TrickplayBubble.tsx | 92 ++ components/video-player/controls/constants.ts | 17 + .../video-player/controls/hooks/index.ts | 4 + .../controls/hooks/useRemoteControl.ts | 170 ++++ .../controls/hooks/useVideoNavigation.ts | 114 +++ .../controls/hooks/useVideoSlider.ts | 99 ++ .../controls/hooks/useVideoTime.ts | 76 ++ 14 files changed, 1406 insertions(+), 844 deletions(-) create mode 100644 components/video-player/controls/BottomControls.tsx create mode 100644 components/video-player/controls/CenterControls.tsx create mode 100644 components/video-player/controls/HeaderControls.tsx create mode 100644 components/video-player/controls/TimeDisplay.tsx create mode 100644 components/video-player/controls/TrickplayBubble.tsx create mode 100644 components/video-player/controls/constants.ts create mode 100644 components/video-player/controls/hooks/index.ts create mode 100644 components/video-player/controls/hooks/useRemoteControl.ts create mode 100644 components/video-player/controls/hooks/useVideoNavigation.ts create mode 100644 components/video-player/controls/hooks/useVideoSlider.ts create mode 100644 components/video-player/controls/hooks/useVideoTime.ts diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 4a87c52b..e3558408 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -578,51 +578,57 @@ export default function page() { }, []); // 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], + const startPictureInPicture = useCallback(async () => { + return videoRef.current?.startPictureInPicture?.(); + }, []); + const play = useCallback(() => { + videoRef.current?.play?.(); + }, []); + + const pause = useCallback(() => { + videoRef.current?.pause?.(); + }, []); + + const seek = useCallback((position: number) => { + videoRef.current?.seekTo?.(position); + }, []); + const getAudioTracks = useCallback(async () => { + return videoRef.current?.getAudioTracks?.() || null; + }, []); + + const getSubtitleTracks = useCallback(async () => { + return videoRef.current?.getSubtitleTracks?.() || null; + }, []); + + const setSubtitleTrack = useCallback((index: number) => { + videoRef.current?.setSubtitleTrack?.(index); + }, []); + + const setSubtitleURL = useCallback((url: string, _customName?: string) => { + // Note: VlcPlayer type only expects url parameter + videoRef.current?.setSubtitleURL?.(url); + }, []); + + const setAudioTrack = useCallback((index: number) => { + videoRef.current?.setAudioTrack?.(index); + }, []); + + const setVideoAspectRatio = useCallback( + async (aspectRatio: string | null) => { + return ( + videoRef.current?.setVideoAspectRatio?.(aspectRatio) || + Promise.resolve() + ); + }, + [], ); + const setVideoScaleFactor = useCallback(async (scaleFactor: number) => { + return ( + videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve() + ); + }, []); + console.log("Debug: component render"); // Uncomment to debug re-renders // Show error UI first, before checking loading/missing‐data diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx new file mode 100644 index 00000000..f7bceb99 --- /dev/null +++ b/components/video-player/controls/BottomControls.tsx @@ -0,0 +1,224 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import type { FC } from "react"; +import { View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { type SharedValue } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { useSettings } from "@/utils/atoms/settings"; +import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; +import SkipButton from "./SkipButton"; +import { TimeDisplay } from "./TimeDisplay"; +import { TrickplayBubble } from "./TrickplayBubble"; + +interface BottomControlsProps { + item: BaseItemDto; + showControls: boolean; + isSliding: boolean; + showRemoteBubble: boolean; + currentTime: number; + remainingTime: number; + isVlc: boolean; + showSkipButton: boolean; + showSkipCreditButton: boolean; + skipIntro: () => void; + skipCredit: () => void; + nextItem?: BaseItemDto | null; + handleNextEpisodeAutoPlay: () => void; + handleNextEpisodeManual: () => void; + handleControlsInteraction: () => void; + + // Slider props + min: SharedValue; + max: SharedValue; + effectiveProgress: SharedValue; + cacheProgress: SharedValue; + handleSliderStart: () => void; + handleSliderComplete: (value: number) => void; + handleSliderChange: (value: number) => void; + handleTouchStart: () => void; + handleTouchEnd: () => void; + + // Trickplay props + trickPlayUrl: { + x: number; + y: number; + url: string; + } | null; + trickplayInfo: { + aspectRatio?: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + } | null; + time: { + hours: number; + minutes: number; + seconds: number; + }; +} + +export const BottomControls: FC = ({ + item, + showControls, + isSliding, + showRemoteBubble, + currentTime, + remainingTime, + isVlc, + showSkipButton, + showSkipCreditButton, + skipIntro, + skipCredit, + nextItem, + handleNextEpisodeAutoPlay, + handleNextEpisodeManual, + handleControlsInteraction, + min, + max, + effectiveProgress, + cacheProgress, + handleSliderStart, + handleSliderComplete, + handleSliderChange, + handleTouchStart, + handleTouchEnd, + trickPlayUrl, + trickplayInfo, + time, +}) => { + const [settings] = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + + + {item?.Type === "Episode" && ( + + {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} + + )} + {item?.Name} + {item?.Type === "Movie" && ( + {item?.ProductionYear} + )} + {item?.Type === "Audio" && ( + {item?.Album} + )} + + + + + {(settings.maxAutoPlayEpisodeCount.value === -1 || + settings.autoPlayEpisodeCount < + settings.maxAutoPlayEpisodeCount.value) && ( + + )} + + + + + + null} + cache={cacheProgress} + onSlidingStart={handleSliderStart} + onSlidingComplete={handleSliderComplete} + onValueChange={handleSliderChange} + containerStyle={{ + borderRadius: 100, + }} + renderBubble={() => + (isSliding || showRemoteBubble) && ( + + ) + } + sliderHeight={10} + thumbWidth={0} + progress={effectiveProgress} + minimumValue={min} + maximumValue={max} + /> + + + + + + ); +}; diff --git a/components/video-player/controls/CenterControls.tsx b/components/video-player/controls/CenterControls.tsx new file mode 100644 index 00000000..0f3b154a --- /dev/null +++ b/components/video-player/controls/CenterControls.tsx @@ -0,0 +1,158 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { FC } from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { Loader } from "@/components/Loader"; +import { useSettings } from "@/utils/atoms/settings"; +import AudioSlider from "./AudioSlider"; +import BrightnessSlider from "./BrightnessSlider"; +import { ICON_SIZES } from "./constants"; + +interface CenterControlsProps { + showControls: boolean; + isPlaying: boolean; + isBuffering: boolean; + showAudioSlider: boolean; + setShowAudioSlider: (show: boolean) => void; + togglePlay: () => void; + handleSkipBackward: () => void; + handleSkipForward: () => void; +} + +export const CenterControls: FC = ({ + showControls, + isPlaying, + isBuffering, + showAudioSlider, + setShowAudioSlider, + togglePlay, + handleSkipBackward, + handleSkipForward, +}) => { + const [settings] = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + + + + + {!Platform.isTV && ( + + + + + {settings?.rewindSkipTime} + + + + )} + + + + {!isBuffering ? ( + + ) : ( + + )} + + + + {!Platform.isTV && ( + + + + + {settings?.forwardSkipTime} + + + + )} + + + + + + ); +}; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 7ffdcaeb..822116f2 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -1,11 +1,8 @@ -import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { Image } from "expo-image"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { debounce } from "lodash"; import { type Dispatch, type FC, @@ -13,26 +10,14 @@ import { type SetStateAction, useCallback, useEffect, - useRef, useState, } from "react"; +import { useWindowDimensions } from "react-native"; import { - Platform, - TouchableOpacity, - useTVEventHandler, - useWindowDimensions, - View, -} from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import { - runOnJS, type SharedValue, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Text } from "@/components/common/Text"; -import { Loader } from "@/components/Loader"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useHaptic } from "@/hooks/useHaptic"; @@ -40,30 +25,22 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; -import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; +import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { writeToLog } from "@/utils/log"; -import { - formatTimeString, - msToTicks, - secondsToMs, - ticksToMs, - ticksToSeconds, -} from "@/utils/time"; -import AudioSlider from "./AudioSlider"; -import BrightnessSlider from "./BrightnessSlider"; +import { ticksToMs } from "@/utils/time"; +import { BottomControls } from "./BottomControls"; +import { CenterControls } from "./CenterControls"; +import { CONTROLS_CONSTANTS } from "./constants"; import { ControlProvider } from "./contexts/ControlContext"; -import { VideoProvider } from "./contexts/VideoContext"; -import DropdownView from "./dropdown/DropdownView"; import { EpisodeList } from "./EpisodeList"; -import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; -import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector"; -import SkipButton from "./SkipButton"; +import { HeaderControls } from "./HeaderControls"; +import { useRemoteControl } from "./hooks/useRemoteControl"; +import { useVideoNavigation } from "./hooks/useVideoNavigation"; +import { useVideoSlider } from "./hooks/useVideoSlider"; +import { useVideoTime } from "./hooks/useVideoTime"; +import { type ScaleFactor } from "./ScaleFactorSelector"; import { useControlsTimeout } from "./useControlsTimeout"; -import { - type AspectRatio, - AspectRatioSelector, -} from "./VideoScalingModeSelector"; +import { type AspectRatio } from "./VideoScalingModeSelector"; import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { @@ -100,8 +77,6 @@ interface Props { isVlc?: boolean; } -const CONTROLS_TIMEOUT = 4000; - export const Controls: FC = ({ item, seek, @@ -134,12 +109,9 @@ export const Controls: FC = ({ }) => { const [settings, updateSettings] = useSettings(); const router = useRouter(); - const insets = useSafeAreaInsets(); + const lightHapticFeedback = useHaptic("light"); const [episodeView, setEpisodeView] = useState(false); - const [isSliding, setIsSliding] = useState(false); - - // Used when user changes audio through audio button on device. const [showAudioSlider, setShowAudioSlider] = useState(false); const { height: screenHeight, width: screenWidth } = useWindowDimensions(); @@ -155,130 +127,99 @@ export const Controls: FC = ({ prefetchAllTrickplayImages, } = useTrickplay(item); - const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); - const min = useSharedValue(0); const max = useSharedValue(item.RunTimeTicks || 0); - const wasPlayingRef = useRef(false); - const lastProgressRef = useRef(0); - - const lightHapticFeedback = useHaptic("light"); - useEffect(() => { prefetchAllTrickplayImages(); - }, []); + }, [prefetchAllTrickplayImages]); - const remoteScrubProgress = useSharedValue(null); - const isRemoteScrubbing = useSharedValue(false); - const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10)); - const [showRemoteBubble, setShowRemoteBubble] = useState(false); - - const [longPressScrubMode, setLongPressScrubMode] = useState< - "FF" | "RW" | null - >(null); - - useTVEventHandler((evt) => { - if (!evt) return; - - switch (evt.eventType) { - case "longLeft": { - setLongPressScrubMode((prev) => (!prev ? "RW" : null)); - break; - } - case "longRight": { - setLongPressScrubMode((prev) => (!prev ? "FF" : null)); - break; - } - case "left": - case "right": { - isRemoteScrubbing.value = true; - setShowRemoteBubble(true); - - const direction = evt.eventType === "left" ? -1 : 1; - const base = remoteScrubProgress.value ?? progress.value; - const updated = Math.max( - min.value, - Math.min(max.value, base + direction * SCRUB_INTERVAL), - ); - remoteScrubProgress.value = updated; - const progressInTicks = isVlc ? msToTicks(updated) : updated; - calculateTrickplayUrl(progressInTicks); - const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); - const hours = Math.floor(progressInSeconds / 3600); - const minutes = Math.floor((progressInSeconds % 3600) / 60); - const seconds = progressInSeconds % 60; - setTime({ hours, minutes, seconds }); - break; - } - case "select": { - if (isRemoteScrubbing.value && remoteScrubProgress.value != null) { - progress.value = remoteScrubProgress.value; - - const seekTarget = isVlc - ? Math.max(0, remoteScrubProgress.value) - : Math.max(0, ticksToSeconds(remoteScrubProgress.value)); - - seek(seekTarget); - if (isPlaying) play(); - - isRemoteScrubbing.value = false; - remoteScrubProgress.value = null; - setShowRemoteBubble(false); - } else { - togglePlay(); - } - break; - } - case "down": - case "up": - // cancel scrubbing on other directions - isRemoteScrubbing.value = false; - remoteScrubProgress.value = null; - setShowRemoteBubble(false); - break; - default: - break; + // Initialize progress values + useEffect(() => { + if (item) { + progress.value = isVlc + ? ticksToMs(item?.UserData?.PlaybackPositionTicks) + : item?.UserData?.PlaybackPositionTicks || 0; + max.value = isVlc + ? ticksToMs(item.RunTimeTicks || 0) + : item.RunTimeTicks || 0; } + }, [item, isVlc, progress, max]); - if (!showControls) toggleControls(); + // Navigation hooks + const { + handleSeekBackward, + handleSeekForward, + handleSkipBackward, + handleSkipForward, + } = useVideoNavigation({ + progress, + isPlaying, + isVlc, + seek, + play, }); - const longPressTimeoutRef = useRef | null>( - null, - ); + // Time management hook + const { currentTime, remainingTime } = useVideoTime({ + progress, + max, + isSeeking, + isVlc, + }); - useEffect(() => { - let isActive = true; - let seekTime = 10; - - const scrubWithLongPress = () => { - if (!isActive || !longPressScrubMode) return; - - setIsSliding(true); - const scrubFn = - longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward; - scrubFn(seekTime); - seekTime *= 1.1; - - longPressTimeoutRef.current = setTimeout(scrubWithLongPress, 300); - }; - - if (longPressScrubMode) { - isActive = true; - scrubWithLongPress(); + const toggleControls = useCallback(() => { + if (showControls) { + setShowAudioSlider(false); + setShowControls(false); + } else { + setShowControls(true); } + }, [showControls, setShowControls]); - return () => { - isActive = false; - setIsSliding(false); - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - longPressTimeoutRef.current = null; - } - }; - }, [longPressScrubMode]); + // Remote control hook + const { + remoteScrubProgress, + isRemoteScrubbing, + showRemoteBubble, + isSliding: isRemoteSliding, + time: remoteTime, + } = useRemoteControl({ + progress, + min, + max, + isVlc, + showControls, + isPlaying, + seek, + play, + togglePlay, + toggleControls, + calculateTrickplayUrl, + handleSeekForward, + handleSeekBackward, + }); + + // Slider hook + const { + isSliding, + time, + handleSliderStart, + handleTouchStart, + handleTouchEnd, + handleSliderComplete, + handleSliderChange, + } = useVideoSlider({ + progress, + isSeeking, + isPlaying, + isVlc, + seek, + play, + pause, + calculateTrickplayUrl, + showControls, + }); const effectiveProgress = useSharedValue(0); @@ -301,7 +242,9 @@ export const Controls: FC = ({ : 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 progressUnit = isVlc + ? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS + : CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS; const progressDiff = Math.abs(current.actual - effectiveProgress.value); if (progressDiff >= progressUnit) { effectiveProgress.value = current.actual; @@ -311,17 +254,6 @@ export const Controls: FC = ({ [], ); - useEffect(() => { - if (item) { - progress.value = isVlc - ? ticksToMs(item?.UserData?.PlaybackPositionTicks) - : item?.UserData?.PlaybackPositionTicks || 0; - max.value = isVlc - ? ticksToMs(item.RunTimeTicks || 0) - : item.RunTimeTicks || 0; - } - }, [item, isVlc]); - const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{ bitrateValue: string; audioIndex: string; @@ -463,228 +395,19 @@ 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); - const remaining = isVlc - ? maxValue - currentProgress - : ticksToSeconds(maxValue - currentProgress); - - // 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], - ); - - useAnimatedReaction( - () => ({ - progress: progress.value, - max: max.value, - isSeeking: isSeeking.value, - }), - (result) => { - if (!result.isSeeking) { - runOnJS(updateTimes)(result.progress, result.max); - } - }, - [updateTimes], - ); - const hideControls = useCallback(() => { setShowControls(false); setShowAudioSlider(false); - }, []); + }, [setShowControls]); const { handleControlsInteraction } = useControlsTimeout({ showControls, - isSliding, + isSliding: isSliding || isRemoteSliding, episodeView, onHideControls: hideControls, - timeout: CONTROLS_TIMEOUT, + timeout: CONTROLS_CONSTANTS.TIMEOUT, }); - const toggleControls = () => { - if (showControls) { - setShowAudioSlider(false); - setShowControls(false); - } else { - setShowControls(true); - } - }; - - const handleSliderStart = useCallback(() => { - if (!showControls) { - return; - } - - setIsSliding(true); - wasPlayingRef.current = isPlaying; - lastProgressRef.current = progress.value; - - pause(); - isSeeking.value = true; - }, [showControls, isPlaying, pause]); - - const handleTouchStart = useCallback(() => { - if (!showControls) { - return; - } - }, [showControls]); - - const handleTouchEnd = useCallback(() => { - if (!showControls) { - return; - } - }, [showControls, isSliding]); - - const handleSliderComplete = useCallback( - async (value: number) => { - setIsSliding(false); - isSeeking.value = false; - progress.value = value; - seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))); - if (wasPlayingRef.current) { - play(); - } - }, - [isVlc, seek, play], - ); - - const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); - const handleSliderChange = useCallback( - debounce((value: number) => { - const progressInTicks = isVlc ? msToTicks(value) : value; - calculateTrickplayUrl(progressInTicks); - const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); - const hours = Math.floor(progressInSeconds / 3600); - const minutes = Math.floor((progressInSeconds % 3600) / 60); - const seconds = progressInSeconds % 60; - setTime({ hours, minutes, seconds }); - }, 3), - [], - ); - - const handleSkipBackward = useCallback(async () => { - if (!settings?.rewindSkipTime) { - return; - } - wasPlayingRef.current = isPlaying; - lightHapticFeedback(); - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = isVlc - ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) - : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); - seek(newTime); - if (wasPlayingRef.current) { - play(); - } - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, [settings, isPlaying, isVlc, play, seek]); - - const handleSeekBackward = useCallback( - async (seconds: number) => { - wasPlayingRef.current = isPlaying; - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = isVlc - ? Math.max(0, curr - secondsToMs(seconds)) - : Math.max(0, ticksToSeconds(curr) - seconds); - seek(newTime); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, - [isPlaying, isVlc, seek], - ); - - const handleSeekForward = useCallback( - async (seconds: number) => { - wasPlayingRef.current = isPlaying; - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = isVlc - ? curr + secondsToMs(seconds) - : ticksToSeconds(curr) + seconds; - seek(Math.max(0, newTime)); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, - [isPlaying, isVlc, seek], - ); - - const handleSkipForward = useCallback(async () => { - if (!settings?.forwardSkipTime) { - return; - } - wasPlayingRef.current = isPlaying; - lightHapticFeedback(); - try { - const curr = progress.value; - if (curr !== undefined) { - const newTime = isVlc - ? curr + secondsToMs(settings.forwardSkipTime) - : ticksToSeconds(curr) + settings.forwardSkipTime; - seek(Math.max(0, newTime)); - if (wasPlayingRef.current) { - play(); - } - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, [settings, isPlaying, isVlc, play, seek]); - - const handleAspectRatioChange = useCallback( - async (newRatio: AspectRatio) => { - if (!setAspectRatio || !setVideoAspectRatio) return; - - setAspectRatio(newRatio); - const aspectRatioString = newRatio === "default" ? null : newRatio; - await setVideoAspectRatio(aspectRatioString); - }, - [setAspectRatio, setVideoAspectRatio], - ); - - const handleScaleFactorChange = useCallback( - async (newScale: ScaleFactor) => { - if (!setScaleFactor || !setVideoScaleFactor) return; - - setScaleFactor(newScale); - await setVideoScaleFactor(newScale); - }, - [setScaleFactor, setVideoScaleFactor], - ); - const switchOnEpisodeMode = useCallback(() => { setEpisodeView(true); if (isPlaying) { @@ -692,73 +415,6 @@ export const Controls: FC = ({ } }, [isPlaying, togglePlay]); - const memoizedRenderBubble = useCallback(() => { - if (!trickPlayUrl || !trickplayInfo) { - return null; - } - const { x, y, url } = trickPlayUrl; - const tileWidth = 150; - const tileHeight = 150 / trickplayInfo.aspectRatio!; - - return ( - - - - - - {`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${ - time.seconds < 10 ? `0${time.seconds}` : time.seconds - }`} - - - ); - }, [trickPlayUrl, trickplayInfo, time]); - - const onClose = async () => { - lightHapticFeedback(); - router.back(); - }; - return ( = ({ showControls={showControls} onToggleControls={toggleControls} /> - - - {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( - - - - )} - - - - {!Platform.isTV && - (settings.defaultPlayer === VideoPlayer.VLC_4 || - Platform.OS === "android") && ( - - - - )} - {item?.Type === "Episode" && ( - { - switchOnEpisodeMode(); - }} - className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' - > - - - )} - {previousItem && ( - - - - )} - {nextItem && ( - goToNextItem({ isAutoPlay: false })} - className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' - > - - - )} - {/* Video Controls */} - - - - - - - - - - - - {!Platform.isTV && ( - - - - - {settings?.rewindSkipTime} - - - - )} - - - { - togglePlay(); - }} - > - {!isBuffering ? ( - - ) : ( - - )} - - - - {!Platform.isTV && ( - - - - - {settings?.forwardSkipTime} - - - - )} - - - - - - - - - {item?.Type === "Episode" && ( - - {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} - - )} - {item?.Name} - {item?.Type === "Movie" && ( - - {item?.ProductionYear} - - )} - {item?.Type === "Audio" && ( - {item?.Album} - )} - - - - - {(settings.maxAutoPlayEpisodeCount.value === -1 || - settings.autoPlayEpisodeCount < - settings.maxAutoPlayEpisodeCount.value) && ( - - )} - - - - - - null} - cache={cacheProgress} - onSlidingStart={handleSliderStart} - onSlidingComplete={handleSliderComplete} - onValueChange={handleSliderChange} - containerStyle={{ - borderRadius: 100, - }} - renderBubble={() => - (isSliding || showRemoteBubble) && memoizedRenderBubble() - } - sliderHeight={10} - thumbWidth={0} - progress={effectiveProgress} - minimumValue={min} - maximumValue={max} - /> - - - - {formatTimeString(currentTime, isVlc ? "ms" : "s")} - - - - -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} - - - ends at {(() => { - const now = new Date(); - const remainingMs = isVlc - ? remainingTime - : remainingTime * 1000; - const finishTime = new Date( - now.getTime() + remainingMs, - ); - return finishTime.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - })()} - - - - - - + + + )} {settings.maxAutoPlayEpisodeCount.value !== -1 && ( diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index 67635479..3d3d51c8 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -220,6 +220,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { ( @@ -227,7 +228,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { key={otherItem.Id} style={{}} className={`flex flex-col w-44 ${ - item.Id !== otherItem.Id ? "opacity-75" : "" + item.Id !== otherItem.Id ? "opacity-50" : "" }`} > Promise; + switchOnEpisodeMode: () => void; + goToPreviousItem: () => void; + goToNextItem: (options: { isAutoPlay?: boolean }) => void; + previousItem?: BaseItemDto | null; + nextItem?: BaseItemDto | null; + getAudioTracks?: (() => Promise) | (() => any[]); + getSubtitleTracks?: (() => Promise) | (() => any[]); + setAudioTrack?: (index: number) => void; + setSubtitleTrack?: (index: number) => void; + setSubtitleURL?: (url: string, customName: string) => void; + aspectRatio?: AspectRatio; + scaleFactor?: ScaleFactor; + setAspectRatio?: Dispatch>; + setScaleFactor?: Dispatch>; + setVideoAspectRatio?: (aspectRatio: string | null) => Promise; + setVideoScaleFactor?: (scaleFactor: number) => Promise; +} + +export const HeaderControls: FC = ({ + item, + showControls, + offline, + mediaSource, + startPictureInPicture, + switchOnEpisodeMode, + goToPreviousItem, + goToNextItem, + previousItem, + nextItem, + getAudioTracks, + getSubtitleTracks, + setAudioTrack, + setSubtitleTrack, + setSubtitleURL, + aspectRatio = "default", + scaleFactor = 1.0, + setAspectRatio, + setScaleFactor, + setVideoAspectRatio, + setVideoScaleFactor, +}) => { + const [settings] = useSettings(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { width: screenWidth } = useWindowDimensions(); + const lightHapticFeedback = useHaptic("light"); + + const handleAspectRatioChange = async (newRatio: AspectRatio) => { + if (!setAspectRatio || !setVideoAspectRatio) return; + + setAspectRatio(newRatio); + const aspectRatioString = newRatio === "default" ? null : newRatio; + await setVideoAspectRatio(aspectRatioString); + }; + + const handleScaleFactorChange = async (newScale: ScaleFactor) => { + if (!setScaleFactor || !setVideoScaleFactor) return; + + setScaleFactor(newScale); + await setVideoScaleFactor(newScale); + }; + + const onClose = async () => { + lightHapticFeedback(); + router.back(); + }; + + return ( + + + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( + + + + )} + + + + {!Platform.isTV && + (settings.defaultPlayer === VideoPlayer.VLC_4 || + Platform.OS === "android") && ( + + + + )} + {item?.Type === "Episode" && ( + + + + )} + {previousItem && ( + + + + )} + {nextItem && ( + goToNextItem({ isAutoPlay: false })} + className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' + > + + + )} + + + + + + + + ); +}; diff --git a/components/video-player/controls/TimeDisplay.tsx b/components/video-player/controls/TimeDisplay.tsx new file mode 100644 index 00000000..37ba755b --- /dev/null +++ b/components/video-player/controls/TimeDisplay.tsx @@ -0,0 +1,43 @@ +import type { FC } from "react"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { formatTimeString } from "@/utils/time"; + +interface TimeDisplayProps { + currentTime: number; + remainingTime: number; + isVlc: boolean; +} + +export const TimeDisplay: FC = ({ + currentTime, + remainingTime, + isVlc, +}) => { + const getFinishTime = () => { + const now = new Date(); + const remainingMs = isVlc ? remainingTime : remainingTime * 1000; + const finishTime = new Date(now.getTime() + remainingMs); + return finishTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + return ( + + + {formatTimeString(currentTime, isVlc ? "ms" : "s")} + + + + -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} + + + ends at {getFinishTime()} + + + + ); +}; diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx new file mode 100644 index 00000000..28036d15 --- /dev/null +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -0,0 +1,92 @@ +import { Image } from "expo-image"; +import type { FC } from "react"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { CONTROLS_CONSTANTS } from "./constants"; + +interface TrickplayBubbleProps { + trickPlayUrl: { + x: number; + y: number; + url: string; + } | null; + trickplayInfo: { + aspectRatio?: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + } | null; + time: { + hours: number; + minutes: number; + seconds: number; + }; +} + +export const TrickplayBubble: FC = ({ + trickPlayUrl, + trickplayInfo, + time, +}) => { + if (!trickPlayUrl || !trickplayInfo) { + return null; + } + + const { x, y, url } = trickPlayUrl; + const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; + const tileHeight = tileWidth / trickplayInfo.aspectRatio!; + + return ( + + + + + + {`${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} + + + ); +}; diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts new file mode 100644 index 00000000..afe57070 --- /dev/null +++ b/components/video-player/controls/constants.ts @@ -0,0 +1,17 @@ +export const CONTROLS_CONSTANTS = { + TIMEOUT: 4000, + SCRUB_INTERVAL_MS: 10 * 1000, // 10 seconds in ms + SCRUB_INTERVAL_TICKS: 10 * 10000000, // 10 seconds in ticks + TILE_WIDTH: 150, + PROGRESS_UNIT_MS: 1000, // 1 second in ms + PROGRESS_UNIT_TICKS: 10000000, // 1 second in ticks + LONG_PRESS_INITIAL_SEEK: 10, + LONG_PRESS_ACCELERATION: 1.1, + LONG_PRESS_INTERVAL: 300, + SLIDER_DEBOUNCE_MS: 3, +} as const; + +export const ICON_SIZES = { + HEADER: 24, + CENTER: 50, +} as const; diff --git a/components/video-player/controls/hooks/index.ts b/components/video-player/controls/hooks/index.ts new file mode 100644 index 00000000..08b234ac --- /dev/null +++ b/components/video-player/controls/hooks/index.ts @@ -0,0 +1,4 @@ +export { useRemoteControl } from "./useRemoteControl"; +export { useVideoNavigation } from "./useVideoNavigation"; +export { useVideoSlider } from "./useVideoSlider"; +export { useVideoTime } from "./useVideoTime"; diff --git a/components/video-player/controls/hooks/useRemoteControl.ts b/components/video-player/controls/hooks/useRemoteControl.ts new file mode 100644 index 00000000..b1df69fd --- /dev/null +++ b/components/video-player/controls/hooks/useRemoteControl.ts @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTVEventHandler } from "react-native"; +import { type SharedValue, useSharedValue } from "react-native-reanimated"; +import { msToTicks, ticksToSeconds } from "@/utils/time"; +import { CONTROLS_CONSTANTS } from "../constants"; + +interface UseRemoteControlProps { + progress: SharedValue; + min: SharedValue; + max: SharedValue; + isVlc: boolean; + showControls: boolean; + isPlaying: boolean; + seek: (value: number) => void; + play: () => void; + togglePlay: () => void; + toggleControls: () => void; + calculateTrickplayUrl: (progressInTicks: number) => void; + handleSeekForward: (seconds: number) => void; + handleSeekBackward: (seconds: number) => void; +} + +export function useRemoteControl({ + progress, + min, + max, + isVlc, + showControls, + isPlaying, + seek, + play, + togglePlay, + toggleControls, + calculateTrickplayUrl, + handleSeekForward, + handleSeekBackward, +}: UseRemoteControlProps) { + const remoteScrubProgress = useSharedValue(null); + const isRemoteScrubbing = useSharedValue(false); + const [showRemoteBubble, setShowRemoteBubble] = useState(false); + const [longPressScrubMode, setLongPressScrubMode] = useState< + "FF" | "RW" | null + >(null); + const [isSliding, setIsSliding] = useState(false); + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + + const longPressTimeoutRef = useRef | null>( + null, + ); + const SCRUB_INTERVAL = isVlc + ? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS + : CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS; + + const updateTime = useCallback( + (progressValue: number) => { + const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue; + const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); + const hours = Math.floor(progressInSeconds / 3600); + const minutes = Math.floor((progressInSeconds % 3600) / 60); + const seconds = progressInSeconds % 60; + setTime({ hours, minutes, seconds }); + }, + [isVlc], + ); + + useTVEventHandler((evt) => { + if (!evt) return; + + switch (evt.eventType) { + case "longLeft": { + setLongPressScrubMode((prev) => (!prev ? "RW" : null)); + break; + } + case "longRight": { + setLongPressScrubMode((prev) => (!prev ? "FF" : null)); + break; + } + case "left": + case "right": { + isRemoteScrubbing.value = true; + setShowRemoteBubble(true); + + const direction = evt.eventType === "left" ? -1 : 1; + const base = remoteScrubProgress.value ?? progress.value; + const updated = Math.max( + min.value, + Math.min(max.value, base + direction * SCRUB_INTERVAL), + ); + remoteScrubProgress.value = updated; + const progressInTicks = isVlc ? msToTicks(updated) : updated; + calculateTrickplayUrl(progressInTicks); + updateTime(updated); + break; + } + case "select": { + if (isRemoteScrubbing.value && remoteScrubProgress.value != null) { + progress.value = remoteScrubProgress.value; + + const seekTarget = isVlc + ? Math.max(0, remoteScrubProgress.value) + : Math.max(0, ticksToSeconds(remoteScrubProgress.value)); + + seek(seekTarget); + if (isPlaying) play(); + + isRemoteScrubbing.value = false; + remoteScrubProgress.value = null; + setShowRemoteBubble(false); + } else { + togglePlay(); + } + break; + } + case "down": + case "up": + // cancel scrubbing on other directions + isRemoteScrubbing.value = false; + remoteScrubProgress.value = null; + setShowRemoteBubble(false); + break; + default: + break; + } + + if (!showControls) toggleControls(); + }); + + useEffect(() => { + let isActive = true; + let seekTime = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK; + + const scrubWithLongPress = () => { + if (!isActive || !longPressScrubMode) return; + + setIsSliding(true); + const scrubFn = + longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward; + scrubFn(seekTime); + seekTime *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; + + longPressTimeoutRef.current = setTimeout( + scrubWithLongPress, + CONTROLS_CONSTANTS.LONG_PRESS_INTERVAL, + ); + }; + + if (longPressScrubMode) { + isActive = true; + scrubWithLongPress(); + } + + return () => { + isActive = false; + setIsSliding(false); + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + longPressTimeoutRef.current = null; + } + }; + }, [longPressScrubMode, handleSeekForward, handleSeekBackward]); + + return { + remoteScrubProgress, + isRemoteScrubbing, + showRemoteBubble, + longPressScrubMode, + isSliding, + time, + }; +} diff --git a/components/video-player/controls/hooks/useVideoNavigation.ts b/components/video-player/controls/hooks/useVideoNavigation.ts new file mode 100644 index 00000000..01058b99 --- /dev/null +++ b/components/video-player/controls/hooks/useVideoNavigation.ts @@ -0,0 +1,114 @@ +import { useCallback, useRef } from "react"; +import type { SharedValue } from "react-native-reanimated"; +import { useHaptic } from "@/hooks/useHaptic"; +import { useSettings } from "@/utils/atoms/settings"; +import { writeToLog } from "@/utils/log"; +import { secondsToMs, ticksToSeconds } from "@/utils/time"; + +interface UseVideoNavigationProps { + progress: SharedValue; + isPlaying: boolean; + isVlc: boolean; + seek: (value: number) => void; + play: () => void; +} + +export function useVideoNavigation({ + progress, + isPlaying, + isVlc, + seek, + play, +}: UseVideoNavigationProps) { + const [settings] = useSettings(); + const lightHapticFeedback = useHaptic("light"); + const wasPlayingRef = useRef(false); + + const handleSeekBackward = useCallback( + async (seconds: number) => { + wasPlayingRef.current = isPlaying; + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = isVlc + ? Math.max(0, curr - secondsToMs(seconds)) + : Math.max(0, ticksToSeconds(curr) - seconds); + seek(newTime); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, + [isPlaying, isVlc, seek, progress], + ); + + const handleSeekForward = useCallback( + async (seconds: number) => { + wasPlayingRef.current = isPlaying; + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = isVlc + ? curr + secondsToMs(seconds) + : ticksToSeconds(curr) + seconds; + seek(Math.max(0, newTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, + [isPlaying, isVlc, seek, progress], + ); + + const handleSkipBackward = useCallback(async () => { + if (!settings?.rewindSkipTime) { + return; + } + wasPlayingRef.current = isPlaying; + lightHapticFeedback(); + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = isVlc + ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) + : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); + seek(newTime); + if (wasPlayingRef.current) { + play(); + } + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]); + + const handleSkipForward = useCallback(async () => { + if (!settings?.forwardSkipTime) { + return; + } + wasPlayingRef.current = isPlaying; + lightHapticFeedback(); + try { + const curr = progress.value; + if (curr !== undefined) { + const newTime = isVlc + ? curr + secondsToMs(settings.forwardSkipTime) + : ticksToSeconds(curr) + settings.forwardSkipTime; + seek(Math.max(0, newTime)); + if (wasPlayingRef.current) { + play(); + } + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]); + + return { + handleSeekBackward, + handleSeekForward, + handleSkipBackward, + handleSkipForward, + wasPlayingRef, + }; +} diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts new file mode 100644 index 00000000..85072954 --- /dev/null +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -0,0 +1,99 @@ +import { debounce } from "lodash"; +import { useCallback, useRef, useState } from "react"; +import type { SharedValue } from "react-native-reanimated"; +import { msToTicks, ticksToSeconds } from "@/utils/time"; +import { CONTROLS_CONSTANTS } from "../constants"; + +interface UseVideoSliderProps { + progress: SharedValue; + isSeeking: SharedValue; + isPlaying: boolean; + isVlc: boolean; + seek: (value: number) => void; + play: () => void; + pause: () => void; + calculateTrickplayUrl: (progressInTicks: number) => void; + showControls: boolean; +} + +export function useVideoSlider({ + progress, + isSeeking, + isPlaying, + isVlc, + seek, + play, + pause, + calculateTrickplayUrl, + showControls, +}: UseVideoSliderProps) { + const [isSliding, setIsSliding] = useState(false); + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + const wasPlayingRef = useRef(false); + const lastProgressRef = useRef(0); + + const handleSliderStart = useCallback(() => { + if (!showControls) { + return; + } + + setIsSliding(true); + wasPlayingRef.current = isPlaying; + lastProgressRef.current = progress.value; + + pause(); + isSeeking.value = true; + }, [showControls, isPlaying, pause, progress, isSeeking]); + + const handleTouchStart = useCallback(() => { + if (!showControls) { + return; + } + }, [showControls]); + + const handleTouchEnd = useCallback(() => { + if (!showControls) { + return; + } + }, [showControls]); + + const handleSliderComplete = useCallback( + async (value: number) => { + setIsSliding(false); + isSeeking.value = false; + progress.value = value; + const seekValue = Math.max( + 0, + Math.floor(isVlc ? value : ticksToSeconds(value)), + ); + seek(seekValue); + if (wasPlayingRef.current) { + play(); + } + }, + [isVlc, seek, play, progress, isSeeking], + ); + + const handleSliderChange = useCallback( + debounce((value: number) => { + const progressInTicks = isVlc ? msToTicks(value) : value; + calculateTrickplayUrl(progressInTicks); + const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); + const hours = Math.floor(progressInSeconds / 3600); + const minutes = Math.floor((progressInSeconds % 3600) / 60); + const seconds = progressInSeconds % 60; + setTime({ hours, minutes, seconds }); + }, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS), + [isVlc, calculateTrickplayUrl], + ); + + return { + isSliding, + time, + handleSliderStart, + handleTouchStart, + handleTouchEnd, + handleSliderComplete, + handleSliderChange, + }; +} diff --git a/components/video-player/controls/hooks/useVideoTime.ts b/components/video-player/controls/hooks/useVideoTime.ts new file mode 100644 index 00000000..bb0fa77d --- /dev/null +++ b/components/video-player/controls/hooks/useVideoTime.ts @@ -0,0 +1,76 @@ +import { useCallback, useRef, useState } from "react"; +import { + runOnJS, + type SharedValue, + useAnimatedReaction, +} from "react-native-reanimated"; +import { ticksToSeconds } from "@/utils/time"; + +interface UseVideoTimeProps { + progress: SharedValue; + max: SharedValue; + isSeeking: SharedValue; + isVlc: boolean; +} + +export function useVideoTime({ + progress, + max, + isSeeking, + isVlc, +}: UseVideoTimeProps) { + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); + + const lastCurrentTimeRef = useRef(0); + const lastRemainingTimeRef = useRef(0); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); + const remaining = isVlc + ? maxValue - currentProgress + : ticksToSeconds(maxValue - currentProgress); + + // 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; + } + }, + [isVlc], + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + if (!result.isSeeking) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes], + ); + + return { + currentTime, + remainingTime, + }; +}