import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useLocalSearchParams, useRouter } from "expo-router"; import { debounce } from "lodash"; import { type Dispatch, type FC, type MutableRefObject, type SetStateAction, useCallback, useEffect, useRef, useState, } from "react"; import { Platform, TouchableOpacity, useTVEventHandler, useWindowDimensions, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, type SharedValue, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useHaptic } from "@/hooks/useHaptic"; 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 { 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 { useControlsTimeout } from "./useControlsTimeout"; import { type AspectRatio, AspectRatioSelector, } from "./VideoScalingModeSelector"; import { VideoTouchOverlay } from "./VideoTouchOverlay"; interface Props { item: BaseItemDto; videoRef: MutableRefObject; isPlaying: boolean; isSeeking: SharedValue; cacheProgress: SharedValue; progress: SharedValue; isBuffering: boolean; showControls: boolean; enableTrickplay?: boolean; togglePlay: () => void; setShowControls: (shown: boolean) => void; offline?: boolean; isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; startPictureInPicture?: () => Promise; play: () => void; pause: () => void; 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; aspectRatio?: AspectRatio; scaleFactor?: ScaleFactor; setAspectRatio?: Dispatch>; setScaleFactor?: Dispatch>; isVlc?: boolean; } const CONTROLS_TIMEOUT = 4000; export const Controls: FC = ({ item, seek, startPictureInPicture, play, pause, togglePlay, isPlaying, isSeeking, progress, isBuffering, cacheProgress, showControls, setShowControls, mediaSource, isVideoLoaded, getAudioTracks, getSubtitleTracks, setSubtitleURL, setSubtitleTrack, setAudioTrack, setVideoAspectRatio, setVideoScaleFactor, aspectRatio = "default", scaleFactor = 1.0, setAspectRatio, setScaleFactor, offline = false, isVlc = false, }) => { const [settings, updateSettings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); 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); const min = useSharedValue(0); const max = useSharedValue(item.RunTimeTicks || 0); const wasPlayingRef = useRef(false); const lastProgressRef = useRef(0); const lightHapticFeedback = useHaptic("light"); useEffect(() => { prefetchAllTrickplayImages(); }, []); const remoteScrubProgress = useSharedValue(null); const isRemoteScrubbing = useSharedValue(false); const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10)); const [showRemoteBubble, setShowRemoteBubble] = useState(false); const [longPressScrubMode, setLongPressScrubMode] = useState< "FF" | "RW" | null >(null); useTVEventHandler((evt) => { if (!evt) return; switch (evt.eventType) { case "longLeft": { setLongPressScrubMode((prev) => (!prev ? "RW" : null)); break; } case "longRight": { setLongPressScrubMode((prev) => (!prev ? "FF" : null)); break; } case "left": case "right": { isRemoteScrubbing.value = true; setShowRemoteBubble(true); const direction = evt.eventType === "left" ? -1 : 1; const base = remoteScrubProgress.value ?? progress.value; const updated = Math.max( min.value, Math.min(max.value, base + direction * SCRUB_INTERVAL), ); remoteScrubProgress.value = updated; const progressInTicks = isVlc ? msToTicks(updated) : updated; calculateTrickplayUrl(progressInTicks); const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); const hours = Math.floor(progressInSeconds / 3600); const minutes = Math.floor((progressInSeconds % 3600) / 60); const seconds = progressInSeconds % 60; setTime({ hours, minutes, seconds }); break; } case "select": { if (isRemoteScrubbing.value && remoteScrubProgress.value != null) { progress.value = remoteScrubProgress.value; const seekTarget = isVlc ? Math.max(0, remoteScrubProgress.value) : Math.max(0, ticksToSeconds(remoteScrubProgress.value)); seek(seekTarget); if (isPlaying) play(); isRemoteScrubbing.value = false; remoteScrubProgress.value = null; setShowRemoteBubble(false); } else { togglePlay(); } break; } case "down": case "up": // cancel scrubbing on other directions isRemoteScrubbing.value = false; remoteScrubProgress.value = null; setShowRemoteBubble(false); break; default: break; } if (!showControls) 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 or when progress significantly changes useAnimatedReaction( () => ({ isScrubbing: isRemoteScrubbing.value, scrub: remoteScrubProgress.value, actual: progress.value, }), (current, previous) => { // Always update if scrubbing state changed or we're currently scrubbing if ( current.isScrubbing !== previous?.isScrubbing || current.isScrubbing ) { effectiveProgress.value = current.isScrubbing && current.scrub != null ? current.scrub : current.actual; } else { // When not scrubbing, only update if progress changed significantly (1 second) const progressUnit = isVlc ? 1000 : 10000000; // 1 second in ms or ticks const progressDiff = Math.abs(current.actual - effectiveProgress.value); if (progressDiff >= progressUnit) { effectiveProgress.value = current.actual; } } }, [], ); 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, seek, play, isVlc, offline, ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( item?.Id!, currentTime, seek, play, isVlc, 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 lastCurrentTimeRef = useRef(0); const lastRemainingTimeRef = useRef(0); const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); const remaining = isVlc ? maxValue - currentProgress : ticksToSeconds(maxValue - currentProgress); // Only update state if the displayed time actually changed (avoid sub-second updates) const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1)); const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1)); const lastCurrentSeconds = Math.floor( lastCurrentTimeRef.current / (isVlc ? 1000 : 1), ); const lastRemainingSeconds = Math.floor( lastRemainingTimeRef.current / (isVlc ? 1000 : 1), ); if ( currentSeconds !== lastCurrentSeconds || remainingSeconds !== lastRemainingSeconds ) { setCurrentTime(current); setRemainingTime(remaining); lastCurrentTimeRef.current = current; lastRemainingTimeRef.current = remaining; } }, [goToNextItem, isVlc], ); useAnimatedReaction( () => ({ progress: progress.value, max: max.value, isSeeking: isSeeking.value, }), (result) => { if (!result.isSeeking) { runOnJS(updateTimes)(result.progress, result.max); } }, [updateTimes], ); const hideControls = useCallback(() => { setShowControls(false); setShowAudioSlider(false); }, []); const { handleControlsInteraction } = useControlsTimeout({ showControls, isSliding, episodeView, onHideControls: hideControls, timeout: CONTROLS_TIMEOUT, }); const toggleControls = () => { if (showControls) { setShowAudioSlider(false); setShowControls(false); } else { setShowControls(true); } }; const handleSliderStart = useCallback(() => { if (!showControls) { return; } setIsSliding(true); wasPlayingRef.current = isPlaying; lastProgressRef.current = progress.value; pause(); isSeeking.value = true; }, [showControls, isPlaying, pause]); const handleTouchStart = useCallback(() => { if (!showControls) { return; } }, [showControls]); const handleTouchEnd = useCallback(() => { if (!showControls) { return; } }, [showControls, isSliding]); const handleSliderComplete = useCallback( async (value: number) => { setIsSliding(false); isSeeking.value = false; progress.value = value; seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))); if (wasPlayingRef.current) { play(); } }, [isVlc, seek, play], ); const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 }); const handleSliderChange = useCallback( debounce((value: number) => { const progressInTicks = isVlc ? msToTicks(value) : value; calculateTrickplayUrl(progressInTicks); const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks)); const hours = Math.floor(progressInSeconds / 3600); const minutes = Math.floor((progressInSeconds % 3600) / 60); const seconds = progressInSeconds % 60; setTime({ hours, minutes, seconds }); }, 3), [], ); const handleSkipBackward = useCallback(async () => { if (!settings?.rewindSkipTime) { return; } wasPlayingRef.current = isPlaying; lightHapticFeedback(); try { const curr = progress.value; if (curr !== undefined) { const newTime = isVlc ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); seek(newTime); if (wasPlayingRef.current) { play(); } } } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); } }, [settings, isPlaying, isVlc, play, seek]); const handleSeekBackward = useCallback( async (seconds: number) => { wasPlayingRef.current = isPlaying; try { const curr = progress.value; if (curr !== undefined) { const newTime = isVlc ? Math.max(0, curr - secondsToMs(seconds)) : Math.max(0, ticksToSeconds(curr) - seconds); seek(newTime); } } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); } }, [isPlaying, isVlc, seek], ); const handleSeekForward = useCallback( async (seconds: number) => { wasPlayingRef.current = isPlaying; try { const curr = progress.value; if (curr !== undefined) { const newTime = isVlc ? curr + secondsToMs(seconds) : ticksToSeconds(curr) + seconds; seek(Math.max(0, newTime)); } } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); } }, [isPlaying, isVlc, seek], ); const handleSkipForward = useCallback(async () => { if (!settings?.forwardSkipTime) { return; } wasPlayingRef.current = isPlaying; lightHapticFeedback(); try { const curr = progress.value; if (curr !== undefined) { const newTime = isVlc ? curr + secondsToMs(settings.forwardSkipTime) : ticksToSeconds(curr) + settings.forwardSkipTime; seek(Math.max(0, newTime)); if (wasPlayingRef.current) { play(); } } } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); } }, [settings, isPlaying, isVlc, play, seek]); const handleAspectRatioChange = useCallback( async (newRatio: AspectRatio) => { if (!setAspectRatio || !setVideoAspectRatio) return; setAspectRatio(newRatio); const aspectRatioString = newRatio === "default" ? null : newRatio; await setVideoAspectRatio(aspectRatioString); }, [setAspectRatio, setVideoAspectRatio], ); const handleScaleFactorChange = useCallback( async (newScale: ScaleFactor) => { if (!setScaleFactor || !setVideoScaleFactor) return; setScaleFactor(newScale); await setVideoScaleFactor(newScale); }, [setScaleFactor, setVideoScaleFactor], ); const switchOnEpisodeMode = useCallback(() => { setEpisodeView(true); if (isPlaying) { togglePlay(); } }, [isPlaying, togglePlay]); const memoizedRenderBubble = useCallback(() => { if (!trickPlayUrl || !trickplayInfo) { return null; } const { x, y, url } = trickPlayUrl; const tileWidth = 150; const tileHeight = 150 / trickplayInfo.aspectRatio!; return ( {`${time.hours > 0 ? `${time.hours}:` : ""}${time.minutes < 10 ? `0${time.minutes}` : time.minutes}:${ time.seconds < 10 ? `0${time.seconds}` : time.seconds }`} ); }, [trickPlayUrl, trickplayInfo, time]); const onClose = async () => { lightHapticFeedback(); router.back(); }; return ( {episodeView ? ( setEpisodeView(false)} goToItem={goToItemCommon} /> ) : ( <> {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( )} {!Platform.isTV && (settings.defaultPlayer === VideoPlayer.VLC_4 || Platform.OS === "android") && ( )} {item?.Type === "Episode" && ( { switchOnEpisodeMode(); }} className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' > )} {previousItem && ( )} {nextItem && ( goToNextItem({ isAutoPlay: false })} className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' > )} {/* Video Controls */} {!Platform.isTV && ( {settings?.rewindSkipTime} )} { togglePlay(); }} > {!isBuffering ? ( ) : ( )} {!Platform.isTV && ( {settings?.forwardSkipTime} )} {item?.Type === "Episode" && ( {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} {item?.Name} {item?.Type === "Movie" && ( {item?.ProductionYear} )} {item?.Type === "Audio" && ( {item?.Album} )} {(settings.maxAutoPlayEpisodeCount.value === -1 || settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value) && ( )} null} cache={cacheProgress} onSlidingStart={handleSliderStart} onSlidingComplete={handleSliderComplete} onValueChange={handleSliderChange} containerStyle={{ borderRadius: 100, }} renderBubble={() => (isSliding || showRemoteBubble) && memoizedRenderBubble() } sliderHeight={10} thumbWidth={0} progress={effectiveProgress} minimumValue={min} maximumValue={max} /> {formatTimeString(currentTime, isVlc ? "ms" : "s")} -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} ends at {(() => { const now = new Date(); const remainingMs = isVlc ? remainingTime : remainingTime * 1000; const finishTime = new Date( now.getTime() + remainingMs, ); return finishTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false, }); })()} )} {settings.maxAutoPlayEpisodeCount.value !== -1 && ( )} ); };