diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e58a5a3e..5f83ad03 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -4,37 +4,132 @@ import { runningProcesses } from "@/utils/atoms/downloads"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo"; import Ionicons from "@expo/vector-icons/Ionicons"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; -import { TouchableOpacity, View } from "react-native"; +import { + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; import { Loader } from "./Loader"; import ProgressCircle from "./ProgressCircle"; +import { DownloadQuality, useSettings } from "@/utils/atoms/settings"; +import { useCallback } from "react"; +import ios from "@/utils/profiles/ios"; +import native from "@/utils/profiles/native"; +import old from "@/utils/profiles/old"; -type DownloadProps = { +interface DownloadProps extends TouchableOpacityProps { item: BaseItemDto; - playbackUrl: string; -}; +} -export const DownloadItem: React.FC = ({ - item, - playbackUrl, -}) => { +export const DownloadItem: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [process] = useAtom(runningProcesses); const [queue, setQueue] = useAtom(queueAtom); - const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item); + const [settings] = useSettings(); - const { data: playbackInfo, isLoading } = useQuery({ - queryKey: ["playbackInfo", item.Id], - queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id), - }); + const { startRemuxing } = useRemuxHlsToMp4(item); - const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({ + const initiateDownload = useCallback( + async (qualitySetting: DownloadQuality) => { + if (!api || !user?.Id || !item.Id) { + throw new Error( + "DownloadItem ~ initiateDownload: No api or user or item" + ); + } + + let deviceProfile: any = ios; + + if (settings?.deviceProfile === "Native") { + deviceProfile = native; + } else if (settings?.deviceProfile === "Old") { + deviceProfile = old; + } + + let maxStreamingBitrate: number | undefined = undefined; + + if (qualitySetting === "high") { + maxStreamingBitrate = 8000000; + } else if (qualitySetting === "low") { + maxStreamingBitrate = 2000000; + } + + const response = await api.axiosInstance.post( + `${api.basePath}/Items/${item.Id}/PlaybackInfo`, + { + DeviceProfile: deviceProfile, + UserId: user.Id, + MaxStreamingBitrate: maxStreamingBitrate, + StartTimeTicks: 0, + EnableTranscoding: maxStreamingBitrate ? true : undefined, + AutoOpenLiveStream: true, + MediaSourceId: item.Id, + AllowVideoStreamCopy: maxStreamingBitrate ? false : true, + }, + { + headers: { + Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, + }, + } + ); + + let url: string | undefined = undefined; + + const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo; + + if (!mediaSource) { + throw new Error("No media source"); + } + + if (mediaSource.SupportsDirectPlay) { + if (item.MediaType === "Video") { + console.log("Using direct stream for video!"); + url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`; + } 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()}`; + } + } + + if (mediaSource.TranscodingUrl) { + console.log("Using transcoded stream!"); + url = `${api.basePath}${mediaSource.TranscodingUrl}`; + } else { + throw new Error("No transcoding url"); + } + + return await startRemuxing(url); + }, + [api, item, startRemuxing, user?.Id] + ); + + const { data: downloaded, isFetching } = useQuery({ queryKey: ["downloaded", item.Id], queryFn: async () => { if (!item.Id) return false; @@ -48,7 +143,7 @@ export const DownloadItem: React.FC = ({ enabled: !!item.Id, }); - if (isLoading || isLoadingDownloaded) { + if (isFetching) { return ( @@ -56,20 +151,13 @@ export const DownloadItem: React.FC = ({ ); } - if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) { - return ( - - - - ); - } - if (process && process?.item.Id === item.Id) { return ( { router.push("/downloads"); }} + {...props} > {process.progress === 0 ? ( @@ -96,6 +184,7 @@ export const DownloadItem: React.FC = ({ onPress={() => { router.push("/downloads"); }} + {...props} > @@ -110,6 +199,7 @@ export const DownloadItem: React.FC = ({ onPress={() => { router.push("/downloads"); }} + {...props} > @@ -123,11 +213,16 @@ export const DownloadItem: React.FC = ({ queueActions.enqueue(queue, setQueue, { id: item.Id!, execute: async () => { - await startRemuxing(); + // await startRemuxing(playbackUrl); + if (!settings?.downloadQuality?.value) { + throw new Error("No download quality selected"); + } + await initiateDownload(settings?.downloadQuality?.value); }, item, }); }} + {...props} > diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 70819507..43859c38 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -1,5 +1,5 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; +import { DownloadOptions, useSettings } from "@/utils/atoms/settings"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; @@ -58,6 +58,46 @@ export const SettingToggles: React.FC = () => { onValueChange={(value) => updateSettings({ autoRotate: value })} /> + + + Download quality + + Choose the search engine you want to use. + + + + + + {settings?.downloadQuality?.label} + + + + Quality + {DownloadOptions.map((option) => ( + { + updateSettings({ downloadQuality: option }); + }} + > + {option.label} + + ))} + + + Start videos in fullscreen @@ -73,6 +113,23 @@ export const SettingToggles: React.FC = () => { } /> + + + + Use external player (VLC) + + Open all videos in VLC instead of the default player. This requries + VLC to be installed on the phone. + + + { + updateSettings({ openInVLC: value, forceDirectPlay: value }); + }} + /> + + @@ -157,22 +214,6 @@ export const SettingToggles: React.FC = () => { /> - - - Use external player (VLC) - - Open all videos in VLC instead of the default player. This requries - VLC to be installed on the phone. - - - { - updateSettings({ openInVLC: value, forceDirectPlay: value }); - }} - /> - - { +export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const [_, setProgress] = useAtom(runningProcesses); + const queryClient = useQueryClient(); if (!item.Id || !item.Name) { writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments"); @@ -23,87 +25,94 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => { } const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; - const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; - const startRemuxing = useCallback(async () => { - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`, - ); + const startRemuxing = useCallback( + async (url: string) => { + const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; - try { - setProgress({ item, progress: 0, startTime: new Date(), speed: 0 }); - - FFmpegKitConfig.enableStatisticsCallback((statistics) => { - const videoLength = - (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds - const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; - const totalFrames = videoLength * fps; - const processedFrames = statistics.getVideoFrameNumber(); - const speed = statistics.getSpeed(); - - const percentage = - totalFrames > 0 - ? Math.floor((processedFrames / totalFrames) * 100) - : 0; - - setProgress((prev) => - prev?.item.Id === item.Id! - ? { ...prev, progress: percentage, speed } - : prev, - ); - }); - - // Await the execution of the FFmpeg command and ensure that the callback is awaited properly. - await new Promise((resolve, reject) => { - FFmpegKit.executeAsync(command, async (session) => { - try { - const returnCode = await session.getReturnCode(); - - if (returnCode.isValueSuccess()) { - await updateDownloadedFiles(item); - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`, - ); - resolve(); - } else if (returnCode.isValueError()) { - writeToLog( - "ERROR", - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, - ); - reject(new Error("Remuxing failed")); // Reject the promise on error - } else if (returnCode.isValueCancel()) { - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`, - ); - resolve(); - } - - setProgress(null); - } catch (error) { - reject(error); - } - }); - }); - } catch (error) { - console.error("Failed to remux:", error); writeToLog( - "ERROR", - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, + "INFO", + `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}` ); - setProgress(null); - throw error; // Re-throw the error to propagate it to the caller - } - }, [output, item, command, setProgress]); + + try { + setProgress({ item, progress: 0, startTime: new Date(), speed: 0 }); + + FFmpegKitConfig.enableStatisticsCallback((statistics) => { + const videoLength = + (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds + const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; + const totalFrames = videoLength * fps; + const processedFrames = statistics.getVideoFrameNumber(); + const speed = statistics.getSpeed(); + + const percentage = + totalFrames > 0 + ? Math.floor((processedFrames / totalFrames) * 100) + : 0; + + setProgress((prev) => + prev?.item.Id === item.Id! + ? { ...prev, progress: percentage, speed } + : prev + ); + }); + + // Await the execution of the FFmpeg command and ensure that the callback is awaited properly. + await new Promise((resolve, reject) => { + FFmpegKit.executeAsync(command, async (session) => { + try { + const returnCode = await session.getReturnCode(); + + if (returnCode.isValueSuccess()) { + await updateDownloadedFiles(item); + writeToLog( + "INFO", + `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}` + ); + resolve(); + } else if (returnCode.isValueError()) { + writeToLog( + "ERROR", + `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` + ); + reject(new Error("Remuxing failed")); // Reject the promise on error + } else if (returnCode.isValueCancel()) { + writeToLog( + "INFO", + `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}` + ); + resolve(); + } + + setProgress(null); + } catch (error) { + reject(error); + } + }); + }); + + await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); + await queryClient.invalidateQueries({ queryKey: ["downloaded"] }); + } catch (error) { + console.error("Failed to remux:", error); + writeToLog( + "ERROR", + `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` + ); + setProgress(null); + throw error; // Re-throw the error to propagate it to the caller + } + }, + [output, item, setProgress] + ); const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); setProgress(null); writeToLog( "INFO", - `useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`, + `useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}` ); }, [item.Name, setProgress]); @@ -118,7 +127,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => { async function updateDownloadedFiles(item: BaseItemDto): Promise { try { const currentFiles: BaseItemDto[] = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) || "[]", + (await AsyncStorage.getItem("downloaded_files")) || "[]" ); const updatedFiles = [ ...currentFiles.filter((i) => i.Id !== item.Id), @@ -126,13 +135,13 @@ async function updateDownloadedFiles(item: BaseItemDto): Promise { ]; await AsyncStorage.setItem( "downloaded_files", - JSON.stringify(updatedFiles), + JSON.stringify(updatedFiles) ); } catch (error) { console.error("Error updating downloaded files:", error); writeToLog( "ERROR", - `Failed to update downloaded files for item: ${item.Name}`, + `Failed to update downloaded files for item: ${item.Name}` ); } } diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index a56cd903..4d38d60f 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -2,6 +2,28 @@ import { atom, useAtom } from "jotai"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect } from "react"; +export type DownloadQuality = "original" | "high" | "low"; + +export type DownloadOption = { + label: string; + value: DownloadQuality; +}; + +export const DownloadOptions: DownloadOption[] = [ + { + label: "Original quality", + value: "original", + }, + { + label: "High quality", + value: "high", + }, + { + label: "Small file size", + value: "low", + }, +]; + type Settings = { autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; @@ -13,6 +35,7 @@ type Settings = { searchEngine: "Marlin" | "Jellyfin"; marlinServerUrl?: string; openInVLC?: boolean; + downloadQuality?: DownloadOption; }; /** @@ -23,23 +46,31 @@ type Settings = { * */ -// Utility function to load settings from AsyncStorage const loadSettings = async (): Promise => { - const jsonValue = await AsyncStorage.getItem("settings"); - return jsonValue != null - ? JSON.parse(jsonValue) - : { - autoRotate: true, - forceLandscapeInVideoPlayer: false, - openFullScreenVideoPlayerByDefault: false, - usePopularPlugin: false, - deviceProfile: "Expo", - forceDirectPlay: false, - mediaListCollectionIds: [], - searchEngine: "Jellyfin", - marlinServerUrl: "", - openInVLC: false, - }; + const defaultValues: Settings = { + autoRotate: true, + forceLandscapeInVideoPlayer: false, + openFullScreenVideoPlayerByDefault: false, + usePopularPlugin: false, + deviceProfile: "Expo", + forceDirectPlay: false, + mediaListCollectionIds: [], + searchEngine: "Jellyfin", + marlinServerUrl: "", + openInVLC: false, + downloadQuality: DownloadOptions[0], + }; + + try { + const jsonValue = await AsyncStorage.getItem("settings"); + const loadedValues: Partial = + jsonValue != null ? JSON.parse(jsonValue) : {}; + + return { ...defaultValues, ...loadedValues }; + } catch (error) { + console.error("Failed to load settings:", error); + return defaultValues; + } }; // Utility function to save settings to AsyncStorage