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 { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { writeToLog } from "@/utils/log"; import { formatTimeString, secondsToMs, ticksToMs, ticksToSeconds, } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dimensions, Platform, Pressable, TouchableOpacity, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { runOnJS, SharedValue, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { VideoRef } from "react-native-video"; import { Text } from "../common/Text"; import { Loader } from "../Loader"; import { useAtomValue } from "jotai"; import { apiAtom } from "@/providers/JellyfinProvider"; import * as DropdownMenu from "zeego/dropdown-menu"; 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: (ticks: number) => void; setShowControls: (shown: boolean) => void; offline?: boolean; isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; play: (() => Promise) | (() => void); pause: () => void; getAudioTracks?: () => Promise; 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, videoRef, 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 { setPlaySettings, playSettings } = usePlaySettings(); const api = useAtomValue(apiAtom); const windowDimensions = Dimensions.get("window"); const { previousItem, nextItem } = useAdjacentItems({ item }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( item, !offline && enableTrickplay ); const [currentTime, setCurrentTime] = useState(0); const [remainingTime, setRemainingTime] = useState(0); const min = useSharedValue(0); const max = useSharedValue(item.RunTimeTicks || 0); const wasPlayingRef = useRef(false); const lastProgressRef = useRef(0); const { showSkipButton, skipIntro } = useIntroSkipper( offline ? undefined : item.Id, currentTime, seek, play ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( offline ? undefined : item.Id, currentTime, seek, play ); const goToPreviousItem = useCallback(() => { if (!previousItem || !settings) return; const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(previousItem, settings); setPlaySettings({ item: previousItem, bitrate, mediaSource, audioIndex, subtitleIndex, }); if (Platform.OS === "ios") router.replace("/vlc-player"); else router.replace("/player"); }, [previousItem, settings]); const goToNextItem = useCallback(() => { if (!nextItem || !settings) return; const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(nextItem, settings); setPlaySettings({ item: nextItem, bitrate, mediaSource, audioIndex, subtitleIndex, }); if (Platform.OS === "ios") router.replace("/vlc-player"); else router.replace("/player"); }, [nextItem, settings]); 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); if (currentProgress === maxValue) { setShowControls(true); // Automatically play the next item if it exists goToNextItem(); } }, [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]); const toggleControls = () => setShowControls(!showControls); const handleSliderComplete = useCallback( async (value: number) => { isSeeking.value = false; progress.value = value; await seek(Math.max(0, Math.floor(isVlc ? value : value / 10000000))); if (wasPlayingRef.current === true) play(); }, [isVlc] ); const handleSliderChange = (value: number) => { calculateTrickplayUrl(value); }; const handleSliderStart = useCallback(() => { if (showControls === false) return; wasPlayingRef.current = isPlaying; lastProgressRef.current = progress.value; pause(); isSeeking.value = true; }, [showControls, isPlaying]); const handleSkipBackward = useCallback(async () => { if (!settings?.rewindSkipTime) return; wasPlayingRef.current = isPlaying; 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; 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); }, []); const [selectedSubtitleTrack, setSelectedSubtitleTrack] = useState< MediaStream | undefined >(undefined); const [audioTracks, setAudioTracks] = useState(null); const [subtitleTracks, setSubtitleTracks] = useState( null ); useEffect(() => { const fetchTracks = async () => { if (getAudioTracks && getSubtitleTracks) { const audio = await getAudioTracks(); const subtitles = await getSubtitleTracks(); setAudioTracks(audio); setSubtitleTracks(subtitles); } }; fetchTracks(); }, [isVideoLoaded, getAudioTracks, getSubtitleTracks]); type EmbeddedSubtitle = { name: string; index: number; isExternal: false; }; type ExternalSubtitle = { name: string; index: number; isExternal: true; deliveryUrl: string; }; const allSubtitleTracks = useMemo(() => { const embeddedSubs = subtitleTracks?.map((s) => ({ name: s.name, index: s.index, isExternal: false, deliveryUrl: undefined, })) || []; const externalSubs = mediaSource?.MediaStreams?.filter( (stream) => stream.Type === "Subtitle" && stream.IsExternal && !!stream.DeliveryUrl ).map((s) => ({ name: s.DisplayTitle!, index: s.Index!, isExternal: true, deliveryUrl: s.DeliveryUrl, })) || []; // Create a Set of embedded subtitle names for quick lookup const embeddedSubNames = new Set(embeddedSubs.map((sub) => sub.name)); // Filter out external subs that have the same name as embedded subs const uniqueExternalSubs = externalSubs.filter( (sub) => !embeddedSubNames.has(sub.name) ); // Combine embedded and unique external subs return [...embeddedSubs, ...uniqueExternalSubs] as ( | EmbeddedSubtitle | ExternalSubtitle )[]; }, [item, isVideoLoaded, subtitleTracks, mediaSource]); return ( {/* */} {setSubtitleURL && setSubtitleTrack && setAudioTrack && ( Subtitle {allSubtitleTracks.length > 0 ? allSubtitleTracks?.map((sub, idx: number) => ( { console.log("Trying to set subtitle..."); if (sub.isExternal) { console.log("Setting external sub:", sub); setSubtitleURL( api?.basePath + sub.deliveryUrl, sub.name ); return; } console.log("Setting sub with index:", sub.index); setSubtitleTrack(sub.index); }} > {sub.name} )) : null} Audio {audioTracks?.length ? audioTracks?.map((a, idx: number) => ( { setAudioTrack(a.index); }} > {a.name} )) : null} )} Skip Intro Skip Credits { toggleControls(); }} > { if (stop) await stop(); router.back(); }} className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" > {item?.Name} {item?.Type === "Episode" && ( {item.SeriesName} )} {item?.Type === "Movie" && ( {item?.ProductionYear} )} {item?.Type === "Audio" && ( {item?.Album} )} { togglePlay(progress.value); }} > { if (!trickPlayUrl || !trickplayInfo) { return null; } const { x, y, url } = trickPlayUrl; const tileWidth = 150; const tileHeight = 150 / trickplayInfo.aspectRatio!; return ( ); }} sliderHeight={10} thumbWidth={0} progress={progress} minimumValue={min} maximumValue={max} /> {formatTimeString(currentTime, isVlc ? "ms" : "s")} -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} ); };