This commit is contained in:
Fredrik Burmester
2024-08-06 21:16:37 +02:00
parent 165a9ddde7
commit 57e33428dc
29 changed files with 1113 additions and 296 deletions

View File

@@ -4,6 +4,7 @@ import { atom } from "jotai";
export type ProcessItem = {
item: BaseItemDto;
progress: number;
speed?: number;
};
export const runningProcesses = atom<ProcessItem | null>(null);

View File

@@ -351,3 +351,127 @@ export const chromecastProfile: DeviceProfile = {
},
],
};
export const iOSProfile_2: DeviceProfile = {
Id: "iPhone",
Name: "iPhone",
MaxStreamingBitrate: 20000000,
MaxStaticBitrate: 30000000,
MusicStreamingTranscodingBitrate: 192000,
DirectPlayProfiles: [
{
Container: "mp4,m4v",
Type: "Video",
VideoCodec: "h264,hevc,mp4v",
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
},
{
Container: "mov",
Type: "Video",
VideoCodec: "h264,hevc",
AudioCodec: "aac,mp3,ac3,eac3,flac,alac",
},
{
Container: "m4a",
Type: "Audio",
AudioCodec: "aac,alac",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
},
],
TranscodingProfiles: [
{
Container: "ts",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: 2,
BreakOnNonKeyFrames: true,
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Streaming",
Protocol: "http",
},
],
ContainerProfiles: [],
CodecProfiles: [
{
Type: "VideoAudio",
Codec: "aac",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "VideoAudio",
Conditions: [
{
Condition: "LessThanEqual",
Property: "AudioChannels",
Value: "2",
IsRequired: true,
},
],
},
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "51",
IsRequired: true,
},
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "main|high|baseline",
IsRequired: true,
},
],
},
{
Type: "Video",
Codec: "hevc",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "153",
IsRequired: true,
},
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "main|main10",
IsRequired: true,
},
],
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "External",
},
{
Format: "mov_text",
Method: "Embed",
},
],
};

63
utils/files/useFiles.ts Normal file
View File

@@ -0,0 +1,63 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
export const useFiles = () => {
const queryClient = useQueryClient();
const deleteAllFiles = async () => {
const directoryUri = FileSystem.documentDirectory;
try {
const fileNames = await FileSystem.readDirectoryAsync(directoryUri!);
for (let item of fileNames) {
await FileSystem.deleteAsync(`${directoryUri}/${item}`);
}
AsyncStorage.removeItem("downloaded_files");
} catch (error) {
console.error("Failed to delete the directory:", error);
}
};
const deleteFile = async (id: string) => {
try {
const files = await FileSystem.readDirectoryAsync(
`${FileSystem.documentDirectory}`
);
console.log(`Files:`, files);
await FileSystem.deleteAsync(
`${FileSystem.documentDirectory}/${id}.mp4`
).catch((err) => console.error(err));
const currentFiles = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) ?? "[]"
) as BaseItemDto[];
console.log(
"Current files",
currentFiles.map((i) => i.Name)
);
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
console.log(
"Current files",
currentFiles.map((i) => i.Name)
);
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles)
);
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
} catch (error) {
console.error(error);
}
};
return { deleteFile, deleteAllFiles };
};

View File

@@ -15,40 +15,47 @@ import { useAtom } from "jotai";
import { useCallback, useRef, useState } from "react";
import { runningProcesses } from "./atoms/downloads";
import { iosProfile } from "./device-profiles";
import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native";
import {
FFmpegKit,
FFmpegKitConfig,
ReturnCode,
} from "ffmpeg-kit-react-native";
import { writeToLog } from "./log";
const convertAndReplaceVideo = async (inputUri: string) => {
const tempOutputUri = inputUri.replace(/\.\w+$/, "_temp.mp4");
/**
* Try to convert the downloaded file to a supported format on-device. Leveraging the capability of modern phones.
*
* ⚠️ This function does not work, and the app crashes when running it.
*/
// const convertAndReplaceVideo = async (id: string) => {
// const input = FileSystem.documentDirectory + id;
// const output = FileSystem.documentDirectory + id + "_tmp.mp4";
// Strip the file:/// prefix
const inputPath = inputUri.replace("file://", "");
const tempOutputPath = tempOutputUri.replace("file://", "");
// const command = `-i ${input} -c:v h264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart ${output}`;
// try {
// const session = await FFmpegKit.execute(command);
// const rc: ReturnCode = await session.getReturnCode();
// if (ReturnCode.isSuccess(rc)) {
// console.log("Conversion successful, replacing the original file");
const command = `-i ${inputPath} -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart ${tempOutputPath}`;
try {
const session = await FFmpegKit.execute(command);
const rc: ReturnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(rc)) {
console.log("Conversion successful, replacing the original file");
// await FileSystem.moveAsync({
// from: output,
// to: input,
// });
await FileSystem.moveAsync({
from: tempOutputUri,
to: inputUri,
});
console.log("Replacement successful");
} else {
console.log("Conversion failed");
}
} catch (error) {
console.error("Error during conversion", error);
}
};
// console.log("Replacement successful");
// } else {
// console.log("Conversion failed");
// }
// } catch (error) {
// console.error("Error during conversion", error);
// }
// };
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useAtom(runningProcesses);
const [_, setProgress] = useAtom(runningProcesses);
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
null
);
@@ -60,9 +67,6 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
return false;
}
console.log("MediaSources: ", JSON.stringify(item.MediaSources));
console.log("MediaStreams: ", JSON.stringify(item.MediaStreams));
setIsDownloading(true);
setError(null);
setProgress({
@@ -76,7 +80,7 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
const filename = `${itemId}`;
const fileUri = `${FileSystem.documentDirectory}${filename}`;
const url = `${api.basePath}/Items/${itemId}/Download`;
const url = `${api.basePath}/Items/${itemId}/File`;
downloadResumableRef.current = FileSystem.createDownloadResumable(
url,
@@ -117,8 +121,6 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
JSON.stringify(updatedFiles)
);
await convertAndReplaceVideo(fileUri);
setIsDownloading(false);
setProgress(null);
return true;
@@ -150,6 +152,113 @@ export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
return { downloadMedia, isDownloading, error, cancelDownload };
};
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
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 command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
if (!item.Id || !item.Name) {
writeToLog(
"ERROR",
"useRemuxHlsToMp4 ~ startRemuxing ~ missing arguments"
);
throw new Error("Item must have an Id and Name");
}
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Id} with url ${url}`
);
try {
setProgress({
item,
progress: 0,
});
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
let percentage = 0;
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();
if (totalFrames > 0) {
percentage = Math.floor((processedFrames / totalFrames) * 100);
}
setProgress((prev) => {
return prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev;
});
});
await FFmpegKit.executeAsync(command, async (session) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
const otherItems = currentFiles.filter((i) => i.Id !== item.Id);
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify([...otherItems, item])
);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
setProgress(null);
} else if (returnCode.isValueError()) {
console.error("Failed to remux:");
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
setProgress(null);
} else if (returnCode.isValueCancel()) {
console.log("Remuxing was cancelled");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
);
setProgress(null);
}
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
}
}, [output, item, command]);
const cancelRemuxing = useCallback(async () => {
FFmpegKit.cancel();
setProgress(null);
console.log("Remuxing cancelled");
}, []);
return { startRemuxing, cancelRemuxing };
};
export const markAsNotPlayed = async ({
api,
itemId,
@@ -550,8 +659,12 @@ export const getStreamUrl = async ({
throw new Error("no PlaySessionId");
}
console.log(`${api.basePath}${mediaSource.TranscodingUrl}`);
if (mediaSource.SupportsDirectPlay) {
console.log("Using direct stream!");
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
}
console.log("Using transcoded stream!");
return `${api.basePath}${mediaSource.TranscodingUrl}`;
};

86
utils/profiles/base.js Normal file
View File

@@ -0,0 +1,86 @@
import MediaTypes from '../../constants/MediaTypes';
export default {
Name: 'Expo Base Video Profile',
MaxStaticBitrate: 100000000,
MaxStreamingBitrate: 120000000,
MusicStreamingTranscodingBitrate: 384000,
CodecProfiles: [
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '51'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
}
],
Type: MediaTypes.Video
},
{
Codec: 'hevc',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'main|main 10'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '183'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
}
],
Type: MediaTypes.Video
}
],
ContainerProfiles: [],
DirectPlayProfiles: [],
ResponseProfiles: [
{
Container: 'm4v',
MimeType: 'video/mp4',
Type: MediaTypes.Video
}
],
SubtitleProfiles: [
{
Format: 'vtt',
Method: 'Hls'
}
],
TranscodingProfiles: []
};

149
utils/profiles/ios.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import BaseProfile from './base';
/**
* Device profile for Expo Video player on iOS 13+
*/
export default {
...BaseProfile,
Name: 'Expo iOS Video Profile',
DirectPlayProfiles: [
{
AudioCodec: 'aac,mp3,ac3,eac3,flac,alac',
Container: 'mp4,m4v',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
{
AudioCodec: 'aac,mp3,ac3,eac3,flac,alac',
Container: 'mov',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
{
Container: 'mp3',
Type: MediaTypes.Audio
},
{
Container: 'aac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'flac',
Type: MediaTypes.Audio
},
{
Container: 'alac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'wav',
Type: MediaTypes.Audio
}
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac,mp3',
BreakOnNonKeyFrames: true,
Container: 'ts',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
AudioCodec: 'aac,mp3,ac3,eac3,flac,alac',
Container: 'mp4',
Context: 'Static',
Protocol: 'http',
Type: MediaTypes.Video,
VideoCodec: 'h264'
}
]
};

180
utils/profiles/ios10.js Normal file
View File

@@ -0,0 +1,180 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import BaseProfile from './base';
/**
* Device profile for Expo Video player on iOS 10
*/
export default {
...BaseProfile,
Name: 'Expo iOS 10 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: MediaTypes.Video
},
...BaseProfile.CodecProfiles
],
DirectPlayProfiles: [
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4,m4v',
Type: MediaTypes.Video,
VideoCodec: 'h264,vc1'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mov',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
Container: 'mp3',
Type: MediaTypes.Audio
},
{
Container: 'aac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'alac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'wav',
Type: MediaTypes.Audio
}
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac,mp3',
BreakOnNonKeyFrames: true,
Container: 'ts',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4',
Context: 'Static',
Protocol: 'http',
Type: MediaTypes.Video,
VideoCodec: 'h264'
}
]
};

49
utils/profiles/ios12.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 11-12
*/
export default {
...iOSProfile,
Name: 'Expo iOS 12 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: 'Video'
},
...iOSProfile.CodecProfiles
]
};

35
utils/profiles/iosFmp4.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 13+ with fMP4 support
*/
export default {
...iOSProfile,
Name: 'Expo iOS fMP4 Video Profile',
TranscodingProfiles: [
// Add all audio profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Audio),
// Add fMP4 profile
{
AudioCodec: 'aac,mp3,flac,alac',
BreakOnNonKeyFrames: true,
Container: 'mp4',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
// Add all video profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Video)
]
};