import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes"; import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useTrickplay } from "@/hooks/useTrickplay"; import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { writeToLog } from "@/utils/log"; import { formatTimeString, ticksToSeconds } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, Platform, Pressable, TouchableOpacity, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import Animated, { runOnJS, SharedValue, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, } 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"; 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; } export const Controls: React.FC = ({ item, videoRef, togglePlay, isPlaying, isSeeking, progress, isBuffering, cacheProgress, showControls, setShowControls, ignoreSafeAreas, setIgnoreSafeAreas, enableTrickplay = true, }) => { const [settings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); const { setPlaySettings } = usePlaySettings(); const windowDimensions = Dimensions.get("window"); const op = useSharedValue(1); const tr = useSharedValue(10); const animatedStyles = useAnimatedStyle(() => { return { opacity: op.value, }; }); const animatedTopStyles = useAnimatedStyle(() => { return { opacity: op.value, transform: [ { translateY: -tr.value, }, ], }; }); const animatedBottomStyles = useAnimatedStyle(() => { return { opacity: op.value, transform: [ { translateY: tr.value, }, ], }; }); useEffect(() => { if (showControls || isBuffering) { op.value = withTiming(1, { duration: 200 }); tr.value = withTiming(0, { duration: 200 }); } else { op.value = withTiming(0, { duration: 200 }); tr.value = withTiming(10, { duration: 200 }); } }, [showControls, isBuffering]); const { previousItem, nextItem } = useAdjacentItems({ item }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( item, enableTrickplay ); const [currentTime, setCurrentTime] = useState(0); // Seconds const [remainingTime, setRemainingTime] = useState(0); // Seconds const min = useSharedValue(0); const max = useSharedValue(item.RunTimeTicks || 0); const wasPlayingRef = useRef(false); const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { const current = ticksToSeconds(currentProgress); const remaining = ticksToSeconds(maxValue - currentProgress); setCurrentTime(current); setRemainingTime(remaining); }, [] ); const { showSkipButton, skipIntro } = useIntroSkipper( item.Id, currentTime, videoRef ); const { showSkipCreditButton, skipCredit } = useCreditSkipper( item.Id, currentTime, videoRef ); const goToPreviousItem = useCallback(() => { if (!previousItem || !settings) return; const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(previousItem, settings); setPlaySettings({ item: previousItem, bitrate, mediaSource, audioIndex, subtitleIndex, }); router.replace("/play-video"); }, [previousItem, settings]); const goToNextItem = useCallback(() => { if (!nextItem || !settings) return; const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(nextItem, settings); setPlaySettings({ item: nextItem, bitrate, mediaSource, audioIndex, subtitleIndex, }); router.replace("/play-video"); }, [nextItem, settings]); 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 = item?.UserData?.PlaybackPositionTicks || 0; max.value = item.RunTimeTicks || 0; } }, [item]); const toggleControls = () => setShowControls(!showControls); const handleSliderComplete = useCallback((value: number) => { progress.value = value; isSeeking.value = false; videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000))); if (wasPlayingRef.current === true) videoRef.current?.resume(); }, []); const handleSliderChange = (value: number) => { calculateTrickplayUrl(value); }; const handleSliderStart = useCallback(() => { if (showControls === false) return; wasPlayingRef.current = isPlaying; videoRef.current?.pause(); isSeeking.value = true; }, [showControls, isPlaying]); const handleSkipBackward = useCallback(async () => { console.log("handleSkipBackward"); if (!settings?.rewindSkipTime) return; wasPlayingRef.current = isPlaying; try { const curr = await videoRef.current?.getCurrentPosition(); if (curr !== undefined) { videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); setTimeout(() => { if (wasPlayingRef.current === true) videoRef.current?.resume(); }, 10); } } catch (error) { writeToLog("ERROR", "Error seeking video backwards", error); } }, [settings, isPlaying]); const handleSkipForward = useCallback(async () => { console.log("handleSkipForward"); if (!settings?.forwardSkipTime) return; wasPlayingRef.current = isPlaying; try { const curr = await videoRef.current?.getCurrentPosition(); if (curr !== undefined) { videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); setTimeout(() => { if (wasPlayingRef.current === true) videoRef.current?.resume(); }, 10); } } catch (error) { writeToLog("ERROR", "Error seeking video forwards", error); } }, [settings, isPlaying]); const toggleIgnoreSafeAreas = useCallback(() => { setIgnoreSafeAreas((prev) => !prev); }, []); return ( Skip Intro Skip Credits { toggleControls(); }} > { 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)} -{formatTimeString(remainingTime)} ); };