diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 00e9ccc1..07ba98a9 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -1,4 +1,5 @@ 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"; @@ -6,8 +7,10 @@ import { usePlayback } from "@/providers/PlaybackProvider"; 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"; @@ -31,8 +34,6 @@ 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 { @@ -44,17 +45,20 @@ export const CurrentlyPlayingBar: React.FC = () => { setIsPlaying, isPlaying, videoRef, - presentFullscreenPlayer, progressTicks, onProgress, isBuffering: _isBuffering, setIsBuffering, } = usePlayback(); + + useNavigationBarVisibility(isPlaying); + const insets = useSafeAreaInsets(); const segments = useSegments(); const router = useRouter(); - useNavigationBarVisibility(isPlaying); + const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = + useTrickplay(currentlyPlaying); const [api] = useAtom(apiAtom); @@ -65,30 +69,21 @@ export const CurrentlyPlayingBar: React.FC = () => { 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 { isVisible, showControls, hideControls } = useControlsVisibility(3000); const animatedControlsStyle = useAnimatedStyle(() => { return { - opacity: withTiming(controlsOpacity.value > 0 ? 1 : 0, { + opacity: withTiming(isVisible ? 1 : 0, { duration: 300, }), }; @@ -133,32 +128,6 @@ export const CurrentlyPlayingBar: React.FC = () => { }; }, [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]); @@ -185,7 +154,7 @@ export const CurrentlyPlayingBar: React.FC = () => { const animatedVideoContainerStyle = useAnimatedStyle(() => { return { opacity: withTiming( - controlsOpacity.value > 0 || localIsBuffering.value === true ? 0.5 : 1, + isVisible || localIsBuffering.value === true ? 0.5 : 1, { duration: 300, } @@ -193,34 +162,7 @@ export const CurrentlyPlayingBar: React.FC = () => { }; }); - 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, }); @@ -232,7 +174,6 @@ export const CurrentlyPlayingBar: React.FC = () => { return null; } - console.log("Getting intro timestamps"); const res = await api?.axiosInstance.get( `${api.basePath}/Episode/${currentlyPlaying.item.Id}/IntroTimestamps`, { @@ -263,11 +204,7 @@ export const CurrentlyPlayingBar: React.FC = () => { progress.value > showButtonAt && progress.value < hideButtonAt; return { opacity: withTiming( - localIsBuffering.value === false && - controlsOpacity.value > 0 && - showButton - ? 1 - : 0, + localIsBuffering.value === false && isVisible && showButton ? 1 : 0, { duration: 300, } @@ -281,371 +218,369 @@ export const CurrentlyPlayingBar: React.FC = () => { }, [introTimestamps]); useEffect(() => { - console.log({ introTimestamps }); - }, [introTimestamps]); + showControls(); + }, [currentlyPlaying]); if (!api || !currentlyPlaying) return null; return ( - - - + + + { + 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" + > + + + + + + + + { + 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) { + hideControls(); + } else { + showControls(); + } + }} + style={{ + width: "100%", + height: "100%", + }} > - - { - if (controlsOpacity.value === 0) return; - toggleIgnoreSafeArea(); + {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 (!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(); }} - className="aspect-square rounded flex flex-col items-center justify-center p-2" > + + { + if (!isVisible) return; + if (isPlaying) pauseVideo(); + else playVideo(); + showControls(); + }} + > + { - if (controlsOpacity.value === 0) return; - stopPlayback(); + onPress={async () => { + if (!isVisible) return; + const curr = await videoRef.current?.getCurrentPosition(); + if (!curr) return; + videoRef.current?.seek(Math.max(0, curr + 15)); + showControls(); }} - className="aspect-square rounded flex flex-col items-center justify-center p-2" > - + - - - - - { - if (controlsOpacity.value === 0) return; - skipIntro(); + disabled={!nextItem} + style={{ + opacity: !nextItem ? 0.5 : 1, + }} + onPress={() => { + if (!isVisible) return; + if (!nextItem || !from) return; + const url = itemRouter(nextItem, from); + stopPlayback(); + // @ts-ignore + router.push(url); }} - className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full" > - Skip intro + - + + { + 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; - - { - 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)} - - + source={{ uri: url }} + contentFit="cover" + /> + + ); + }} + sliderHeight={8} + thumbWidth={0} + progress={progress} + minimumValue={min} + maximumValue={max} + /> + + + {runtimeTicksToSeconds(progress.value)} + + + -{runtimeTicksToSeconds(max.value - progress.value)} + - + + - - - - + + + ); }; diff --git a/hooks/useAdjacentEpisodes.ts b/hooks/useAdjacentEpisodes.ts index 3616021f..ed371383 100644 --- a/hooks/useAdjacentEpisodes.ts +++ b/hooks/useAdjacentEpisodes.ts @@ -3,16 +3,18 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { useQuery } from "@tanstack/react-query"; import { CurrentlyPlayingState } from "@/providers/PlaybackProvider"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; interface AdjacentEpisodesProps { - api: Api | null; currentlyPlaying?: CurrentlyPlayingState | null; } export const useAdjacentEpisodes = ({ - api, currentlyPlaying, }: AdjacentEpisodesProps) => { + const [api] = useAtom(apiAtom); + const { data: previousItem } = useQuery({ queryKey: [ "previousItem", diff --git a/hooks/useControlsVisibility.ts b/hooks/useControlsVisibility.ts new file mode 100644 index 00000000..3ccfd0ef --- /dev/null +++ b/hooks/useControlsVisibility.ts @@ -0,0 +1,25 @@ +import { useRef, useCallback, useState, useEffect } from "react"; + +export const useControlsVisibility = (timeout: number = 3000) => { + const [isVisible, setIsVisible] = useState(true); + const hideControlsTimerRef = useRef(null); + + const showControls = useCallback(() => { + setIsVisible(true); + if (hideControlsTimerRef.current) { + clearTimeout(hideControlsTimerRef.current); + } + hideControlsTimerRef.current = setTimeout(() => { + setIsVisible(false); + }, timeout); + }, [timeout]); + + const hideControls = useCallback(() => { + setIsVisible(false); + if (hideControlsTimerRef.current) { + clearTimeout(hideControlsTimerRef.current); + } + }, []); + + return { isVisible, showControls, hideControls }; +}; diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts index 47d4ebfd..264d6a0a 100644 --- a/hooks/useTrickplay.ts +++ b/hooks/useTrickplay.ts @@ -1,19 +1,25 @@ // hooks/useTrickplay.ts -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import { Api } from "@jellyfin/sdk"; import { SharedValue } from "react-native-reanimated"; +import { CurrentlyPlayingState } from "@/providers/PlaybackProvider"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; + +interface TrickplayData { + Interval?: number; + TileWidth?: number; + TileHeight?: number; + Height?: number; + Width?: number; + ThumbnailCount?: number; +} interface TrickplayInfo { - data: { - Interval?: number; - TileWidth?: number; - TileHeight?: number; - Height?: number; - Width?: number; - ThumbnailCount?: number; - }; - resolution?: string; + resolution: string; + aspectRatio: number; + data: TrickplayData; } interface TrickplayUrl { @@ -22,33 +28,47 @@ interface TrickplayUrl { url: string; } -export const useTrickplay = () => { +export const useTrickplay = ( + currentlyPlaying?: CurrentlyPlayingState | null +) => { + const [api] = useAtom(apiAtom); const [trickPlayUrl, setTrickPlayUrl] = useState(null); + 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 calculateTrickplayUrl = useCallback( - ( - info: TrickplayInfo | null, - progress: SharedValue, - api: Api | null, - id: string - ) => { - if (!info || !id || !api) { + (progress: SharedValue) => { + if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) { return null; } - const { data, resolution } = info; - const { Interval, TileWidth, TileHeight, Height, Width, ThumbnailCount } = - data; + const { data, resolution } = trickplayInfo; + const { Interval, TileWidth, TileHeight } = data; - if ( - !Interval || - !TileWidth || - !TileHeight || - !Height || - !Width || - !ThumbnailCount || - !resolution - ) { + if (!Interval || !TileWidth || !TileHeight || !resolution) { throw new Error("Invalid trickplay data"); } @@ -67,14 +87,14 @@ export const useTrickplay = () => { const newTrickPlayUrl = { x: rowInTile, y: colInTile, - url: `${api.basePath}/Videos/${id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`, + url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`, }; setTrickPlayUrl(newTrickPlayUrl); return newTrickPlayUrl; }, - [] + [trickplayInfo, currentlyPlaying, api] ); - return { trickPlayUrl, calculateTrickplayUrl }; + return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo }; };