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 { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; import { useTrickplay } from "@/hooks/useTrickplay"; import { Text } from "./common/Text"; import { Loader } from "./Loader"; 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"; const windowDimensions = Dimensions.get("window"); const screenDimensions = Dimensions.get("screen"); export const FullScreenVideoPlayer: React.FC = () => { const { currentlyPlaying, pauseVideo, playVideo, stopPlayback, setVolume, setIsPlaying, isPlaying, videoRef, onProgress, setIsBuffering, } = usePlayback(); const [settings] = useSettings(); const [api] = useAtom(apiAtom); const router = useRouter(); const segments = useSegments(); const insets = useSafeAreaInsets(); const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(currentlyPlaying); const [showControls, setShowControls] = useState(true); const [isBuffering, setIsBufferingState] = useState(true); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); 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 [dimensions, setDimensions] = useState({ window: windowDimensions, screen: screenDimensions, }); useEffect(() => { const subscription = Dimensions.addEventListener( "change", ({ window, screen }) => { setDimensions({ window, screen }); } ); return () => subscription?.remove(); }); 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) { Alert.alert("Hold on!", "Are you sure you want to exit?", [ { text: "Cancel", onPress: () => null, style: "cancel", }, { text: "Yes", onPress: () => { stopPlayback(); router.back(); }, }, ]); return true; } return false; }; const backHandler = BackHandler.addEventListener( "hardwareBackPress", backAction ); return () => backHandler.remove(); }, [currentlyPlaying, stopPlayback, router]); const [orientation, setOrientation] = useState( ScreenOrientation.OrientationLock.UNKNOWN ); /** * Event listener for orientation */ useEffect(() => { const subscription = ScreenOrientation.addOrientationChangeListener( (event) => { setOrientation( orientationToOrientationLock(event.orientationInfo.orientation) ); } ); ScreenOrientation.getOrientationAsync().then((orientation) => { setOrientation(orientationToOrientationLock(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 ""; return currentlyPlaying.item.Type === "Audio" ? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200` : getBackdropUrl({ api, item: currentlyPlaying.item, quality: 70, width: 200, }); }, [currentlyPlaying?.item, api]); const videoSource = useMemo(() => { if (!api || !currentlyPlaying || !poster) return null; const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks ? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000) : 0; return { uri: currentlyPlaying.url, isNetwork: true, startPosition, headers: getAuthHeaders(api), metadata: { artist: currentlyPlaying.item?.AlbumArtist ?? undefined, title: currentlyPlaying.item?.Name || "Unknown", description: currentlyPlaying.item?.Overview ?? undefined, imageUri: poster, subtitle: currentlyPlaying.item?.Album ?? undefined, }, }; }, [currentlyPlaying, api, poster]); useEffect(() => { if (currentlyPlaying) { progress.value = currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; setShowControls(true); } }, [currentlyPlaying]); const toggleControls = () => setShowControls(!showControls); 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 = useCallback(() => { if (isPlaying) pauseVideo(); else playVideo(); }, [isPlaying, pauseVideo, 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 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 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 = 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 toggleIgnoreSafeArea = useCallback(() => { setIgnoreSafeArea((prev) => !prev); }, []); const { data: introTimestamps } = useQuery({ queryKey: ["introTimestamps", currentlyPlaying?.item.Id], queryFn: async () => { if (!currentlyPlaying?.item.Id) { console.log("No item id"); return null; } const res = await api?.axiosInstance.get( `${api.basePath}/Episode/${currentlyPlaying.item.Id}/IntroTimestamps`, { headers: getAuthHeaders(api), } ); if (res?.status !== 200) { return null; } return res?.data as { EpisodeId: string; HideSkipPromptAt: number; IntroEnd: number; IntroStart: number; ShowSkipPromptAt: number; Valid: boolean; }; }, enabled: !!currentlyPlaying?.item.Id, }); const skipIntro = useCallback(async () => { if (!introTimestamps || !videoRef.current) return; try { videoRef.current.seek(introTimestamps.IntroEnd); } catch (error) { writeToLog("ERROR", "Error skipping intro", error); } }, [introTimestamps]); if (!currentlyPlaying) return null; return ( {videoSource && ( {(showControls || isBuffering) && ( )} {isBuffering && ( )} {introTimestamps && currentTime > introTimestamps.ShowSkipPromptAt && currentTime < introTimestamps.HideSkipPromptAt && ( Skip Intro )} {showControls && ( <> { stopPlayback(); router.back(); }} className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2" > {currentlyPlaying.item?.Name} {currentlyPlaying.item?.Type === "Episode" && ( {currentlyPlaying.item.SeriesName} )} {currentlyPlaying.item?.Type === "Movie" && ( {currentlyPlaying.item?.ProductionYear} )} {currentlyPlaying.item?.Type === "Audio" && ( {currentlyPlaying.item?.Album} )} { 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)} )} ); };