From 838940497588e19ee515d5bcc72a6917f1a519d2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 19 Aug 2025 09:02:56 +0200 Subject: [PATCH] chore: refactor controls (#946) Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com> --- components/video-player/controls/Controls.tsx | 1164 +++-------------- .../controls/components/BottomControls.tsx | 229 ++++ .../controls/components/CenterControls.tsx | 162 +++ .../controls/components/TopControlsBar.tsx | 166 +++ .../controls/components/TrickplayBubble.tsx | 94 ++ components/video-player/controls/constants.ts | 28 + .../controls/contexts/VideoContext.tsx | 2 - .../controls/hooks/useEpisodeNavigation.ts | 169 +++ .../controls/hooks/useRemoteControls.ts | 212 +++ .../controls/hooks/useSkipControls.ts | 75 ++ .../controls/hooks/useSliderInteractions.ts | 120 ++ .../controls/hooks/useTimeManagement.ts | 69 + .../controls/hooks/useVideoScaling.ts | 43 + .../controls/utils/progressUtils.ts | 14 + .../controls/utils/trickplayUtils.ts | 23 + 15 files changed, 1610 insertions(+), 960 deletions(-) create mode 100644 components/video-player/controls/components/BottomControls.tsx create mode 100644 components/video-player/controls/components/CenterControls.tsx create mode 100644 components/video-player/controls/components/TopControlsBar.tsx create mode 100644 components/video-player/controls/components/TrickplayBubble.tsx create mode 100644 components/video-player/controls/constants.ts create mode 100644 components/video-player/controls/hooks/useEpisodeNavigation.ts create mode 100644 components/video-player/controls/hooks/useRemoteControls.ts create mode 100644 components/video-player/controls/hooks/useSkipControls.ts create mode 100644 components/video-player/controls/hooks/useSliderInteractions.ts create mode 100644 components/video-player/controls/hooks/useTimeManagement.ts create mode 100644 components/video-player/controls/hooks/useVideoScaling.ts create mode 100644 components/video-player/controls/utils/progressUtils.ts create mode 100644 components/video-player/controls/utils/trickplayUtils.ts diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index c0d6c801..dbbe47e7 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 { useRouter } from "expo-router"; import { type Dispatch, type FC, @@ -13,59 +10,42 @@ 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 Animated, { - runOnJS, type SharedValue, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, } 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"; 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 { 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 { useSettings } from "@/utils/atoms/settings"; +import { BottomControls } from "./components/BottomControls"; +import { CenterControls } from "./components/CenterControls"; +// Extracted components +import { TopControlsBar } from "./components/TopControlsBar"; +// Constants and utilities +import { ANIMATION_DURATION, CONTROLS_TIMEOUT } 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 { useEpisodeNavigation } from "./hooks/useEpisodeNavigation"; +// Extracted hooks +import { useRemoteControls } from "./hooks/useRemoteControls"; +import { useSkipControls } from "./hooks/useSkipControls"; +import { useSliderInteractions } from "./hooks/useSliderInteractions"; +import { useTimeManagement } from "./hooks/useTimeManagement"; +import { useVideoScaling } from "./hooks/useVideoScaling"; +import { type ScaleFactor } from "./ScaleFactorSelector"; import { useControlsTimeout } from "./useControlsTimeout"; -import { - type AspectRatio, - AspectRatioSelector, -} from "./VideoScalingModeSelector"; +import { initializeProgress } from "./utils/progressUtils"; +import { type AspectRatio } from "./VideoScalingModeSelector"; import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { @@ -102,8 +82,6 @@ interface Props { isVlc?: boolean; } -const CONTROLS_TIMEOUT = 4000; - export const Controls: FC = ({ item, seek, @@ -134,223 +112,140 @@ export const Controls: FC = ({ offline = false, isVlc = false, }) => { - const [settings, updateSettings] = useSettings(); + const [settings] = useSettings(); const router = useRouter(); - const insets = useSafeAreaInsets(); + const lightHapticFeedback = useHaptic("light"); + // Local state 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(); - const { previousItem, nextItem } = usePlaybackManager({ - item, - isOffline: offline, - }); - - const { - trickPlayUrl, - calculateTrickplayUrl, - trickplayInfo, - prefetchAllTrickplayImages, - } = useTrickplay(item); - - const [currentTime, setCurrentTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); + // Initialize progress values const min = useSharedValue(0); const max = useSharedValue(item.RunTimeTicks || 0); // Animated opacity for smooth transitions const controlsOpacity = useSharedValue(showControls ? 1 : 0); - // Animated scale for slider - const sliderScale = useSharedValue(1); + // Trickplay + const { trickPlayUrl, trickplayInfo, prefetchAllTrickplayImages } = + useTrickplay(item); - const wasPlayingRef = useRef(false); - const lastProgressRef = useRef(0); + // Initialize progress on item change + useEffect(() => { + if (item) { + const { initialProgress, maxProgress } = initializeProgress(item, isVlc); + progress.value = initialProgress; + max.value = maxProgress; + } + }, [item, isVlc, progress, max]); - const lightHapticFeedback = useHaptic("light"); + // Prefetch trickplay images + useEffect(() => { + prefetchAllTrickplayImages(); + }, [prefetchAllTrickplayImages]); - // Animate controls opacity when showControls changes + // Animate controls opacity useEffect(() => { controlsOpacity.value = withTiming(showControls ? 1 : 0, { - duration: 300, + duration: ANIMATION_DURATION.CONTROLS_FADE, }); }, [showControls, controlsOpacity]); - // Animated styles for controls - const animatedControlsStyle = useAnimatedStyle(() => { - return { - opacity: controlsOpacity.value, - }; + // Animated styles + const animatedControlsStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + })); + + const animatedOverlayStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value * 0.75, + })); + + // Extracted hooks + const { currentTime, remainingTime, getEndTime } = useTimeManagement({ + progress, + max, + isSeeking, + isVlc, }); - // Animated style for black overlay (75% opacity when visible) - const animatedOverlayStyle = useAnimatedStyle(() => { - return { - opacity: controlsOpacity.value * 0.75, - }; + const { + isSliding, + time, + sliderScale, + handleSliderStart, + handleTouchStart, + handleTouchEnd, + handleSliderComplete, + handleSliderChange, + } = useSliderInteractions({ + progress, + isSeeking, + isPlaying, + isVlc, + showControls, + item, + seek, + play, + pause, }); - // Animated style for slider scale - const animatedSliderStyle = useAnimatedStyle(() => { - return { - transform: [{ scaleY: sliderScale.value }], - }; + const { + previousItem, + nextItem, + goToItemCommon, + goToPreviousItem, + handleNextEpisodeAutoPlay, + handleNextEpisodeManual, + handleContinueWatching, + } = useEpisodeNavigation({ + item, + offline, + mediaSource, }); - useEffect(() => { - prefetchAllTrickplayImages(); - }, []); + const { handleAspectRatioChange, handleScaleFactorChange } = useVideoScaling({ + setAspectRatio, + setScaleFactor, + setVideoAspectRatio, + setVideoScaleFactor, + }); - const remoteScrubProgress = useSharedValue(null); - const isRemoteScrubbing = useSharedValue(false); - const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10)); - const [showRemoteBubble, setShowRemoteBubble] = useState(false); + const { handleSkipBackward, handleSkipForward } = useSkipControls({ + progress, + isPlaying, + isVlc, + seek, + play, + }); - 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; + // Helper functions + const toggleControls = useCallback(() => { + if (showControls) { + setShowAudioSlider(false); + setShowControls(false); + } else { + setShowControls(true); } + }, [showControls, setShowControls]); - if (!showControls) toggleControls(); + const { showRemoteBubble, time: remoteTime } = useRemoteControls({ + progress, + min, + max, + isVlc, + showControls, + isPlaying, + item, + seek, + play, + togglePlay, + toggleControls, }); - const longPressTimeoutRef = useRef | null>( - null, - ); - - 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(); - } - - return () => { - isActive = false; - setIsSliding(false); - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - longPressTimeoutRef.current = null; - } - }; - }, [longPressScrubMode]); - - const effectiveProgress = useSharedValue(0); - - // Recompute progress whenever remote scrubbing is active - useAnimatedReaction( - () => ({ - isScrubbing: isRemoteScrubbing.value, - scrub: remoteScrubProgress.value, - actual: progress.value, - }), - (current) => { - effectiveProgress.value = - current.isScrubbing && current.scrub != null - ? current.scrub - : current.actual; - }, - [], - ); - - 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; - subtitleIndex: string; - }>(); - + // Skip intro/credits const { showSkipButton, skipIntro } = useIntroSkipper( item?.Id!, currentTime, @@ -369,154 +264,11 @@ export const Controls: FC = ({ offline, ); - const goToItemCommon = useCallback( - (item: BaseItemDto) => { - if (!item || !settings) { - return; - } - lightHapticFeedback(); - const previousIndexes = { - subtitleIndex: subtitleIndex - ? Number.parseInt(subtitleIndex, 10) - : undefined, - audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, - }; - - const { - mediaSource: newMediaSource, - audioIndex: defaultAudioIndex, - subtitleIndex: defaultSubtitleIndex, - } = getDefaultPlaySettings( - item, - settings, - previousIndexes, - mediaSource ?? undefined, - ); - - const queryParams = new URLSearchParams({ - ...(offline && { offline: "true" }), - itemId: item.Id ?? "", - audioIndex: defaultAudioIndex?.toString() ?? "", - subtitleIndex: defaultSubtitleIndex?.toString() ?? "", - mediaSourceId: newMediaSource?.Id ?? "", - bitrateValue: bitrateValue?.toString(), - playbackPosition: - item.UserData?.PlaybackPositionTicks?.toString() ?? "", - }).toString(); - - console.log("queryParams", queryParams); - - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - }, - [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], - ); - - const goToPreviousItem = useCallback(() => { - if (!previousItem) { - return; - } - goToItemCommon(previousItem); - }, [previousItem, goToItemCommon]); - - const goToNextItem = useCallback( - ({ - isAutoPlay, - resetWatchCount, - }: { - isAutoPlay?: boolean; - resetWatchCount?: boolean; - }) => { - if (!nextItem) { - return; - } - - if (!isAutoPlay) { - // if we are not autoplaying, we won't update anything, we just go to the next item - goToItemCommon(nextItem); - if (resetWatchCount) { - updateSettings({ - autoPlayEpisodeCount: 0, - }); - } - return; - } - - // Skip autoplay logic if maxAutoPlayEpisodeCount is -1 - if (settings.maxAutoPlayEpisodeCount.value === -1) { - goToItemCommon(nextItem); - return; - } - - if ( - settings.autoPlayEpisodeCount + 1 < - settings.maxAutoPlayEpisodeCount.value - ) { - goToItemCommon(nextItem); - } - - // Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay - if ( - settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value - ) { - // update the autoPlayEpisodeCount in settings - updateSettings({ - autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1, - }); - } - }, - [nextItem, goToItemCommon], - ); - - // Add a memoized handler for autoplay next episode - const handleNextEpisodeAutoPlay = useCallback(() => { - goToNextItem({ isAutoPlay: true }); - }, [goToNextItem]); - - // Add a memoized handler for manual next episode - const handleNextEpisodeManual = useCallback(() => { - goToNextItem({ isAutoPlay: false }); - }, [goToNextItem]); - - // Add a memoized handler for ContinueWatchingOverlay - const handleContinueWatching = useCallback( - (options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => { - goToNextItem(options); - }, - [goToNextItem], - ); - - const updateTimes = useCallback( - (currentProgress: number, maxValue: number) => { - const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); - const remaining = isVlc - ? maxValue - currentProgress - : ticksToSeconds(maxValue - currentProgress); - - setCurrentTime(current); - setRemainingTime(remaining); - }, - [goToNextItem, isVlc], - ); - - useAnimatedReaction( - () => ({ - progress: progress.value, - max: max.value, - isSeeking: isSeeking.value, - }), - (result) => { - if (!result.isSeeking) { - runOnJS(updateTimes)(result.progress, result.max); - } - }, - [updateTimes], - ); - + // Controls timeout const hideControls = useCallback(() => { setShowControls(false); setShowAudioSlider(false); - }, []); + }, [setShowControls]); const { handleControlsInteraction } = useControlsTimeout({ showControls, @@ -526,179 +278,22 @@ export const Controls: FC = ({ timeout: CONTROLS_TIMEOUT, }); - const toggleControls = () => { - if (showControls) { - setShowAudioSlider(false); - setShowControls(false); - } else { - setShowControls(true); - } - }; + // Effective progress calculation + const effectiveProgress = useSharedValue(0); - 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; - } - - // Scale up the slider immediately on touch - sliderScale.value = withTiming(1.4, { duration: 300 }); - }, [showControls]); - - const handleTouchEnd = useCallback(() => { - if (!showControls) { - return; - } - - // Scale down the slider on touch end (only if not sliding, to avoid conflict with onSlidingComplete) - if (!isSliding) { - sliderScale.value = withTiming(1.0, { duration: 300 }); - } - }, [showControls, isSliding]); - - const handleSliderComplete = useCallback( - async (value: number) => { - isSeeking.value = false; - progress.value = value; - setIsSliding(false); - - // Scale down the slider - sliderScale.value = withTiming(1.0, { duration: 200 }); - - seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))); - if (wasPlayingRef.current) { - play(); - } + // For remote scrubbing, we'll need to adapt this - for now using the basic progress + useAnimatedReaction( + () => progress.value, + (value) => { + effectiveProgress.value = value; }, - [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], - ); + // Animated style for slider scale + const animatedSliderStyle = useAnimatedStyle(() => ({ + transform: [{ scaleY: sliderScale.value }], + })); const switchOnEpisodeMode = useCallback(() => { setEpisodeView(true); @@ -707,72 +302,10 @@ 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 () => { + const onClose = useCallback(async () => { lightHapticFeedback(); router.back(); - }; + }, [lightHapticFeedback, router]); return ( = ({ onToggleControls={toggleControls} animatedStyle={animatedOverlayStyle} /> - - - {!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 */} - - - - - - - + handleNextEpisodeManual()} + onClose={onClose} + /> - - {/* Brightness Control */} - - - + - {/* Skip Backward */} - {!Platform.isTV && ( - - - - - {settings?.rewindSkipTime} - - - - )} - - {/* Play/Pause Button */} - - { - togglePlay(); - }} - > - {!isBuffering ? ( - - ) : ( - - )} - - - - {/* Skip Forward */} - {!Platform.isTV && ( - - - - - {settings?.forwardSkipTime} - - - - )} - - {/* Volume/Audio Control */} - - - - - - - - - {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/components/BottomControls.tsx b/components/video-player/controls/components/BottomControls.tsx new file mode 100644 index 00000000..feedb270 --- /dev/null +++ b/components/video-player/controls/components/BottomControls.tsx @@ -0,0 +1,229 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import Animated, { 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 { formatTimeString } from "@/utils/time"; +import { SLIDER_CONFIG, SLIDER_THEME } from "../constants"; +import NextEpisodeCountDownButton from "../NextEpisodeCountDownButton"; +import SkipButton from "../SkipButton"; +import { TrickplayBubble } from "./TrickplayBubble"; + +interface BottomControlsProps { + item: BaseItemDto; + showControls: boolean; + isSliding: boolean; + showRemoteBubble: boolean; + currentTime: number; + remainingTime: number; + isVlc: boolean; + nextItem?: BaseItemDto; + showSkipButton: boolean; + showSkipCreditButton: boolean; + cacheProgress: SharedValue; + min: SharedValue; + max: SharedValue; + effectiveProgress: SharedValue; + animatedControlsStyle: any; + animatedSliderStyle: any; + trickPlayUrl?: { + x: number; + y: number; + url: string; + }; + trickplayInfo?: { + aspectRatio: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + }; + time: { + hours: number; + minutes: number; + seconds: number; + }; + getEndTime: () => string; + onControlsInteraction: () => void; + onTouchStart: () => void; + onTouchEnd: () => void; + onSliderStart: () => void; + onSliderComplete: (value: number) => void; + onSliderChange: (value: number) => void; + onSkipIntro: () => void; + onSkipCredit: () => void; + onNextEpisodeAutoPlay: () => void; + onNextEpisodeManual: () => void; +} + +export const BottomControls: React.FC = ({ + item, + showControls, + isSliding, + showRemoteBubble, + currentTime, + remainingTime, + isVlc, + nextItem, + showSkipButton, + showSkipCreditButton, + cacheProgress, + min, + max, + effectiveProgress, + animatedControlsStyle, + animatedSliderStyle, + trickPlayUrl, + trickplayInfo, + time, + getEndTime, + onControlsInteraction, + onTouchStart, + onTouchEnd, + onSliderStart, + onSliderComplete, + onSliderChange, + onSkipIntro, + onSkipCredit, + onNextEpisodeAutoPlay, + onNextEpisodeManual, +}) => { + const [settings] = useSettings(); + const insets = useSafeAreaInsets(); + + const renderTrickplayBubble = () => ( + + ); + + 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={onSliderStart} + onSlidingComplete={onSliderComplete} + onValueChange={onSliderChange} + containerStyle={{ + borderRadius: SLIDER_CONFIG.BORDER_RADIUS, + }} + renderBubble={() => + (isSliding || showRemoteBubble) && renderTrickplayBubble() + } + sliderHeight={SLIDER_CONFIG.HEIGHT} + thumbWidth={SLIDER_CONFIG.THUMB_WIDTH} + progress={effectiveProgress} + minimumValue={min} + maximumValue={max} + /> + + + + + {formatTimeString(currentTime, isVlc ? "ms" : "s")} + + + + -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} + + + ends at {getEndTime()} + + + + + + + ); +}; diff --git a/components/video-player/controls/components/CenterControls.tsx b/components/video-player/controls/components/CenterControls.tsx new file mode 100644 index 00000000..b9d12bdb --- /dev/null +++ b/components/video-player/controls/components/CenterControls.tsx @@ -0,0 +1,162 @@ +import { Ionicons } from "@expo/vector-icons"; +import React from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; +import Animated from "react-native-reanimated"; +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"; + +interface CenterControlsProps { + showControls: boolean; + showAudioSlider: boolean; + isPlaying: boolean; + isBuffering: boolean; + rewindSkipTime?: number; + forwardSkipTime?: number; + animatedControlsStyle: any; + setShowAudioSlider: (show: boolean) => void; + onTogglePlay: () => void; + onSkipBackward: () => void; + onSkipForward: () => void; +} + +export const CenterControls: React.FC = ({ + showControls, + showAudioSlider, + isPlaying, + isBuffering, + rewindSkipTime, + forwardSkipTime, + animatedControlsStyle, + setShowAudioSlider, + onTogglePlay, + onSkipBackward, + onSkipForward, +}) => { + const [settings] = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + {/* Brightness Control */} + + + + + {/* Skip Backward */} + {!Platform.isTV && ( + + + + + {rewindSkipTime} + + + + )} + + {/* Play/Pause Button */} + + + {!isBuffering ? ( + + ) : ( + + )} + + + + {/* Skip Forward */} + {!Platform.isTV && ( + + + + + {forwardSkipTime} + + + + )} + + {/* Volume/Audio Control */} + + + + + ); +}; diff --git a/components/video-player/controls/components/TopControlsBar.tsx b/components/video-player/controls/components/TopControlsBar.tsx new file mode 100644 index 00000000..7d4bd31b --- /dev/null +++ b/components/video-player/controls/components/TopControlsBar.tsx @@ -0,0 +1,166 @@ +import { Ionicons, MaterialIcons } from "@expo/vector-icons"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import React from "react"; +import { Platform, TouchableOpacity, View } from "react-native"; +import Animated from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { TrackInfo } from "@/modules/VlcPlayer.types"; +import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; +import { VideoProvider } from "../contexts/VideoContext"; +import DropdownView from "../dropdown/DropdownView"; +import { type ScaleFactor, ScaleFactorSelector } from "../ScaleFactorSelector"; +import { + type AspectRatio, + AspectRatioSelector, +} from "../VideoScalingModeSelector"; + +interface TopControlsBarProps { + item: BaseItemDto; + mediaSource?: MediaSourceInfo | null; + offline: boolean; + showControls: boolean; + aspectRatio: AspectRatio; + scaleFactor: ScaleFactor; + previousItem?: BaseItemDto; + nextItem?: BaseItemDto; + animatedControlsStyle: any; + screenWidth: number; + getAudioTracks?: (() => Promise) | (() => TrackInfo[]); + getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); + setSubtitleURL?: (url: string, customName: string) => void; + setSubtitleTrack?: (index: number) => void; + setAudioTrack?: (index: number) => void; + setVideoAspectRatio?: (aspectRatio: string | null) => Promise; + setVideoScaleFactor?: (scaleFactor: number) => Promise; + startPictureInPicture?: () => Promise; + onAspectRatioChange: (ratio: AspectRatio) => void; + onScaleFactorChange: (scale: ScaleFactor) => void; + onEpisodeModeToggle: () => void; + onGoToPreviousItem: () => void; + onGoToNextItem: () => void; + onClose: () => void; +} + +export const TopControlsBar: React.FC = ({ + item, + mediaSource, + offline, + showControls, + aspectRatio, + scaleFactor, + previousItem, + nextItem, + animatedControlsStyle, + screenWidth, + getAudioTracks, + getSubtitleTracks, + setSubtitleURL, + setSubtitleTrack, + setAudioTrack, + setVideoAspectRatio, + setVideoScaleFactor, + startPictureInPicture, + onAspectRatioChange, + onScaleFactorChange, + onEpisodeModeToggle, + onGoToPreviousItem, + onGoToNextItem, + onClose, +}) => { + const [settings] = useSettings(); + const insets = useSafeAreaInsets(); + + return ( + + + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( + + + + )} + + + + {!Platform.isTV && + (settings.defaultPlayer === VideoPlayer.VLC_4 || + Platform.OS === "android") && ( + + + + )} + {item?.Type === "Episode" && ( + + + + )} + {previousItem && ( + + + + )} + {nextItem && ( + + + + )} + {/* Video Controls */} + + + + + + + + ); +}; diff --git a/components/video-player/controls/components/TrickplayBubble.tsx b/components/video-player/controls/components/TrickplayBubble.tsx new file mode 100644 index 00000000..aa8746a0 --- /dev/null +++ b/components/video-player/controls/components/TrickplayBubble.tsx @@ -0,0 +1,94 @@ +import { Image } from "expo-image"; +import React from "react"; +import { View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { + calculateTrickplayDimensions, + formatTimeForBubble, +} from "../utils/trickplayUtils"; + +interface TrickplayBubbleProps { + trickPlayUrl?: { + x: number; + y: number; + url: string; + }; + trickplayInfo?: { + aspectRatio: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + }; + time: { + hours: number; + minutes: number; + seconds: number; + }; +} + +export const TrickplayBubble: React.FC = ({ + trickPlayUrl, + trickplayInfo, + time, +}) => { + if (!trickPlayUrl || !trickplayInfo) { + return null; + } + + const { x, y, url } = trickPlayUrl; + const { tileWidth, tileHeight, scaledWidth } = calculateTrickplayDimensions( + trickplayInfo.aspectRatio, + ); + + return ( + + + + + + {formatTimeForBubble(time)} + + + ); +}; diff --git a/components/video-player/controls/constants.ts b/components/video-player/controls/constants.ts new file mode 100644 index 00000000..ddaf15a3 --- /dev/null +++ b/components/video-player/controls/constants.ts @@ -0,0 +1,28 @@ +export const CONTROLS_TIMEOUT = 4000; + +export const TRICKPLAY_TILE_WIDTH = 150; +export const TRICKPLAY_TILE_SCALE = 1.4; + +export const SLIDER_SCALE_UP = 1.4; +export const SLIDER_SCALE_NORMAL = 1.0; + +export const ANIMATION_DURATION = { + CONTROLS_FADE: 300, + SLIDER_SCALE: 300, + SLIDER_SCALE_COMPLETE: 200, +} as const; + +export const SLIDER_CONFIG = { + HEIGHT: 10, + THUMB_WIDTH: 0, + BORDER_RADIUS: 100, +} as const; + +export const SLIDER_THEME = { + maximumTrackTintColor: "rgba(255,255,255,0.2)", + minimumTrackTintColor: "#fff", + cacheTrackTintColor: "rgba(255,255,255,0.3)", + bubbleBackgroundColor: "#fff", + bubbleTextColor: "#666", + heartbeatColor: "#999", +} as const; diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index 3940d520..351457f0 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -85,7 +85,6 @@ export const VideoProvider: React.FC = ({ chosenAudioIndex?: string; chosenSubtitleIndex?: string; }) => { - console.log("chosenSubtitleIndex", chosenSubtitleIndex); const queryParams = new URLSearchParams({ itemId: itemId ?? "", audioIndex: chosenAudioIndex, @@ -115,7 +114,6 @@ export const VideoProvider: React.FC = ({ mediaSource?.TranscodingUrl && !onTextBasedSubtitle; - console.log("Set player params", index, serverIndex); if (shouldChangePlayerParams) { setPlayerParams({ chosenSubtitleIndex: serverIndex.toString(), diff --git a/components/video-player/controls/hooks/useEpisodeNavigation.ts b/components/video-player/controls/hooks/useEpisodeNavigation.ts new file mode 100644 index 00000000..fcc43f07 --- /dev/null +++ b/components/video-player/controls/hooks/useEpisodeNavigation.ts @@ -0,0 +1,169 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback } from "react"; +import { useHaptic } from "@/hooks/useHaptic"; +import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import { useSettings } from "@/utils/atoms/settings"; +import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; + +interface UseEpisodeNavigationProps { + item: BaseItemDto; + offline: boolean; + mediaSource?: MediaSourceInfo | null; +} + +export const useEpisodeNavigation = ({ + item, + offline, + mediaSource, +}: UseEpisodeNavigationProps) => { + const [settings, updateSettings] = useSettings(); + const router = useRouter(); + const lightHapticFeedback = useHaptic("light"); + + const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{ + bitrateValue: string; + audioIndex: string; + subtitleIndex: string; + }>(); + + const { previousItem, nextItem } = usePlaybackManager({ + item, + isOffline: offline, + }); + + const goToItemCommon = useCallback( + (item: BaseItemDto) => { + if (!item || !settings) { + return; + } + lightHapticFeedback(); + const previousIndexes = { + subtitleIndex: subtitleIndex + ? Number.parseInt(subtitleIndex, 10) + : undefined, + audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, + }; + + const { + mediaSource: newMediaSource, + audioIndex: defaultAudioIndex, + subtitleIndex: defaultSubtitleIndex, + } = getDefaultPlaySettings( + item, + settings, + previousIndexes, + mediaSource ?? undefined, + ); + + const queryParams = new URLSearchParams({ + ...(offline && { offline: "true" }), + itemId: item.Id ?? "", + audioIndex: defaultAudioIndex?.toString() ?? "", + subtitleIndex: defaultSubtitleIndex?.toString() ?? "", + mediaSourceId: newMediaSource?.Id ?? "", + bitrateValue: bitrateValue?.toString(), + playbackPosition: + item.UserData?.PlaybackPositionTicks?.toString() ?? "", + }).toString(); + + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); + }, + [ + settings, + subtitleIndex, + audioIndex, + mediaSource, + bitrateValue, + router, + lightHapticFeedback, + ], + ); + + const goToPreviousItem = useCallback(() => { + if (!previousItem) { + return; + } + goToItemCommon(previousItem); + }, [previousItem, goToItemCommon]); + + const goToNextItem = useCallback( + ({ + isAutoPlay, + resetWatchCount, + }: { + isAutoPlay?: boolean; + resetWatchCount?: boolean; + }) => { + if (!nextItem) { + return; + } + + if (!isAutoPlay) { + // if we are not autoplaying, we won't update anything, we just go to the next item + goToItemCommon(nextItem); + if (resetWatchCount) { + updateSettings({ + autoPlayEpisodeCount: 0, + }); + } + return; + } + + // Skip autoplay logic if maxAutoPlayEpisodeCount is -1 + if (settings.maxAutoPlayEpisodeCount.value === -1) { + goToItemCommon(nextItem); + return; + } + + if ( + settings.autoPlayEpisodeCount + 1 < + settings.maxAutoPlayEpisodeCount.value + ) { + goToItemCommon(nextItem); + } + + // Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay + if ( + settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value + ) { + // update the autoPlayEpisodeCount in settings + updateSettings({ + autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1, + }); + } + }, + [nextItem, goToItemCommon, settings, updateSettings], + ); + + // Memoized handlers + const handleNextEpisodeAutoPlay = useCallback(() => { + goToNextItem({ isAutoPlay: true }); + }, [goToNextItem]); + + const handleNextEpisodeManual = useCallback(() => { + goToNextItem({ isAutoPlay: false }); + }, [goToNextItem]); + + const handleContinueWatching = useCallback( + (options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => { + goToNextItem(options); + }, + [goToNextItem], + ); + + return { + previousItem, + nextItem, + goToItemCommon, + goToPreviousItem, + goToNextItem, + handleNextEpisodeAutoPlay, + handleNextEpisodeManual, + handleContinueWatching, + }; +}; diff --git a/components/video-player/controls/hooks/useRemoteControls.ts b/components/video-player/controls/hooks/useRemoteControls.ts new file mode 100644 index 00000000..8b465468 --- /dev/null +++ b/components/video-player/controls/hooks/useRemoteControls.ts @@ -0,0 +1,212 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useEffect, useRef, useState } from "react"; +import { useTVEventHandler } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import { msToTicks, secondsToMs, ticksToSeconds } from "@/utils/time"; + +interface UseRemoteControlsProps { + progress: SharedValue; + min: SharedValue; + max: SharedValue; + isVlc: boolean; + showControls: boolean; + isPlaying: boolean; + item: BaseItemDto; + seek: (ticks: number) => void; + play: () => void; + togglePlay: () => void; + toggleControls: () => void; +} + +export const useRemoteControls = ({ + progress, + min, + max, + isVlc, + showControls, + isPlaying, + item, + seek, + play, + togglePlay, + toggleControls, +}: UseRemoteControlsProps) => { + const { calculateTrickplayUrl } = useTrickplay(item); + + const remoteScrubProgress = useRef>(null); + const isRemoteScrubbing = useRef>(null); + const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10)); + const [showRemoteBubble, setShowRemoteBubble] = useState(false); + const [longPressScrubMode, setLongPressScrubMode] = useState< + "FF" | "RW" | null + >(null); + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + + const longPressTimeoutRef = useRef | null>( + null, + ); + + // Initialize shared values if not provided + if (!remoteScrubProgress.current) { + remoteScrubProgress.current = { value: null } as SharedValue; + } + if (!isRemoteScrubbing.current) { + isRemoteScrubbing.current = { value: false } as SharedValue; + } + + 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.current!.value = true; + setShowRemoteBubble(true); + + const direction = evt.eventType === "left" ? -1 : 1; + const base = remoteScrubProgress.current!.value ?? progress.value; + const updated = Math.max( + min.value, + Math.min(max.value, base + direction * SCRUB_INTERVAL), + ); + remoteScrubProgress.current!.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.current!.value && + remoteScrubProgress.current!.value != null + ) { + progress.value = remoteScrubProgress.current!.value; + + const seekTarget = isVlc + ? Math.max(0, remoteScrubProgress.current!.value) + : Math.max(0, ticksToSeconds(remoteScrubProgress.current!.value)); + + seek(seekTarget); + if (isPlaying) play(); + + isRemoteScrubbing.current!.value = false; + remoteScrubProgress.current!.value = null; + setShowRemoteBubble(false); + } else { + togglePlay(); + } + break; + } + case "down": + case "up": + // cancel scrubbing on other directions + isRemoteScrubbing.current!.value = false; + remoteScrubProgress.current!.value = null; + setShowRemoteBubble(false); + break; + default: + break; + } + + if (!showControls) toggleControls(); + }); + + const handleSeekBackward = ( + seconds: number, + wasPlayingRef: React.MutableRefObject, + ) => { + 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) { + console.error("Error seeking video backwards", error); + } + }; + + const handleSeekForward = ( + seconds: number, + wasPlayingRef: React.MutableRefObject, + ) => { + 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) { + console.error("Error seeking video forwards", error); + } + }; + + // Long press scrubbing effect + useEffect(() => { + let isActive = true; + let seekTime = 10; + + if (longPressScrubMode) { + // Function is used, but eslint doesn't detect it inside setTimeout + const scrubWithLongPress = ( + wasPlayingRef: React.MutableRefObject, + ) => { + if (!isActive || !longPressScrubMode) return; + + const scrubFn = + longPressScrubMode === "FF" + ? (time: number) => handleSeekForward(time, wasPlayingRef) + : (time: number) => handleSeekBackward(time, wasPlayingRef); + + scrubFn(seekTime); + seekTime *= 1.1; + + longPressTimeoutRef.current = setTimeout( + () => scrubWithLongPress(wasPlayingRef), + 300, + ); + }; + + // Start the scrubbing + const wasPlayingRef = { current: isPlaying }; + scrubWithLongPress(wasPlayingRef); + } + + return () => { + isActive = false; + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + longPressTimeoutRef.current = null; + } + }; + }, [longPressScrubMode, handleSeekBackward, handleSeekForward, isPlaying]); + + return { + remoteScrubProgress: remoteScrubProgress.current, + isRemoteScrubbing: isRemoteScrubbing.current, + showRemoteBubble, + longPressScrubMode, + time, + handleSeekBackward, + handleSeekForward, + }; +}; diff --git a/components/video-player/controls/hooks/useSkipControls.ts b/components/video-player/controls/hooks/useSkipControls.ts new file mode 100644 index 00000000..3c883636 --- /dev/null +++ b/components/video-player/controls/hooks/useSkipControls.ts @@ -0,0 +1,75 @@ +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 UseSkipControlsProps { + progress: SharedValue; + isPlaying: boolean; + isVlc: boolean; + seek: (ticks: number) => void; + play: () => void; +} + +export const useSkipControls = ({ + progress, + isPlaying, + isVlc, + seek, + play, +}: UseSkipControlsProps) => { + const [settings] = useSettings(); + const lightHapticFeedback = useHaptic("light"); + const wasPlayingRef = useRef(false); + + 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, 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, lightHapticFeedback]); + + return { + handleSkipBackward, + handleSkipForward, + }; +}; diff --git a/components/video-player/controls/hooks/useSliderInteractions.ts b/components/video-player/controls/hooks/useSliderInteractions.ts new file mode 100644 index 00000000..bcdd845b --- /dev/null +++ b/components/video-player/controls/hooks/useSliderInteractions.ts @@ -0,0 +1,120 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { debounce } from "lodash"; +import { useCallback, useRef, useState } from "react"; +import { + type SharedValue, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useTrickplay } from "@/hooks/useTrickplay"; +import { msToTicks, ticksToSeconds } from "@/utils/time"; + +interface UseSliderInteractionsProps { + progress: SharedValue; + isSeeking: SharedValue; + isPlaying: boolean; + isVlc: boolean; + showControls: boolean; + item: BaseItemDto; + seek: (ticks: number) => void; + play: () => void; + pause: () => void; +} + +export const useSliderInteractions = ({ + progress, + isSeeking, + isPlaying, + isVlc, + showControls, + item, + seek, + play, + pause, +}: UseSliderInteractionsProps) => { + const [isSliding, setIsSliding] = useState(false); + const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); + + const wasPlayingRef = useRef(false); + const lastProgressRef = useRef(0); + + // Animated scale for slider + const sliderScale = useSharedValue(1); + + const { calculateTrickplayUrl } = useTrickplay(item); + + 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; + } + + // Scale up the slider immediately on touch + sliderScale.value = withTiming(1.4, { duration: 300 }); + }, [showControls]); + + const handleTouchEnd = useCallback(() => { + if (!showControls) { + return; + } + + // Scale down the slider on touch end (only if not sliding, to avoid conflict with onSlidingComplete) + if (!isSliding) { + sliderScale.value = withTiming(1.0, { duration: 300 }); + } + }, [showControls, isSliding]); + + const handleSliderComplete = useCallback( + async (value: number) => { + isSeeking.value = false; + progress.value = value; + setIsSliding(false); + + // Scale down the slider + sliderScale.value = withTiming(1.0, { duration: 200 }); + + seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))); + if (wasPlayingRef.current) { + play(); + } + }, + [isVlc, seek, play], + ); + + 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), + [isVlc, calculateTrickplayUrl], + ); + + return { + isSliding, + setIsSliding, + time, + sliderScale, + handleSliderStart, + handleTouchStart, + handleTouchEnd, + handleSliderComplete, + handleSliderChange, + }; +}; diff --git a/components/video-player/controls/hooks/useTimeManagement.ts b/components/video-player/controls/hooks/useTimeManagement.ts new file mode 100644 index 00000000..a0499d2c --- /dev/null +++ b/components/video-player/controls/hooks/useTimeManagement.ts @@ -0,0 +1,69 @@ +import { useCallback, useState } from "react"; +import { + runOnJS, + type SharedValue, + useAnimatedReaction, +} from "react-native-reanimated"; +import { ticksToSeconds } from "@/utils/time"; + +interface UseTimeManagementProps { + progress: SharedValue; + max: SharedValue; + isSeeking: SharedValue; + isVlc: boolean; +} + +export const useTimeManagement = ({ + progress, + max, + isSeeking, + isVlc, +}: UseTimeManagementProps) => { + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); + const remaining = isVlc + ? maxValue - currentProgress + : ticksToSeconds(maxValue - currentProgress); + + setCurrentTime(current); + setRemainingTime(remaining); + }, + [isVlc], + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + if (!result.isSeeking) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes], + ); + + const getEndTime = () => { + 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 { + currentTime, + remainingTime, + updateTimes, + getEndTime, + }; +}; diff --git a/components/video-player/controls/hooks/useVideoScaling.ts b/components/video-player/controls/hooks/useVideoScaling.ts new file mode 100644 index 00000000..505f04de --- /dev/null +++ b/components/video-player/controls/hooks/useVideoScaling.ts @@ -0,0 +1,43 @@ +import { type Dispatch, type SetStateAction, useCallback } from "react"; +import type { ScaleFactor } from "../ScaleFactorSelector"; +import type { AspectRatio } from "../VideoScalingModeSelector"; + +interface UseVideoScalingProps { + setAspectRatio?: Dispatch>; + setScaleFactor?: Dispatch>; + setVideoAspectRatio?: (aspectRatio: string | null) => Promise; + setVideoScaleFactor?: (scaleFactor: number) => Promise; +} + +export const useVideoScaling = ({ + setAspectRatio, + setScaleFactor, + setVideoAspectRatio, + setVideoScaleFactor, +}: UseVideoScalingProps) => { + 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], + ); + + return { + handleAspectRatioChange, + handleScaleFactorChange, + }; +}; diff --git a/components/video-player/controls/utils/progressUtils.ts b/components/video-player/controls/utils/progressUtils.ts new file mode 100644 index 00000000..a043893f --- /dev/null +++ b/components/video-player/controls/utils/progressUtils.ts @@ -0,0 +1,14 @@ +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { ticksToMs } from "@/utils/time"; + +export const initializeProgress = (item: BaseItemDto, isVlc: boolean) => { + const initialProgress = isVlc + ? ticksToMs(item?.UserData?.PlaybackPositionTicks) + : item?.UserData?.PlaybackPositionTicks || 0; + + const maxProgress = isVlc + ? ticksToMs(item.RunTimeTicks || 0) + : item.RunTimeTicks || 0; + + return { initialProgress, maxProgress }; +}; diff --git a/components/video-player/controls/utils/trickplayUtils.ts b/components/video-player/controls/utils/trickplayUtils.ts new file mode 100644 index 00000000..356a0e82 --- /dev/null +++ b/components/video-player/controls/utils/trickplayUtils.ts @@ -0,0 +1,23 @@ +import { TRICKPLAY_TILE_SCALE, TRICKPLAY_TILE_WIDTH } from "../constants"; + +export const calculateTrickplayDimensions = (aspectRatio: number) => { + const tileWidth = TRICKPLAY_TILE_WIDTH; + const tileHeight = TRICKPLAY_TILE_WIDTH / aspectRatio; + + return { + tileWidth, + tileHeight, + scaledWidth: tileWidth * TRICKPLAY_TILE_SCALE, + scaledHeight: tileHeight * TRICKPLAY_TILE_SCALE, + }; +}; + +export const formatTimeForBubble = (time: { + hours: number; + minutes: number; + seconds: number; +}) => { + return `${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`; +};