Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Kim
d0f600b68d Remove unused import 2025-04-19 04:13:41 +10:00
Alex Kim
76a2c86452 Fixed external subs not being supportedfor direct playback 2025-04-19 04:11:58 +10:00
21 changed files with 1024 additions and 446 deletions

View File

@@ -48,6 +48,7 @@
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-video",
{

View File

@@ -30,7 +30,7 @@ import {
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -46,7 +46,6 @@ const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
@@ -237,65 +236,30 @@ const Page: React.FC = () => {
}}
/>
</View>
<View>
<View className='mb-4'>
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
<Button loading={true} disabled={true} color='purple' />
) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'>
<Button color='purple' onPress={request}>
{t("jellyseerr.request_button")}
</Button>
) : (
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'>
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
// @ts-expect-error
router.push(url);
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>Play</Text>
</Button>
</View>
)
<Button
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name='warning-outline' size={24} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className='mt-4' />
</View>

View File

@@ -433,6 +433,15 @@ const Page = () => {
</View>
);
if (flatData.length === 0)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_items_found")}
</Text>
</View>
);
return (
<FlashList
key={orientation}

View File

@@ -17,7 +17,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import generateDeviceProfile from "@/utils/profiles/native";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
type BaseItemDto,
@@ -159,7 +159,6 @@ export default function page() {
useEffect(() => {
const fetchStreamData = async () => {
const native = await generateDeviceProfile();
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {

View File

@@ -80,8 +80,9 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return (
<View
className={`
relative aspect-video rounded-lg overflow-hidden border border-neutral-800
`}
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
${size === "small" ? "w-32" : "w-44"}
`}
>
<View className='w-full h-full flex items-center justify-center'>
<Image

View File

@@ -1,3 +1,4 @@
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
@@ -151,7 +152,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
closeModal();
initiateDownload(...itemsNotDownloaded);
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
})),
);
}
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -191,6 +203,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({
@@ -203,8 +216,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
});
if (!res) {
@@ -219,8 +230,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source, maxBitrate);
if (usingOptimizedServer) {
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source);
} else {
//await startRemuxing(item, url, source);
}
}
},
[
@@ -234,6 +249,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
//startRemuxing,
],
);

View File

@@ -7,6 +7,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";

View File

@@ -91,7 +91,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
item={item}
key={item.Id}
className={`mr-2
${orientation === "horizontal" ? "w-56" : "w-28"}
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (

231
hooks/useRemuxHlsToMp4.ts Normal file
View File

@@ -0,0 +1,231 @@
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { writeErrorLog, writeInfoLog } from "@/utils/log";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
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";
const FFMPEGKitReactNative = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
import { useSettings } from "@/utils/atoms/settings";
import useDownloadHelper from "@/utils/download";
import type { JobStatus } from "@/utils/optimize-server";
import type { Api } from "@jellyfin/sdk";
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage";
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
type Statistics = typeof FFMPEGKitReactNative.Statistics;
const FFmpegKit = Platform.isTV
? null
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
const createFFmpegCommand = (url: string, output: string) => [
"-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
// 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,
];
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
*
* @param url - The URL of the HLS stream
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings();
const { saveImage } = useImageStorage();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const {
saveDownloadedItemInfo,
setProcesses,
processes,
APP_CACHE_DOWNLOAD_DIRECTORY,
} = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
};
const completeCallback = useCallback(
async (session: FFmpegSession, item: BaseItemDto) => {
try {
console.log("completeCallback");
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
const stat = await session.getLastReceivedStatistics();
await FileSystem.moveAsync({
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
});
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
saveDownloadedItemInfo(item, stat.getSize());
toast.success(t("home.downloads.toasts.download_completed"));
}
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id,
);
});
} catch (e) {
console.error(e);
}
console.log("completeCallback ~ end");
},
[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: JobStatus[]) => {
return prev.map((process: JobStatus) => {
if (process.itemId === item.Id) {
return {
...process,
id: statistics.getSessionId().toString(),
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
},
[setProcesses, completeCallback],
);
const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
const cacheDir = await FileSystem.getInfoAsync(
APP_CACHE_DOWNLOAD_DIRECTORY,
);
if (!cacheDir.exists) {
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
intermediates: true,
});
}
const output = `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
// First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item);
toast.success(
t("home.downloads.toasts.download_started_for", { item: item.Name }),
{
action: {
label: "Go to download",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
},
);
try {
const job: JobStatus = {
id: "",
deviceId: "",
inputUrl: url,
item: item,
itemId: item.Id!,
outputPath: output,
progress: 0,
status: "downloading",
timestamp: new Date(),
};
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev: any) => [...prev, job]);
await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "),
(session: any) => completeCallback(session, item),
undefined,
(s: any) => statisticsCallback(s, item),
);
} catch (e) {
const error = e as Error;
console.error("Failed to remux:", error);
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`,
);
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id,
);
});
throw error; // Re-throw the error to propagate it to the caller
}
},
[settings, processes, setProcesses, completeCallback, statisticsCallback],
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProcesses([]);
}, []);
return { startRemuxing, cancelRemuxing };
};

View File

@@ -12,6 +12,7 @@ Pod::Spec.new do |s|
s.dependency 'ExpoModulesCore'
s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
s.dependency 'Alamofire', '~> 5.10'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@bottom-tabs/react-navigation": "0.8.6",
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
"@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4",

View File

@@ -1,6 +1,5 @@
import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage";
import { useInterval } from "@/hooks/useInterval";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
@@ -19,7 +18,6 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
@@ -40,7 +38,6 @@ import {
import { useTranslation } from "react-i18next";
import { AppState, type AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { Bitrate } from "../components/BitrateSelector";
import { apiAtom } from "./JellyfinProvider";
export type DownloadedItem = {
@@ -77,17 +74,6 @@ function useDownloadProvider() {
return api?.accessToken;
}, [api]);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const getDownloadUrl = (process: JobStatus) => {
return usingOptimizedServer
? `${settings.optimizedVersionsServerUrl}download/${process.id}`
: process.inputUrl;
};
const { data: downloadedFiles, refetch } = useQuery({
queryKey: ["downloadedItems"],
queryFn: getAllDownloadedItems,
@@ -178,59 +164,6 @@ function useDownloadProvider() {
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
/// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
return;
}
// const response = await getSessionApi(api).getSessions({
// activeWithinSeconds: 300,
// });
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const updatedProcesses = processes.map((p) => {
// const result = response.data.find((s) => s.Id == p.sessionId);
// if (result) {
// return {
// ...p,
// progress: result.TranscodingInfo?.CompletionPercentage,
// };
// }
// fallback. Doesn't really work for transcodes as they may be a lot smaller. We make an wild guess
const task = tasks.find((s) => s.id === p.id);
if (task) {
let progress = p.progress;
let size = p.mediaSource.Size;
const maxBitrate = p.maxBitrate.value;
if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) {
size = (size / p.mediaSource.Bitrate) * maxBitrate;
}
// console.log(
// p.mediaSource.Size,
// size,
// maxBitrate,
// p.mediaSource.Bitrate,
// );
progress = (100 / size) * task.bytesDownloaded;
if (progress >= 100) {
progress = 99;
}
return {
...p,
progress,
};
}
return p;
});
setProcesses(updatedProcesses);
};
useInterval(updateProgress, 3000);
useEffect(() => {
const checkIfShouldStartDownload = async () => {
if (processes.length === 0) return;
@@ -243,25 +176,18 @@ function useDownloadProvider() {
const removeProcess = useCallback(
async (id: string) => {
const deviceId = await getOrSetDeviceId();
if (!deviceId || !authHeader) return;
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
return;
if (usingOptimizedServer) {
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
}
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
}
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) => process.id !== id,
);
});
},
[settings?.optimizedVersionsServerUrl, authHeader],
);
@@ -312,7 +238,7 @@ function useDownloadProvider() {
BackGroundDownloader?.download({
id: process.id,
url: getDownloadUrl(process),
url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`,
destination: `${baseDirectory}/${process.item.Id}.mp4`,
})
.begin(() => {
@@ -330,9 +256,6 @@ function useDownloadProvider() {
);
})
.progress((data) => {
if (!usingOptimizedServer) {
return;
}
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
setProcesses((prev) =>
prev.map((p) =>
@@ -405,12 +328,7 @@ function useDownloadProvider() {
);
const startBackgroundDownload = useCallback(
async (
url: string,
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate?: Bitrate,
) => {
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
if (!api || !item.Id || !authHeader)
throw new Error("startBackgroundDownload ~ Missing required params");
@@ -427,42 +345,26 @@ function useDownloadProvider() {
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
if (usingOptimizedServer) {
const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`,
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
},
);
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
}
} else {
const job: JobStatus = {
id: item.Id!,
deviceId: deviceId,
inputUrl: url,
item: item,
itemId: item.Id!,
mediaSource,
progress: 0,
maxBitrate,
status: "downloading",
timestamp: new Date(),
};
setProcesses([...processes, job]);
startDownload(job);
const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`,
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
},
);
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
}
toast.success(

View File

@@ -0,0 +1,108 @@
import { storage } from "@/utils/mmkv";
import type { JobStatus } from "@/utils/optimize-server";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { atom, useAtom } from "jotai";
import type React from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const downloadedFiles: DownloadedItem[] = [];
const removeProcess = useCallback(async (id: string) => {}, []);
const startDownload = useCallback(async (process: JobStatus) => {
return null;
}, []);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
return null;
},
[],
);
const deleteAllFiles = async (): Promise<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
const deleteItems = async (items: BaseItemDto[]) => {};
const cleanCacheDirectory = async () => {};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
const appSizeUsage = useMemo(async () => {
return 0;
}, []);
function getDownloadedItem(itemId: string): DownloadedItem | null {
return null;
}
function saveDownloadedItemInfo(item: BaseItemDto, size = 0) {}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString(`downloadedItemSize-${itemId}`);
return size ? Number.parseInt(size) : 0;
}
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
return (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -1,7 +1,7 @@
import type { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import generateDeviceProfile from "@/utils/profiles/native";
import native from "@/utils/profiles/native";
import type {
BaseItemDto,
MediaSourceInfo,
@@ -84,7 +84,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
}
try {
const native = await generateDeviceProfile();
const data = await getStreamUrl({
api,
deviceProfile: native,

View File

@@ -1,4 +1,4 @@
import generateDeviceProfile from "@/utils/profiles/native";
import native from "@/utils/profiles/native";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
@@ -14,27 +14,23 @@ export const getStreamUrl = async ({
userId,
startTimeTicks = 0,
maxStreamingBitrate,
playSessionId,
deviceProfile = generateDeviceProfile(),
sessionData,
deviceProfile = native,
audioStreamIndex = 0,
subtitleStreamIndex = undefined,
mediaSourceId,
download = false,
deviceId,
}: {
api: Api | null | undefined;
item: BaseItemDto | null | undefined;
userId: string | null | undefined;
startTimeTicks: number;
maxStreamingBitrate?: number;
playSessionId?: string | null;
sessionData?: PlaybackInfoResponse | null;
deviceProfile?: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
height?: number;
mediaSourceId?: string | null;
download?: bool;
deviceId?: string | null;
}): Promise<{
url: string | null;
sessionId: string | null;
@@ -48,82 +44,111 @@ export const getStreamUrl = async ({
let mediaSource: MediaSourceInfo | undefined;
let sessionId: string | null | undefined;
const res = await getMediaInfoApi(api).getPlaybackInfo(
if (item.Type === "Program") {
console.log("Item is of type program...");
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
{
userId,
itemId: item.ChannelId!,
},
{
method: "POST",
params: {
startTimeTicks: 0,
isPlayback: true,
autoOpenLiveStream: true,
maxStreamingBitrate,
audioStreamIndex,
},
data: {
deviceProfile,
},
},
);
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
sessionId = res0.data.PlaySessionId || null;
if (transcodeUrl) {
return {
url: `${api.basePath}${transcodeUrl}`,
sessionId,
mediaSource: res0.data.MediaSources?.[0],
};
}
}
const itemId = item.Id;
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
{
itemId: item.Id!,
},
{
method: "POST",
data: {
userId,
deviceProfile,
subtitleStreamIndex,
startTimeTicks,
isPlayback: true,
autoOpenLiveStream: true,
userId,
maxStreamingBitrate,
audioStreamIndex,
startTimeTicks,
autoOpenLiveStream: true,
mediaSourceId,
audioStreamIndex,
subtitleStreamIndex,
},
},
);
if (res.status !== 200) {
console.error("Error getting playback info:", res.status, res.statusText);
if (res2.status !== 200) {
console.error("Error getting playback info:", res2.status, res2.statusText);
}
sessionId = res.data.PlaySessionId || null;
mediaSource = res.data.MediaSources[0];
let transcodeUrl = mediaSource.TranscodingUrl;
sessionId = res2.data.PlaySessionId || null;
if (transcodeUrl) {
if (download) {
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream.mp4");
mediaSource = res2.data.MediaSources?.find(
(source: MediaSourceInfo) => source.Id === mediaSourceId,
);
if (item.MediaType === "Video") {
if (mediaSource?.TranscodingUrl) {
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
// Get the updated URL
const transcodeUrl = urlObj.toString();
console.log("Video has transcoding URL:", `${transcodeUrl}`);
return {
url: transcodeUrl,
sessionId: sessionId,
mediaSource,
};
}
console.log("Video is being transcoded:", transcodeUrl);
const searchParams = new URLSearchParams({
playSessionId: sessionData?.PlaySessionId || "",
mediaSourceId: mediaSource?.Id || "",
static: "true",
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
audioStreamIndex: audioStreamIndex?.toString() || "",
deviceId: api.deviceInfo.id,
api_key: api.accessToken,
startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "",
});
const directPlayUrl = `${
api.basePath
}/Videos/${itemId}/stream.mp4?${searchParams.toString()}`;
console.log("Video is being direct played:", directPlayUrl);
return {
url: `${api.basePath}${transcodeUrl}`,
sessionId,
url: directPlayUrl,
sessionId: sessionId,
mediaSource,
};
}
let downloadParams = {};
Alert.alert("Error", "Could not play this item");
if (download) {
// We need to disable static so we can have a remux with subtitle.
downloadParams = {
subtitleMethod: "Embed",
enableSubtitlesInManifest: true,
static: "false",
allowVideoStreamCopy: true,
allowAudioStreamCopy: true,
playSessionId: sessionId || "",
};
}
const streamParams = new URLSearchParams({
static: "true",
mediaSourceId: mediaSource?.Id || "",
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
audioStreamIndex: audioStreamIndex?.toString() || "",
deviceId: deviceId || api.deviceInfo.id,
api_key: api.accessToken,
startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "",
...downloadParams,
});
const directPlayUrl = `${
api.basePath
}/Videos/${item.Id}/stream.mp4?${streamParams.toString()}`;
console.log("Video is being direct played:", directPlayUrl);
return {
url: directPlayUrl,
sessionId: sessionId || playSessionId,
mediaSource,
};
return null;
};

View File

@@ -1,5 +1,7 @@
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
import type { Settings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import type { Api } from "@jellyfin/sdk";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";

View File

@@ -1,5 +1,5 @@
import type { Settings } from "@/utils/atoms/settings";
import generateDeviceProfile from "@/utils/profiles/native";
import native from "@/utils/profiles/native";
import type { Api } from "@jellyfin/sdk";
import type { AxiosResponse } from "axios";
import { getAuthHeaders } from "../jellyfin";
@@ -43,7 +43,7 @@ export const postCapabilities = async ({
],
supportsMediaControl: true,
id: sessionId,
DeviceProfile: generateDeviceProfile(),
DeviceProfile: native,
},
{
headers: getAuthHeaders(api),

143
utils/profiles/android.js Normal file
View File

@@ -0,0 +1,143 @@
/**
* 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";
/**
* Device profile for Native video player
*/
export default {
Name: "1. Native iOS Video Profile",
MaxStaticBitrate: 100000000,
MaxStreamingBitrate: 120000000,
MusicStreamingTranscodingBitrate: 384000,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp",
VideoCodec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
AudioCodec: "aac,mp3,flac,alac,opus,vorbis,wma",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec: "mp3,aac,flac,alac,opus,vorbis,wma,pcm",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264",
AudioCodec: "aac,mp3,ac3",
MaxAudioChannels: "8",
MinSegments: "2",
BreakOnNonKeyFrames: true,
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
ResponseProfiles: [
{
Container: "mkv",
MimeType: "video/x-matroska",
Type: MediaTypes.Video,
},
{
Container: "mp4",
MimeType: "video/mp4",
Type: MediaTypes.Video,
},
],
SubtitleProfiles: [
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "srt", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "ass", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
{ Format: "ssa", Method: "Encode" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "sub", Method: "Encode" },
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
{ Format: "vtt", Method: "Encode" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "ttml", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "External" },
{ Format: "pgs", Method: "Encode" },
{ Format: "dvdsub", Method: "Embed" },
{ Format: "dvdsub", Method: "External" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvbsub", Method: "External" },
{ Format: "dvbsub", Method: "Encode" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
{ Format: "xsub", Method: "Encode" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mov_text", Method: "Encode" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "scc", Method: "Encode" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "smi", Method: "Encode" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "External" },
{ Format: "teletext", Method: "Encode" },
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "microdvd", Method: "Encode" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "mpl2", Method: "Encode" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "pjs", Method: "Encode" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "realtext", Method: "Encode" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "stl", Method: "Encode" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "subrip", Method: "Encode" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "subviewer", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "text", Method: "Encode" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "vplayer", Method: "Encode" },
],
};

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",
},
],
};

View File

@@ -1,5 +1,3 @@
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";
/**
* 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
@@ -7,190 +5,132 @@ import DeviceInfo from "react-native-device-info";
*/
import MediaTypes from "../../constants/MediaTypes";
// Helper function to detect Dolby Vision support
const supportsDolbyVision = async () => {
if (Platform.OS === "ios") {
const deviceModel = await DeviceInfo.getModel();
// iPhone 12 and newer generally support Dolby Vision
const modelNumber = Number.parseInt(deviceModel.replace(/iPhone/, ""), 10);
return !Number.isNaN(modelNumber) && modelNumber >= 12;
}
/**
* Device profile for Native video player
*/
export default {
Name: "1. Vlc Player",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "fmp4",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3,dts",
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Official formats
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
if (Platform.OS === "android") {
const apiLevel = await DeviceInfo.getApiLevel();
const isHighEndDevice =
(await DeviceInfo.getTotalMemory()) > 4 * 1024 * 1024 * 1024; // >4GB RAM
// Very rough approximation - Android 10+ on higher-end devices may support it
return apiLevel >= 29 && isHighEndDevice;
}
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "External" },
return false;
};
export const generateDeviceProfile = async () => {
const dolbyVisionSupported = await supportsDolbyVision();
/**
* Device profile for Native video player
*/
const profile = {
Name: "1. Vlc Player",
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Video,
Codec: "hevc,h265",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "153",
IsRequired: false,
},
// We'll add Dolby Vision condition below if not supported
],
},
{
Type: MediaTypes.Audio,
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
},
],
DirectPlayProfiles: [
{
Type: MediaTypes.Video,
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
VideoCodec:
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
},
{
Type: MediaTypes.Audio,
Container: "mp3,aac,flac,alac,wav,ogg,wma",
AudioCodec:
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
},
],
TranscodingProfiles: [
{
Type: MediaTypes.Video,
Context: "Streaming",
Protocol: "hls",
Container: "fmp4",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3,dts",
},
{
Type: MediaTypes.Audio,
Context: "Streaming",
Protocol: "http",
Container: "mp3",
AudioCodec: "mp3",
MaxAudioChannels: "2",
},
],
SubtitleProfiles: [
// Official formats
{ Format: "vtt", Method: "Embed" },
{ Format: "vtt", Method: "External" },
{ Format: "webvtt", Method: "Embed" },
{ Format: "webvtt", Method: "External" },
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
],
};
// Add Dolby Vision restriction if not supported
if (!dolbyVisionSupported) {
const hevcProfile = profile.CodecProfiles.find(
(p) => p.Type === MediaTypes.Video && p.Codec.includes("hevc"),
);
if (hevcProfile) {
hevcProfile.Conditions.push({
Condition: "NotEquals",
Property: "VideoRangeType",
Value: "DOVI", //no dolby vision at all
IsRequired: true,
});
}
}
return profile;
};
export default async () => {
return await generateDeviceProfile();
{ Format: "srt", Method: "Embed" },
{ Format: "srt", Method: "External" },
{ Format: "subrip", Method: "Embed" },
{ Format: "subrip", Method: "External" },
{ Format: "ttml", Method: "Embed" },
{ Format: "ttml", Method: "External" },
{ Format: "dvbsub", Method: "Embed" },
{ Format: "dvdsub", Method: "Encode" },
{ Format: "ass", Method: "Embed" },
{ Format: "ass", Method: "External" },
{ Format: "idx", Method: "Embed" },
{ Format: "idx", Method: "Encode" },
{ Format: "pgs", Method: "Embed" },
{ Format: "pgs", Method: "Encode" },
{ Format: "pgssub", Method: "Embed" },
{ Format: "pgssub", Method: "Encode" },
{ Format: "ssa", Method: "Embed" },
{ Format: "ssa", Method: "External" },
// Other formats
{ Format: "microdvd", Method: "Embed" },
{ Format: "microdvd", Method: "External" },
{ Format: "mov_text", Method: "Embed" },
{ Format: "mov_text", Method: "External" },
{ Format: "mpl2", Method: "Embed" },
{ Format: "mpl2", Method: "External" },
{ Format: "pjs", Method: "Embed" },
{ Format: "pjs", Method: "External" },
{ Format: "realtext", Method: "Embed" },
{ Format: "realtext", Method: "External" },
{ Format: "scc", Method: "Embed" },
{ Format: "scc", Method: "External" },
{ Format: "smi", Method: "Embed" },
{ Format: "smi", Method: "External" },
{ Format: "stl", Method: "Embed" },
{ Format: "stl", Method: "External" },
{ Format: "sub", Method: "Embed" },
{ Format: "sub", Method: "External" },
{ Format: "subviewer", Method: "Embed" },
{ Format: "subviewer", Method: "External" },
{ Format: "teletext", Method: "Embed" },
{ Format: "teletext", Method: "Encode" },
{ Format: "text", Method: "Embed" },
{ Format: "text", Method: "External" },
{ Format: "vplayer", Method: "Embed" },
{ Format: "vplayer", Method: "External" },
{ Format: "xsub", Method: "Embed" },
{ Format: "xsub", Method: "External" },
],
};