import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; import { useControlsVisibility } from "@/hooks/useControlsVisibility"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; 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 { 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 { useNavigation, useRouter, useSegments } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, AppState, AppStateStatus, BackHandler, Dimensions, Platform, 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 { Text } from "./common/Text"; import { itemRouter } from "./common/TouchableItemRouter"; import { Loader } from "./Loader"; import * as NavigationBar from "expo-navigation-bar"; import { setStatusBarHidden, StatusBar } from "expo-status-bar"; import orientationToOrientationLock from "@/utils/OrientationLockConverter"; import { BlurView } from "expo-blur"; import { PlatformBlurView } from "./PlatformBlurView"; async function lockOrientation(orientation: ScreenOrientation.OrientationLock) { await ScreenOrientation.lockAsync(orientation); } async function resetOrientation() { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); } export const FullScreenVideoPlayer: React.FC = () => { const { currentlyPlaying, pauseVideo, playVideo, stopPlayback, setVolume, setIsPlaying, isPlaying, videoRef, onProgress, isBuffering: _isBuffering, setIsBuffering, } = usePlayback(); const [settings] = useSettings(); const [api] = useAtom(apiAtom); const insets = useSafeAreaInsets(); const segments = useSegments(); const router = useRouter(); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(currentlyPlaying); const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying }); const { showControls, hideControls, opacity } = useControlsVisibility(3000); const [isInteractive, setIsInteractive] = useState(true); const [orientation, setOrientation] = useState( ScreenOrientation.OrientationLock.UNKNOWN ); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); const from = useMemo(() => segments[2], [segments]); const progress = useSharedValue(0); const min = useSharedValue(0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); const sliding = useRef(false); const localIsBuffering = useSharedValue(false); const cacheProgress = useSharedValue(0); const [isStatusBarHidden, setIsStatusBarHidden] = useState(false); 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", onPress: () => null, style: "cancel", }, { text: "Yes", onPress: () => stopPlayback() }, ]); return true; } return false; }; const backHandler = BackHandler.addEventListener( "hardwareBackPress", backAction ); return () => backHandler.remove(); }, [currentlyPlaying]); const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); 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, // Change here }, }; }, [currentlyPlaying, api, poster]); useEffect(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => { if (nextAppState === "active") { setIsInteractive(true); showControls(); } else { setIsInteractive(false); } }; const subscription = AppState.addEventListener( "change", handleAppStateChange ); return () => { subscription.remove(); }; }, [showControls]); useEffect(() => { max.value = currentlyPlaying?.item.RunTimeTicks || 0; }, [currentlyPlaying?.item.RunTimeTicks]); useEffect(() => { if (!currentlyPlaying) { resetOrientation(); progress.value = 0; min.value = 0; max.value = 0; cacheProgress.value = 0; localIsBuffering.value = false; sliding.current = false; hideControls(); setStatusBarHidden(false); // NavigationBar.setVisibilityAsync("visible") } else { setStatusBarHidden(true); // NavigationBar.setVisibilityAsync("hidden") lockOrientation( settings?.defaultVideoOrientation || ScreenOrientation.OrientationLock.DEFAULT ); progress.value = currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; showControls(); } }, [currentlyPlaying, settings]); /** * 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 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 ? 1 : 0, { duration: 300 }), })), }; 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 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 ? 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 () => { 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(() => { if (isPlaying) pauseVideo(); else playVideo(); showControls(); }, [isPlaying, pauseVideo, playVideo, showControls]); const handleSliderStart = useCallback(() => { sliding.current = true; }, []); const handleSliderComplete = useCallback( (val: number) => { const tick = Math.floor(val); videoRef.current?.seek(tick / 10000000); sliding.current = false; }, [videoRef] ); const handleSliderChange = useCallback( (val: number) => { 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() .enabled(opacity.value !== 0) .onStart(() => { runOnJS(handlePlayPause)(); }); 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; return ( ); };