mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-17 01:43:00 +01:00
fix: offline playback using player component
This commit is contained in:
@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
@@ -20,16 +20,16 @@ const downloads: React.FC = () => {
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const movies = useMemo(
|
const movies = useMemo(
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
() => downloadedFiles?.filter((f) => f.item.Type === "Movie") || [],
|
||||||
[downloadedFiles]
|
[downloadedFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
const groupedBySeries = useMemo(() => {
|
||||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
const episodes = downloadedFiles?.filter((f) => f.item.Type === "Episode");
|
||||||
const series: { [key: string]: BaseItemDto[] } = {};
|
const series: { [key: string]: DownloadedItem[] } = {};
|
||||||
episodes?.forEach((e) => {
|
episodes?.forEach((e) => {
|
||||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||||
series[e.SeriesName!].push(e);
|
series[e.item.SeriesName!].push(e);
|
||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
@@ -98,17 +98,20 @@ const downloads: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{movies?.map((item: BaseItemDto) => (
|
{movies?.map((item) => (
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
<MovieCard item={item} />
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
{groupedBySeries?.map((items, index) => (
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className="flex px-4">
|
<View className="flex px-4">
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// @ts-ignore
|
|
||||||
export { default } from "@/components/video-player/offline-player";
|
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
@@ -58,7 +58,7 @@ export default function page() {
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
} = useLocalSearchParams<{
|
} = useGlobalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
@@ -82,21 +82,20 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return;
|
console.log("Offline:", offline);
|
||||||
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const item = await getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
itemId,
|
itemId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
enabled: !!itemId && !!api,
|
enabled: !!itemId,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,8 +113,7 @@ export default function page() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return;
|
console.log("Offline:", offline);
|
||||||
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const item = await getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (!item?.mediaSource) return null;
|
if (!item?.mediaSource) return null;
|
||||||
@@ -146,7 +144,10 @@ export default function page() {
|
|||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert("Error", "Failed to get stream url");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaSource,
|
mediaSource,
|
||||||
@@ -154,7 +155,7 @@ export default function page() {
|
|||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!itemId && !!api && !!item && !offline,
|
enabled: !!itemId && !!item,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ export default function page() {
|
|||||||
pauseVideo: pause,
|
pauseVideo: pause,
|
||||||
playVideo: play,
|
playVideo: play,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline: offline,
|
offline,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
@@ -338,7 +339,13 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!stream || !item) return null;
|
if (!stream || !item)
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white">No stream or item</Text>
|
||||||
|
<Text className="text-white">Offline: {offline}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -21,7 +20,8 @@ import {
|
|||||||
import { router, useFocusEffect } from "expo-router";
|
import { router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { MMKV } from "react-native-mmkv";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
@@ -31,7 +31,7 @@ import { Loader } from "./Loader";
|
|||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -111,19 +111,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
deviceProfile: native,
|
deviceProfile: native,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) return null;
|
if (!res) {
|
||||||
|
Alert.alert(
|
||||||
|
"Something went wrong",
|
||||||
|
"Could not get stream url from Jellyfin"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { mediaSource, url } = res;
|
const { mediaSource, url } = res;
|
||||||
|
|
||||||
if (!url || !mediaSource) throw new Error("No url");
|
if (!url || !mediaSource) throw new Error("No url");
|
||||||
if (!mediaSource.TranscodingContainer) throw new Error("No file extension");
|
|
||||||
|
saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
if (settings?.downloadMethod === "optimized") {
|
||||||
return await startBackgroundDownload(
|
return await startBackgroundDownload(url, item, mediaSource);
|
||||||
url,
|
|
||||||
item,
|
|
||||||
mediaSource.TranscodingContainer
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return await startRemuxing(item, url, mediaSource);
|
return await startRemuxing(item, url, mediaSource);
|
||||||
}
|
}
|
||||||
@@ -147,7 +150,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const isDownloaded = useMemo(() => {
|
const isDownloaded = useMemo(() => {
|
||||||
if (!downloadedFiles) return false;
|
if (!downloadedFiles) return false;
|
||||||
|
|
||||||
return downloadedFiles.some((file) => file.Id === item.Id);
|
return downloadedFiles.some((file) => file.item.Id === item.Id);
|
||||||
}, [downloadedFiles, item.Id]);
|
}, [downloadedFiles, item.Id]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
@@ -164,7 +167,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const process = useMemo(() => {
|
const process = useMemo(() => {
|
||||||
if (!processes) return null;
|
if (!processes) return null;
|
||||||
|
|
||||||
return processes.find((process) => process?.item?.item.Id === item.Id);
|
return processes.find((process) => process?.item?.Id === item.Id);
|
||||||
}, [processes, item.Id]);
|
}, [processes, item.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,7 +175,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{process && process?.item.item.Id === item.Id ? (
|
{process && process?.item.Id === item.Id ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/downloads");
|
router.push("/downloads");
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv";
|
|||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||||
const { processes, startDownload } = useDownload();
|
const { processes } = useDownload();
|
||||||
if (processes?.length === 0)
|
if (processes?.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
@@ -93,20 +93,18 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
const eta = (p: JobStatus) => {
|
const eta = (p: JobStatus) => {
|
||||||
if (!p.speed || !p.progress) return null;
|
if (!p.speed || !p.progress) return null;
|
||||||
|
|
||||||
const length = p?.item?.item.RunTimeTicks || 0;
|
const length = p?.item?.RunTimeTicks || 0;
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||||
return formatTimeString(timeLeft, "tick");
|
return formatTimeString(timeLeft, "tick");
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(process.item.item.Id!);
|
return storage.getString(process.item.Id!);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
||||||
router.push(`/(auth)/items/page?id=${process.item.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -140,12 +138,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="shrink mb-1">
|
<View className="shrink mb-1">
|
||||||
<Text className="text-xs opacity-50">{process.item.item.Type}</Text>
|
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||||
<Text className="font-semibold shrink">
|
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
||||||
{process.item.item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
{process.item.item.ProductionYear}
|
{process.item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||||
{process.progress === 0 ? (
|
{process.progress === 0 ? (
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
PlaybackType,
|
|
||||||
usePlaySettings,
|
|
||||||
} from "@/providers/PlaySettingsProvider";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
|
|
||||||
const OfflinePlayer = () => {
|
|
||||||
const { playSettings, playUrl } = usePlaySettings();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const videoSource = useVideoSource(playSettings, api, playUrl);
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const dimensions = useWindowDimensions();
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsReady(true);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
}
|
|
||||||
}, [isPlaying]);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
setIsPlaying(true);
|
|
||||||
videoRef.current?.resume();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
const onProgress = useCallback(async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isReady) return null;
|
|
||||||
|
|
||||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full"
|
|
||||||
>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
videoRef={videoRef}
|
|
||||||
enableTrickplay={true}
|
|
||||||
item={playSettings.item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
seek={seek}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
playSettings: PlaybackType | null,
|
|
||||||
api: Api | null,
|
|
||||||
playUrl?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!playSettings || !api || !playUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: playUrl,
|
|
||||||
isNetwork: false,
|
|
||||||
startPosition,
|
|
||||||
metadata: {
|
|
||||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
|
||||||
title: playSettings.item?.Name || "Unknown",
|
|
||||||
description: playSettings.item?.Overview ?? undefined,
|
|
||||||
subtitle: playSettings.item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [playSettings, api]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OfflinePlayer;
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
|
||||||
import {
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { SelectedTrackType } from "react-native-video";
|
|
||||||
|
|
||||||
const OfflinePlayer = () => {
|
|
||||||
const { playSettings, playUrl } = usePlaySettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
|
||||||
|
|
||||||
const dimensions = useWindowDimensions();
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const [playbackState, setPlaybackState] = useState<
|
|
||||||
PlaybackStatePayload["nativeEvent"] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
if (!playSettings || !playUrl || !api || !playSettings.item) return null;
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.play();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.stop();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const { currentTime, duration, isBuffering, isPlaying } =
|
|
||||||
data.nativeEvent;
|
|
||||||
|
|
||||||
progress.value = currentTime;
|
|
||||||
|
|
||||||
// cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
// setIsBuffering(data.playableDuration === 0);
|
|
||||||
},
|
|
||||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
const selectedSubtitleTrack = useMemo(() => {
|
|
||||||
const a = playSettings?.mediaSource?.MediaStreams?.find(
|
|
||||||
(s) => s.Index === playSettings.subtitleIndex
|
|
||||||
);
|
|
||||||
console.log(a);
|
|
||||||
return a;
|
|
||||||
}, [playSettings]);
|
|
||||||
|
|
||||||
const [hlsSubTracks, setHlsSubTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const selectedTextTrack = useMemo(() => {
|
|
||||||
for (let st of hlsSubTracks) {
|
|
||||||
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
|
|
||||||
return {
|
|
||||||
type: SelectedTrackType.TITLE,
|
|
||||||
value: selectedSubtitleTrack?.DisplayTitle ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [hlsSubTracks]);
|
|
||||||
|
|
||||||
const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
|
|
||||||
const { target, state, isBuffering, isPlaying } = e.nativeEvent;
|
|
||||||
|
|
||||||
if (state === "Playing") {
|
|
||||||
setIsPlaying(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlaybackState(e.nativeEvent);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(playUrl);
|
|
||||||
}, [playUrl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: dimensions.width,
|
|
||||||
height: dimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full"
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: playUrl,
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: false,
|
|
||||||
initOptions: ["--sub-text-scale=60"],
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
onVideoProgress={onProgress}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
{videoRef.current && (
|
|
||||||
<Controls
|
|
||||||
item={playSettings.item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
enableTrickplay={false}
|
|
||||||
offline={true}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
stop={stop}
|
|
||||||
seek={videoRef.current?.seekTo}
|
|
||||||
isVlc
|
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
|
||||||
setAudioTrack={videoRef.current?.setAudioTrack}
|
|
||||||
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
|
|
||||||
setSubtitleURL={videoRef.current?.setSubtitleURL}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OfflinePlayer;
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
|
||||||
import {
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import { msToTicks, ticksToMs } from "@/utils/time";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
|
|
||||||
const Player = () => {
|
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
const windowDimensions = useWindowDimensions();
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: BITRATES[0].value;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"stream-url",
|
|
||||||
itemId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
|
||||||
|
|
||||||
console.log(url);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api && !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ms: number) => {
|
|
||||||
if (!api || !stream) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(ms),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream.url?.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
console.log("ACtually marked as paused");
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(ms),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.play();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.stop();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: currentTimeInTicks,
|
|
||||||
playSessionId: stream?.sessionId!,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!api || !stream) return;
|
|
||||||
await getPlaystateApi(api).onPlaybackStart({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
if (!item?.Id || !api || !stream) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.value = currentTime;
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(currentTimeInTicks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped]
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
|
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
|
||||||
|
|
||||||
if (state === "Playing") {
|
|
||||||
setIsPlaying(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!stream || !item) return null;
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? ticksToMs(item.UserData.PlaybackPositionTicks)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full"
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: stream.url,
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
initOptions: ["--sub-text-scale=60"],
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
onVideoProgress={onProgress}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
|
||||||
onVideoLoadStart={() => {}}
|
|
||||||
onVideoLoadEnd={() => {
|
|
||||||
setIsVideoLoaded(true);
|
|
||||||
}}
|
|
||||||
onVideoError={(e) => {
|
|
||||||
console.error("Video Error:", e.nativeEvent);
|
|
||||||
Alert.alert(
|
|
||||||
"Error",
|
|
||||||
"An error occurred while playing the video. Check logs in settings."
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{videoRef.current && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream.mediaSource}
|
|
||||||
item={item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
|
||||||
play={videoRef.current?.play}
|
|
||||||
pause={videoRef.current?.pause}
|
|
||||||
seek={videoRef.current?.seekTo}
|
|
||||||
enableTrickplay={true}
|
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
|
||||||
offline={false}
|
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
|
||||||
stop={stop}
|
|
||||||
isVlc
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
|
||||||
import {
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import { msToTicks, ticksToMs } from "@/utils/time";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
|
|
||||||
const Player = () => {
|
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
const windowDimensions = useWindowDimensions();
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: BITRATES[0].value;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"stream-url",
|
|
||||||
itemId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
|
||||||
|
|
||||||
console.log(url);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api && !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ms: number) => {
|
|
||||||
if (!api || !stream) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(ms),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream.url?.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
console.log("ACtually marked as paused");
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(ms),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.play();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.stop();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: currentTimeInTicks,
|
|
||||||
playSessionId: stream?.sessionId!,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!api || !stream) return;
|
|
||||||
await getPlaystateApi(api).onPlaybackStart({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
if (!item?.Id || !api || !stream) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.value = currentTime;
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
|
||||||
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(currentTimeInTicks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped]
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
|
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
|
||||||
|
|
||||||
if (state === "Playing") {
|
|
||||||
setIsPlaying(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!stream || !item) return null;
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? ticksToMs(item.UserData.PlaybackPositionTicks)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full"
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: stream.url,
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
initOptions: ["--sub-text-scale=60"],
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
onVideoProgress={onProgress}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
|
||||||
onVideoLoadStart={() => {}}
|
|
||||||
onVideoLoadEnd={() => {
|
|
||||||
setIsVideoLoaded(true);
|
|
||||||
}}
|
|
||||||
onVideoError={(e) => {
|
|
||||||
console.error("Video Error:", e.nativeEvent);
|
|
||||||
Alert.alert(
|
|
||||||
"Error",
|
|
||||||
"An error occurred while playing the video. Check logs in settings."
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{videoRef.current && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream.mediaSource}
|
|
||||||
item={item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
|
||||||
play={videoRef.current?.play}
|
|
||||||
pause={videoRef.current?.pause}
|
|
||||||
seek={videoRef.current?.seekTo}
|
|
||||||
enableTrickplay={true}
|
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
|
||||||
offline={false}
|
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
|
||||||
stop={stop}
|
|
||||||
isVlc
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
@@ -34,13 +34,10 @@ export const useDownloadedFileOpener = () => {
|
|||||||
const openFile = useCallback(
|
const openFile = useCallback(
|
||||||
async (item: BaseItemDto) => {
|
async (item: BaseItemDto) => {
|
||||||
try {
|
try {
|
||||||
const url = await getDownloadedFileUrl(item.Id!);
|
console.log(
|
||||||
|
"Go to offline movie",
|
||||||
setOfflineSettings({
|
"/player?offline=true&itemId=" + item.Id
|
||||||
item,
|
);
|
||||||
});
|
|
||||||
setPlayUrl(url);
|
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.push("/player?offline=true&itemId=" + item.Id);
|
router.push("/player?offline=true&itemId=" + item.Id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -71,10 +71,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
id: "",
|
id: "",
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
inputUrl: "",
|
inputUrl: "",
|
||||||
item: {
|
item: item,
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
},
|
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
outputPath: "",
|
outputPath: "",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -119,7 +116,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
if (!item) throw new Error("Item is undefined");
|
if (!item) throw new Error("Item is undefined");
|
||||||
await saveDownloadedItemInfo(item, mediaSource);
|
await saveDownloadedItemInfo(item);
|
||||||
toast.success("Download completed");
|
toast.success("Download completed");
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { writeToLog } from "@/utils/log";
|
|||||||
import {
|
import {
|
||||||
cancelAllJobs,
|
cancelAllJobs,
|
||||||
cancelJobById,
|
cancelJobById,
|
||||||
|
deleteDownloadItemInfoFromDiskTmp,
|
||||||
getAllJobsByDeviceId,
|
getAllJobsByDeviceId,
|
||||||
|
getDownloadItemInfoFromDiskTmp,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
} from "@/utils/optimize-server";
|
} from "@/utils/optimize-server";
|
||||||
import {
|
import {
|
||||||
@@ -130,7 +132,7 @@ function useDownloadProvider() {
|
|||||||
if (settings.autoDownload) {
|
if (settings.autoDownload) {
|
||||||
startDownload(job);
|
startDownload(job);
|
||||||
} else {
|
} else {
|
||||||
toast.info(`${job.item.item.Name} is ready to be downloaded`, {
|
toast.info(`${job.item.Name} is ready to be downloaded`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Go to downloads",
|
label: "Go to downloads",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -141,8 +143,8 @@ function useDownloadProvider() {
|
|||||||
});
|
});
|
||||||
Notifications.scheduleNotificationAsync({
|
Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: job.item.item.Name,
|
title: job.item.Name,
|
||||||
body: `${job.item.item.Name} is ready to be downloaded`,
|
body: `${job.item.Name} is ready to be downloaded`,
|
||||||
data: {
|
data: {
|
||||||
url: `/downloads`,
|
url: `/downloads`,
|
||||||
},
|
},
|
||||||
@@ -190,7 +192,7 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const startDownload = useCallback(
|
const startDownload = useCallback(
|
||||||
async (process: JobStatus) => {
|
async (process: JobStatus) => {
|
||||||
if (!process?.item.item.Id || !authHeader) throw new Error("No item id");
|
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||||
|
|
||||||
setProcesses((prev) =>
|
setProcesses((prev) =>
|
||||||
prev.map((p) =>
|
prev.map((p) =>
|
||||||
@@ -213,7 +215,7 @@ function useDownloadProvider() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info(`Download started for ${process.item.item.Name}`, {
|
toast.info(`Download started for ${process.item.Name}`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Go to downloads",
|
label: "Go to downloads",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -228,7 +230,7 @@ function useDownloadProvider() {
|
|||||||
download({
|
download({
|
||||||
id: process.id,
|
id: process.id,
|
||||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||||
destination: `${baseDirectory}/${process.item.item.Id}.mp4`,
|
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
||||||
})
|
})
|
||||||
.begin(() => {
|
.begin(() => {
|
||||||
setProcesses((prev) =>
|
setProcesses((prev) =>
|
||||||
@@ -260,11 +262,8 @@ function useDownloadProvider() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.done(async () => {
|
.done(async () => {
|
||||||
await saveDownloadedItemInfo(
|
await saveDownloadedItemInfo(process.item);
|
||||||
process.item.item,
|
toast.success(`Download completed for ${process.item.Name}`, {
|
||||||
process.item.mediaSource
|
|
||||||
);
|
|
||||||
toast.success(`Download completed for ${process.item.item.Name}`, {
|
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
action: {
|
action: {
|
||||||
label: "Go to downloads",
|
label: "Go to downloads",
|
||||||
@@ -289,15 +288,13 @@ function useDownloadProvider() {
|
|||||||
if (error.errorCode === 404) {
|
if (error.errorCode === 404) {
|
||||||
errorMsg = "File not found on server";
|
errorMsg = "File not found on server";
|
||||||
}
|
}
|
||||||
toast.error(
|
toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
|
||||||
`Download failed for ${process.item.item.Name} - ${errorMsg}`
|
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||||
);
|
|
||||||
writeToLog("ERROR", `Download failed for ${process.item.item.Name}`, {
|
|
||||||
error,
|
error,
|
||||||
processDetails: {
|
processDetails: {
|
||||||
id: process.id,
|
id: process.id,
|
||||||
itemName: process.item.item.Name,
|
itemName: process.item.Name,
|
||||||
itemId: process.item.item.Id,
|
itemId: process.item.Id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error("Error details:", {
|
console.error("Error details:", {
|
||||||
@@ -309,12 +306,15 @@ function useDownloadProvider() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const startBackgroundDownload = useCallback(
|
const startBackgroundDownload = useCallback(
|
||||||
async (url: string, item: BaseItemDto, fileExtension: string) => {
|
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||||
if (!api || !item.Id || !authHeader)
|
if (!api || !item.Id || !authHeader)
|
||||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const fileExtension = mediaSource.TranscodingContainer;
|
||||||
const deviceId = await getOrSetDeviceId();
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
|
||||||
|
// Save poster to disk
|
||||||
const itemImage = getItemImage({
|
const itemImage = getItemImage({
|
||||||
item,
|
item,
|
||||||
api,
|
api,
|
||||||
@@ -322,9 +322,9 @@ function useDownloadProvider() {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 500,
|
width: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
await saveImage(item.Id, itemImage?.uri);
|
await saveImage(item.Id, itemImage?.uri);
|
||||||
|
|
||||||
|
// POST to start optimization job on the server
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
settings?.optimizedVersionsServerUrl + "optimize-version",
|
settings?.optimizedVersionsServerUrl + "optimize-version",
|
||||||
{
|
{
|
||||||
@@ -529,17 +529,23 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDownloadedItemInfo(
|
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
let items: { item: BaseItemDto; mediaSource: MediaSourceInfo }[] =
|
let items: DownloadedItem[] = downloadedItems
|
||||||
downloadedItems ? JSON.parse(downloadedItems) : [];
|
? JSON.parse(downloadedItems)
|
||||||
|
: [];
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
||||||
const newItem = { item, mediaSource };
|
|
||||||
|
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
||||||
|
|
||||||
|
if (!data?.mediaSource)
|
||||||
|
throw new Error(
|
||||||
|
"Media source not found in tmp storage. Did you forget to save it before starting download?"
|
||||||
|
);
|
||||||
|
|
||||||
|
const newItem = { item, mediaSource: data.mediaSource };
|
||||||
|
|
||||||
if (existingItemIndex !== -1) {
|
if (existingItemIndex !== -1) {
|
||||||
items[existingItemIndex] = newItem;
|
items[existingItemIndex] = newItem;
|
||||||
@@ -547,6 +553,8 @@ function useDownloadProvider() {
|
|||||||
items.push(newItem);
|
items.push(newItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||||
|
|
||||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||||
refetch();
|
refetch();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { writeToLog } from "./log";
|
import { writeToLog } from "./log";
|
||||||
import { DownloadedItem } from "@/providers/DownloadProvider";
|
import { DownloadedItem } from "@/providers/DownloadProvider";
|
||||||
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
interface IJobInput {
|
interface IJobInput {
|
||||||
deviceId?: string | null;
|
deviceId?: string | null;
|
||||||
@@ -27,7 +28,7 @@ export interface JobStatus {
|
|||||||
inputUrl: string;
|
inputUrl: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
item: DownloadedItem;
|
item: BaseItemDto;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
base64Image?: string;
|
base64Image?: string;
|
||||||
@@ -158,3 +159,81 @@ export async function getStatistics({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the download item info to disk - this data is used temporarily to fetch additional download information
|
||||||
|
* in combination with the optimize server. This is used to not have to send all item info to the optimize server.
|
||||||
|
*
|
||||||
|
* @param {BaseItemDto} item - The item to save.
|
||||||
|
* @param {MediaSourceInfo} mediaSource - The media source of the item.
|
||||||
|
* @param {string} url - The URL of the item.
|
||||||
|
* @return {boolean} A promise that resolves when the item info is saved.
|
||||||
|
*/
|
||||||
|
export function saveDownloadItemInfoToDiskTmp(
|
||||||
|
item: BaseItemDto,
|
||||||
|
mediaSource: MediaSourceInfo,
|
||||||
|
url: string
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const storage = new MMKV();
|
||||||
|
|
||||||
|
const downloadInfo = JSON.stringify({
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
storage.set(`tmp_download_info_${item.Id}`, downloadInfo);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save download item info to disk:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the download item info from disk.
|
||||||
|
*
|
||||||
|
* @param {string} itemId - The ID of the item to retrieve.
|
||||||
|
* @return {{
|
||||||
|
* item: BaseItemDto;
|
||||||
|
* mediaSource: MediaSourceInfo;
|
||||||
|
* url: string;
|
||||||
|
* } | null} The retrieved download item info or null if not found.
|
||||||
|
*/
|
||||||
|
export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
||||||
|
item: BaseItemDto;
|
||||||
|
mediaSource: MediaSourceInfo;
|
||||||
|
url: string;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const storage = new MMKV();
|
||||||
|
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
||||||
|
|
||||||
|
if (rawInfo) {
|
||||||
|
return JSON.parse(rawInfo);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve download item info from disk:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the download item info from disk.
|
||||||
|
*
|
||||||
|
* @param {string} itemId - The ID of the item to delete.
|
||||||
|
* @return {boolean} True if the item info was successfully deleted, false otherwise.
|
||||||
|
*/
|
||||||
|
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
||||||
|
try {
|
||||||
|
const storage = new MMKV();
|
||||||
|
storage.delete(`tmp_download_info_${itemId}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete download item info from disk:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user