import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes"; import { useControlsVisibility } from "@/hooks/useControlsVisibility"; import { useNavigationBarVisibility } from "@/hooks/useNavigationBarVisibility"; import { useTrickplay } from "@/hooks/useTrickplay"; import { apiAtom } from "@/providers/JellyfinProvider"; import { usePlayback } from "@/providers/PlaybackProvider"; import { parseM3U8ForSubtitles } from "@/utils/hls/parseM3U8ForSubtitles"; 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 { 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 * as ScreenOrientation from "expo-screen-orientation"; import { useSettings } from "@/utils/atoms/settings"; async function setOrientation(orientation: ScreenOrientation.OrientationLock) { await ScreenOrientation.lockAsync(orientation); } async function resetOrientation() { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); } export const CurrentlyPlayingBar: React.FC = () => { const { currentlyPlaying, pauseVideo, playVideo, stopPlayback, setVolume, setIsPlaying, isPlaying, videoRef, onProgress, isBuffering: _isBuffering, setIsBuffering, } = usePlayback(); useNavigationBarVisibility(isPlaying); const [settings] = useSettings(); const insets = useSafeAreaInsets(); const segments = useSegments(); const router = useRouter(); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(currentlyPlaying); 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 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 toggleIgnoreSafeArea = () => { setIgnoreSafeArea((prev) => !prev); }; const { isVisible, showControls, hideControls } = useControlsVisibility(3000); const animatedControlsStyle = useAnimatedStyle(() => { return { opacity: withTiming(isVisible ? 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]); 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( isVisible || localIsBuffering.value === true ? 0.5 : 1, { duration: 300, } ), }; }); const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying, }); 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 && isVisible && showButton ? 1 : 0, { duration: 300, } ), }; }); const skipIntro = useCallback(async () => { if (!introTimestamps) return; videoRef.current?.seek(introTimestamps.IntroEnd); }, [introTimestamps]); useEffect(() => { showControls(); }, [currentlyPlaying]); const { data: subtitleTracks } = useQuery({ queryKey: ["subtitleTracks", currentlyPlaying?.url], queryFn: async () => { if (!currentlyPlaying?.url) { console.log("No item url"); return null; } const tracks = await parseM3U8ForSubtitles(currentlyPlaying.url); console.log("Subtitle tracks", tracks); return tracks; }, }); /** * This should clean up all values if curentlyPlaying sets to null or changes */ useEffect(() => { if (!currentlyPlaying) { progress.value = 0; min.value = 0; max.value = 0; cacheProgress.value = 0; localIsBuffering.value = false; sliding.current = false; hideControls(); } else { progress.value = currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0; max.value = currentlyPlaying.item.RunTimeTicks || 0; showControls(); } }, [currentlyPlaying]); useEffect(() => { if (!currentlyPlaying) { resetOrientation(); } else { setOrientation( settings?.defaultVideoOrientation || ScreenOrientation.OrientationLock.DEFAULT ); } }, [settings, currentlyPlaying]); if (!api || !currentlyPlaying) return null; return ( { if (isVisible) { hideControls(); } else { showControls(); } }} style={{ width: "100%", height: "100%", }} > {videoSource && ( { if (!isVisible) return; skipIntro(); }} className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full" > Skip intro { if (!isVisible) return; toggleIgnoreSafeArea(); }} className="aspect-square rounded flex flex-col items-center justify-center p-2" > { if (!isVisible) return; stopPlayback(); }} className="aspect-square rounded flex flex-col 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 (!isVisible) return; if (!previousItem || !from) return; const url = itemRouter(previousItem, from); stopPlayback(); // @ts-ignore router.push(url); }} > { if (!isVisible) return; const curr = await videoRef.current?.getCurrentPosition(); if (!curr) return; videoRef.current?.seek(Math.max(0, curr - 15)); showControls(); }} > { if (!isVisible) return; if (isPlaying) pauseVideo(); else playVideo(); showControls(); }} > { if (!isVisible) return; const curr = await videoRef.current?.getCurrentPosition(); if (!curr) return; videoRef.current?.seek(Math.max(0, curr + 15)); showControls(); }} > { if (!isVisible) return; if (!nextItem || !from) return; const url = itemRouter(nextItem, from); stopPlayback(); // @ts-ignore router.push(url); }} > { if (!isVisible) return; sliding.current = true; }} onSlidingComplete={(val) => { if (!isVisible) return; const tick = Math.floor(val); videoRef.current?.seek(tick / 10000000); sliding.current = false; }} onValueChange={(val) => { if (!isVisible) return; const tick = Math.floor(val); progress.value = tick; calculateTrickplayUrl(progress); showControls(); }} 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)} ); };