diff --git a/components/FullScreenVideoPlayer.tsx b/components/FullScreenVideoPlayer.tsx index 10e79eb4..d9faf3f4 100644 --- a/components/FullScreenVideoPlayer.tsx +++ b/components/FullScreenVideoPlayer.tsx @@ -1,54 +1,49 @@ -import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; -import { useControlsVisibility } from "@/hooks/useControlsVisibility"; -import { useTrickplay } from "@/hooks/useTrickplay"; -import { apiAtom } from "@/providers/JellyfinProvider"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + View, + TouchableOpacity, + Alert, + Dimensions, + BackHandler, + Pressable, + Touchable, +} from "react-native"; +import Video, { OnProgressData } from "react-native-video"; +import { Slider } from "react-native-awesome-slider"; +import { Ionicons } from "@expo/vector-icons"; import { usePlayback } from "@/providers/PlaybackProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; -import { writeToLog } from "@/utils/log"; -import orientationToOrientationLock from "@/utils/OrientationLockConverter"; -import { secondsToTicks } from "@/utils/secondsToTicks"; -import { runtimeTicksToSeconds } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; -import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { useRouter, useSegments } from "expo-router"; -import * as ScreenOrientation from "expo-screen-orientation"; -import { setStatusBarHidden, StatusBar } from "expo-status-bar"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - AppState, - AppStateStatus, - BackHandler, - Dimensions, - TouchableOpacity, - View, -} from "react-native"; -import { Slider } from "react-native-awesome-slider"; -import "react-native-gesture-handler"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Video, { OnProgressData } from "react-native-video"; +import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; +import { useTrickplay } from "@/hooks/useTrickplay"; import { Text } from "./common/Text"; -import { itemRouter } from "./common/TouchableItemRouter"; import { Loader } from "./Loader"; - -async function lockOrientation(orientation: ScreenOrientation.OrientationLock) { - await ScreenOrientation.lockAsync(orientation); -} - -async function resetOrientation() { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); -} +import { writeToLog } from "@/utils/log"; +import { useRouter, useSegments } from "expo-router"; +import { itemRouter } from "./common/TouchableItemRouter"; +import { Image } from "expo-image"; +import { StatusBar } from "expo-status-bar"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useQuery } from "@tanstack/react-query"; +import { + runOnJS, + useAnimatedReaction, + useSharedValue, +} from "react-native-reanimated"; +import { secondsToTicks } from "@/utils/secondsToTicks"; +import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import orientationToOrientationLock from "@/utils/OrientationLockConverter"; +import { + formatTimeString, + runtimeTicksToSeconds, + ticksToSeconds, +} from "@/utils/time"; export const FullScreenVideoPlayer: React.FC = () => { const { @@ -61,51 +56,67 @@ export const FullScreenVideoPlayer: React.FC = () => { isPlaying, videoRef, onProgress, - isBuffering: _isBuffering, setIsBuffering, } = usePlayback(); const [settings] = useSettings(); const [api] = useAtom(apiAtom); - const insets = useSafeAreaInsets(); - const segments = useSegments(); const router = useRouter(); + const segments = useSegments(); + const insets = useSafeAreaInsets(); + const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(currentlyPlaying); - const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); - const [orientation, setOrientation] = useState( - ScreenOrientation.OrientationLock.UNKNOWN - ); - - const opacity = useSharedValue(1); + const [showControls, setShowControls] = useState(true); + const [isBuffering, setIsBufferingState] = useState(true); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); - const from = useMemo(() => segments[2], [segments]); + const [isStatusBarHidden, setIsStatusBarHidden] = useState(false); + // Seconds + const [currentTime, setCurrentTime] = useState(0); + const [remainingTime, setRemainingTime] = useState(0); + + const isSeeking = useSharedValue(false); + + const cacheProgress = useSharedValue(0); const progress = useSharedValue(0); const min = useSharedValue(0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); - const sliding = useRef(false); - const localIsBuffering = useSharedValue(true); - const cacheProgress = useSharedValue(0); - const [isStatusBarHidden, setIsStatusBarHidden] = useState(false); - const hideControls = useCallback(() => { - "worklet"; - opacity.value = 0; - }, [opacity]); + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); - const showControls = useCallback(() => { - "worklet"; - opacity.value = 1; - }, [opacity]); + const from = useMemo(() => segments[2], [segments]); + + const updateTimes = useCallback( + (currentProgress: number, maxValue: number) => { + const current = ticksToSeconds(currentProgress); + const remaining = ticksToSeconds(maxValue - current); + + setCurrentTime(current); + setRemainingTime(remaining); + }, + [] + ); + + useAnimatedReaction( + () => ({ + progress: progress.value, + max: max.value, + isSeeking: isSeeking.value, + }), + (result) => { + if (result.isSeeking === false) { + runOnJS(updateTimes)(result.progress, result.max); + } + }, + [updateTimes] + ); useEffect(() => { const backAction = () => { if (currentlyPlaying) { - // Your custom back action here - console.log("onback"); Alert.alert("Hold on!", "Are you sure you want to exit?", [ { text: "Cancel", @@ -114,7 +125,6 @@ export const FullScreenVideoPlayer: React.FC = () => { }, { text: "Yes", onPress: () => stopPlayback() }, ]); - return true; } return false; @@ -126,9 +136,35 @@ export const FullScreenVideoPlayer: React.FC = () => { ); return () => backHandler.remove(); - }, [currentlyPlaying]); + }, [currentlyPlaying, stopPlayback]); - const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const [orientation, setOrientation] = useState( + ScreenOrientation.OrientationLock.UNKNOWN + ); + + /** + * Event listener for orientation + */ + useEffect(() => { + const subscription = ScreenOrientation.addOrientationChangeListener( + (event) => { + setOrientation( + orientationToOrientationLock(event.orientationInfo.orientation) + ); + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + const isLandscape = useMemo(() => { + return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || + orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ? true + : false; + }, [orientation]); const poster = useMemo(() => { if (!currentlyPlaying?.item || !api) return ""; @@ -157,82 +193,118 @@ export const FullScreenVideoPlayer: React.FC = () => { title: currentlyPlaying.item?.Name || "Unknown", description: currentlyPlaying.item?.Overview ?? undefined, imageUri: poster, - subtitle: currentlyPlaying.item?.Album ?? undefined, // Change here + subtitle: currentlyPlaying.item?.Album ?? undefined, }, }; }, [currentlyPlaying, api, poster]); - useEffect(() => { - max.value = currentlyPlaying?.item.RunTimeTicks || 0; - }, [currentlyPlaying?.item.RunTimeTicks]); - useEffect(() => { if (!currentlyPlaying) { - resetOrientation(); + ScreenOrientation.unlockAsync(); progress.value = 0; - min.value = 0; max.value = 0; - cacheProgress.value = 0; - sliding.current = false; - hideControls(); - setStatusBarHidden(false); - // NavigationBar.setVisibilityAsync("visible") + setShowControls(true); + setIsStatusBarHidden(false); + isSeeking.value = false; } else { - setStatusBarHidden(true); - // NavigationBar.setVisibilityAsync("hidden") - lockOrientation( + setIsStatusBarHidden(true); + ScreenOrientation.lockAsync( settings?.defaultVideoOrientation || ScreenOrientation.OrientationLock.DEFAULT ); progress.value = currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; - showControls(); + setShowControls(true); } }, [currentlyPlaying, settings]); - /** - * Event listener for orientation - */ - useEffect(() => { - const subscription = ScreenOrientation.addOrientationChangeListener( - (event) => { - setOrientation( - orientationToOrientationLock(event.orientationInfo.orientation) - ); - } - ); + const toggleControls = () => setShowControls(!showControls); - return () => { - subscription.remove(); - }; + const handleVideoProgress = useCallback( + (data: OnProgressData) => { + if (isSeeking.value === true) return; + progress.value = secondsToTicks(data.currentTime); + cacheProgress.value = secondsToTicks(data.playableDuration); + setIsBufferingState(data.playableDuration === 0); + setIsBuffering(data.playableDuration === 0); + onProgress(data); + }, + [onProgress, setIsBuffering, isSeeking] + ); + + const handleVideoError = useCallback( + (e: any) => { + console.log(e); + writeToLog("ERROR", "Video playback error: " + JSON.stringify(e)); + Alert.alert("Error", "Cannot play this video file."); + setIsPlaying(false); + }, + [setIsPlaying] + ); + + const handlePlayPause = () => { + if (isPlaying) pauseVideo(); + else playVideo(); + }; + + const handleSliderComplete = (value: number) => { + progress.value = value; + isSeeking.value = false; + videoRef.current?.seek(value / 10000000); + }; + + const handleSliderChange = (value: number) => { + calculateTrickplayUrl(value); + }; + + const handleSliderStart = useCallback(() => { + if (showControls === false) return; + isSeeking.value = true; }, []); - const isLandscape = useMemo(() => { - return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT || - orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT - ? true - : false; - }, [orientation]); + const handleSkipBackward = useCallback(async () => { + if (!settings) return; + try { + const curr = await videoRef.current?.getCurrentPosition(); + if (curr !== undefined) { + videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video backwards", error); + } + }, [settings]); - const animatedStyles = { - controls: useAnimatedStyle(() => ({ - opacity: withTiming(opacity.value, { duration: 300 }), - })), - videoContainer: useAnimatedStyle(() => ({ - opacity: withTiming( - opacity.value === 1 || localIsBuffering.value ? 0.5 : 1, - { - duration: 300, - } - ), - })), - loader: useAnimatedStyle(() => ({ - opacity: withTiming( - localIsBuffering.value === true || progress.value === 0 ? 1 : 0, - { duration: 300 } - ), - })), + const handleSkipForward = useCallback(async () => { + if (!settings) return; + try { + const curr = await videoRef.current?.getCurrentPosition(); + if (curr !== undefined) { + videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime)); + } + } catch (error) { + writeToLog("ERROR", "Error seeking video forwards", error); + } + }, [settings]); + + const handleGoToPreviousItem = () => { + if (!previousItem || !from) return; + const url = itemRouter(previousItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }; + + const handleGoToNextItem = () => { + if (!nextItem || !from) return; + const url = itemRouter(nextItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); + }; + + const toggleIgnoreSafeArea = () => { + setIgnoreSafeArea(!ignoreSafeArea); }; const { data: introTimestamps } = useQuery({ @@ -266,201 +338,16 @@ export const FullScreenVideoPlayer: React.FC = () => { enabled: !!currentlyPlaying?.item.Id, }); - const animatedIntroSkipperStyle = useAnimatedStyle(() => { - const showButtonAt = secondsToTicks(introTimestamps?.ShowSkipPromptAt || 0); - const hideButtonAt = secondsToTicks(introTimestamps?.HideSkipPromptAt || 0); - const showButton = - progress.value > showButtonAt && progress.value < hideButtonAt; - return { - opacity: withTiming( - localIsBuffering.value === false && showButton && progress.value !== 0 - ? 1 - : 0, - { - duration: 300, - } - ), - bottom: withTiming( - opacity.value === 0 ? insets.bottom + 8 : isLandscape ? 85 : 140, - { - duration: 300, - } - ), - }; - }); - - const toggleIgnoreSafeArea = useCallback(() => { - setIgnoreSafeArea((prev) => !prev); - }, []); - - const handleToggleControlsPress = useCallback(() => { - if (opacity.value === 1) { - hideControls(); - } else { - showControls(); - } - }, [opacity.value, hideControls, showControls]); - - const skipIntro = useCallback(async () => { + const skipIntro = async () => { if (!introTimestamps || !videoRef.current) return; try { videoRef.current.seek(introTimestamps.IntroEnd); } catch (error) { writeToLog("ERROR", "Error skipping intro", error); } - }, [introTimestamps]); + }; - const handleVideoProgress = useCallback( - (e: OnProgressData) => { - if (e.playableDuration === 0) { - setIsBuffering(true); - localIsBuffering.value = true; - } else { - setIsBuffering(false); - localIsBuffering.value = false; - } - - if (sliding.current) return; - onProgress(e); - progress.value = secondsToTicks(e.currentTime); - cacheProgress.value = secondsToTicks(e.playableDuration); - }, - [onProgress, setIsBuffering] - ); - - const handleVideoError = useCallback( - (e: any) => { - console.log(e); - writeToLog("ERROR", "Video playback error: " + JSON.stringify(e)); - Alert.alert("Error", "Cannot play this video file."); - setIsPlaying(false); - }, - [setIsPlaying] - ); - - const handleSkipBackward = useCallback(async () => { - try { - const curr = await videoRef.current?.getCurrentPosition(); - if (curr !== undefined) { - videoRef.current?.seek(Math.max(0, curr - 15)); - showControls(); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video backwards", error); - } - }, [videoRef, showControls]); - - const handleSkipForward = useCallback(async () => { - try { - const curr = await videoRef.current?.getCurrentPosition(); - if (curr !== undefined) { - videoRef.current?.seek(Math.max(0, curr + 15)); - showControls(); - } - } catch (error) { - writeToLog("ERROR", "Error seeking video forwards", error); - } - }, [videoRef, showControls]); - - const handlePlayPause = useCallback(() => { - console.log("handlePlayPause"); - if (isPlaying) pauseVideo(); - else playVideo(); - showControls(); - }, [isPlaying, pauseVideo, playVideo, showControls]); - - const handleSliderStart = useCallback(() => { - if (opacity.value === 0) return; - sliding.current = true; - }, []); - - const handleSliderComplete = useCallback( - (val: number) => { - if (opacity.value === 0) return; - const tick = Math.floor(val); - videoRef.current?.seek(tick / 10000000); - sliding.current = false; - }, - [videoRef] - ); - - const handleSliderChange = useCallback( - (val: number) => { - if (opacity.value === 0) return; - const tick = Math.floor(val); - progress.value = tick; - calculateTrickplayUrl(progress); - showControls(); - }, - [progress, calculateTrickplayUrl, showControls] - ); - - const handleGoToPreviousItem = useCallback(() => { - if (!previousItem || !from) return; - const url = itemRouter(previousItem, from); - stopPlayback(); - // @ts-ignore - router.push(url); - }, [previousItem, from, stopPlayback, router]); - - const handleGoToNextItem = useCallback(() => { - if (!nextItem || !from) return; - const url = itemRouter(nextItem, from); - stopPlayback(); - // @ts-ignore - router.push(url); - }, [nextItem, from, stopPlayback, router]); - - const videoTap = Gesture.Tap().onBegin(() => { - runOnJS(handleToggleControlsPress)(); - }); - - const toggleIgnoreSafeAreaGesture = Gesture.Tap() - .enabled(opacity.value !== 0) - .onStart(() => { - runOnJS(toggleIgnoreSafeArea)(); - }); - - const playPauseGesture = Gesture.Tap() - .onBegin(() => { - console.log("playPauseGesture ~", opacity.value); - }) - .onStart(() => { - runOnJS(handlePlayPause)(); - }) - .onFinalize(() => { - if (opacity.value === 0) opacity.value = 1; - }); - - const goToPreviouItemGesture = Gesture.Tap() - .enabled(opacity.value !== 0) - .onStart(() => { - runOnJS(handleGoToPreviousItem)(); - }); - - const goToNextItemGesture = Gesture.Tap() - .enabled(opacity.value !== 0) - .onStart(() => { - runOnJS(handleGoToNextItem)(); - }); - - const skipBackwardGesture = Gesture.Tap() - .enabled(opacity.value !== 0) - .onStart(() => { - runOnJS(handleSkipBackward)(); - }); - - const skipForwardGesture = Gesture.Tap() - .enabled(opacity.value !== 0) - .onStart(() => { - runOnJS(handleSkipForward)(); - }); - - const skipIntroGesture = Gesture.Tap().onStart(() => { - runOnJS(skipIntro)(); - }); - - if (!api || !currentlyPlaying) return null; + if (!currentlyPlaying) return null; return ( { }} > ); }; diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index 5c724b5c..1fb0f720 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -9,6 +9,8 @@ interface Props extends ViewProps {} export const MediaToggles: React.FC = ({ ...props }) => { const [settings, updateSettings] = useSettings(); + if (!settings) return null; + return ( Media @@ -119,6 +121,82 @@ export const MediaToggles: React.FC = ({ ...props }) => { + + + + Forward skip length + + Choose length in seconds when skipping in video playback. + + + + + updateSettings({ + forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), + }) + } + className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" + > + - + + + {settings.forwardSkipTime}s + + + updateSettings({ + forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), + }) + } + > + + + + + + + + + Rewind length + + Choose length in seconds when skipping in video playback. + + + + + updateSettings({ + rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), + }) + } + className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" + > + - + + + {settings.rewindSkipTime}s + + + updateSettings({ + rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), + }) + } + > + + + + + ); diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts index 502faefa..bf51709b 100644 --- a/hooks/useTrickplay.ts +++ b/hooks/useTrickplay.ts @@ -34,7 +34,7 @@ export const useTrickplay = ( const [api] = useAtom(apiAtom); const [trickPlayUrl, setTrickPlayUrl] = useState(null); const lastCalculationTime = useRef(0); - const throttleDelay = 100; // 200ms throttle + const throttleDelay = 200; // 200ms throttle const trickplayInfo = useMemo(() => { if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) { @@ -62,7 +62,7 @@ export const useTrickplay = ( }, [currentlyPlaying]); const calculateTrickplayUrl = useCallback( - (progress: SharedValue) => { + (progress: number) => { const now = Date.now(); if (now - lastCalculationTime.current < throttleDelay) { return null; @@ -80,7 +80,7 @@ export const useTrickplay = ( throw new Error("Invalid trickplay data"); } - const currentSecond = Math.max(0, Math.floor(progress.value / 10000000)); + const currentSecond = Math.max(0, Math.floor(progress / 10000000)); const cols = TileWidth; const rows = TileHeight; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 2a09ca93..571ec2ba 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -70,6 +70,8 @@ type Settings = { defaultAudioLanguage: DefaultLanguageOption | null; showHomeTitles: boolean; defaultVideoOrientation: ScreenOrientation.OrientationLock; + forwardSkipTime: number; + rewindSkipTime: number; }; /** @@ -103,6 +105,8 @@ const loadSettings = async (): Promise => { defaultSubtitleLanguage: null, showHomeTitles: true, defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT, + forwardSkipTime: 30, + rewindSkipTime: 10, }; try { diff --git a/utils/secondsToTicks.ts b/utils/secondsToTicks.ts index 8d8d38db..df13813e 100644 --- a/utils/secondsToTicks.ts +++ b/utils/secondsToTicks.ts @@ -1,6 +1,5 @@ // seconds to ticks util export function secondsToTicks(seconds: number): number { - "worklet"; return seconds * 10000000; } diff --git a/utils/time.ts b/utils/time.ts index c12d8da8..80b44cf0 100644 --- a/utils/time.ts +++ b/utils/time.ts @@ -6,7 +6,7 @@ * @returns A string formatted as "Xh Ym" where X is hours and Y is minutes. */ export const runtimeTicksToMinutes = ( - ticks: number | null | undefined, + ticks: number | null | undefined ): string => { if (!ticks) return "0h 0m"; @@ -20,7 +20,7 @@ export const runtimeTicksToMinutes = ( }; export const runtimeTicksToSeconds = ( - ticks: number | null | undefined, + ticks: number | null | undefined ): string => { if (!ticks) return "0h 0m"; @@ -34,3 +34,37 @@ export const runtimeTicksToSeconds = ( if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; else return `${minutes}m ${seconds}s`; }; + +export const formatTimeString = ( + t: number | null | undefined, + tick = false +): string => { + if (t === null || t === undefined) return "0:00"; + + let seconds = t; + if (tick) { + seconds = Math.floor(t / 10000000); // Convert ticks to seconds + } + + if (seconds < 0) return "0:00"; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}h ${minutes}m ${remainingSeconds}s`; + } else { + return `${minutes}m ${remainingSeconds}s`; + } +}; + +export const secondsToTicks = (seconds?: number | undefined) => { + if (!seconds) return 0; + return seconds * 10000000; +}; + +export const ticksToSeconds = (ticks?: number | undefined) => { + if (!ticks) return 0; + return ticks / 10000000; +};