This commit is contained in:
Fredrik Burmester
2024-09-15 18:39:20 +02:00
parent ce2e5e0fb8
commit e3c4a291f0
9 changed files with 289 additions and 147 deletions

View File

@@ -45,7 +45,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
} }
const index = source.DefaultAudioStreamIndex; const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) { if (index !== undefined && index !== null) {
console.log("DefaultAudioStreamIndex", index);
onChange(index); onChange(index);
return; return;
} }

View File

@@ -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 { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider"; import { usePlayback } from "@/providers/PlaybackProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -5,17 +8,13 @@ import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; 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 { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Alert, Alert,
Dimensions, Dimensions,
Platform,
Pressable, Pressable,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -23,7 +22,6 @@ import {
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import "react-native-gesture-handler"; import "react-native-gesture-handler";
import Animated, { import Animated, {
SharedValue,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@@ -33,7 +31,8 @@ import Video from "react-native-video";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter"; import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader"; 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 = () => { export const CurrentlyPlayingBar: React.FC = () => {
const { const {
@@ -55,6 +54,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments(); const segments = useSegments();
const router = useRouter(); const router = useRouter();
useNavigationBarVisibility(isPlaying);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const from = useMemo(() => segments[2], [segments]); const from = useMemo(() => segments[2], [segments]);
@@ -217,144 +218,71 @@ export const CurrentlyPlayingBar: React.FC = () => {
: null; : null;
}, [currentlyPlaying]); }, [currentlyPlaying]);
const [trickPlayUrl, _setTrickPlayUrl] = useState<{ const { trickPlayUrl, calculateTrickplayUrl } = useTrickplay();
x: number; const { previousItem, nextItem } = useAdjacentEpisodes({
y: number; api,
url: string; currentlyPlaying,
} | null>(null); });
const setTrickplayUrl = ( const { data: introTimestamps } = useQuery({
info: typeof trickplayInfo | null, queryKey: ["introTimestamps", currentlyPlaying?.item.Id],
progress: SharedValue<number>, queryFn: async () => {
api: Api, if (!currentlyPlaying?.item.Id) {
id: string console.log("No item id");
) => { return null;
if (!info || !id || !api) { }
return null;
}
const { data, resolution } = info; console.log("Getting intro timestamps");
const { Interval, TileWidth, TileHeight, Height, Width, ThumbnailCount } = const res = await api?.axiosInstance.get(
data; `${api.basePath}/Episode/${currentlyPlaying.item.Id}/IntroTimestamps`,
{
headers: getAuthHeaders(api),
}
);
if ( if (res?.status !== 200) {
!Interval || return null;
!TileWidth || }
!TileHeight ||
!Height ||
!Width ||
!ThumbnailCount ||
!resolution
) {
throw new Error("Invalid trickplay data");
}
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 animatedIntroSkipperStyle = useAnimatedStyle(() => {
const rows = TileHeight; const showButtonAt = secondsToTicks(introTimestamps?.ShowSkipPromptAt || 0);
const imagesPerTile = cols * rows; const hideButtonAt = secondsToTicks(introTimestamps?.HideSkipPromptAt || 0);
const imageIndex = Math.floor(currentSecond / (Interval / 1000)); // Interval is in ms const showButton =
const tileIndex = Math.floor(imageIndex / imagesPerTile); progress.value > showButtonAt && progress.value < hideButtonAt;
return {
const positionInTile = imageIndex % imagesPerTile; opacity: withTiming(
const rowInTile = Math.floor(positionInTile / cols); localIsBuffering.value === false &&
const colInTile = positionInTile % cols; controlsOpacity.value > 0 &&
showButton
const res = { ? 1
x: rowInTile, : 0,
y: colInTile, {
url: `${api.basePath}/Videos/${id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`, 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({ const skipIntro = useCallback(async () => {
queryKey: [ if (!introTimestamps) return;
"nextItem", videoRef.current?.seek(introTimestamps.IntroEnd);
currentlyPlaying?.item.ParentId, }, [introTimestamps]);
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]);
useEffect(() => { useEffect(() => {
if (Platform.OS === "android") { console.log({ introTimestamps });
if (currentlyPlaying) NavigationBar.setVisibilityAsync("hidden"); }, [introTimestamps]);
else NavigationBar.setVisibilityAsync("visible");
}
return () => {
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
}
};
}, [currentlyPlaying]);
if (!api || !currentlyPlaying) return null; if (!api || !currentlyPlaying) return null;
@@ -399,6 +327,30 @@ export const CurrentlyPlayingBar: React.FC = () => {
</View> </View>
</Animated.View> </Animated.View>
<Animated.View
style={[
{
position: "absolute",
bottom: insets.bottom + 8 * 7,
right: insets.right + 32,
zIndex: 10,
},
animatedIntroSkipperStyle,
]}
>
<View className="flex flex-row items-center h-full">
<TouchableOpacity
onPress={() => {
if (controlsOpacity.value === 0) return;
skipIntro();
}}
className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full"
>
<Text>Skip intro</Text>
</TouchableOpacity>
</View>
</Animated.View>
<Animated.View <Animated.View
style={[videoContainerStyle, animatedVideoContainerStyle]} style={[videoContainerStyle, animatedVideoContainerStyle]}
> >
@@ -513,13 +465,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="flex flex-row items-center space-x-6 rounded-full py-1.5 pl-4 pr-4 z-10 bg-neutral-800"> <View className="flex flex-row items-center space-x-6 rounded-full py-1.5 pl-4 pr-4 z-10 bg-neutral-800">
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<TouchableOpacity <TouchableOpacity
disabled={prevButtonEnabled === false} disabled={!previousItem}
style={{ style={{
opacity: prevButtonEnabled === false ? 0.5 : 1, opacity: !previousItem ? 0.5 : 1,
}} }}
onPress={() => { onPress={() => {
if (controlsOpacity.value === 0) return; if (controlsOpacity.value === 0) return;
if (prevButtonEnabled === false) return;
if (!previousItem || !from) return; if (!previousItem || !from) return;
const url = itemRouter(previousItem, from); const url = itemRouter(previousItem, from);
stopPlayback(); stopPlayback();
@@ -573,13 +524,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
<Ionicons name="refresh-outline" size={22} color="white" /> <Ionicons name="refresh-outline" size={22} color="white" />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
disabled={nextButtonEnabled === false} disabled={!nextItem}
style={{ style={{
opacity: nextButtonEnabled === false ? 0.5 : 1, opacity: !nextItem ? 0.5 : 1,
}} }}
onPress={() => { onPress={() => {
if (controlsOpacity.value === 0) return; if (controlsOpacity.value === 0) return;
if (nextButtonEnabled === false) return;
if (!nextItem || !from) return; if (!nextItem || !from) return;
const url = itemRouter(nextItem, from); const url = itemRouter(nextItem, from);
stopPlayback(); stopPlayback();
@@ -614,12 +564,13 @@ export const CurrentlyPlayingBar: React.FC = () => {
if (controlsOpacity.value === 0) return; if (controlsOpacity.value === 0) return;
const tick = Math.floor(val); const tick = Math.floor(val);
progress.value = tick; progress.value = tick;
setTrickplayUrl( calculateTrickplayUrl(
trickplayInfo, trickplayInfo,
progress, progress,
api, api,
currentlyPlaying.item.Id! currentlyPlaying.item.Id!
); );
// resetHideControlsTimer(); // resetHideControlsTimer();
}} }}
containerStyle={{ containerStyle={{

View File

@@ -126,7 +126,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
if (mediaSource.SupportsDirectPlay) { if (mediaSource.SupportsDirectPlay) {
if (item.MediaType === "Video") { 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}`; 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") { } else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!"); console.log("Using direct stream for audio!");
@@ -149,7 +148,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
}/universal?${searchParams.toString()}`; }/universal?${searchParams.toString()}`;
} }
} else if (mediaSource.TranscodingUrl) { } else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`; url = `${api.basePath}${mediaSource.TranscodingUrl}`;
} }

View File

@@ -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<BaseItemDto | null> => {
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<BaseItemDto | null> => {
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 };
};

View File

@@ -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]);
};

80
hooks/useTrickplay.ts Normal file
View File

@@ -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<TrickplayUrl | null>(null);
const calculateTrickplayUrl = useCallback(
(
info: TrickplayInfo | null,
progress: SharedValue<number>,
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 };
};

View File

@@ -26,7 +26,7 @@ import { Alert } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video"; import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
type CurrentlyPlayingState = { export type CurrentlyPlayingState = {
url: string; url: string;
item: BaseItemDto; item: BaseItemDto;
}; };

View File

@@ -104,7 +104,6 @@ export const getStreamUrl = async ({
}/Audio/${itemId}/universal?${searchParams.toString()}`; }/Audio/${itemId}/universal?${searchParams.toString()}`;
} }
} else if (mediaSource.TranscodingUrl) { } else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`; url = `${api.basePath}${mediaSource.TranscodingUrl}`;
} }

6
utils/secondsToTicks.ts Normal file
View File

@@ -0,0 +1,6 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
"worklet";
return seconds * 10000000;
}