diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 9a55adf3..7a01caba 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -45,7 +45,6 @@ export const AudioTrackSelector: React.FC = ({ } const index = source.DefaultAudioStreamIndex; if (index !== undefined && index !== null) { - console.log("DefaultAudioStreamIndex", index); onChange(index); return; } diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index f0c26173..ab8fad03 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -1,3 +1,6 @@ +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"; @@ -5,17 +8,13 @@ import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { writeToLog } from "@/utils/log"; import { runtimeTicksToSeconds } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; -import { Api } from "@jellyfin/sdk"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useRouter, useSegments } from "expo-router"; import { useAtom } from "jotai"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Dimensions, - Platform, Pressable, TouchableOpacity, View, @@ -23,7 +22,6 @@ import { import { Slider } from "react-native-awesome-slider"; import "react-native-gesture-handler"; import Animated, { - SharedValue, useAnimatedStyle, useSharedValue, withTiming, @@ -33,7 +31,8 @@ import Video 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 { useQuery } from "@tanstack/react-query"; +import { secondsToTicks } from "@/utils/secondsToTicks"; export const CurrentlyPlayingBar: React.FC = () => { const { @@ -55,6 +54,8 @@ export const CurrentlyPlayingBar: React.FC = () => { const segments = useSegments(); const router = useRouter(); + useNavigationBarVisibility(isPlaying); + const [api] = useAtom(apiAtom); const from = useMemo(() => segments[2], [segments]); @@ -217,144 +218,71 @@ export const CurrentlyPlayingBar: React.FC = () => { : null; }, [currentlyPlaying]); - const [trickPlayUrl, _setTrickPlayUrl] = useState<{ - x: number; - y: number; - url: string; - } | null>(null); + const { trickPlayUrl, calculateTrickplayUrl } = useTrickplay(); + const { previousItem, nextItem } = useAdjacentEpisodes({ + api, + currentlyPlaying, + }); - const setTrickplayUrl = ( - info: typeof trickplayInfo | null, - progress: SharedValue, - api: Api, - id: string - ) => { - if (!info || !id || !api) { - return null; - } + const { data: introTimestamps } = useQuery({ + queryKey: ["introTimestamps", currentlyPlaying?.item.Id], + queryFn: async () => { + if (!currentlyPlaying?.item.Id) { + console.log("No item id"); + return null; + } - const { data, resolution } = info; - const { Interval, TileWidth, TileHeight, Height, Width, ThumbnailCount } = - data; + console.log("Getting intro timestamps"); + const res = await api?.axiosInstance.get( + `${api.basePath}/Episode/${currentlyPlaying.item.Id}/IntroTimestamps`, + { + headers: getAuthHeaders(api), + } + ); - if ( - !Interval || - !TileWidth || - !TileHeight || - !Height || - !Width || - !ThumbnailCount || - !resolution - ) { - throw new Error("Invalid trickplay data"); - } + if (res?.status !== 200) { + return null; + } - const currentSecond = Math.max(0, Math.floor(progress.value / 10000000)); // Convert ticks to seconds + return res?.data as { + EpisodeId: string; + HideSkipPromptAt: number; + IntroEnd: number; + IntroStart: number; + ShowSkipPromptAt: number; + Valid: boolean; + }; + }, + enabled: !!currentlyPlaying?.item.Id, + }); - const cols = TileWidth; - const rows = TileHeight; - const imagesPerTile = cols * rows; - const imageIndex = Math.floor(currentSecond / (Interval / 1000)); // Interval is in ms - const tileIndex = Math.floor(imageIndex / imagesPerTile); - - const positionInTile = imageIndex % imagesPerTile; - const rowInTile = Math.floor(positionInTile / cols); - const colInTile = positionInTile % cols; - - const res = { - x: rowInTile, - y: colInTile, - url: `${api.basePath}/Videos/${id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`, + 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, + } + ), }; - - _setTrickPlayUrl(res); - }; - - const { data: previousItem } = useQuery({ - queryKey: [ - "previousItem", - currentlyPlaying?.item.ParentId, - currentlyPlaying?.item.IndexNumber, - ], - queryFn: async () => { - if ( - !api || - !currentlyPlaying?.item.ParentId || - currentlyPlaying?.item.IndexNumber === undefined || - currentlyPlaying?.item.IndexNumber === null || - currentlyPlaying.item.IndexNumber - 2 < 0 - ) { - console.log("No previous item"); - return null; - } - - const res = await getItemsApi(api).getItems({ - parentId: currentlyPlaying.item.ParentId!, - startIndex: currentlyPlaying.item.IndexNumber! - 2, - limit: 1, - }); - - console.log( - "Prev: ", - res.data.Items?.map((i) => i.Name) - ); - return res.data.Items?.[0]; - }, - enabled: currentlyPlaying?.item.Type === "Episode", }); - const { data: nextItem } = useQuery({ - queryKey: [ - "nextItem", - currentlyPlaying?.item.ParentId, - currentlyPlaying?.item.IndexNumber, - ], - queryFn: async () => { - if ( - !api || - !currentlyPlaying?.item.ParentId || - currentlyPlaying?.item.IndexNumber === undefined || - currentlyPlaying?.item.IndexNumber === null - ) { - console.log("No next item"); - return null; - } - - const res = await getItemsApi(api).getItems({ - parentId: currentlyPlaying.item.ParentId!, - startIndex: currentlyPlaying.item.IndexNumber!, - limit: 1, - }); - - console.log( - "Next: ", - res.data.Items?.map((i) => i.Name) - ); - return res.data.Items?.[0]; - }, - enabled: currentlyPlaying?.item.Type === "Episode", - }); - - const prevButtonEnabled = useMemo(() => { - return !!previousItem; - }, [previousItem]); - - const nextButtonEnabled = useMemo(() => { - return !!nextItem; - }, [nextItem]); + const skipIntro = useCallback(async () => { + if (!introTimestamps) return; + videoRef.current?.seek(introTimestamps.IntroEnd); + }, [introTimestamps]); useEffect(() => { - if (Platform.OS === "android") { - if (currentlyPlaying) NavigationBar.setVisibilityAsync("hidden"); - else NavigationBar.setVisibilityAsync("visible"); - } - - return () => { - if (Platform.OS === "android") { - NavigationBar.setVisibilityAsync("visible"); - } - }; - }, [currentlyPlaying]); + console.log({ introTimestamps }); + }, [introTimestamps]); if (!api || !currentlyPlaying) return null; @@ -399,6 +327,30 @@ export const CurrentlyPlayingBar: React.FC = () => { + + + { + 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 + + + + @@ -513,13 +465,12 @@ export const CurrentlyPlayingBar: React.FC = () => { { if (controlsOpacity.value === 0) return; - if (prevButtonEnabled === false) return; if (!previousItem || !from) return; const url = itemRouter(previousItem, from); stopPlayback(); @@ -573,13 +524,12 @@ export const CurrentlyPlayingBar: React.FC = () => { { if (controlsOpacity.value === 0) return; - if (nextButtonEnabled === false) return; if (!nextItem || !from) return; const url = itemRouter(nextItem, from); stopPlayback(); @@ -614,12 +564,13 @@ export const CurrentlyPlayingBar: React.FC = () => { if (controlsOpacity.value === 0) return; const tick = Math.floor(val); progress.value = tick; - setTrickplayUrl( + calculateTrickplayUrl( trickplayInfo, progress, api, currentlyPlaying.item.Id! ); + // resetHideControlsTimer(); }} containerStyle={{ diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e74f800c..ab6540c3 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -126,7 +126,6 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { if (mediaSource.SupportsDirectPlay) { if (item.MediaType === "Video") { - console.log("Using direct stream for video!"); url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; } else if (item.MediaType === "Audio") { console.log("Using direct stream for audio!"); @@ -149,7 +148,6 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { }/universal?${searchParams.toString()}`; } } else if (mediaSource.TranscodingUrl) { - console.log("Using transcoded stream!"); url = `${api.basePath}${mediaSource.TranscodingUrl}`; } diff --git a/hooks/useAdjacentEpisodes.ts b/hooks/useAdjacentEpisodes.ts new file mode 100644 index 00000000..3616021f --- /dev/null +++ b/hooks/useAdjacentEpisodes.ts @@ -0,0 +1,82 @@ +import { Api } from "@jellyfin/sdk"; +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"; + +interface AdjacentEpisodesProps { + api: Api | null; + currentlyPlaying?: CurrentlyPlayingState | null; +} + +export const useAdjacentEpisodes = ({ + api, + currentlyPlaying, +}: AdjacentEpisodesProps) => { + const { data: previousItem } = useQuery({ + queryKey: [ + "previousItem", + currentlyPlaying?.item.ParentId, + currentlyPlaying?.item.IndexNumber, + ], + queryFn: async (): Promise => { + if ( + !api || + !currentlyPlaying?.item.ParentId || + currentlyPlaying?.item.IndexNumber === undefined || + currentlyPlaying?.item.IndexNumber === null || + currentlyPlaying.item.IndexNumber - 2 < 0 + ) { + console.log("No previous item"); + return null; + } + + const res = await getItemsApi(api).getItems({ + parentId: currentlyPlaying.item.ParentId!, + startIndex: currentlyPlaying.item.IndexNumber! - 2, + limit: 1, + }); + + console.log( + "Prev: ", + res.data.Items?.map((i) => i.Name) + ); + return res.data.Items?.[0] || null; + }, + enabled: currentlyPlaying?.item.Type === "Episode", + }); + + const { data: nextItem } = useQuery({ + queryKey: [ + "nextItem", + currentlyPlaying?.item.ParentId, + currentlyPlaying?.item.IndexNumber, + ], + queryFn: async (): Promise => { + if ( + !api || + !currentlyPlaying?.item.ParentId || + currentlyPlaying?.item.IndexNumber === undefined || + currentlyPlaying?.item.IndexNumber === null + ) { + console.log("No next item"); + return null; + } + + const res = await getItemsApi(api).getItems({ + parentId: currentlyPlaying.item.ParentId!, + startIndex: currentlyPlaying.item.IndexNumber!, + limit: 1, + }); + + console.log( + "Next: ", + res.data.Items?.map((i) => i.Name) + ); + return res.data.Items?.[0] || null; + }, + enabled: currentlyPlaying?.item.Type === "Episode", + }); + + return { previousItem, nextItem }; +}; diff --git a/hooks/useNavigationBarVisibility.ts b/hooks/useNavigationBarVisibility.ts new file mode 100644 index 00000000..5af3e5d7 --- /dev/null +++ b/hooks/useNavigationBarVisibility.ts @@ -0,0 +1,27 @@ +// hooks/useNavigationBarVisibility.ts + +import { useEffect } from "react"; +import { Platform } from "react-native"; +import * as NavigationBar from "expo-navigation-bar"; + +export const useNavigationBarVisibility = (isPlaying: boolean | null) => { + useEffect(() => { + const handleVisibility = async () => { + if (Platform.OS === "android") { + if (isPlaying) { + await NavigationBar.setVisibilityAsync("hidden"); + } else { + await NavigationBar.setVisibilityAsync("visible"); + } + } + }; + + handleVisibility(); + + return () => { + if (Platform.OS === "android") { + NavigationBar.setVisibilityAsync("visible"); + } + }; + }, [isPlaying]); +}; diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts new file mode 100644 index 00000000..47d4ebfd --- /dev/null +++ b/hooks/useTrickplay.ts @@ -0,0 +1,80 @@ +// hooks/useTrickplay.ts + +import { useState, useCallback } from "react"; +import { Api } from "@jellyfin/sdk"; +import { SharedValue } from "react-native-reanimated"; + +interface TrickplayInfo { + data: { + Interval?: number; + TileWidth?: number; + TileHeight?: number; + Height?: number; + Width?: number; + ThumbnailCount?: number; + }; + resolution?: string; +} + +interface TrickplayUrl { + x: number; + y: number; + url: string; +} + +export const useTrickplay = () => { + const [trickPlayUrl, setTrickPlayUrl] = useState(null); + + const calculateTrickplayUrl = useCallback( + ( + info: TrickplayInfo | null, + progress: SharedValue, + api: Api | null, + id: string + ) => { + if (!info || !id || !api) { + return null; + } + + const { data, resolution } = info; + const { Interval, TileWidth, TileHeight, Height, Width, ThumbnailCount } = + data; + + if ( + !Interval || + !TileWidth || + !TileHeight || + !Height || + !Width || + !ThumbnailCount || + !resolution + ) { + throw new Error("Invalid trickplay data"); + } + + const currentSecond = Math.max(0, Math.floor(progress.value / 10000000)); + + const cols = TileWidth; + const rows = TileHeight; + const imagesPerTile = cols * rows; + const imageIndex = Math.floor(currentSecond / (Interval / 1000)); + const tileIndex = Math.floor(imageIndex / imagesPerTile); + + const positionInTile = imageIndex % imagesPerTile; + const rowInTile = Math.floor(positionInTile / cols); + const colInTile = positionInTile % cols; + + const newTrickPlayUrl = { + x: rowInTile, + y: colInTile, + url: `${api.basePath}/Videos/${id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`, + }; + + setTrickPlayUrl(newTrickPlayUrl); + return newTrickPlayUrl; + }, + [] + ); + + return { trickPlayUrl, calculateTrickplayUrl }; +}; diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 77fa4476..d7f52acd 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -26,7 +26,7 @@ import { Alert } from "react-native"; import { OnProgressData, type VideoRef } from "react-native-video"; import { apiAtom, userAtom } from "./JellyfinProvider"; -type CurrentlyPlayingState = { +export type CurrentlyPlayingState = { url: string; item: BaseItemDto; }; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 28b47f8e..b5e46780 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -104,7 +104,6 @@ export const getStreamUrl = async ({ }/Audio/${itemId}/universal?${searchParams.toString()}`; } } else if (mediaSource.TranscodingUrl) { - console.log("Using transcoded stream!"); url = `${api.basePath}${mediaSource.TranscodingUrl}`; } diff --git a/utils/secondsToTicks.ts b/utils/secondsToTicks.ts new file mode 100644 index 00000000..8d8d38db --- /dev/null +++ b/utils/secondsToTicks.ts @@ -0,0 +1,6 @@ +// seconds to ticks util + +export function secondsToTicks(seconds: number): number { + "worklet"; + return seconds * 10000000; +}