import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; import { useNavigationBarVisibility } from "@/hooks/useNavigationBarVisibility"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; import { usePlayback } from "@/providers/PlaybackProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { writeToLog } from "@/utils/log"; import { runtimeTicksToSeconds } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; import { useRouter, useSegments } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Dimensions, Pressable, TouchableOpacity, View, } from "react-native"; import { Slider } from "react-native-awesome-slider"; import "react-native-gesture-handler"; import Animated, { useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Video from "react-native-video"; import { Text } from "./common/Text"; import { itemRouter } from "./common/TouchableItemRouter"; import { Loader } from "./Loader"; import { useQuery } from "@tanstack/react-query"; import { secondsToTicks } from "@/utils/secondsToTicks"; export const CurrentlyPlayingBar: React.FC = () => { const { currentlyPlaying, pauseVideo, playVideo, stopPlayback, setVolume, setIsPlaying, isPlaying, videoRef, presentFullscreenPlayer, progressTicks, onProgress, isBuffering: _isBuffering, setIsBuffering, } = usePlayback(); const insets = useSafeAreaInsets(); const segments = useSegments(); const router = useRouter(); useNavigationBarVisibility(isPlaying); const [api] = useAtom(apiAtom); const from = useMemo(() => segments[2], [segments]); const [ignoreSafeArea, setIgnoreSafeArea] = useState(false); const screenHeight = Dimensions.get("window").height; const screenWidth = Dimensions.get("window").width; const controlsOpacity = useSharedValue(1); const progress = useSharedValue(progressTicks || 0); const min = useSharedValue(0); const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0); const sliding = useRef(false); const localIsBuffering = useSharedValue(false); // const hideControlsTimerRef = useRef(null); const toggleIgnoreSafeArea = () => { setIgnoreSafeArea((prev) => !prev); }; const showControls = () => { controlsOpacity.value = 1; }; const hideControls = () => { controlsOpacity.value = 0; }; const animatedControlsStyle = useAnimatedStyle(() => { return { opacity: withTiming(controlsOpacity.value > 0 ? 1 : 0, { duration: 300, }), }; }); const poster = useMemo(() => { if (currentlyPlaying?.item.Type === "Audio") return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`; else return getBackdropUrl({ api, item: currentlyPlaying?.item, quality: 70, width: 200, }); }, [currentlyPlaying?.item.Id, api]); const startPosition = useMemo( () => currentlyPlaying?.item?.UserData?.PlaybackPositionTicks ? Math.round( currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000 ) : 0, [currentlyPlaying?.item] ); const videoSource = useMemo(() => { if (!api || !currentlyPlaying || !poster) return null; 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, startPosition, api, poster]); const showControlsAndResetTimer = () => { showControls(); // resetHideControlsTimer(); }; // const resetHideControlsTimer = () => { // if (hideControlsTimerRef.current) { // clearTimeout(hideControlsTimerRef.current); // } // hideControlsTimerRef.current = setTimeout(() => { // hideControls(); // }, 3000); // }; // useEffect(() => { // if (controlsOpacity.value > 0) { // resetHideControlsTimer(); // } // return () => { // if (hideControlsTimerRef.current) { // clearTimeout(hideControlsTimerRef.current); // } // }; // }, [controlsOpacity.value]); useEffect(() => { max.value = currentlyPlaying?.item.RunTimeTicks || 0; }, [currentlyPlaying?.item.RunTimeTicks]); const videoContainerStyle = { position: "absolute" as const, top: 0, bottom: 0, left: ignoreSafeArea ? 0 : insets.left, right: ignoreSafeArea ? 0 : insets.right, width: ignoreSafeArea ? screenWidth : screenWidth - (insets.left + insets.right), }; const animatedLoaderStyle = useAnimatedStyle(() => { return { opacity: withTiming(localIsBuffering.value === true ? 1 : 0, { duration: 300, }), }; }); const animatedVideoContainerStyle = useAnimatedStyle(() => { return { opacity: withTiming( controlsOpacity.value > 0 || localIsBuffering.value === true ? 0.5 : 1, { duration: 300, } ), }; }); const trickplayInfo = useMemo(() => { if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) { return null; } const mediaSourceId = currentlyPlaying.item.Id; const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId]; if (!trickplayData) { return null; } // Get the first available resolution const firstResolution = Object.keys(trickplayData)[0]; return firstResolution ? { resolution: firstResolution, aspectRatio: trickplayData[firstResolution].Width! / trickplayData[firstResolution].Height!, data: trickplayData[firstResolution], } : null; }, [currentlyPlaying]); const { trickPlayUrl, calculateTrickplayUrl } = useTrickplay(); const { previousItem, nextItem } = useAdjacentEpisodes({ api, currentlyPlaying, }); const { data: introTimestamps } = useQuery({ queryKey: ["introTimestamps", currentlyPlaying?.item.Id], queryFn: async () => { if (!currentlyPlaying?.item.Id) { console.log("No item id"); return null; } console.log("Getting intro timestamps"); 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 && controlsOpacity.value > 0 && showButton ? 1 : 0, { duration: 300, } ), }; }); const skipIntro = useCallback(async () => { if (!introTimestamps) return; videoRef.current?.seek(introTimestamps.IntroEnd); }, [introTimestamps]); useEffect(() => { console.log({ introTimestamps }); }, [introTimestamps]); if (!api || !currentlyPlaying) return null; return ( { if (controlsOpacity.value === 0) return; toggleIgnoreSafeArea(); }} className="aspect-square rounded flex flex-col items-center justify-center p-2" > { if (controlsOpacity.value === 0) return; stopPlayback(); }} className="aspect-square rounded flex flex-col items-center justify-center p-2" > { if (controlsOpacity.value === 0) return; skipIntro(); }} className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full" > Skip intro { if (controlsOpacity.value > 0) { hideControls(); } else { showControlsAndResetTimer(); } }} style={{ width: "100%", height: "100%", }} > {videoSource && ( {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 (controlsOpacity.value === 0) return; if (!previousItem || !from) return; const url = itemRouter(previousItem, from); stopPlayback(); // @ts-ignore router.push(url); }} > { if (controlsOpacity.value === 0) return; const curr = await videoRef.current?.getCurrentPosition(); if (!curr) return; videoRef.current?.seek(Math.max(0, curr - 15)); // resetHideControlsTimer(); }} > { if (controlsOpacity.value === 0) return; if (isPlaying) pauseVideo(); else playVideo(); // resetHideControlsTimer(); }} > { if (controlsOpacity.value === 0) return; const curr = await videoRef.current?.getCurrentPosition(); if (!curr) return; videoRef.current?.seek(Math.max(0, curr + 15)); // resetHideControlsTimer(); }} > { if (controlsOpacity.value === 0) return; if (!nextItem || !from) return; const url = itemRouter(nextItem, from); stopPlayback(); // @ts-ignore router.push(url); }} > { if (controlsOpacity.value === 0) return; sliding.current = true; }} onSlidingComplete={(val) => { if (controlsOpacity.value === 0) return; const tick = Math.floor(val); videoRef.current?.seek(tick / 10000000); sliding.current = false; }} onValueChange={(val) => { if (controlsOpacity.value === 0) return; const tick = Math.floor(val); progress.value = tick; calculateTrickplayUrl( trickplayInfo, progress, api, currentlyPlaying.item.Id! ); // resetHideControlsTimer(); }} containerStyle={{ borderRadius: 100, }} renderBubble={() => { if (!trickPlayUrl || !trickplayInfo) { return null; } const { x, y, url } = trickPlayUrl; const tileWidth = 150; const tileHeight = 150 / trickplayInfo.aspectRatio!; return ( ); }} sliderHeight={8} thumbWidth={0} progress={progress} minimumValue={min} maximumValue={max} /> {runtimeTicksToSeconds(progress.value)} -{runtimeTicksToSeconds(max.value - progress.value)} ); };