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"; 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 { width: screenWidth, height: screenHeight } = Dimensions.get("window"); 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() }, ]); return true; } return false; }; const backHandler = BackHandler.addEventListener( "hardwareBackPress", backAction ); return () => backHandler.remove(); }, [currentlyPlaying, stopPlayback]); 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 ""; 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) { ScreenOrientation.unlockAsync(); progress.value = 0; max.value = 0; setShowControls(true); setIsStatusBarHidden(false); isSeeking.value = false; } else { setIsStatusBarHidden(true); ScreenOrientation.lockAsync( settings?.defaultVideoOrientation || ScreenOrientation.OrientationLock.DEFAULT ); progress.value = currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; setShowControls(true); } }, [currentlyPlaying, settings]); 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 = () => { 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 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 = () => { 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({ 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 = async () => { if (!introTimestamps || !videoRef.current) return; try { videoRef.current.seek(introTimestamps.IntroEnd); } catch (error) { writeToLog("ERROR", "Error skipping intro", error); } }; if (!currentlyPlaying) return null; return ( ); };