From 55f8af70698c01a94fbf9fe016664d0bebbdd718 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 23 Nov 2024 22:42:04 +0100 Subject: [PATCH] wip --- app/(auth)/player/player.tsx | 128 +++++++++------ components/BitrateSelector.tsx | 4 - components/DownloadItem.tsx | 89 +++------- components/downloads/ActiveDownloads.tsx | 16 +- components/downloads/EpisodeCard.tsx | 4 +- components/downloads/MovieCard.tsx | 4 +- components/video-player/Controls.tsx | 20 +-- hooks/useDownloadedFileOpener.ts | 74 +++++---- hooks/useRemuxHlsToMp4.ts | 38 +++-- hooks/useWebsockets.ts | 10 +- providers/DownloadProvider.tsx | 84 +++++++--- utils/optimize-server.ts | 8 +- utils/profiles/native.js | 197 +++++++++++++---------- 13 files changed, 366 insertions(+), 310 deletions(-) diff --git a/app/(auth)/player/player.tsx b/app/(auth)/player/player.tsx index 57b28db2..c86f2281 100644 --- a/app/(auth)/player/player.tsx +++ b/app/(auth)/player/player.tsx @@ -2,6 +2,7 @@ import { BITRATES } from "@/components/BitrateSelector"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; import { Controls } from "@/components/video-player/Controls"; +import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { useOrientation } from "@/hooks/useOrientation"; import { useOrientationSettings } from "@/hooks/useOrientationSettings"; import { useWebSocket } from "@/hooks/useWebsockets"; @@ -11,12 +12,12 @@ import { ProgressUpdatePayload, VlcPlayerViewRef, } from "@/modules/vlc-player/src/VlcPlayer.types"; +import { useDownload } from "@/providers/DownloadProvider"; 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 android from "@/utils/profiles/android"; import { msToTicks, ticksToSeconds } from "@/utils/time"; import { Api } from "@jellyfin/sdk"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; @@ -29,13 +30,7 @@ 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, - Platform, - Pressable, - useWindowDimensions, - View, -} from "react-native"; +import { Alert, Pressable, View } from "react-native"; import { useSharedValue } from "react-native-reanimated"; export default function page() { @@ -43,8 +38,6 @@ export default function page() { 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); @@ -56,20 +49,26 @@ export default function page() { const isSeeking = useSharedValue(false); const cacheProgress = useSharedValue(0); + const { getDownloadedItem } = useDownload(); + const { itemId, audioIndex: audioIndexStr, subtitleIndex: subtitleIndexStr, mediaSourceId, bitrateValue: bitrateValueStr, + offline: offlineStr, } = useLocalSearchParams<{ itemId: string; audioIndex: string; subtitleIndex: string; mediaSourceId: string; bitrateValue: string; + offline: string; }>(); + const offline = offlineStr === "true"; + const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; const bitrateValue = bitrateValueStr @@ -84,6 +83,12 @@ export default function page() { queryKey: ["item", itemId], queryFn: async () => { if (!api) return; + + if (offline) { + const item = await getDownloadedItem(itemId); + if (item) return item.item; + } + const res = await getUserLibraryApi(api).getItem({ itemId, userId: user?.Id, @@ -110,6 +115,21 @@ export default function page() { ], queryFn: async () => { if (!api) return; + + if (offline) { + const item = await getDownloadedItem(itemId); + if (!item?.mediaSource) return null; + + const url = await getDownloadedFileUrl(item.item.Id!); + + if (item) + return { + mediaSource: item.mediaSource, + url, + sessionId: undefined, + }; + } + const res = await getStreamUrl({ api, item, @@ -134,7 +154,7 @@ export default function page() { url, }; }, - enabled: !!itemId && !!api && !!item, + enabled: !!itemId && !!api && !!item && !offline, staleTime: 0, }); @@ -146,33 +166,38 @@ export default function page() { 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, - }); + if (!offline) { + 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, - }); + if (!offline) { + 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, + }); + } } }, [ @@ -184,6 +209,7 @@ export default function page() { audioIndex, subtitleIndex, mediaSourceId, + offline, ] ); @@ -202,19 +228,20 @@ export default function page() { reportPlaybackStopped(); }, [videoRef]); - const reportPlaybackStopped = async () => { + const reportPlaybackStopped = useCallback(async () => { + if (offline) return; const currentTimeInTicks = msToTicks(progress.value); - await getPlaystateApi(api!).onPlaybackStopped({ itemId: item?.Id!, mediaSourceId: mediaSourceId, positionTicks: currentTimeInTicks, playSessionId: stream?.sessionId!, }); - }; + }, [api, item, mediaSourceId, stream]); - const reportPlaybackStart = async () => { + const reportPlaybackStart = useCallback(async () => { if (!api || !stream) return; + if (offline) return; await getPlaystateApi(api).onPlaybackStart({ itemId: item?.Id!, audioStreamIndex: audioIndex ? audioIndex : undefined, @@ -223,7 +250,7 @@ export default function page() { playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream", playSessionId: stream?.sessionId ? stream?.sessionId : undefined, }); - }; + }, [api, item, mediaSourceId, stream]); const onProgress = useCallback( async (data: ProgressUpdatePayload) => { @@ -238,6 +265,9 @@ export default function page() { } progress.value = currentTime; + + if (offline) return; + const currentTimeInTicks = msToTicks(currentTime); await getPlaystateApi(api).onPlaybackProgress({ @@ -262,9 +292,10 @@ export default function page() { pauseVideo: pause, playVideo: play, stopPlayback: stop, + offline: offline, }); - const onPlaybackStateChanged = (e: PlaybackStatePayload) => { + const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const { state, isBuffering, isPlaying } = e.nativeEvent; if (state === "Playing") { @@ -283,7 +314,15 @@ export default function page() { } else if (isBuffering) { setIsBuffering(true); } - }; + }, []); + + const startPosition = useMemo(() => { + if (offline) return 0; + + return item?.UserData?.PlaybackPositionTicks + ? ticksToSeconds(item.UserData.PlaybackPositionTicks) + : 0; + }, [item]); if (isLoadingItem || isLoadingStreamUrl) return ( @@ -301,11 +340,6 @@ export default function page() { if (!stream || !item) return null; - console.log("AudioIndex", audioIndex); - const startPosition = item?.UserData?.PlaybackPositionTicks - ? ticksToSeconds(item.UserData.PlaybackPositionTicks) - : 0; - return ( (b.value || Infinity) - (a.value || Infinity)); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e1f90a10..abe3e702 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -31,6 +31,7 @@ import { Loader } from "./Loader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; interface DownloadProps extends ViewProps { item: BaseItemDto; @@ -42,7 +43,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const [queue, setQueue] = useAtom(queueAtom); const [settings] = useSettings(); const { processes, startBackgroundDownload } = useDownload(); - const { startRemuxing } = useRemuxHlsToMp4(item); + const { startRemuxing } = useRemuxHlsToMp4(); const [selectedMediaSource, setSelectedMediaSource] = useState< MediaSourceInfo | undefined | null @@ -98,73 +99,33 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { ); } - const response = await api.axiosInstance.post( - `${api.basePath}/Items/${item.Id}/PlaybackInfo`, - { - DeviceProfile: native, - UserId: user.Id, - MaxStreamingBitrate: maxBitrate.value, - StartTimeTicks: 0, - EnableTranscoding: maxBitrate.value ? true : undefined, - AutoOpenLiveStream: true, - AllowVideoStreamCopy: maxBitrate.value ? false : true, - MediaSourceId: selectedMediaSource?.Id, - AudioStreamIndex: selectedAudioStream, - SubtitleStreamIndex: selectedSubtitleStream, - }, - { - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - } - ); + const res = await getStreamUrl({ + api, + item, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, + audioStreamIndex: selectedAudioStream, + maxStreamingBitrate: maxBitrate.value, + mediaSourceId: selectedMediaSource.Id, + subtitleStreamIndex: selectedSubtitleStream, + deviceProfile: native, + }); - let url: string | undefined = undefined; - let fileExtension: string | undefined | null = "mp4"; + if (!res) return null; - const mediaSource: MediaSourceInfo = response.data.MediaSources.find( - (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id - ); + const { mediaSource, url } = res; - if (!mediaSource) { - throw new Error("No media source"); - } - - if (mediaSource.SupportsDirectPlay) { - if (item.MediaType === "Video") { - url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; - } else if (item.MediaType === "Audio") { - console.log("Using direct stream for audio!"); - const searchParams = new URLSearchParams({ - UserId: user.Id, - DeviceId: api.deviceInfo.id, - MaxStreamingBitrate: "140000000", - Container: - "opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg", - TranscodingContainer: "mp4", - TranscodingProtocol: "hls", - AudioCodec: "aac", - api_key: api.accessToken, - StartTimeTicks: "0", - EnableRedirection: "true", - EnableRemoteMedia: "false", - }); - url = `${api.basePath}/Audio/${ - item.Id - }/universal?${searchParams.toString()}`; - } - } else if (mediaSource.TranscodingUrl) { - url = `${api.basePath}${mediaSource.TranscodingUrl}`; - fileExtension = mediaSource.TranscodingContainer; - } - - if (!url) throw new Error("No url"); - if (!fileExtension) throw new Error("No file extension"); + if (!url || !mediaSource) throw new Error("No url"); + if (!mediaSource.TranscodingContainer) throw new Error("No file extension"); if (settings?.downloadMethod === "optimized") { - return await startBackgroundDownload(url, item, fileExtension); + return await startBackgroundDownload( + url, + item, + mediaSource.TranscodingContainer + ); } else { - return await startRemuxing(url); + return await startRemuxing(item, url, mediaSource); } }, [ api, @@ -203,7 +164,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const process = useMemo(() => { if (!processes) return null; - return processes.find((process) => process?.item?.Id === item.Id); + return processes.find((process) => process?.item?.item.Id === item.Id); }, [processes, item.Id]); return ( @@ -211,7 +172,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.Id === item.Id ? ( + {process && process?.item.item.Id === item.Id ? ( { router.push("/downloads"); diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 034f2f4b..05bc0efe 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -93,18 +93,20 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const eta = (p: JobStatus) => { if (!p.speed || !p.progress) return null; - const length = p?.item?.RunTimeTicks || 0; + const length = p?.item?.item.RunTimeTicks || 0; const timeLeft = (length - length * (p.progress / 100)) / p.speed; return formatTimeString(timeLeft, "tick"); }; const base64Image = useMemo(() => { - return storage.getString(process.item.Id!); + return storage.getString(process.item.item.Id!); }, []); return ( router.push(`/(auth)/items/page?id=${process.item.Id}`)} + onPress={() => + router.push(`/(auth)/items/page?id=${process.item.item.Id}`) + } className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" {...props} > @@ -138,10 +140,12 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { )} - {process.item.Type} - {process.item.Name} + {process.item.item.Type} + + {process.item.item.Name} + - {process.item.ProductionYear} + {process.item.item.ProductionYear} {process.progress === 0 ? ( diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 3d95821c..80a50d3d 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -7,7 +7,7 @@ import { useActionSheet, } from "@expo/react-native-action-sheet"; -import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; +import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { Text } from "../common/Text"; import { useDownload } from "@/providers/DownloadProvider"; import { storage } from "@/utils/mmkv"; @@ -26,7 +26,7 @@ interface EpisodeCardProps { */ export const EpisodeCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); - const { openFile } = useFileOpener(); + const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); const base64Image = useMemo(() => { diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 54381c08..9cc52afd 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -10,7 +10,7 @@ import { import { runtimeTicksToMinutes } from "@/utils/time"; import { Text } from "../common/Text"; -import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; +import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useDownload } from "@/providers/DownloadProvider"; import { storage } from "@/utils/mmkv"; import { Image } from "expo-image"; @@ -28,7 +28,7 @@ interface MovieCardProps { */ export const MovieCard: React.FC = ({ item }) => { const { deleteFile } = useDownload(); - const { openFile } = useFileOpener(); + const { openFile } = useDownloadedFileOpener(); const { showActionSheetWithOptions } = useActionSheet(); const handleOpenFile = useCallback(() => { diff --git a/components/video-player/Controls.tsx b/components/video-player/Controls.tsx index e9a26f35..5b766dd0 100644 --- a/components/video-player/Controls.tsx +++ b/components/video-player/Controls.tsx @@ -116,9 +116,7 @@ export const Controls: React.FC = ({ const [settings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); - const { setPlaySettings, playSettings } = usePlaySettings(); const api = useAtomValue(apiAtom); - const windowDimensions = Dimensions.get("window"); const { previousItem, nextItem } = useAdjacentItems({ item }); const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( @@ -157,14 +155,6 @@ export const Controls: React.FC = ({ const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(previousItem, settings); - setPlaySettings({ - item: previousItem, - bitrate, - mediaSource, - audioIndex, - subtitleIndex, - }); - const queryParams = new URLSearchParams({ itemId: previousItem.Id ?? "", // Ensure itemId is a string audioIndex: audioIndex?.toString() ?? "", @@ -183,14 +173,6 @@ export const Controls: React.FC = ({ const { bitrate, mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(nextItem, settings); - setPlaySettings({ - item: nextItem, - bitrate, - mediaSource, - audioIndex, - subtitleIndex, - }); - const queryParams = new URLSearchParams({ itemId: nextItem.Id ?? "", // Ensure itemId is a string audioIndex: audioIndex?.toString() ?? "", @@ -374,6 +356,8 @@ export const Controls: React.FC = ({ })) .filter((sub) => !sub.name.endsWith("[External]")) || []; + console.log("embeddedSubs ~", embeddedSubs); + const externalSubs = mediaSource?.MediaStreams?.filter( (stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index 67244d2c..791dd753 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -1,51 +1,55 @@ -// hooks/useFileOpener.ts - import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { writeToLog } from "@/utils/log"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; import { useCallback } from "react"; -import { Platform } from "react-native"; -export const useFileOpener = () => { +export const getDownloadedFileUrl = async (itemId: string): Promise => { + const directory = FileSystem.documentDirectory; + + if (!directory) { + throw new Error("Document directory is not available"); + } + + if (!itemId) { + throw new Error("Item ID is not available"); + } + + const files = await FileSystem.readDirectoryAsync(directory); + const path = itemId!; + const matchingFile = files.find((file) => file.startsWith(path)); + + if (!matchingFile) { + throw new Error(`No file found for item ${path}`); + } + + return `${directory}${matchingFile}`; +}; + +export const useDownloadedFileOpener = () => { const router = useRouter(); const { setPlayUrl, setOfflineSettings } = usePlaySettings(); - const openFile = useCallback(async (item: BaseItemDto) => { - const directory = FileSystem.documentDirectory; + const openFile = useCallback( + async (item: BaseItemDto) => { + try { + const url = await getDownloadedFileUrl(item.Id!); - if (!directory) { - throw new Error("Document directory is not available"); - } + setOfflineSettings({ + item, + }); + setPlayUrl(url); - if (!item.Id) { - throw new Error("Item ID is not available"); - } - - try { - const files = await FileSystem.readDirectoryAsync(directory); - const path = item.Id!; - const matchingFile = files.find((file) => file.startsWith(path)); - - if (!matchingFile) { - throw new Error(`No file found for item ${path}`); + // @ts-expect-error + router.push("/player?offline=true&itemId=" + item.Id); + } catch (error) { + writeToLog("ERROR", "Error opening file", error); + console.error("Error opening file:", error); } - - const url = `${directory}${matchingFile}`; - - setOfflineSettings({ - item, - }); - setPlayUrl(url); - - if (Platform.OS === "ios") router.push("/offline-vlc-player"); - else router.push("/offline-player"); - } catch (error) { - writeToLog("ERROR", "Error opening file", error); - console.error("Error opening file:", error); - } - }, []); + }, + [setOfflineSettings, setPlayUrl, router] + ); return { openFile }; }; diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index db7c0a88..d38ae713 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -3,7 +3,10 @@ import { useAtom, useAtomValue } from "jotai"; import AsyncStorage from "@react-native-async-storage/async-storage"; import * as FileSystem from "expo-file-system"; import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { writeToLog } from "@/utils/log"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner-native"; @@ -21,22 +24,16 @@ import { apiAtom } from "@/providers/JellyfinProvider"; * @param item - The BaseItemDto object representing the media item * @returns An object with remuxing-related functions */ -export const useRemuxHlsToMp4 = (item: BaseItemDto) => { +export const useRemuxHlsToMp4 = () => { const api = useAtomValue(apiAtom); const queryClient = useQueryClient(); const { saveDownloadedItemInfo, setProcesses } = useDownload(); const router = useRouter(); const { saveImage } = useImageStorage(); - if (!item.Id || !item.Name) { - writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments"); - throw new Error("Item must have an Id and Name"); - } - - const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; - const startRemuxing = useCallback( - async (url: string) => { + async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { + const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; if (!api) throw new Error("API is not defined"); if (!item.Id) throw new Error("Item must have an Id"); @@ -74,13 +71,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { id: "", deviceId: "", inputUrl: "", - item, - itemId: item.Id, + item: { + item, + mediaSource, + }, + itemId: item.Id!, outputPath: "", progress: 0, status: "downloading", timestamp: new Date(), - } as JobStatus, + }, ]); FFmpegKitConfig.enableStatisticsCallback((statistics) => { @@ -119,7 +119,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { if (returnCode.isValueSuccess()) { if (!item) throw new Error("Item is undefined"); - await saveDownloadedItemInfo(item); + await saveDownloadedItemInfo(item, mediaSource); toast.success("Download completed"); writeToLog( "INFO", @@ -134,7 +134,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); - reject(new Error("Remuxing failed")); // Reject the promise on error + reject(new Error("Remuxing failed")); } else if (returnCode.isValueCancel()) { writeToLog( "INFO", @@ -163,15 +163,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { throw error; // Re-throw the error to propagate it to the caller } }, - [output, item] + [] ); const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); - setProcesses((prev) => { - return prev.filter((process) => process.itemId !== item.Id); - }); - }, [item.Name]); + setProcesses([]); + }, []); return { startRemuxing, cancelRemuxing }; }; diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts index 92c647bb..473106d1 100644 --- a/hooks/useWebsockets.ts +++ b/hooks/useWebsockets.ts @@ -15,6 +15,7 @@ interface UseWebSocketProps { pauseVideo: () => void; playVideo: () => void; stopPlayback: () => void; + offline?: boolean; } export const useWebSocket = ({ @@ -22,6 +23,7 @@ export const useWebSocket = ({ pauseVideo, playVideo, stopPlayback, + offline = false, }: UseWebSocketProps) => { const router = useRouter(); const user = useAtomValue(userAtom); @@ -38,7 +40,7 @@ export const useWebSocket = ({ }); useEffect(() => { - if (!deviceId || !api?.accessToken) return; + if (offline || !deviceId || !api?.accessToken) return; const protocol = api?.basePath.includes("https") ? "wss" : "ws"; @@ -80,10 +82,10 @@ export const useWebSocket = ({ } newWebSocket.close(); }; - }, [api, deviceId, user]); + }, [api, deviceId, user, offline]); useEffect(() => { - if (!ws) return; + if (offline || !ws) return; ws.onmessage = (e) => { const json = JSON.parse(e.data); @@ -106,7 +108,7 @@ export const useWebSocket = ({ Alert.alert("Message from server: " + title, body); } }; - }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]); + }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]); return { isConnected }; }; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index b1286c81..49b6c3fe 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -7,7 +7,10 @@ import { getAllJobsByDeviceId, JobStatus, } from "@/utils/optimize-server"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { checkForExistingDownloads, completeHandler, @@ -41,6 +44,11 @@ import * as Notifications from "expo-notifications"; import { getItemImage } from "@/utils/getItemImage"; import useImageStorage from "@/hooks/useImageStorage"; +export type DownloadedItem = { + item: Partial; + mediaSource: MediaSourceInfo; +}; + function onAppStateChange(status: AppStateStatus) { focusManager.setFocused(status === "active"); } @@ -122,7 +130,7 @@ function useDownloadProvider() { if (settings.autoDownload) { startDownload(job); } else { - toast.info(`${job.item.Name} is ready to be downloaded`, { + toast.info(`${job.item.item.Name} is ready to be downloaded`, { action: { label: "Go to downloads", onClick: () => { @@ -133,8 +141,8 @@ function useDownloadProvider() { }); Notifications.scheduleNotificationAsync({ content: { - title: job.item.Name, - body: `${job.item.Name} is ready to be downloaded`, + title: job.item.item.Name, + body: `${job.item.item.Name} is ready to be downloaded`, data: { url: `/downloads`, }, @@ -182,7 +190,7 @@ function useDownloadProvider() { const startDownload = useCallback( async (process: JobStatus) => { - if (!process?.item.Id || !authHeader) throw new Error("No item id"); + if (!process?.item.item.Id || !authHeader) throw new Error("No item id"); setProcesses((prev) => prev.map((p) => @@ -205,7 +213,7 @@ function useDownloadProvider() { }, }); - toast.info(`Download started for ${process.item.Name}`, { + toast.info(`Download started for ${process.item.item.Name}`, { action: { label: "Go to downloads", onClick: () => { @@ -220,7 +228,7 @@ function useDownloadProvider() { download({ id: process.id, url: settings?.optimizedVersionsServerUrl + "download/" + process.id, - destination: `${baseDirectory}/${process.item.Id}.mp4`, + destination: `${baseDirectory}/${process.item.item.Id}.mp4`, }) .begin(() => { setProcesses((prev) => @@ -252,8 +260,11 @@ function useDownloadProvider() { ); }) .done(async () => { - await saveDownloadedItemInfo(process.item); - toast.success(`Download completed for ${process.item.Name}`, { + await saveDownloadedItemInfo( + process.item.item, + process.item.mediaSource + ); + toast.success(`Download completed for ${process.item.item.Name}`, { duration: 3000, action: { label: "Go to downloads", @@ -278,13 +289,15 @@ function useDownloadProvider() { if (error.errorCode === 404) { errorMsg = "File not found on server"; } - toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`); - writeToLog("ERROR", `Download failed for ${process.item.Name}`, { + toast.error( + `Download failed for ${process.item.item.Name} - ${errorMsg}` + ); + writeToLog("ERROR", `Download failed for ${process.item.item.Name}`, { error, processDetails: { id: process.id, - itemName: process.item.Name, - itemId: process.item.Id, + itemName: process.item.item.Name, + itemId: process.item.item.Id, }, }); console.error("Error details:", { @@ -485,11 +498,28 @@ function useDownloadProvider() { } }; - async function getAllDownloadedItems(): Promise { + async function getDownloadedItem( + itemId: string + ): Promise { try { const downloadedItems = await AsyncStorage.getItem("downloadedItems"); if (downloadedItems) { - return JSON.parse(downloadedItems) as BaseItemDto[]; + const items: DownloadedItem[] = JSON.parse(downloadedItems); + const item = items.find((i) => i.item.Id === itemId); + return item || null; + } + return null; + } catch (error) { + console.error(`Failed to retrieve item with ID ${itemId}:`, error); + return null; + } + } + + async function getAllDownloadedItems(): Promise { + try { + const downloadedItems = await AsyncStorage.getItem("downloadedItems"); + if (downloadedItems) { + return JSON.parse(downloadedItems) as DownloadedItem[]; } else { return []; } @@ -499,25 +529,32 @@ function useDownloadProvider() { } } - async function saveDownloadedItemInfo(item: BaseItemDto) { + async function saveDownloadedItemInfo( + item: BaseItemDto, + mediaSource: MediaSourceInfo + ) { try { const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - let items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; + let items: { item: BaseItemDto; mediaSource: MediaSourceInfo }[] = + downloadedItems ? JSON.parse(downloadedItems) : []; + + const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id); + const newItem = { item, mediaSource }; - const existingItemIndex = items.findIndex((i) => i.Id === item.Id); if (existingItemIndex !== -1) { - items[existingItemIndex] = item; + items[existingItemIndex] = newItem; } else { - items.push(item); + items.push(newItem); } await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }); refetch(); } catch (error) { - console.error("Failed to save downloaded item information:", error); + console.error( + "Failed to save downloaded item information with media source:", + error + ); } } @@ -531,6 +568,7 @@ function useDownloadProvider() { removeProcess, setProcesses, startDownload, + getDownloadedItem, }; } diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 244ed0da..54c0439c 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -1,7 +1,11 @@ import { itemRouter } from "@/components/common/TouchableItemRouter"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; import axios from "axios"; import { writeToLog } from "./log"; +import { DownloadedItem } from "@/providers/DownloadProvider"; interface IJobInput { deviceId?: string | null; @@ -23,7 +27,7 @@ export interface JobStatus { inputUrl: string; deviceId: string; itemId: string; - item: Partial; + item: DownloadedItem; speed?: number; timestamp: Date; base64Image?: string; diff --git a/utils/profiles/native.js b/utils/profiles/native.js index 37f6773b..e54c8764 100644 --- a/utils/profiles/native.js +++ b/utils/profiles/native.js @@ -58,97 +58,128 @@ export default { }, ], SubtitleProfiles: [ - { Format: "ass", Method: "Embed" }, - { Format: "ass", Method: "Hls" }, - { Format: "ass", Method: "External" }, - { Format: "ass", Method: "Encode" }, - { Format: "dvbsub", Method: "Embed" }, - { Format: "dvbsub", Method: "Hls" }, - { Format: "dvbsub", Method: "External" }, - { Format: "dvdsub", Method: "Encode" }, - { Format: "microdvd", Method: "Embed" }, - { Format: "microdvd", Method: "Hls" }, - { Format: "microdvd", Method: "External" }, - { Format: "microdvd", Method: "Encode" }, - { Format: "mov_text", Method: "Embed" }, - { Format: "mov_text", Method: "Hls" }, - { Format: "mov_text", Method: "External" }, - { Format: "mov_text", Method: "Encode" }, - { Format: "mpl2", Method: "Embed" }, - { Format: "mpl2", Method: "Hls" }, - { Format: "mpl2", Method: "External" }, - { Format: "mpl2", Method: "Encode" }, - { Format: "pgs", Method: "Embed" }, - { Format: "pgs", Method: "Hls" }, - { Format: "pgs", Method: "External" }, - { Format: "pgs", Method: "Encode" }, - { Format: "pgssub", Method: "Embed" }, - { Format: "pgssub", Method: "External" }, - { Format: "pgssub", Method: "Encode" }, - { Format: "pjs", Method: "Embed" }, - { Format: "pjs", Method: "Hls" }, - { Format: "pjs", Method: "External" }, - { Format: "pjs", Method: "Encode" }, - { Format: "realtext", Method: "Embed" }, - { Format: "realtext", Method: "Hls" }, - { Format: "realtext", Method: "External" }, - { Format: "realtext", Method: "Encode" }, - { Format: "scc", Method: "Embed" }, - { Format: "scc", Method: "Hls" }, - { Format: "scc", Method: "External" }, - { Format: "scc", Method: "Encode" }, - { Format: "smi", Method: "Embed" }, - { Format: "smi", Method: "Hls" }, - { Format: "smi", Method: "External" }, - { Format: "smi", Method: "Encode" }, - { Format: "srt", Method: "Embed" }, - { Format: "srt", Method: "Hls" }, - { Format: "srt", Method: "External" }, - { Format: "srt", Method: "Encode" }, - { Format: "ssa", Method: "Embed" }, - { Format: "ssa", Method: "Hls" }, - { Format: "ssa", Method: "External" }, - { Format: "ssa", Method: "Encode" }, - { Format: "stl", Method: "Embed" }, - { Format: "stl", Method: "Hls" }, - { Format: "stl", Method: "External" }, - { Format: "stl", Method: "Encode" }, - { Format: "sub", Method: "Embed" }, - { Format: "sub", Method: "Hls" }, - { Format: "sub", Method: "External" }, - { Format: "sub", Method: "Encode" }, - { Format: "subrip", Method: "Embed" }, - { Format: "subrip", Method: "Hls" }, - { Format: "subrip", Method: "External" }, - { Format: "subrip", Method: "Encode" }, - { Format: "subviewer", Method: "Embed" }, - { Format: "subviewer", Method: "Hls" }, - { Format: "subviewer", Method: "External" }, - { Format: "subviewer", Method: "Encode" }, - { Format: "teletext", Method: "Embed" }, - { Format: "teletext", Method: "Hls" }, - { Format: "teletext", Method: "External" }, - { Format: "teletext", Method: "Encode" }, - { Format: "text", Method: "Embed" }, - { Format: "text", Method: "Hls" }, - { Format: "text", Method: "External" }, - { Format: "text", Method: "Encode" }, - { Format: "ttml", Method: "Embed" }, - { Format: "ttml", Method: "Hls" }, - { Format: "ttml", Method: "External" }, - { Format: "ttml", Method: "Encode" }, - { Format: "vplayer", Method: "Embed" }, - { Format: "vplayer", Method: "Hls" }, - { Format: "vplayer", Method: "External" }, - { Format: "vplayer", Method: "Encode" }, + // Official foramts { Format: "vtt", Method: "Embed" }, { Format: "vtt", Method: "Hls" }, { Format: "vtt", Method: "External" }, { Format: "vtt", Method: "Encode" }, + { Format: "webvtt", Method: "Embed" }, { Format: "webvtt", Method: "Hls" }, { Format: "webvtt", Method: "External" }, { Format: "webvtt", Method: "Encode" }, + + { Format: "srt", Method: "Embed" }, + { Format: "srt", Method: "Hls" }, + { Format: "srt", Method: "External" }, + { Format: "srt", Method: "Encode" }, + + { Format: "subrip", Method: "Embed" }, + { Format: "subrip", Method: "Hls" }, + { Format: "subrip", Method: "External" }, + { Format: "subrip", Method: "Encode" }, + + { Format: "ttml", Method: "Embed" }, + { Format: "ttml", Method: "Hls" }, + { Format: "ttml", Method: "External" }, + { Format: "ttml", Method: "Encode" }, + + { Format: "dvbsub", Method: "Embed" }, + { Format: "dvbsub", Method: "Hls" }, + { Format: "dvbsub", Method: "External" }, + { Format: "dvdsub", Method: "Encode" }, + + { Format: "ass", Method: "Embed" }, + { Format: "ass", Method: "Hls" }, + { Format: "ass", Method: "External" }, + { Format: "ass", Method: "Encode" }, + + { Format: "idx", Method: "Embed" }, + { Format: "idx", Method: "Hls" }, + { Format: "idx", Method: "External" }, + { Format: "idx", Method: "Encode" }, + + { Format: "pgs", Method: "Embed" }, + { Format: "pgs", Method: "Hls" }, + { Format: "pgs", Method: "External" }, + { Format: "pgs", Method: "Encode" }, + + { Format: "pgssub", Method: "Embed" }, + { Format: "pgssub", Method: "Hls" }, + { Format: "pgssub", Method: "External" }, + { Format: "pgssub", Method: "Encode" }, + + { Format: "ssa", Method: "Embed" }, + { Format: "ssa", Method: "Hls" }, + { Format: "ssa", Method: "External" }, + { Format: "ssa", Method: "Encode" }, + + // Other formats + { Format: "microdvd", Method: "Embed" }, + { Format: "microdvd", Method: "Hls" }, + { Format: "microdvd", Method: "External" }, + { Format: "microdvd", Method: "Encode" }, + + { Format: "mov_text", Method: "Embed" }, + { Format: "mov_text", Method: "Hls" }, + { Format: "mov_text", Method: "External" }, + { Format: "mov_text", Method: "Encode" }, + + { Format: "mpl2", Method: "Embed" }, + { Format: "mpl2", Method: "Hls" }, + { Format: "mpl2", Method: "External" }, + { Format: "mpl2", Method: "Encode" }, + + { Format: "pjs", Method: "Embed" }, + { Format: "pjs", Method: "Hls" }, + { Format: "pjs", Method: "External" }, + { Format: "pjs", Method: "Encode" }, + + { Format: "realtext", Method: "Embed" }, + { Format: "realtext", Method: "Hls" }, + { Format: "realtext", Method: "External" }, + { Format: "realtext", Method: "Encode" }, + + { Format: "scc", Method: "Embed" }, + { Format: "scc", Method: "Hls" }, + { Format: "scc", Method: "External" }, + { Format: "scc", Method: "Encode" }, + + { Format: "smi", Method: "Embed" }, + { Format: "smi", Method: "Hls" }, + { Format: "smi", Method: "External" }, + { Format: "smi", Method: "Encode" }, + + { Format: "stl", Method: "Embed" }, + { Format: "stl", Method: "Hls" }, + { Format: "stl", Method: "External" }, + { Format: "stl", Method: "Encode" }, + + { Format: "sub", Method: "Embed" }, + { Format: "sub", Method: "Hls" }, + { Format: "sub", Method: "External" }, + { Format: "sub", Method: "Encode" }, + + { Format: "subviewer", Method: "Embed" }, + { Format: "subviewer", Method: "Hls" }, + { Format: "subviewer", Method: "External" }, + { Format: "subviewer", Method: "Encode" }, + + { Format: "teletext", Method: "Embed" }, + { Format: "teletext", Method: "Hls" }, + { Format: "teletext", Method: "External" }, + { Format: "teletext", Method: "Encode" }, + + { Format: "text", Method: "Embed" }, + { Format: "text", Method: "Hls" }, + { Format: "text", Method: "External" }, + { Format: "text", Method: "Encode" }, + + { Format: "vplayer", Method: "Embed" }, + { Format: "vplayer", Method: "Hls" }, + { Format: "vplayer", Method: "External" }, + { Format: "vplayer", Method: "Encode" }, + { Format: "xsub", Method: "Embed" }, { Format: "xsub", Method: "Hls" }, { Format: "xsub", Method: "External" },