From 6a50eb904485c21bcc3354d034ecfcb305baac1c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 24 Nov 2024 10:36:06 +0100 Subject: [PATCH] fix: offline playback using player component --- app/(auth)/(tabs)/(home)/downloads.tsx | 25 +- app/(auth)/player/offline-player.tsx | 2 - app/(auth)/player/player.tsx | 31 +- components/DownloadItem.tsx | 31 +- components/downloads/ActiveDownloads.tsx | 18 +- .../video-player/offline-player.android.tsx | 207 --------- .../video-player/offline-player.ios.tsx | 232 ---------- components/video-player/player.android.tsx | 400 ------------------ components/video-player/player.ios.tsx | 400 ------------------ hooks/useDownloadedFileOpener.ts | 11 +- hooks/useRemuxHlsToMp4.ts | 7 +- providers/DownloadProvider.tsx | 60 +-- utils/optimize-server.ts | 81 +++- 13 files changed, 177 insertions(+), 1328 deletions(-) delete mode 100644 app/(auth)/player/offline-player.tsx delete mode 100644 components/video-player/offline-player.android.tsx delete mode 100644 components/video-player/offline-player.ios.tsx delete mode 100644 components/video-player/player.android.tsx delete mode 100644 components/video-player/player.ios.tsx diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 30ba6352..ab61ad8d 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text"; import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; -import { useDownload } from "@/providers/DownloadProvider"; +import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { useSettings } from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; @@ -20,16 +20,16 @@ const downloads: React.FC = () => { const [settings] = useSettings(); const movies = useMemo( - () => downloadedFiles?.filter((f) => f.Type === "Movie") || [], + () => downloadedFiles?.filter((f) => f.item.Type === "Movie") || [], [downloadedFiles] ); const groupedBySeries = useMemo(() => { - const episodes = downloadedFiles?.filter((f) => f.Type === "Episode"); - const series: { [key: string]: BaseItemDto[] } = {}; + const episodes = downloadedFiles?.filter((f) => f.item.Type === "Episode"); + const series: { [key: string]: DownloadedItem[] } = {}; episodes?.forEach((e) => { - if (!series[e.SeriesName!]) series[e.SeriesName!] = []; - series[e.SeriesName!].push(e); + if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = []; + series[e.item.SeriesName!].push(e); }); return Object.values(series); }, [downloadedFiles]); @@ -98,17 +98,20 @@ const downloads: React.FC = () => { - {movies?.map((item: BaseItemDto) => ( - - + {movies?.map((item) => ( + + ))} )} - {groupedBySeries?.map((items: BaseItemDto[], index: number) => ( - + {groupedBySeries?.map((items, index) => ( + i.item)} + key={items[0].item.SeriesId} + /> ))} {downloadedFiles?.length === 0 && ( diff --git a/app/(auth)/player/offline-player.tsx b/app/(auth)/player/offline-player.tsx deleted file mode 100644 index 8ef74b37..00000000 --- a/app/(auth)/player/offline-player.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// @ts-ignore -export { default } from "@/components/video-player/offline-player"; diff --git a/app/(auth)/player/player.tsx b/app/(auth)/player/player.tsx index c86f2281..d332cb8d 100644 --- a/app/(auth)/player/player.tsx +++ b/app/(auth)/player/player.tsx @@ -27,7 +27,7 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; -import { useLocalSearchParams } from "expo-router"; +import { useGlobalSearchParams, useLocalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { Alert, Pressable, View } from "react-native"; @@ -58,7 +58,7 @@ export default function page() { mediaSourceId, bitrateValue: bitrateValueStr, offline: offlineStr, - } = useLocalSearchParams<{ + } = useGlobalSearchParams<{ itemId: string; audioIndex: string; subtitleIndex: string; @@ -82,21 +82,20 @@ export default function page() { } = useQuery({ queryKey: ["item", itemId], queryFn: async () => { - if (!api) return; - + console.log("Offline:", offline); if (offline) { const item = await getDownloadedItem(itemId); if (item) return item.item; } - const res = await getUserLibraryApi(api).getItem({ + const res = await getUserLibraryApi(api!).getItem({ itemId, userId: user?.Id, }); return res.data; }, - enabled: !!itemId && !!api, + enabled: !!itemId, staleTime: 0, }); @@ -114,8 +113,7 @@ export default function page() { bitrateValue, ], queryFn: async () => { - if (!api) return; - + console.log("Offline:", offline); if (offline) { const item = await getDownloadedItem(itemId); if (!item?.mediaSource) return null; @@ -146,7 +144,10 @@ export default function page() { 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 { mediaSource, @@ -154,7 +155,7 @@ export default function page() { url, }; }, - enabled: !!itemId && !!api && !!item && !offline, + enabled: !!itemId && !!item, staleTime: 0, }); @@ -292,7 +293,7 @@ export default function page() { pauseVideo: pause, playVideo: play, stopPlayback: stop, - offline: offline, + offline, }); const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { @@ -338,7 +339,13 @@ export default function page() { ); - if (!stream || !item) return null; + if (!stream || !item) + return ( + + No stream or item + Offline: {offline} + + ); return ( = ({ item, ...props }) => { 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; 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") { - return await startBackgroundDownload( - url, - item, - mediaSource.TranscodingContainer - ); + return await startBackgroundDownload(url, item, mediaSource); } else { return await startRemuxing(item, url, mediaSource); } @@ -147,7 +150,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const isDownloaded = useMemo(() => { if (!downloadedFiles) return false; - return downloadedFiles.some((file) => file.Id === item.Id); + return downloadedFiles.some((file) => file.item.Id === item.Id); }, [downloadedFiles, item.Id]); const renderBackdrop = useCallback( @@ -164,7 +167,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const process = useMemo(() => { 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]); return ( @@ -172,7 +175,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center" {...props} > - {process && process?.item.item.Id === item.Id ? ( + {process && process?.item.Id === item.Id ? ( { router.push("/downloads"); diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 05bc0efe..9a418357 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv"; interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { - const { processes, startDownload } = useDownload(); + const { processes } = useDownload(); if (processes?.length === 0) return ( @@ -93,20 +93,18 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const eta = (p: JobStatus) => { 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; return formatTimeString(timeLeft, "tick"); }; const base64Image = useMemo(() => { - return storage.getString(process.item.item.Id!); + return storage.getString(process.item.Id!); }, []); return ( - router.push(`/(auth)/items/page?id=${process.item.item.Id}`) - } + onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)} className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" {...props} > @@ -140,12 +138,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { )} - {process.item.item.Type} - - {process.item.item.Name} - + {process.item.Type} + {process.item.Name} - {process.item.item.ProductionYear} + {process.item.ProductionYear} {process.progress === 0 ? ( diff --git a/components/video-player/offline-player.android.tsx b/components/video-player/offline-player.android.tsx deleted file mode 100644 index a77912be..00000000 --- a/components/video-player/offline-player.android.tsx +++ /dev/null @@ -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(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 ( - - { - setShowControls(!showControls); - }} - className="absolute z-0 h-full w-full" - > - - - - - ); -}; - -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; diff --git a/components/video-player/offline-player.ios.tsx b/components/video-player/offline-player.ios.tsx deleted file mode 100644 index 451e446b..00000000 --- a/components/video-player/offline-player.ios.tsx +++ /dev/null @@ -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(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 ( - - { - setShowControls(!showControls); - }} - className="absolute z-0 h-full w-full" - > - - - {videoRef.current && ( - - )} - - ); -}; - -export default OfflinePlayer; diff --git a/components/video-player/player.android.tsx b/components/video-player/player.android.tsx deleted file mode 100644 index 6f4bd52f..00000000 --- a/components/video-player/player.android.tsx +++ /dev/null @@ -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(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 ( - - - - ); - - if (isErrorItem || isErrorStreamUrl) - return ( - - Error - - ); - - if (!stream || !item) return null; - - const startPosition = item?.UserData?.PlaybackPositionTicks - ? ticksToMs(item.UserData.PlaybackPositionTicks) - : 0; - - return ( - - { - setShowControls(!showControls); - }} - className="absolute z-0 h-full w-full" - > - {}} - 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); - }} - /> - - - {videoRef.current && ( - - )} - - ); -}; - -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; diff --git a/components/video-player/player.ios.tsx b/components/video-player/player.ios.tsx deleted file mode 100644 index 6f4bd52f..00000000 --- a/components/video-player/player.ios.tsx +++ /dev/null @@ -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(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 ( - - - - ); - - if (isErrorItem || isErrorStreamUrl) - return ( - - Error - - ); - - if (!stream || !item) return null; - - const startPosition = item?.UserData?.PlaybackPositionTicks - ? ticksToMs(item.UserData.PlaybackPositionTicks) - : 0; - - return ( - - { - setShowControls(!showControls); - }} - className="absolute z-0 h-full w-full" - > - {}} - 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); - }} - /> - - - {videoRef.current && ( - - )} - - ); -}; - -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; diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 791dd753..b8e28295 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -34,13 +34,10 @@ export const useDownloadedFileOpener = () => { const openFile = useCallback( async (item: BaseItemDto) => { try { - const url = await getDownloadedFileUrl(item.Id!); - - setOfflineSettings({ - item, - }); - setPlayUrl(url); - + console.log( + "Go to offline movie", + "/player?offline=true&itemId=" + item.Id + ); // @ts-expect-error router.push("/player?offline=true&itemId=" + item.Id); } catch (error) { diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index d38ae713..ffba7c7c 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -71,10 +71,7 @@ export const useRemuxHlsToMp4 = () => { id: "", deviceId: "", inputUrl: "", - item: { - item, - mediaSource, - }, + item: item, itemId: item.Id!, outputPath: "", progress: 0, @@ -119,7 +116,7 @@ export const useRemuxHlsToMp4 = () => { if (returnCode.isValueSuccess()) { if (!item) throw new Error("Item is undefined"); - await saveDownloadedItemInfo(item, mediaSource); + await saveDownloadedItemInfo(item); toast.success("Download completed"); writeToLog( "INFO", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 49b6c3fe..a8dbf663 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -4,7 +4,9 @@ import { writeToLog } from "@/utils/log"; import { cancelAllJobs, cancelJobById, + deleteDownloadItemInfoFromDiskTmp, getAllJobsByDeviceId, + getDownloadItemInfoFromDiskTmp, JobStatus, } from "@/utils/optimize-server"; import { @@ -130,7 +132,7 @@ function useDownloadProvider() { if (settings.autoDownload) { startDownload(job); } else { - toast.info(`${job.item.item.Name} is ready to be downloaded`, { + toast.info(`${job.item.Name} is ready to be downloaded`, { action: { label: "Go to downloads", onClick: () => { @@ -141,8 +143,8 @@ function useDownloadProvider() { }); Notifications.scheduleNotificationAsync({ content: { - title: job.item.item.Name, - body: `${job.item.item.Name} is ready to be downloaded`, + title: job.item.Name, + body: `${job.item.Name} is ready to be downloaded`, data: { url: `/downloads`, }, @@ -190,7 +192,7 @@ function useDownloadProvider() { const startDownload = useCallback( 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) => 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: { label: "Go to downloads", onClick: () => { @@ -228,7 +230,7 @@ function useDownloadProvider() { download({ id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${baseDirectory}/${process.item.item.Id}.mp4`, + destination: `${baseDirectory}/${process.item.Id}.mp4`, }) .begin(() => { setProcesses((prev) => @@ -260,11 +262,8 @@ function useDownloadProvider() { ); }) .done(async () => { - await saveDownloadedItemInfo( - process.item.item, - process.item.mediaSource - ); - toast.success(`Download completed for ${process.item.item.Name}`, { + await saveDownloadedItemInfo(process.item); + toast.success(`Download completed for ${process.item.Name}`, { duration: 3000, action: { label: "Go to downloads", @@ -289,15 +288,13 @@ function useDownloadProvider() { if (error.errorCode === 404) { errorMsg = "File not found on server"; } - toast.error( - `Download failed for ${process.item.item.Name} - ${errorMsg}` - ); - writeToLog("ERROR", `Download failed for ${process.item.item.Name}`, { + toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`); + writeToLog("ERROR", `Download failed for ${process.item.Name}`, { error, processDetails: { id: process.id, - itemName: process.item.item.Name, - itemId: process.item.item.Id, + itemName: process.item.Name, + itemId: process.item.Id, }, }); console.error("Error details:", { @@ -309,12 +306,15 @@ function useDownloadProvider() { ); const startBackgroundDownload = useCallback( - async (url: string, item: BaseItemDto, fileExtension: string) => { + async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { if (!api || !item.Id || !authHeader) throw new Error("startBackgroundDownload ~ Missing required params"); try { + const fileExtension = mediaSource.TranscodingContainer; const deviceId = await getOrSetDeviceId(); + + // Save poster to disk const itemImage = getItemImage({ item, api, @@ -322,9 +322,9 @@ function useDownloadProvider() { quality: 90, width: 500, }); - await saveImage(item.Id, itemImage?.uri); + // POST to start optimization job on the server const response = await axios.post( settings?.optimizedVersionsServerUrl + "optimize-version", { @@ -529,17 +529,23 @@ function useDownloadProvider() { } } - async function saveDownloadedItemInfo( - item: BaseItemDto, - mediaSource: MediaSourceInfo - ) { + async function saveDownloadedItemInfo(item: BaseItemDto) { try { const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - let items: { item: BaseItemDto; mediaSource: MediaSourceInfo }[] = - downloadedItems ? JSON.parse(downloadedItems) : []; + let items: DownloadedItem[] = downloadedItems + ? JSON.parse(downloadedItems) + : []; 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) { items[existingItemIndex] = newItem; @@ -547,6 +553,8 @@ function useDownloadProvider() { items.push(newItem); } + deleteDownloadItemInfoFromDiskTmp(item.Id!); + await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); refetch(); diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 54c0439c..61d17a9a 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -6,6 +6,7 @@ import { import axios from "axios"; import { writeToLog } from "./log"; import { DownloadedItem } from "@/providers/DownloadProvider"; +import { MMKV } from "react-native-mmkv"; interface IJobInput { deviceId?: string | null; @@ -27,7 +28,7 @@ export interface JobStatus { inputUrl: string; deviceId: string; itemId: string; - item: DownloadedItem; + item: BaseItemDto; speed?: number; timestamp: Date; base64Image?: string; @@ -158,3 +159,81 @@ export async function getStatistics({ 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; + } +}