mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
wip
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
hooks/useAdjacentEpisodes.ts
Normal file
82
hooks/useAdjacentEpisodes.ts
Normal 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 };
|
||||||
|
};
|
||||||
27
hooks/useNavigationBarVisibility.ts
Normal file
27
hooks/useNavigationBarVisibility.ts
Normal 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
80
hooks/useTrickplay.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
6
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
"worklet";
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user