import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useTrickplay } from "@/hooks/useTrickplay"; import { TrackInfo, VlcPlayerViewRef, } from "@/modules/vlc-player/src/VlcPlayer.types"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings, previousIndexes, } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; import { writeToLog } from "@/utils/log"; import { formatTimeString, msToTicks, secondsToMs, ticksToMs, ticksToSeconds, } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; import * as Haptics from "expo-haptics"; import { Image } from "expo-image"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useAtom } from "jotai"; import { debounce } from "lodash"; import { useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, Pressable, TouchableOpacity, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, SharedValue, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; import { SafeAreaView, useSafeAreaInsets, } from "react-native-safe-area-context"; import { VideoRef } from "react-native-video"; import AudioSlider from "./AudioSlider"; import BrightnessSlider from "./BrightnessSlider"; import { ControlProvider } from "./contexts/ControlContext"; import { VideoProvider } from "./contexts/VideoContext"; import DropdownViewDirect from "./dropdown/DropdownViewDirect"; import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import { EpisodeList } from "./EpisodeList"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; interface Props { item: BaseItemDto; videoRef: React.MutableRefObject; isPlaying: boolean; isSeeking: SharedValue; cacheProgress: SharedValue; progress: SharedValue; isBuffering: boolean; showControls: boolean; ignoreSafeAreas?: boolean; setIgnoreSafeAreas: React.Dispatch>; enableTrickplay?: boolean; togglePlay: () => void; setShowControls: (shown: boolean) => void; offline?: boolean; isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; play: (() => Promise) | (() => void); pause: () => void; getAudioTracks?: (() => Promise) | (() => TrackInfo[]); getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]); setSubtitleURL?: (url: string, customName: string) => void; setSubtitleTrack?: (index: number) => void; setAudioTrack?: (index: number) => void; stop: (() => Promise) | (() => void); isVlc?: boolean; } export const Controls: React.FC = ({ item, seek, play, pause, togglePlay, isPlaying, isSeeking, progress, isBuffering, cacheProgress, showControls, setShowControls, ignoreSafeAreas, setIgnoreSafeAreas, mediaSource, isVideoLoaded, getAudioTracks, getSubtitleTracks, setSubtitleURL, setSubtitleTrack, setAudioTrack, stop, offline = false, enableTrickplay = true, isVlc = false, }) => { const [settings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); const { previousItem, nextItem } = useAdjacentItems({ item }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo, prefetchAllTrickplayImages, } = useTrickplay(item, !offline && enableTrickplay); const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(Infinity); const min = useSharedValue(0); const max = useSharedValue(item.RunTimeTicks || 0); const wasPlayingRef = useRef(false); const lastProgressRef = useRef(0); const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{ bitrateValue: string; audioIndex: string; subtitleIndex: string; }>(); const { showSkipButton, skipIntro } = useIntroSkipper( offline ? undefined : item.Id, currentTime, seek, play, isVlc ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( offline ? undefined : item.Id, currentTime, seek, play, isVlc ); const goToPreviousItem = useCallback(() => { if (!previousItem || !settings) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const previousIndexes: previousIndexes = { subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, audioIndex: audioIndex ? parseInt(audioIndex) : undefined, }; const { mediaSource: newMediaSource, audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex, } = getDefaultPlaySettings( previousItem, settings, previousIndexes, mediaSource ?? undefined ); const queryParams = new URLSearchParams({ itemId: previousItem.Id ?? "", // Ensure itemId is a string audioIndex: defaultAudioIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "", mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string bitrateValue: bitrateValue.toString(), }).toString(); if (!bitrateValue) { // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); return; } // @ts-expect-error router.replace(`player/transcoding-player?${queryParams}`); }, [previousItem, settings, subtitleIndex, audioIndex]); const goToNextItem = useCallback(() => { if (!nextItem || !settings) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const previousIndexes: previousIndexes = { subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, audioIndex: audioIndex ? parseInt(audioIndex) : undefined, }; const { mediaSource: newMediaSource, audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex, } = getDefaultPlaySettings( nextItem, settings, previousIndexes, mediaSource ?? undefined ); const queryParams = new URLSearchParams({ itemId: nextItem.Id ?? "", // Ensure itemId is a string audioIndex: defaultAudioIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "", mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string bitrateValue: bitrateValue.toString(), }).toString(); if (!bitrateValue) { // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); return; } // @ts-expect-error router.replace(`player/transcoding-player?${queryParams}`); }, [nextItem, settings, subtitleIndex, audioIndex]); const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); const remaining = isVlc ? maxValue - currentProgress : ticksToSeconds(maxValue - currentProgress); console.log("remaining: ", remaining); setCurrentTime(current); setRemainingTime(remaining); }, [goToNextItem, isVlc] ); useAnimatedReaction( () => ({ progress: progress.value, max: max.value, isSeeking: isSeeking.value, }), (result) => { if (result.isSeeking === false) { runOnJS(updateTimes)(result.progress, result.max); } }, [updateTimes] ); 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]); useEffect(() => { prefetchAllTrickplayImages(); }, []); const toggleControls = () => { if (showControls) { setShowAudioSlider(false); setShowControls(false); } else { setShowControls(true); } }; const handleSliderStart = useCallback(() => { if (showControls === false) return; setIsSliding(true); wasPlayingRef.current = isPlaying; lastProgressRef.current = progress.value; pause(); isSeeking.value = true; }, [showControls, isPlaying]); const [isSliding, setIsSliding] = useState(false); const handleSliderComplete = useCallback( async (value: number) => { isSeeking.value = false; progress.value = value; setIsSliding(false); await seek( Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))) ); if (wasPlayingRef.current === true) play(); }, [isVlc] ); 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; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 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); await seek(newTime); if (wasPlayingRef.current === true) play(); } } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); } }, [settings, isPlaying, isVlc]); const handleSkipForward = useCallback(async () => { if (!settings?.forwardSkipTime) return; wasPlayingRef.current = isPlaying; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); try { const curr = progress.value; console.log(curr); if (curr !== undefined) { const newTime = isVlc ? curr + secondsToMs(settings.forwardSkipTime) : ticksToSeconds(curr) + settings.forwardSkipTime; await seek(Math.max(0, newTime)); if (wasPlayingRef.current === true) play(); } } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); } }, [settings, isPlaying, isVlc]); const toggleIgnoreSafeAreas = useCallback(() => { setIgnoreSafeAreas((prev) => !prev); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }, []); const memoizedRenderBubble = useCallback(() => { if (!trickPlayUrl || !trickplayInfo) { return null; } const { x, y, url } = trickPlayUrl; const tileWidth = 150; const tileHeight = 150 / trickplayInfo.aspectRatio!; console.log("time, ", time); 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 [EpisodeView, setEpisodeView] = useState(false); const switchOnEpisodeMode = () => { setEpisodeView(true); if (isPlaying) togglePlay(); }; const goToItem = useCallback( async (itemId: string) => { try { const gotoItem = await getItemById(api, itemId); if (!settings || !gotoItem) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const previousIndexes: previousIndexes = { subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, audioIndex: audioIndex ? parseInt(audioIndex) : undefined, }; const { mediaSource: newMediaSource, audioIndex: defaultAudioIndex, subtitleIndex: defaultSubtitleIndex, } = getDefaultPlaySettings( gotoItem, settings, previousIndexes, mediaSource ?? undefined ); const queryParams = new URLSearchParams({ itemId: gotoItem.Id ?? "", // Ensure itemId is a string audioIndex: defaultAudioIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "", mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string bitrateValue: bitrateValue.toString(), }).toString(); if (!bitrateValue) { // @ts-expect-error router.replace(`player/direct-player?${queryParams}`); return; } // @ts-expect-error router.replace(`player/transcoding-player?${queryParams}`); } catch (error) { console.error("Error in gotoEpisode:", error); } }, [settings, subtitleIndex, audioIndex] ); // Used when user changes audio through audio button on device. const [showAudioSlider, setShowAudioSlider] = useState(false); return ( {EpisodeView ? ( setEpisodeView(false)} goToItem={goToItem} /> ) : ( <> {!mediaSource?.TranscodingUrl ? ( ) : ( )} { toggleControls(); }} style={{ position: "absolute", width: Dimensions.get("window").width, height: Dimensions.get("window").height, }} > {item?.Type === "Episode" && ( { switchOnEpisodeMode(); }} className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" > )} {previousItem && ( )} {nextItem && ( )} {mediaSource?.TranscodingUrl && ( )} { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); router.back(); }} className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" > {settings?.rewindSkipTime} { togglePlay(); }} > {!isBuffering ? ( ) : ( )} {settings?.forwardSkipTime} {item?.Name} {item?.Type === "Episode" && ( {item.SeriesName} )} {item?.Type === "Movie" && ( {item?.ProductionYear} )} {item?.Type === "Audio" && ( {item?.Album} )} ( )} cache={cacheProgress} onSlidingStart={handleSliderStart} onSlidingComplete={handleSliderComplete} onValueChange={handleSliderChange} containerStyle={{ borderRadius: 100, }} renderBubble={() => isSliding && memoizedRenderBubble()} sliderHeight={10} thumbWidth={0} progress={progress} minimumValue={min} maximumValue={max} /> {formatTimeString(currentTime, isVlc ? "ms" : "s")} -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} )} ); };