Compare commits

...

2 Commits

Author SHA1 Message Date
Fredrik Burmester
8b6c7a7603 fix: incorrect matrix 2024-12-09 15:53:53 +01:00
Fredrik Burmester
5a07eccd9b fix: remove black background in some logo images 2024-12-09 15:39:23 +01:00
5 changed files with 158 additions and 109 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -231,7 +231,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (usingOptimizedServer) { if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source); await startBackgroundDownload(url, item, source);
} else { } else {
await startRemuxing(item, url, source); await startRemuxing(item, url);
} }
} }
}, },

View File

@@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import {
brightness,
ColorMatrix,
colorTone,
concatColorMatrices,
contrast,
saturate,
sepia,
tint,
} from "react-native-color-matrix-image-filters";
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useImageColors({ item }); useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(false);
const [headerHeight, setHeaderHeight] = useState(350); const [headerHeight, setHeaderHeight] = useState(350);
const [selectedOptions, setSelectedOptions] = useState< const [selectedOptions, setSelectedOptions] = useState<
@@ -139,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
logo={ logo={
<> <>
{logoUrl ? ( {logoUrl ? (
<Image <ColorMatrix
source={{ matrix={[
uri: logoUrl, 1,
}} 0,
0,
0,
0, // Red channel remains unchanged
0,
1,
0,
0,
0, // Green channel remains unchanged
0,
0,
1,
0,
0, // Blue channel remains unchanged
1,
1,
1,
1,
-1, // Make black (R=0, G=0, B=0) transparent
]}
style={{ style={{
height: 130, height: 130,
width: "100%", width: "100%",
resizeMode: "contain",
}} }}
onLoad={() => setLoadingLogo(false)} >
onError={() => setLoadingLogo(false)} <Image
/> source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
</ColorMatrix>
) : null} ) : null}
</> </>
} }

View File

@@ -1,7 +1,7 @@
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log"; import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
@@ -9,34 +9,34 @@ import {
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native"; import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
import {useAtomValue} from "jotai"; import { useAtomValue } from "jotai";
import {useCallback} from "react"; import { useCallback } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage"; import useImageStorage from "./useImageStorage";
import useDownloadHelper from "@/utils/download"; import useDownloadHelper from "@/utils/download";
import {Api} from "@jellyfin/sdk"; import { Api } from "@jellyfin/sdk";
import {useSettings} from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import {JobStatus} from "@/utils/optimize-server"; import { JobStatus } from "@/utils/optimize-server";
const createFFmpegCommand = (url: string, output: string) => [ const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking "-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html // region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist "-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
"-multiple_requests 1", // http "-multiple_requests 1", // http
"-tcp_nodelay 1", // http "-tcp_nodelay 1", // http
// endregion ffmpeg protocol commands // endregion ffmpeg protocol commands
"-fflags +genpts", // format flags "-fflags +genpts", // format flags
`-i ${url}`, // infile `-i ${url}`, // infile
"-map 0:v -map 0:a", // select all streams for video & audio "-map 0:v -map 0:a", // select all streams for video & audio
"-c copy", // streamcopy, preventing transcoding "-c copy", // streamcopy, preventing transcoding
"-bufsize 25M", // amount of data processed before calculating current bitrate "-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 "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
output output,
] ];
/** /**
* Custom hook for remuxing HLS to MP4 using FFmpeg. * Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -51,9 +51,9 @@ export const useRemuxHlsToMp4 = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings] = useSettings(); const [settings] = useSettings();
const {saveImage} = useImageStorage(); const { saveImage } = useImageStorage();
const {saveSeriesPrimaryImage} = useDownloadHelper(); const { saveSeriesPrimaryImage } = useDownloadHelper();
const {saveDownloadedItemInfo, setProcesses, processes} = useDownload(); const { saveDownloadedItemInfo, setProcesses, processes } = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => { const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item); await saveSeriesPrimaryImage(item);
@@ -66,89 +66,100 @@ export const useRemuxHlsToMp4 = () => {
}); });
await saveImage(item.Id, itemImage?.uri); await saveImage(item.Id, itemImage?.uri);
} };
const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => { const completeCallback = useCallback(
try { async (session: FFmpegSession, item: BaseItemDto) => {
let endTime; try {
const returnCode = await session.getReturnCode(); let endTime;
const startTime = new Date(); const returnCode = await session.getReturnCode();
const startTime = new Date();
if (returnCode.isValueSuccess()) { if (returnCode.isValueSuccess()) {
endTime = new Date(); endTime = new Date();
const stat = await session.getLastReceivedStatistics(); const stat = await session.getLastReceivedStatistics();
await queryClient.invalidateQueries({queryKey: ["downloadedItems"]}); await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
saveDownloadedItemInfo(item, stat.getSize()); saveDownloadedItemInfo(item, stat.getSize());
writeInfoLog( writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}, `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
item.Name
},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s` duration: ${
.replace(/^ +/g, '') (endTime.getTime() - startTime.getTime()) / 1000
) }s`.replace(/^ +/g, "")
toast.success("Download completed"); );
} else if (returnCode.isValueError()) { toast.success("Download completed");
endTime = new Date(); } else if (returnCode.isValueError()) {
const allLogs = session.getAllLogsAsString(); endTime = new Date();
writeErrorLog( const allLogs = session.getAllLogsAsString();
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}` duration: ${
.replace(/^ +/g, '') (endTime.getTime() - startTime.getTime()) / 1000
) }s. All logs: ${allLogs}`.replace(/^ +/g, "")
} else if (returnCode.isValueCancel()) { );
endTime = new Date(); } else if (returnCode.isValueCancel()) {
writeInfoLog( endTime = new Date();
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}, writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s` duration: ${
.replace(/^ +/g, '') (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),
};
} }
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( const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { async (item: BaseItemDto, url: string) => {
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined"); if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id"); if (!item.Id) throw new Error("Item must have an Id");
@@ -177,17 +188,17 @@ export const useRemuxHlsToMp4 = () => {
progress: 0, progress: 0,
status: "downloading", status: "downloading",
timestamp: new Date(), timestamp: new Date(),
} };
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev) => [...prev, job]); setProcesses((prev) => [...prev, job]);
await FFmpegKit.executeAsync( await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "), createFFmpegCommand(url, output).join(" "),
session => completeCallback(session, item), (session) => completeCallback(session, item),
undefined, undefined,
s => statisticsCallback(s, item) (s) => statisticsCallback(s, item)
) );
} catch (e) { } catch (e) {
const error = e as Error; const error = e as Error;
console.error("Failed to remux:", error); console.error("Failed to remux:", error);

View File

@@ -73,6 +73,7 @@
"react-native-awesome-slider": "^2.5.6", "react-native-awesome-slider": "^2.5.6",
"react-native-bottom-tabs": "^0.7.3", "react-native-bottom-tabs": "^0.7.3",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
"react-native-color-matrix-image-filters": "^7.0.1",
"react-native-compressor": "^1.9.0", "react-native-compressor": "^1.9.0",
"react-native-device-info": "^14.0.1", "react-native-device-info": "^14.0.1",
"react-native-edge-to-edge": "^1.1.1", "react-native-edge-to-edge": "^1.1.1",