diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index dbbe47e7..c0d6c801 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -1,8 +1,11 @@ +import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; -import { useRouter } from "expo-router"; +import { Image } from "expo-image"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { debounce } from "lodash"; import { type Dispatch, type FC, @@ -10,42 +13,59 @@ 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 } 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 { 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 { ControlProvider } from "./contexts/ControlContext"; +import { VideoProvider } from "./contexts/VideoContext"; +import DropdownView from "./dropdown/DropdownView"; import { EpisodeList } from "./EpisodeList"; -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 NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; +import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector"; +import SkipButton from "./SkipButton"; import { useControlsTimeout } from "./useControlsTimeout"; -import { initializeProgress } from "./utils/progressUtils"; -import { type AspectRatio } from "./VideoScalingModeSelector"; +import { + type AspectRatio, + AspectRatioSelector, +} from "./VideoScalingModeSelector"; import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { @@ -82,6 +102,8 @@ interface Props { isVlc?: boolean; } +const CONTROLS_TIMEOUT = 4000; + export const Controls: FC = ({ item, seek, @@ -112,140 +134,223 @@ export const Controls: FC = ({ offline = false, isVlc = false, }) => { - const [settings] = useSettings(); + const [settings, updateSettings] = useSettings(); const router = useRouter(); - const lightHapticFeedback = useHaptic("light"); + const insets = useSafeAreaInsets(); - // 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); - // Trickplay - const { trickPlayUrl, trickplayInfo, prefetchAllTrickplayImages } = - useTrickplay(item); + // Animated scale for slider + const sliderScale = useSharedValue(1); - // 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 wasPlayingRef = useRef(false); + const lastProgressRef = useRef(0); - // Prefetch trickplay images - useEffect(() => { - prefetchAllTrickplayImages(); - }, [prefetchAllTrickplayImages]); + const lightHapticFeedback = useHaptic("light"); - // Animate controls opacity + // Animate controls opacity when showControls changes useEffect(() => { controlsOpacity.value = withTiming(showControls ? 1 : 0, { - duration: ANIMATION_DURATION.CONTROLS_FADE, + duration: 300, }); }, [showControls, controlsOpacity]); - // 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 styles for controls + const animatedControlsStyle = useAnimatedStyle(() => { + return { + opacity: controlsOpacity.value, + }; }); - const { - isSliding, - time, - sliderScale, - handleSliderStart, - handleTouchStart, - handleTouchEnd, - handleSliderComplete, - handleSliderChange, - } = useSliderInteractions({ - progress, - isSeeking, - isPlaying, - isVlc, - showControls, - item, - seek, - play, - pause, + // Animated style for black overlay (75% opacity when visible) + const animatedOverlayStyle = useAnimatedStyle(() => { + return { + opacity: controlsOpacity.value * 0.75, + }; }); - const { - previousItem, - nextItem, - goToItemCommon, - goToPreviousItem, - handleNextEpisodeAutoPlay, - handleNextEpisodeManual, - handleContinueWatching, - } = useEpisodeNavigation({ - item, - offline, - mediaSource, + // Animated style for slider scale + const animatedSliderStyle = useAnimatedStyle(() => { + return { + transform: [{ scaleY: sliderScale.value }], + }; }); - const { handleAspectRatioChange, handleScaleFactorChange } = useVideoScaling({ - setAspectRatio, - setScaleFactor, - setVideoAspectRatio, - setVideoScaleFactor, - }); + useEffect(() => { + prefetchAllTrickplayImages(); + }, []); - const { handleSkipBackward, handleSkipForward } = useSkipControls({ - progress, - isPlaying, - isVlc, - seek, - play, - }); + const remoteScrubProgress = useSharedValue(null); + const isRemoteScrubbing = useSharedValue(false); + const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10)); + const [showRemoteBubble, setShowRemoteBubble] = useState(false); - // Helper functions - const toggleControls = useCallback(() => { - if (showControls) { - setShowAudioSlider(false); - setShowControls(false); - } else { - setShowControls(true); + 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; } - }, [showControls, setShowControls]); - const { showRemoteBubble, time: remoteTime } = useRemoteControls({ - progress, - min, - max, - isVlc, - showControls, - isPlaying, - item, - seek, - play, - togglePlay, - toggleControls, + if (!showControls) toggleControls(); }); - // Skip intro/credits + 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; + }>(); + const { showSkipButton, skipIntro } = useIntroSkipper( item?.Id!, currentTime, @@ -264,11 +369,154 @@ export const Controls: FC = ({ offline, ); - // Controls timeout + 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], + ); + const hideControls = useCallback(() => { setShowControls(false); setShowAudioSlider(false); - }, [setShowControls]); + }, []); const { handleControlsInteraction } = useControlsTimeout({ showControls, @@ -278,22 +526,179 @@ export const Controls: FC = ({ timeout: CONTROLS_TIMEOUT, }); - // Effective progress calculation - const effectiveProgress = useSharedValue(0); + const toggleControls = () => { + if (showControls) { + setShowAudioSlider(false); + setShowControls(false); + } else { + setShowControls(true); + } + }; - // For remote scrubbing, we'll need to adapt this - for now using the basic progress - useAnimatedReaction( - () => progress.value, - (value) => { - effectiveProgress.value = value; + 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 [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), [], ); - // Animated style for slider scale - const animatedSliderStyle = useAnimatedStyle(() => ({ - transform: [{ scaleY: sliderScale.value }], - })); + 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); @@ -302,10 +707,72 @@ export const Controls: FC = ({ } }, [isPlaying, togglePlay]); - const onClose = useCallback(async () => { + 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(); - }, [lightHapticFeedback, router]); + }; return ( = ({ onToggleControls={toggleControls} animatedStyle={animatedOverlayStyle} /> + + + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( + + + + )} + - handleNextEpisodeManual()} - onClose={onClose} - /> + + {!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 */} + + + + + + + - + + {/* 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 deleted file mode 100644 index feedb270..00000000 --- a/components/video-player/controls/components/BottomControls.tsx +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index b9d12bdb..00000000 --- a/components/video-player/controls/components/CenterControls.tsx +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index 7d4bd31b..00000000 --- a/components/video-player/controls/components/TopControlsBar.tsx +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index aa8746a0..00000000 --- a/components/video-player/controls/components/TrickplayBubble.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index ddaf15a3..00000000 --- a/components/video-player/controls/constants.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 351457f0..3940d520 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -85,6 +85,7 @@ export const VideoProvider: React.FC = ({ chosenAudioIndex?: string; chosenSubtitleIndex?: string; }) => { + console.log("chosenSubtitleIndex", chosenSubtitleIndex); const queryParams = new URLSearchParams({ itemId: itemId ?? "", audioIndex: chosenAudioIndex, @@ -114,6 +115,7 @@ 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 deleted file mode 100644 index fcc43f07..00000000 --- a/components/video-player/controls/hooks/useEpisodeNavigation.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 8b465468..00000000 --- a/components/video-player/controls/hooks/useRemoteControls.ts +++ /dev/null @@ -1,212 +0,0 @@ -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 deleted file mode 100644 index 3c883636..00000000 --- a/components/video-player/controls/hooks/useSkipControls.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index bcdd845b..00000000 --- a/components/video-player/controls/hooks/useSliderInteractions.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index a0499d2c..00000000 --- a/components/video-player/controls/hooks/useTimeManagement.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 505f04de..00000000 --- a/components/video-player/controls/hooks/useVideoScaling.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a043893f..00000000 --- a/components/video-player/controls/utils/progressUtils.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 356a0e82..00000000 --- a/components/video-player/controls/utils/trickplayUtils.ts +++ /dev/null @@ -1,23 +0,0 @@ -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}`; -};