Compare commits

..

10 Commits

Author SHA1 Message Date
Alex Kim
de95b2dd18 Add more 2025-05-29 18:30:30 +10:00
Alex Kim
3a8fa09881 update 2025-04-21 22:30:29 +10:00
Alex Kim
b0c8aefda6 Updated 2025-04-21 19:54:49 +10:00
Alex Kim
f477e86718 Update Seeking behaviour 2025-04-21 15:50:07 +10:00
Alex Kim
5ce4eb1be1 Working prototype 2025-04-21 14:55:27 +10:00
Alex Kim
dd25feea25 Update 2025-04-21 05:01:21 +10:00
Alex Kim
d8f8224d0c update name 2025-04-21 04:46:44 +10:00
Alex Kim
6631cc5d65 added mpv 2025-04-21 04:46:00 +10:00
Alex Kim
f1f2777119 added mpv 2025-04-21 04:45:22 +10:00
Alex Kim
b6198b21bd MPV Player init 2025-04-21 04:38:03 +10:00
35 changed files with 3162 additions and 504 deletions

View File

@@ -9,7 +9,7 @@
},
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
"editor.defaultFormatter": "swiftlang.swift-vscode"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",

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

@@ -6,18 +6,24 @@ import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import { MpvPlayerView, ProgressUpdatePayload, VlcPlayerView } from "@/modules";
// import type {
// PipStartedPayload,
// PlaybackStatePayload,
// ProgressUpdatePayload,
// VlcPlayerViewRef,
// } from "@/modules/VlcPlayer.types";
import type {
MpvPlayerViewRef,
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
} from "@/modules/MpvPlayer.types";
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,
@@ -50,7 +56,7 @@ const downloadProvider = !Platform.isTV
: null;
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
@@ -159,7 +165,6 @@ export default function page() {
useEffect(() => {
const fetchStreamData = async () => {
const native = await generateDeviceProfile();
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
@@ -392,20 +397,23 @@ export default function page() {
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
const initOptions = [
`--sub-text-scale=${settings.subtitleSize}`,
`--start=${startPosition}`,
];
// if (
// chosenSubtitleTrack &&
// (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
// ) {
// const finalIndex = notTranscoding
// ? allSubs.indexOf(chosenSubtitleTrack)
// : textSubs.indexOf(chosenSubtitleTrack);
// initOptions.push(`--sub-track=${finalIndex}`);
// }
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
// if (notTranscoding && chosenAudioTrack) {
// initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
// }
const [isMounted, setIsMounted] = useState(false);
@@ -444,7 +452,7 @@ export default function page() {
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView
<MpvPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
@@ -488,7 +496,6 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}

View File

@@ -44,14 +44,13 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-sharing": "~13.0.1",
"expo-sharing": "~13.1.0",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
"expo-updates": "~0.26.17",
"expo-web-browser": "~14.0.2",
"ffmpeg-kit-react-native": "^6.0.2",
"i18next": "^24.2.2",
"jotai": "^2.11.3",
"lodash": "^4.17.21",
@@ -1174,7 +1173,7 @@
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
"expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="],
"expo-image": ["expo-image@2.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug=="],
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
@@ -1202,7 +1201,7 @@
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
"expo-sharing": ["expo-sharing@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw=="],
"expo-sharing": ["expo-sharing@13.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-7O29Bdm95v6aBXBhrbKx9FBqL5loQcK0nvCMFSbZHMy1r7Z6vb6sTMsaGbvknfOH+tEzn+LIleTw5TreoxNT9g=="],
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
@@ -1250,8 +1249,6 @@
"fetch-retry": ["fetch-retry@4.1.1", "", {}, "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA=="],
"ffmpeg-kit-react-native": ["ffmpeg-kit-react-native@6.0.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-r9uSmahq8TeyIb7fXf3ft+uUXyoeWRFa99+khjo0TAzWO9y0z9wU7eGnab9JLw1MmCB9v64o4yojNluJhVm9nQ=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],

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" && (

View File

@@ -5,7 +5,8 @@ import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import type { MpvPlayerViewRef, TrackInfo } from "@/modules/MpvPlayer.types";
import { VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
@@ -65,7 +66,7 @@ import { useControlsTimeout } from "./useControlsTimeout";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
videoRef: MutableRefObject<VlcPlayerViewRef | MpvPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
@@ -81,7 +82,7 @@ interface Props {
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
startPictureInPicture?: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -454,9 +455,9 @@ export const Controls: FC<Props> = ({
const onClose = async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
// await ScreenOrientation.lockAsync(
// ScreenOrientation.OrientationLock.PORTRAIT_UP,
// );
router.back();
};

View File

@@ -1,5 +1,4 @@
import type { TrackInfo } from "@/modules/VlcPlayer.types";
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
import { router, useLocalSearchParams } from "expo-router";
import type React from "react";
import {
@@ -48,7 +47,6 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}) => {
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const [settings] = useSettings();
const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded;
@@ -128,13 +126,15 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
if (getSubtitleTracks) {
const subtitleData = await getSubtitleTracks();
console.log("subtitleData", subtitleData);
// Step 1: Move external subs to the end, because VLC puts external subs at the end
const sortedSubs = allSubs.sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
);
// Step 2: Apply VLC indexing logic
let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1;
let textSubIndex = 0;
const processedSubs: Track[] = sortedSubs?.map((sub) => {
// Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding
@@ -172,6 +172,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
});
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
const audioData = await getAudioTracks();

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

@@ -0,0 +1,98 @@
import { ViewStyle } from "react-native";
export type PlaybackStatePayload = {
nativeEvent: {
target: number;
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
currentTime: number;
duration: number;
isBuffering: boolean;
isPlaying: boolean;
};
};
export type ProgressUpdatePayload = {
nativeEvent: {
currentTime: number;
duration: number;
isPlaying: boolean;
isBuffering: boolean;
};
};
export type VideoLoadStartPayload = {
nativeEvent: {
target: number;
};
};
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
export type MpvPlayerSource = {
uri: string;
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[];
mediaOptions?: { [key: string]: any };
startPosition?: number;
};
export type TrackInfo = {
name: string;
index: number;
language?: string;
};
export type ChapterInfo = {
name: string;
timeOffset: number;
duration: number;
};
export type MpvPlayerViewProps = {
source: MpvPlayerSource;
style?: ViewStyle | ViewStyle[];
progressUpdateInterval?: number;
paused?: boolean;
muted?: boolean;
volume?: number;
videoAspectRatio?: string;
onVideoProgress?: (event: ProgressUpdatePayload) => void;
onVideoStateChange?: (event: PlaybackStatePayload) => void;
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
};
export interface MpvPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;
seekTo: (time: number) => Promise<void>;
setAudioTrack: (trackIndex: number) => Promise<void>;
getAudioTracks: () => Promise<TrackInfo[] | null>;
setSubtitleTrack: (trackIndex: number) => Promise<void>;
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
setSubtitleDelay: (delay: number) => Promise<void>;
setAudioDelay: (delay: number) => Promise<void>;
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
setRate: (rate: number) => Promise<void>;
nextChapter: () => Promise<void>;
previousChapter: () => Promise<void>;
getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string, name: string) => Promise<void>;
}

139
modules/MpvPlayerView.tsx Normal file
View File

@@ -0,0 +1,139 @@
import { requireNativeViewManager } from "expo-modules-core";
import * as React from "react";
import { ViewStyle } from "react-native";
import type {
MpvPlayerSource,
MpvPlayerViewProps,
MpvPlayerViewRef,
} from "./MpvPlayer.types";
interface NativeViewRef extends MpvPlayerViewRef {
setNativeProps?: (props: Partial<MpvPlayerViewProps>) => void;
}
const MpvViewManager = requireNativeViewManager("MpvPlayer");
// Create a forwarded ref version of the native view
const NativeView = React.forwardRef<NativeViewRef, MpvPlayerViewProps>(
(props, ref) => {
return <MpvViewManager {...props} ref={ref} />;
},
);
const MpvPlayerView = React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
(props, ref) => {
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture();
},
play: async () => {
await nativeRef.current?.play();
},
pause: async () => {
await nativeRef.current?.pause();
},
stop: async () => {
await nativeRef.current?.stop();
},
seekTo: async (time: number) => {
await nativeRef.current?.seekTo(time);
},
setAudioTrack: async (trackIndex: number) => {
await nativeRef.current?.setAudioTrack(trackIndex);
},
getAudioTracks: async () => {
const tracks = await nativeRef.current?.getAudioTracks();
return tracks ?? null;
},
setSubtitleTrack: async (trackIndex: number) => {
await nativeRef.current?.setSubtitleTrack(trackIndex);
},
getSubtitleTracks: async () => {
const tracks = await nativeRef.current?.getSubtitleTracks();
return tracks ?? null;
},
setSubtitleDelay: async (delay: number) => {
await nativeRef.current?.setSubtitleDelay(delay);
},
setAudioDelay: async (delay: number) => {
await nativeRef.current?.setAudioDelay(delay);
},
takeSnapshot: async (path: string, width: number, height: number) => {
await nativeRef.current?.takeSnapshot(path, width, height);
},
setRate: async (rate: number) => {
await nativeRef.current?.setRate(rate);
},
nextChapter: async () => {
await nativeRef.current?.nextChapter();
},
previousChapter: async () => {
await nativeRef.current?.previousChapter();
},
getChapters: async () => {
const chapters = await nativeRef.current?.getChapters();
return chapters ?? null;
},
setVideoCropGeometry: async (geometry: string | null) => {
await nativeRef.current?.setVideoCropGeometry(geometry);
},
getVideoCropGeometry: async () => {
const geometry = await nativeRef.current?.getVideoCropGeometry();
return geometry ?? null;
},
setSubtitleURL: async (url: string, name: string) => {
await nativeRef.current?.setSubtitleURL(url, name);
},
}));
const {
source,
style,
progressUpdateInterval = 500,
paused,
muted,
volume,
videoAspectRatio,
onVideoLoadStart,
onVideoStateChange,
onVideoProgress,
onVideoLoadEnd,
onVideoError,
onPipStarted,
...otherProps
} = props;
const processedSource: MpvPlayerSource =
typeof source === "string"
? ({ uri: source } as unknown as MpvPlayerSource)
: source;
if (processedSource.startPosition !== undefined) {
processedSource.startPosition = Math.floor(processedSource.startPosition);
}
return (
<NativeView
{...otherProps}
ref={nativeRef}
source={processedSource}
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
progressUpdateInterval={progressUpdateInterval}
paused={paused}
muted={muted}
volume={volume}
videoAspectRatio={videoAspectRatio}
onVideoLoadStart={onVideoLoadStart}
onVideoLoadEnd={onVideoLoadEnd}
onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
/>
);
},
);
export default MpvPlayerView;

View File

@@ -12,6 +12,13 @@ import {
} from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView";
import {
MpvPlayerSource,
MpvPlayerViewProps,
MpvPlayerViewRef,
} from "./MpvPlayer.types";
import MpvPlayerView from "./MpvPlayerView";
export {
VlcPlayerView,
VlcPlayerViewProps,
@@ -24,4 +31,9 @@ export {
VlcPlayerSource,
TrackInfo,
ChapterInfo,
// MPV Player exports
MpvPlayerView,
MpvPlayerViewProps,
MpvPlayerViewRef,
MpvPlayerSource,
};

View File

@@ -0,0 +1,6 @@
{
"platforms": ["ios", "tvos"],
"ios": {
"modules": ["MpvPlayerModule"]
}
}

View File

@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'MpvPlayer'
s.version = '0.40.0'
s.summary = 'MPVKit player for iOS/tvOS'
s.description = 'A module that integrates MPVKit for video playback in iOS and tvOS applications'
s.author = ''
s.source = { git: '' }
s.homepage = 'https://github.com/mpvkit/MPVKit'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit', '~> 0.40.6'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,71 @@
import ExpoModulesCore
public class MpvPlayerModule: Module {
public func definition() -> ModuleDefinition {
Name("MpvPlayer")
View(MpvPlayerView.self) {
Prop("source") { (view: MpvPlayerView, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: MpvPlayerView, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: MpvPlayerView) in
view.play()
}
AsyncFunction("pause") { (view: MpvPlayerView) in
view.pause()
}
AsyncFunction("stop") { (view: MpvPlayerView) in
view.stop()
}
AsyncFunction("seekTo") { (view: MpvPlayerView, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") {
(view: MpvPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
}
}
}

View File

@@ -0,0 +1,892 @@
import ExpoModulesCore
import Libmpv
import SwiftUI
import UIKit
// MARK: - Metal Layer
class MetalLayer: CAMetalLayer {
// Workaround for MoltenVK issue that sets drawableSize to 1x1
override var drawableSize: CGSize {
get { return super.drawableSize }
set {
if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
// Handle extended dynamic range content on iOS 16+
@available(iOS 16.0, *)
override var wantsExtendedDynamicRangeContent: Bool {
get { return super.wantsExtendedDynamicRangeContent }
set {
if Thread.isMainThread {
super.wantsExtendedDynamicRangeContent = newValue
} else {
DispatchQueue.main.sync {
super.wantsExtendedDynamicRangeContent = newValue
}
}
}
}
// Helper to set HDR content safely
func setHDRContent(_ enabled: Bool) {
if #available(iOS 16.0, *) {
if Thread.isMainThread {
self.wantsExtendedDynamicRangeContent = enabled
} else {
DispatchQueue.main.sync {
self.wantsExtendedDynamicRangeContent = enabled
}
}
}
}
}
// MARK: - MPV Properties
enum MpvProperty {
static let timePosition = "time-pos"
static let duration = "duration"
static let pause = "pause"
static let pausedForCache = "paused-for-cache"
static let videoParamsSigPeak = "video-params/sig-peak"
}
// MARK: - Protocol
protocol MpvPlayerDelegate: AnyObject {
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?)
}
// MARK: - MPV Player View
class MpvPlayerView: ExpoView {
// MARK: - Properties
private var playerController: MpvMetalViewController?
private var source: [String: Any]?
private var externalSubtitles: [[String: String]]?
// MARK: - Event Emitters
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
}
// MARK: - Setup
private func setupView() {
backgroundColor = .black
print("Setting up direct MPV view")
// Create player controller
let controller = MpvMetalViewController()
// Configure player delegate
controller.delegate = self
playerController = controller
// Add the controller's view to our view hierarchy
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.backgroundColor = .clear
addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
controller.view.topAnchor.constraint(equalTo: topAnchor),
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
// MARK: - Public Methods
func setSource(_ source: [String: Any]) {
self.source = source
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoLoadStart?(["target": self.reactTag as Any])
// Store external subtitle data
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
if let uri = source["uri"] as? String, let url = URL(string: uri) {
print("Loading file: \(url.absoluteString)")
self.playerController?.playUrl = url
// Set start position if available
if let startPosition = source["startPosition"] as? Double {
self.playerController?.setStartPosition(startPosition)
}
self.playerController?.loadFile(url)
// Set video to fill the screen
self.setVideoScalingMode("cover")
// Add external subtitles after the video is loaded
self.setInitialExternalSubtitles()
self.onVideoLoadEnd?(["target": self.reactTag as Any])
} else {
self.onVideoError?(["error": "Invalid or empty URI"])
}
}
}
func startPictureInPicture() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
}
}
func play() {
playerController?.play()
}
func pause() {
playerController?.pause()
}
func stop() {
playerController?.command("stop", args: [])
}
func seekTo(_ time: Int32) {
let seconds = Double(time) / 1000.0
print("Seeking to absolute position: \(seconds) seconds")
playerController?.command("seek", args: ["\(seconds)", "absolute"])
}
func setAudioTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["aid", "\(trackIndex)"])
}
func getAudioTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to audio tracks only
var audioTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "audio" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Audio \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
audioTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return audioTracks
}
func setSubtitleTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["sid", "\(trackIndex)"])
}
func getSubtitleTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to subtitle tracks only
var subtitleTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "sub" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Subtitle \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
subtitleTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return subtitleTracks
}
func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { return }
print("Adding subtitle: \(name) from \(subtitleURL)")
// Add the subtitle file
playerController?.command("sub-add", args: [url.absoluteString])
}
@objc
func setVideoScalingMode(_ mode: String) {
// Mode can be: "contain" (letterbox), "cover" (crop/fill), or "stretch"
guard let playerController = playerController else { return }
switch mode.lowercased() {
case "cover", "fill", "crop":
// Fill the screen, cropping if necessary
playerController.command("set", args: ["panscan", "1.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Center the crop
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
case "stretch":
// Stretch to fill without maintaining aspect ratio
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "-1"])
// No need for alignment as it stretches to fill entire area
case "contain", "letterbox", "fit":
// Keep aspect ratio, fit within screen (letterbox)
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Set alignment to center
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
default:
break
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
// MARK: - Private Methods
private func isPaused() -> Bool {
return playerController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return playerController?.getDouble(MpvProperty.duration) ?? 0
}
// MARK: - Cleanup
override func removeFromSuperview() {
cleanup()
super.removeFromSuperview()
}
private func cleanup() {
// Check if we already cleaned up
print("Cleaning up player")
guard playerController != nil else { return }
// First stop playback
stop()
// Break reference cycles
playerController?.delegate = nil
// Remove from view hierarchy
playerController?.view.removeFromSuperview()
// Release references
playerController = nil
}
deinit {
cleanup()
}
// Check if player needs reset when the view appears
override func didMoveToWindow() {
super.didMoveToWindow()
// If we're returning to the window and player is missing, reset
if window != nil && playerController == nil {
setupView()
// Reload previous source if available
if let source = source {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setSource(source)
}
}
}
}
}
// MARK: - MPV Player Delegate
extension MpvPlayerView: MpvPlayerDelegate {
// Move the static properties to class level
private static var lastTimePositionUpdate = Date(timeIntervalSince1970: 0)
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
// Add throttling for frequently updated properties
switch propertyName {
case MpvProperty.timePosition:
// Throttle timePosition updates to once per second
let now = Date()
if now.timeIntervalSince(MpvPlayerView.lastTimePositionUpdate) < 1.0 {
return
}
MpvPlayerView.lastTimePositionUpdate = now
if let position = value as? Double {
let timeMs = position * 1000
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("IsPlaying: \(!self.isPaused())")
self.onVideoProgress?([
"currentTime": timeMs,
"duration": self.getVideoDuration() * 1000,
"isPlaying": !self.isPaused(),
"isBuffering": self.isBuffering(),
"target": self.reactTag as Any,
])
}
}
case MpvProperty.pausedForCache:
// We want to respond immediately to buffering state changes
let isBuffering = value as? Bool ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoStateChange?([
"isBuffering": isBuffering, "target": self.reactTag as Any,
"isPlaying": !self.isPaused(),
"state": self.isPaused() ? "Paused" : "Playing",
])
}
case MpvProperty.pause:
// We want to respond immediately to play/pause state changes
if let isPaused = value as? Bool {
let state = isPaused ? "Paused" : "Playing"
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("onPlaybackStateChanged: \(state)")
self.onPlaybackStateChanged?([
"state": state,
"isPlaying": !isPaused,
"isBuffering": self.isBuffering(),
"currentTime": self.getCurrentTime() * 1000,
"duration": self.getVideoDuration() * 1000,
"target": self.reactTag as Any,
])
}
}
default:
break
}
}
}
// MARK: - Player Controller
final class MpvMetalViewController: UIViewController {
// MARK: - Properties
var metalLayer = MetalLayer()
var mpv: OpaquePointer?
weak var delegate: MpvPlayerDelegate?
let mpvQueue = DispatchQueue(label: "mpv.queue", qos: .userInitiated)
private var isBeingDeallocated = false
// Use a static dictionary to store controller references instead of WeakContainer
private static var controllers = [UInt: MpvMetalViewController]()
private var controllerId: UInt = 0
var playUrl: URL?
var hdrAvailable: Bool {
if #available(iOS 16.0, *) {
let maxEDRRange = view.window?.screen.potentialEDRHeadroom ?? 1.0
let sigPeak = getDouble(MpvProperty.videoParamsSigPeak)
return maxEDRRange > 1.0 && sigPeak > 1.0
} else {
return false
}
}
var hdrEnabled = false {
didSet {
guard let mpv = mpv else { return }
if hdrEnabled {
mpv_set_option_string(mpv, "target-colorspace-hint", "yes")
metalLayer.setHDRContent(true)
} else {
mpv_set_option_string(mpv, "target-colorspace-hint", "no")
metalLayer.setHDRContent(false)
}
}
}
// Add a new property to track shutdown state
private var isShuttingDown = false
private let syncQueue = DispatchQueue(label: "com.mpv.sync", qos: .userInitiated)
private var startPosition: Double?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupMetalLayer()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.setupMPV()
if let url = self?.playUrl {
self?.loadFile(url)
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
metalLayer.frame = view.bounds
}
deinit {
// Flag that we're being deinitialized
isBeingDeallocated = true
// Clean up on main thread to avoid threading issues
if Thread.isMainThread {
safeCleanup()
} else {
DispatchQueue.main.sync {
self.safeCleanup()
}
}
}
private func safeCleanup() {
// Remove from controllers dictionary first
if controllerId != 0 {
MpvMetalViewController.controllers.removeValue(forKey: controllerId)
}
// Remove the wakeup callback
if let mpv = self.mpv {
mpv_set_wakeup_callback(mpv, nil, nil)
}
// Terminate and destroy MPV instance
if let mpv = self.mpv {
// Unobserve all properties
mpv_unobserve_property(mpv, 0)
// Store locally to avoid accessing after freeing
let mpvToDestroy = mpv
self.mpv = nil
// Terminate and destroy
mpv_terminate_destroy(mpvToDestroy)
}
}
// MARK: - Setup
private func setupMetalLayer() {
metalLayer.frame = view.bounds
metalLayer.contentsScale = UIScreen.main.nativeScale
metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer)
}
private func setupMPV() {
guard let mpvHandle = mpv_create() else {
print("Failed to create MPV instance")
return
}
mpv = mpvHandle
// Configure mpv options
#if DEBUG
// mpv_request_log_messages(mpvHandle, "debug")
#else
mpv_request_log_messages(mpvHandle, "no")
#endif
// Force a proper window setup to prevent black screens
mpv_set_option_string(mpvHandle, "force-window", "yes")
mpv_set_option_string(mpvHandle, "reset-on-next-file", "all")
// Set rendering options
var layerPtr = Unmanaged.passUnretained(metalLayer).toOpaque()
mpv_set_option(mpvHandle, "wid", MPV_FORMAT_INT64, &layerPtr)
mpv_set_option_string(mpvHandle, "vo", "gpu-next")
mpv_set_option_string(mpvHandle, "gpu-api", "metal")
mpv_set_option_string(mpvHandle, "gpu-context", "auto")
mpv_set_option_string(mpvHandle, "hwdec", "videotoolbox")
// Set subtitle options
mpv_set_option_string(mpvHandle, "subs-match-os-language", "yes")
mpv_set_option_string(mpvHandle, "subs-fallback", "yes")
mpv_set_option_string(mpvHandle, "sub-auto", "no")
// Disable subtitle selection at start
mpv_set_option_string(mpvHandle, "sid", "no")
// Set starting point if available
if let startPos = startPosition {
let startPosString = String(format: "%.1f", startPos)
print("Setting initial start position to \(startPosString)")
mpv_set_option_string(mpvHandle, "start", startPosString)
}
// Set video options
mpv_set_option_string(mpvHandle, "video-rotate", "no")
mpv_set_option_string(mpvHandle, "ytdl", "no")
// Initialize mpv
let status = mpv_initialize(mpvHandle)
if status < 0 {
print("Failed to initialize MPV: \(String(cString: mpv_error_string(status)))")
mpv_terminate_destroy(mpvHandle)
mpv = nil
return
}
// Observe properties
observeProperty(mpvHandle, MpvProperty.videoParamsSigPeak, MPV_FORMAT_DOUBLE)
observeProperty(mpvHandle, MpvProperty.pausedForCache, MPV_FORMAT_FLAG)
observeProperty(mpvHandle, MpvProperty.timePosition, MPV_FORMAT_DOUBLE)
observeProperty(mpvHandle, MpvProperty.duration, MPV_FORMAT_DOUBLE)
observeProperty(mpvHandle, MpvProperty.pause, MPV_FORMAT_FLAG)
// Store controller in static dictionary and set its unique ID
controllerId = UInt(bitPattern: ObjectIdentifier(self))
MpvMetalViewController.controllers[controllerId] = self
// Set wakeup callback using the static method
mpv_set_wakeup_callback(
mpvHandle, MpvMetalViewController.mpvWakeupCallback,
UnsafeMutableRawPointer(bitPattern: controllerId))
print("MPV initialized")
}
// Static callback function - no WeakContainer needed
private static let mpvWakeupCallback: (@convention(c) (UnsafeMutableRawPointer?) -> Void) = {
(ctx) in
guard let ctx = ctx else { return }
// Get the controllerId from the context pointer
let controllerId = UInt(bitPattern: ctx)
// Dispatch to main queue to handle UI updates safely
DispatchQueue.main.async {
// Get the controller safely from the dictionary
if let controller = MpvMetalViewController.controllers[controllerId] {
// Only process events if not being deallocated
if !controller.isBeingDeallocated {
controller.processEvents()
}
}
}
}
// Helper method for safer property observation
private func observeProperty(_ handle: OpaquePointer, _ name: String, _ format: mpv_format) {
let status = mpv_observe_property(handle, 0, name, format)
if status < 0 {
print(
"Failed to observe property \(name): \(String(cString: mpv_error_string(status)))")
}
}
// MARK: - MPV Methods
func loadFile(_ url: URL) {
guard let mpv = mpv else { return }
print("Loading file: \(url.absoluteString)")
// Use string array extension for safer command execution
command("loadfile", args: [url.absoluteString, "replace"])
}
func play() {
setFlag(MpvProperty.pause, false)
}
func pause() {
print("Pausing")
setFlag(MpvProperty.pause, true)
}
func getDouble(_ name: String) -> Double {
guard let mpv = mpv else { return 0.0 }
var data = 0.0
let status = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
if status < 0 {
print(
"Failed to get double property \(name): \(String(cString: mpv_error_string(status)))"
)
}
return data
}
func getNode(_ name: String) -> String? {
guard let mpv = mpv else { return nil }
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
// Use defer to ensure memory is freed even if an exception occurs
defer {
mpv_free(UnsafeMutableRawPointer(mutating: cString))
}
return String(cString: cString)
}
func getString(_ name: String) -> String? {
guard let mpv = mpv else { return nil }
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
// Use defer to ensure memory is freed even if an exception occurs
defer {
mpv_free(UnsafeMutableRawPointer(mutating: cString))
}
return String(cString: cString)
}
func getFlag(_ name: String) -> Bool {
guard let mpv = mpv else { return false }
var data: Int32 = 0
let status = mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
if status < 0 {
print(
"Failed to get flag property \(name): \(String(cString: mpv_error_string(status)))")
}
return data > 0
}
func setFlag(_ name: String, _ value: Bool) {
guard let mpv = mpv else { return }
var data: Int32 = value ? 1 : 0
print("Setting flag \(name) to \(value)")
let status = mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
if status < 0 {
print(
"Failed to set flag property \(name): \(String(cString: mpv_error_string(status)))")
}
}
func command(
_ command: String,
args: [String] = [],
checkErrors: Bool = true,
completion: ((Int32) -> Void)? = nil
) {
guard let mpv = mpv else {
completion?(-1)
return
}
// Approach 1: Create array of C strings directly from Swift strings
let allArgs = [command] + args
// Allocate array of C string pointers of the correct type
let cArray = UnsafeMutablePointer<UnsafePointer<CChar>?>.allocate(
capacity: allArgs.count + 1)
// Convert Swift strings to C strings and store in the array
for i in 0..<allArgs.count {
cArray[i] = (allArgs[i] as NSString).utf8String
}
// Set final element to nil
cArray[allArgs.count] = nil
// Execute the command
let status = mpv_command(mpv, cArray)
// Clean up
cArray.deallocate()
if checkErrors && status < 0 {
print("MPV command error: \(String(cString: mpv_error_string(status)))")
}
completion?(status)
}
// MARK: - Event Processing
private func processEvents() {
// Exit if we're being deallocated
if isBeingDeallocated {
return
}
guard let mpv = mpv else { return }
// Process a limited number of events to avoid infinite loops
let maxEvents = 10
var eventCount = 0
while !isBeingDeallocated && eventCount < maxEvents {
guard let event = mpv_wait_event(mpv, 0) else { break }
if event.pointee.event_id == MPV_EVENT_NONE { break }
handleEvent(event)
eventCount += 1
}
}
private func handleEvent(_ event: UnsafePointer<mpv_event>) {
// Exit early if we're being deallocated
if isBeingDeallocated {
return
}
guard let mpv = mpv else { return }
switch event.pointee.event_id {
case MPV_EVENT_PROPERTY_CHANGE:
guard let propertyData = event.pointee.data else { break }
// Safely create a typed pointer to the property data
let propertyPtr = propertyData.bindMemory(
to: mpv_event_property.self, capacity: 1)
// Safely get the property name
guard let namePtr = propertyPtr.pointee.name else { break }
let propertyName = String(cString: namePtr)
var value: Any?
// Handle different property types safely
switch propertyName {
case MpvProperty.pausedForCache, MpvProperty.pause:
if propertyPtr.pointee.format == MPV_FORMAT_FLAG,
let data = propertyPtr.pointee.data
{
// Cast to Int32 which is MPV's flag format
let flagPtr = data.bindMemory(to: Int32.self, capacity: 1)
value = flagPtr.pointee != 0
}
case MpvProperty.timePosition, MpvProperty.duration:
if propertyPtr.pointee.format == MPV_FORMAT_DOUBLE,
let data = propertyPtr.pointee.data
{
// Cast to Double which is MPV's double format
let doublePtr = data.bindMemory(to: Double.self, capacity: 1)
value = doublePtr.pointee
}
default:
break
}
// Notify delegate on main thread
if let value = value {
DispatchQueue.main.async { [weak self] in
guard let self = self, !self.isBeingDeallocated else { return }
self.delegate?.propertyChanged(
mpv: mpv, propertyName: propertyName, value: value)
}
}
case MPV_EVENT_SHUTDOWN:
print("MPV shutdown event received")
isBeingDeallocated = true
case MPV_EVENT_LOG_MESSAGE:
return
default:
if let eventName = mpv_event_name(event.pointee.event_id) {
print("MPV event: \(String(cString: eventName))")
}
}
}
// MARK: - Public Methods
func setStartPosition(_ position: Double) {
startPosition = position
// If MPV is already initialized, we need to update the option
if let mpv = mpv {
let positionString = String(format: "%.1f", position)
print("Setting start position to \(positionString)")
mpv_set_option_string(mpv, "start", positionString)
}
}
}

View File

@@ -0,0 +1,831 @@
import ExpoModulesCore
import Foundation
import GLKit
import Libmpv
import SwiftUI
import UIKit
// MARK: - MPV Properties
enum MpvProperty {
static let timePosition = "time-pos"
static let duration = "duration"
static let pause = "pause"
static let pausedForCache = "paused-for-cache"
static let videoParamsSigPeak = "video-params/sig-peak"
}
// MARK: - Protocol
protocol MpvPlayerDelegate: AnyObject {
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?)
}
// MARK: - MPV Player View
class MpvPlayerView: ExpoView {
// MARK: - Properties
private var playerController: MpvGLViewController?
private var source: [String: Any]?
private var externalSubtitles: [[String: String]]?
// MARK: - Event Emitters
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
}
// MARK: - Setup
private func setupView() {
backgroundColor = .black
print("Setting up MPV GL view")
// Create player controller - IMPORTANT: Use init(nibName:bundle:) to ensure proper GLKView setup
let controller = MpvGLViewController(nibName: nil, bundle: nil)
// Force view loading immediately
_ = controller.view
// Configure player delegate
controller.mpvDelegate = self
playerController = controller
// Make sure controller view is properly set up as GLKView
controller.view.backgroundColor = .black
// Set explicit frame to ensure it's visible
controller.view.frame = bounds
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Add to hierarchy
addSubview(controller.view)
// Use constraints to ensure proper sizing
NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
controller.view.topAnchor.constraint(equalTo: topAnchor),
controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
// Override layoutSubviews to make sure the player view is properly sized
override func layoutSubviews() {
super.layoutSubviews()
playerController?.view.frame = bounds
}
// MARK: - Public Methods
func setSource(_ source: [String: Any]) {
self.source = source
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoLoadStart?(["target": self.reactTag as Any])
// Store external subtitle data
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
if let uri = source["uri"] as? String, let url = URL(string: uri) {
print("Loading file: \(url.absoluteString)")
self.playerController?.playUrl = url
// Set start position if available
if let startPosition = source["startPosition"] as? Double {
self.playerController?.startPosition = startPosition
}
self.playerController?.loadFile(url)
// Set video to fill the screen
self.setVideoScalingMode("cover")
// Add external subtitles after the video is loaded
self.setInitialExternalSubtitles()
self.onVideoLoadEnd?(["target": self.reactTag as Any])
} else {
self.onVideoError?(["error": "Invalid or empty URI"])
}
}
}
func startPictureInPicture() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
}
}
func play() {
playerController?.play()
}
func pause() {
playerController?.pause()
}
func stop() {
playerController?.command("stop", args: [])
}
func seekTo(_ time: Int32) {
let seconds = Double(time) / 1000.0
print("Seeking to absolute position: \(seconds) seconds")
playerController?.command("seek", args: ["\(seconds)", "absolute"])
}
func setAudioTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["aid", "\(trackIndex)"])
}
func getAudioTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to audio tracks only
var audioTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "audio" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Audio \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
audioTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return audioTracks
}
func setSubtitleTrack(_ trackIndex: Int) {
playerController?.command("set", args: ["sid", "\(trackIndex)"])
}
func getSubtitleTracks() -> [[String: Any]] {
guard let playerController = playerController else {
return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to subtitle tracks only
var subtitleTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "sub" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Subtitle \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
subtitleTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return subtitleTracks
}
func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { return }
print("Adding subtitle: \(name) from \(subtitleURL)")
// Add the subtitle file
playerController?.command("sub-add", args: [url.absoluteString])
}
@objc
func setVideoScalingMode(_ mode: String) {
// Mode can be: "contain" (letterbox), "cover" (crop/fill), or "stretch"
guard let playerController = playerController else { return }
switch mode.lowercased() {
case "cover", "fill", "crop":
// Fill the screen, cropping if necessary
playerController.command("set", args: ["panscan", "1.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Center the crop
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
case "stretch":
// Stretch to fill without maintaining aspect ratio
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "-1"])
// No need for alignment as it stretches to fill entire area
case "contain", "letterbox", "fit":
// Keep aspect ratio, fit within screen (letterbox)
playerController.command("set", args: ["panscan", "0.0"])
playerController.command("set", args: ["video-unscaled", "no"])
playerController.command("set", args: ["video-aspect-override", "no"])
// Set alignment to center
playerController.command("set", args: ["video-align-x", "0.5"])
playerController.command("set", args: ["video-align-y", "0.5"])
default:
break
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
// MARK: - Private Methods
private func isPaused() -> Bool {
return playerController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return playerController?.getDouble(MpvProperty.duration) ?? 0
}
// MARK: - Cleanup
override func removeFromSuperview() {
cleanup()
super.removeFromSuperview()
}
private func cleanup() {
// Check if we already cleaned up
print("Cleaning up player")
guard playerController != nil else { return }
// First stop playback
stop()
// Break reference cycles
playerController?.mpvDelegate = nil
// Remove from view hierarchy
playerController?.view.removeFromSuperview()
// Release references
playerController = nil
}
deinit {
cleanup()
}
// Check if player needs reset when the view appears
override func didMoveToWindow() {
super.didMoveToWindow()
// If we're returning to the window and player is missing, reset
if window != nil && playerController == nil {
setupView()
// Reload previous source if available
if let source = source {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setSource(source)
}
}
}
}
}
// MARK: - MPV Player Delegate
extension MpvPlayerView: MpvPlayerDelegate {
// Move the static properties to class level
private static var lastTimePositionUpdate = Date(timeIntervalSince1970: 0)
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
// Add throttling for frequently updated properties
switch propertyName {
case MpvProperty.timePosition:
// Throttle timePosition updates to once per second
let now = Date()
if now.timeIntervalSince(MpvPlayerView.lastTimePositionUpdate) < 1.0 {
return
}
MpvPlayerView.lastTimePositionUpdate = now
if let position = value as? Double {
let timeMs = position * 1000
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("IsPlaying: \(!self.isPaused())")
self.onVideoProgress?([
"currentTime": timeMs,
"duration": self.getVideoDuration() * 1000,
"isPlaying": !self.isPaused(),
"isBuffering": self.isBuffering(),
"target": self.reactTag as Any,
])
}
}
case MpvProperty.pausedForCache:
// We want to respond immediately to buffering state changes
let isBuffering = value as? Bool ?? false
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.onVideoStateChange?([
"isBuffering": isBuffering, "target": self.reactTag as Any,
"isPlaying": !self.isPaused(),
"state": self.isPaused() ? "Paused" : "Playing",
])
}
case MpvProperty.pause:
// We want to respond immediately to play/pause state changes
if let isPaused = value as? Bool {
let state = isPaused ? "Paused" : "Playing"
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("onPlaybackStateChanged: \(state)")
self.onPlaybackStateChanged?([
"state": state,
"isPlaying": !isPaused,
"isBuffering": self.isBuffering(),
"currentTime": self.getCurrentTime() * 1000,
"duration": self.getVideoDuration() * 1000,
"target": self.reactTag as Any,
])
}
}
default:
break
}
}
}
// MARK: - Player Controller
final class MpvGLViewController: GLKViewController {
// MARK: - Properties
var mpv: OpaquePointer!
var mpvGL: OpaquePointer!
weak var mpvDelegate: MpvPlayerDelegate?
var queue: DispatchQueue = DispatchQueue(label: "mpv", qos: .userInteractive)
private var defaultFBO: GLint = -1
var playUrl: URL?
var startPosition: Double?
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupContext()
setupMpv()
if let url = playUrl {
self.loadFile(url)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
print("GLKViewController viewWillAppear")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
print("GLKViewController viewDidAppear")
}
deinit {
// Clean up on deallocation
if mpvGL != nil {
mpv_render_context_free(mpvGL)
mpvGL = nil
}
if mpv != nil {
mpv_terminate_destroy(mpv)
mpv = nil
}
}
// MARK: - Setup
func setupContext() {
print("Setting up OpenGL ES context")
let context = EAGLContext(api: .openGLES3)!
if context == nil {
print("ERROR: Failed to create OpenGL ES context")
return
}
let isSuccess = EAGLContext.setCurrent(context)
if !isSuccess {
print("ERROR: Failed to set current GL context")
return
}
// Set the context on our GLKView
let glkView = self.view as! GLKView
glkView.context = context
print("Successfully set up OpenGL ES context")
}
func setupMpv() {
print("Setting up MPV")
mpv = mpv_create()
if mpv == nil {
print("ERROR: failed creating mpv context\n")
exit(1)
}
// https://mpv.io/manual/stable/#options
#if DEBUG
checkError(mpv_request_log_messages(mpv, "debug"))
#else
checkError(mpv_request_log_messages(mpv, "no"))
#endif
#if os(macOS)
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
#endif
// Set options
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "hwdec", "auto-copy"))
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
checkError(mpv_set_option_string(mpv, "profile", "gpu-hq"))
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
// Add in setupMpv before initialization
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
checkError(mpv_set_option_string(mpv, "opengl-version", "3"))
// Initialize MPV
checkError(mpv_initialize(mpv))
// Set starting point if available
if let startPos = startPosition {
let startPosString = String(format: "%.1f", startPos)
print("Setting initial start position to \(startPosString)")
checkError(mpv_set_option_string(mpv, "start", startPosString))
}
// Set up rendering
print("Setting up MPV GL rendering context")
let api = UnsafeMutableRawPointer(
mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
var initParams = mpv_opengl_init_params(
get_proc_address: {
(ctx, name) in
return MpvGLViewController.getProcAddress(ctx, name)
},
get_proc_address_ctx: nil
)
withUnsafeMutablePointer(to: &initParams) { initParams in
var params = [
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
mpv_render_param(),
]
if mpv_render_context_create(&mpvGL, mpv, &params) < 0 {
puts("ERROR: failed to initialize mpv GL context")
exit(1)
}
print("Successfully created MPV GL render context")
mpv_render_context_set_update_callback(
mpvGL,
mpvGLUpdate,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self.view).toOpaque())
)
}
// Observe properties
mpv_observe_property(mpv, 0, MpvProperty.videoParamsSigPeak, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 0, MpvProperty.pausedForCache, MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 0, MpvProperty.timePosition, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 0, MpvProperty.duration, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 0, MpvProperty.pause, MPV_FORMAT_FLAG)
// Set wakeup callback
mpv_set_wakeup_callback(
self.mpv,
{ (ctx) in
let client = unsafeBitCast(ctx, to: MpvGLViewController.self)
client.readEvents()
}, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
print("MPV setup complete")
// Configure GLKView properly for better performance
let glkView = self.view as! GLKView
glkView.enableSetNeedsDisplay = false // Allow continuous rendering
glkView.drawableMultisample = .multisample4X // Might help or hurt - test both
glkView.drawableColorFormat = .RGBA8888
// Set higher preferred frame rate
self.preferredFramesPerSecond = 60 // Or even higher on newer devices
}
// MARK: - MPV Methods
func loadFile(_ url: URL) {
print("Loading file: \(url.absoluteString)")
var args = [url.absoluteString]
args.append("replace")
print("MPV Command: loadfile with args \(args)")
command("loadfile", args: args.map { $0 as String? })
// Set video settings for visibility
command("set", args: ["video-unscaled", "no"])
command("set", args: ["panscan", "1.0"]) // Ensure video fills screen
}
func togglePause() {
getFlag(MpvProperty.pause) ? play() : pause()
}
func play() {
setFlag(MpvProperty.pause, false)
}
func pause() {
setFlag(MpvProperty.pause, true)
}
func getDouble(_ name: String) -> Double {
var data = 0.0
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
return data
}
func getNode(_ name: String) -> String? {
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
defer {
mpv_free(UnsafeMutableRawPointer(mutating: cString))
}
return String(cString: cString)
}
func getFlag(_ name: String) -> Bool {
var data = Int64()
mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
return data > 0
}
func setFlag(_ name: String, _ flag: Bool) {
guard mpv != nil else { return }
var data: Int = flag ? 1 : 0
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
}
func command(
_ command: String,
args: [String?] = [],
checkForErrors: Bool = true,
returnValueCallback: ((Int32) -> Void)? = nil
) {
guard mpv != nil else {
return
}
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
defer {
for ptr in cargs where ptr != nil {
free(UnsafeMutablePointer(mutating: ptr!))
}
}
let returnValue = mpv_command(mpv, &cargs)
if checkForErrors {
checkError(returnValue)
}
if let cb = returnValueCallback {
cb(returnValue)
}
}
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
if !args.isEmpty, args.last == nil {
fatalError("Command do not need a nil suffix")
}
var strArgs = args
strArgs.insert(command, at: 0)
strArgs.append(nil)
return strArgs
}
// MARK: - Event Processing
func readEvents() {
queue.async { [self] in
while self.mpv != nil {
let event = mpv_wait_event(self.mpv, 0)
if event!.pointee.event_id == MPV_EVENT_NONE {
break
}
switch event!.pointee.event_id {
case MPV_EVENT_PROPERTY_CHANGE:
let dataOpaquePtr = OpaquePointer(event!.pointee.data)
if let property = UnsafePointer<mpv_event_property>(dataOpaquePtr)?.pointee {
let propertyName = String(cString: property.name)
// Handle different property types
var value: Any?
switch propertyName {
case MpvProperty.pausedForCache, MpvProperty.pause:
if property.format == MPV_FORMAT_FLAG,
let data = property.data
{
let boolValue =
UnsafePointer<Bool>(OpaquePointer(data))?.pointee ?? false
value = boolValue
}
case MpvProperty.timePosition, MpvProperty.duration:
if property.format == MPV_FORMAT_DOUBLE,
let data = property.data
{
let doubleValue =
UnsafePointer<Double>(OpaquePointer(data))?.pointee ?? 0.0
value = doubleValue
}
default:
break
}
// Notify delegate if we have a value
if let value = value {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mpvDelegate?.propertyChanged(
mpv: self.mpv, propertyName: propertyName, value: value)
}
}
}
case MPV_EVENT_SHUTDOWN:
mpv_render_context_free(mpvGL)
mpv_terminate_destroy(mpv)
mpv = nil
print("event: shutdown\n")
break
case MPV_EVENT_LOG_MESSAGE:
let msg = UnsafeMutablePointer<mpv_event_log_message>(
OpaquePointer(event!.pointee.data))
print(
"[\(String(cString: (msg!.pointee.prefix)!))] \(String(cString: (msg!.pointee.level)!)): \(String(cString: (msg!.pointee.text)!))",
terminator: "")
default:
let eventName = mpv_event_name(event!.pointee.event_id)
print("event: \(String(cString: (eventName)!))")
}
}
}
}
private func checkError(_ status: CInt) {
if status < 0 {
print("MPV API error: \(String(cString: mpv_error_string(status)))\n")
}
}
private var machine: String {
var systeminfo = utsname()
uname(&systeminfo)
return withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in
let data = Data(bufPtr)
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
return String(data: data[0...lastIndex], encoding: .isoLatin1)!
} else {
return String(data: data, encoding: .isoLatin1)!
}
}
}
// MARK: - GL Rendering
override func glkView(_ view: GLKView, drawIn rect: CGRect) {
guard let mpvGL else {
return
}
// fill black background
glClearColor(0, 0, 0, 0)
glClear(UInt32(GL_COLOR_BUFFER_BIT))
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO)
var dims: [GLint] = [0, 0, 0, 0]
glGetIntegerv(GLenum(GL_VIEWPORT), &dims)
var data = mpv_opengl_fbo(
fbo: Int32(defaultFBO),
w: Int32(dims[2]),
h: Int32(dims[3]),
internal_format: 0
)
var flip: CInt = 1
withUnsafeMutablePointer(to: &flip) { flip in
withUnsafeMutablePointer(to: &data) { data in
var params = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
mpv_render_param(),
]
mpv_render_context_render(mpvGL, &params)
}
}
}
private static func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?)
-> UnsafeMutableRawPointer?
{
let symbolName = CFStringCreateWithCString(
kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
let identifier = CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString)
return CFBundleGetFunctionPointerForName(identifier, symbolName)
}
}
private func mpvGLUpdate(_ ctx: UnsafeMutableRawPointer?) {
let glView = unsafeBitCast(ctx, to: GLKView.self)
DispatchQueue.main.async {
glView.display()
}
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule("MpvPlayer");

View File

@@ -1,10 +1,10 @@
import ExpoModulesCore
#if os(tvOS)
import TVVLCKit
import TVVLCKit
#else
import MobileVLCKit
import MobileVLCKit
#endif
import UIKit
class VlcPlayer3View: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
@@ -16,7 +16,7 @@ class VlcPlayer3View: ExpoView {
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var externalSubtitles: [[String: String]]?
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
@@ -61,7 +61,7 @@ class VlcPlayer3View: ExpoView {
}
// MARK: - Public Methods
func startPictureInPicture() {}
func startPictureInPicture() { }
@objc func play() {
self.mediaPlayer?.play()
@@ -109,7 +109,6 @@ class VlcPlayer3View: ExpoView {
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
initOptions.append("--start-time=\(self.startPosition)")
guard let uri = source["uri"] as? String, !uri.isEmpty else {
@@ -144,8 +143,8 @@ class VlcPlayer3View: ExpoView {
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
@@ -183,9 +182,9 @@ class VlcPlayer3View: ExpoView {
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count)"
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
@@ -193,19 +192,6 @@ class VlcPlayer3View: ExpoView {
}
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
self.setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
return nil
@@ -290,6 +276,16 @@ class VlcPlayer3View: ExpoView {
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,

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