diff --git a/bun.lockb b/bun.lockb index 2c4c76c0..83cb1f93 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 9b065975..8438f6bf 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -231,7 +231,7 @@ export const DownloadItems: React.FC = ({ if (usingOptimizedServer) { await startBackgroundDownload(url, item, source); } else { - await startRemuxing(item, url, source); + await startRemuxing(item, url); } } }, diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index b3eb242e..ab85f4b4 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast"; import { ItemHeader } from "./ItemHeader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; +import { + brightness, + ColorMatrix, + colorTone, + concatColorMatrices, + contrast, + saturate, + sepia, + tint, +} from "react-native-color-matrix-image-filters"; export type SelectedOptions = { bitrate: Bitrate; @@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( const insets = useSafeAreaInsets(); useImageColors({ item }); - const [loadingLogo, setLoadingLogo] = useState(true); + const [loadingLogo, setLoadingLogo] = useState(false); const [headerHeight, setHeaderHeight] = useState(350); const [selectedOptions, setSelectedOptions] = useState< @@ -139,18 +149,26 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( logo={ <> {logoUrl ? ( - setLoadingLogo(false)} - onError={() => setLoadingLogo(false)} - /> + > + + ) : null} } diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index b722c3e6..a209a992 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -1,7 +1,7 @@ import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getItemImage } from "@/utils/getItemImage"; -import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log"; +import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log"; import { BaseItemDto, MediaSourceInfo, @@ -9,34 +9,34 @@ import { import { useQueryClient } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; -import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native"; -import {useAtomValue} from "jotai"; -import {useCallback} from "react"; +import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; import { toast } from "sonner-native"; import useImageStorage from "./useImageStorage"; import useDownloadHelper from "@/utils/download"; -import {Api} from "@jellyfin/sdk"; -import {useSettings} from "@/utils/atoms/settings"; -import {JobStatus} from "@/utils/optimize-server"; +import { Api } from "@jellyfin/sdk"; +import { useSettings } from "@/utils/atoms/settings"; +import { JobStatus } from "@/utils/optimize-server"; const createFFmpegCommand = (url: string, output: string) => [ - "-y", // overwrite output files without asking - "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options + "-y", // overwrite output files without asking + "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options // region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html "-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist - "-multiple_requests 1", // http - "-tcp_nodelay 1", // http + "-multiple_requests 1", // http + "-tcp_nodelay 1", // http // endregion ffmpeg protocol commands - "-fflags +genpts", // format flags - `-i ${url}`, // infile - "-map 0:v -map 0:a", // select all streams for video & audio - "-c copy", // streamcopy, preventing transcoding - "-bufsize 25M", // amount of data processed before calculating current bitrate - "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output - output -] + "-fflags +genpts", // format flags + `-i ${url}`, // infile + "-map 0:v -map 0:a", // select all streams for video & audio + "-c copy", // streamcopy, preventing transcoding + "-bufsize 25M", // amount of data processed before calculating current bitrate + "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output + output, +]; /** * Custom hook for remuxing HLS to MP4 using FFmpeg. @@ -51,9 +51,9 @@ export const useRemuxHlsToMp4 = () => { const queryClient = useQueryClient(); const [settings] = useSettings(); - const {saveImage} = useImageStorage(); - const {saveSeriesPrimaryImage} = useDownloadHelper(); - const {saveDownloadedItemInfo, setProcesses, processes} = useDownload(); + const { saveImage } = useImageStorage(); + const { saveSeriesPrimaryImage } = useDownloadHelper(); + const { saveDownloadedItemInfo, setProcesses, processes } = useDownload(); const onSaveAssets = async (api: Api, item: BaseItemDto) => { await saveSeriesPrimaryImage(item); @@ -66,89 +66,100 @@ export const useRemuxHlsToMp4 = () => { }); await saveImage(item.Id, itemImage?.uri); - } + }; - const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => { - try { - let endTime; - const returnCode = await session.getReturnCode(); - const startTime = new Date(); + const completeCallback = useCallback( + async (session: FFmpegSession, item: BaseItemDto) => { + try { + let endTime; + const returnCode = await session.getReturnCode(); + const startTime = new Date(); - if (returnCode.isValueSuccess()) { - endTime = new Date(); - const stat = await session.getLastReceivedStatistics(); - await queryClient.invalidateQueries({queryKey: ["downloadedItems"]}); + if (returnCode.isValueSuccess()) { + endTime = new Date(); + const stat = await session.getLastReceivedStatistics(); + await queryClient.invalidateQueries({ + queryKey: ["downloadedItems"], + }); - saveDownloadedItemInfo(item, stat.getSize()); - writeInfoLog( - `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}, + saveDownloadedItemInfo(item, stat.getSize()); + writeInfoLog( + `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${ + item.Name + }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, - duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s` - .replace(/^ +/g, '') - ) - toast.success("Download completed"); - } else if (returnCode.isValueError()) { - endTime = new Date(); - const allLogs = session.getAllLogsAsString(); - writeErrorLog( - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, + duration: ${ + (endTime.getTime() - startTime.getTime()) / 1000 + }s`.replace(/^ +/g, "") + ); + toast.success("Download completed"); + } else if (returnCode.isValueError()) { + endTime = new Date(); + const allLogs = session.getAllLogsAsString(); + writeErrorLog( + `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, - duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}` - .replace(/^ +/g, '') - ) - } else if (returnCode.isValueCancel()) { - endTime = new Date(); - writeInfoLog( - `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}, + duration: ${ + (endTime.getTime() - startTime.getTime()) / 1000 + }s. All logs: ${allLogs}`.replace(/^ +/g, "") + ); + } else if (returnCode.isValueCancel()) { + endTime = new Date(); + writeInfoLog( + `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, - duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s` - .replace(/^ +/g, '') - ) - } - - setProcesses((prev) => { - return prev.filter((process) => process.itemId !== item.Id); - }); - } catch (e) { - const error = e as Error; - writeErrorLog( - `useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, - Error: ${error.message}, Stack: ${error.stack}` - .replace(/^ +/g, '') - ); - } - }, [processes, setProcesses]); - - const statisticsCallback = useCallback((statistics: Statistics, item: BaseItemDto) => { - 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; - - if (!item.Id) throw new Error("Item is undefined"); - setProcesses((prev) => { - return prev.map((process) => { - if (process.itemId === item.Id) { - return { - ...process, - id: statistics.getSessionId().toString(), - progress: percentage, - speed: Math.max(speed, 0), - }; + duration: ${ + (endTime.getTime() - startTime.getTime()) / 1000 + }s`.replace(/^ +/g, "") + ); } - return process; + + setProcesses((prev) => { + return prev.filter((process) => process.itemId !== item.Id); + }); + } catch (e) { + const error = e as Error; + writeErrorLog( + `useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, + Error: ${error.message}, Stack: ${error.stack}`.replace(/^ +/g, "") + ); + } + }, + [processes, setProcesses] + ); + + const statisticsCallback = useCallback( + (statistics: Statistics, item: BaseItemDto) => { + 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; + + if (!item.Id) throw new Error("Item is undefined"); + setProcesses((prev) => { + return prev.map((process) => { + if (process.itemId === item.Id) { + return { + ...process, + id: statistics.getSessionId().toString(), + progress: percentage, + speed: Math.max(speed, 0), + }; + } + return process; + }); }); - }); - }, [setProcesses, completeCallback]); + }, + [setProcesses, completeCallback] + ); const startRemuxing = useCallback( - async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { + async (item: BaseItemDto, url: string) => { 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"); @@ -177,17 +188,17 @@ export const useRemuxHlsToMp4 = () => { progress: 0, status: "downloading", timestamp: new Date(), - } + }; writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); setProcesses((prev) => [...prev, job]); await FFmpegKit.executeAsync( createFFmpegCommand(url, output).join(" "), - session => completeCallback(session, item), + (session) => completeCallback(session, item), undefined, - s => statisticsCallback(s, item) - ) + (s) => statisticsCallback(s, item) + ); } catch (e) { const error = e as Error; console.error("Failed to remux:", error); diff --git a/package.json b/package.json index 78a924e0..b04839ce 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "react-native-awesome-slider": "^2.5.6", "react-native-bottom-tabs": "^0.7.3", "react-native-circular-progress": "^1.4.1", + "react-native-color-matrix-image-filters": "^7.0.1", "react-native-compressor": "^1.9.0", "react-native-device-info": "^14.0.1", "react-native-edge-to-edge": "^1.1.1",