mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-20 07:44:42 +01:00
Compare commits
2 Commits
feature/mp
...
fix/extern
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f600b68d | ||
|
|
76a2c86452 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"prettier.printWidth": 120,
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "swiftlang.swift-vscode"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
|||||||
@@ -6,19 +6,13 @@ import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { MpvPlayerView, ProgressUpdatePayload, VlcPlayerView } from "@/modules";
|
import { VlcPlayerView } from "@/modules";
|
||||||
// import type {
|
|
||||||
// PipStartedPayload,
|
|
||||||
// PlaybackStatePayload,
|
|
||||||
// ProgressUpdatePayload,
|
|
||||||
// VlcPlayerViewRef,
|
|
||||||
// } from "@/modules/VlcPlayer.types";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MpvPlayerViewRef,
|
|
||||||
PipStartedPayload,
|
PipStartedPayload,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
} from "@/modules/MpvPlayer.types";
|
ProgressUpdatePayload,
|
||||||
|
VlcPlayerViewRef,
|
||||||
|
} from "@/modules/VlcPlayer.types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -56,7 +50,7 @@ const downloadProvider = !Platform.isTV
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -397,23 +391,20 @@ export default function page() {
|
|||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
const initOptions = [
|
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
`--sub-text-scale=${settings.subtitleSize}`,
|
if (
|
||||||
`--start=${startPosition}`,
|
chosenSubtitleTrack &&
|
||||||
];
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
// if (
|
) {
|
||||||
// chosenSubtitleTrack &&
|
const finalIndex = notTranscoding
|
||||||
// (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
// ) {
|
: textSubs.indexOf(chosenSubtitleTrack);
|
||||||
// const finalIndex = notTranscoding
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
// ? allSubs.indexOf(chosenSubtitleTrack)
|
}
|
||||||
// : textSubs.indexOf(chosenSubtitleTrack);
|
|
||||||
// initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (notTranscoding && chosenAudioTrack) {
|
if (notTranscoding && chosenAudioTrack) {
|
||||||
// initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
// }
|
}
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
@@ -452,7 +443,7 @@ export default function page() {
|
|||||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MpvPlayerView
|
<VlcPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream?.url || "",
|
uri: stream?.url || "",
|
||||||
@@ -496,6 +487,7 @@ export default function page() {
|
|||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
|
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={videoRef.current?.play}
|
||||||
pause={videoRef.current?.pause}
|
pause={videoRef.current?.pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={videoRef.current?.seekTo}
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -44,13 +44,14 @@
|
|||||||
"expo-router": "~4.0.17",
|
"expo-router": "~4.0.17",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-screen-orientation": "~8.0.4",
|
||||||
"expo-sensors": "~14.0.2",
|
"expo-sensors": "~14.0.2",
|
||||||
"expo-sharing": "~13.1.0",
|
"expo-sharing": "~13.0.1",
|
||||||
"expo-splash-screen": "~0.29.22",
|
"expo-splash-screen": "~0.29.22",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "~4.0.8",
|
"expo-system-ui": "~4.0.8",
|
||||||
"expo-task-manager": "~12.0.5",
|
"expo-task-manager": "~12.0.5",
|
||||||
"expo-updates": "~0.26.17",
|
"expo-updates": "~0.26.17",
|
||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"jotai": "^2.11.3",
|
"jotai": "^2.11.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -1173,7 +1174,7 @@
|
|||||||
|
|
||||||
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
|
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
|
||||||
|
|
||||||
"expo-image": ["expo-image@2.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug=="],
|
"expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="],
|
||||||
|
|
||||||
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
|
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
|
||||||
|
|
||||||
@@ -1201,7 +1202,7 @@
|
|||||||
|
|
||||||
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
|
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
|
||||||
|
|
||||||
"expo-sharing": ["expo-sharing@13.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-7O29Bdm95v6aBXBhrbKx9FBqL5loQcK0nvCMFSbZHMy1r7Z6vb6sTMsaGbvknfOH+tEzn+LIleTw5TreoxNT9g=="],
|
"expo-sharing": ["expo-sharing@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw=="],
|
||||||
|
|
||||||
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
|
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
|
||||||
|
|
||||||
@@ -1249,6 +1250,8 @@
|
|||||||
|
|
||||||
"fetch-retry": ["fetch-retry@4.1.1", "", {}, "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA=="],
|
"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=="],
|
"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=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { MpvPlayerViewRef, TrackInfo } from "@/modules/MpvPlayer.types";
|
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||||
import { VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -66,7 +65,7 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
videoRef: MutableRefObject<VlcPlayerViewRef | MpvPlayerViewRef | null>;
|
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isSeeking: SharedValue<boolean>;
|
isSeeking: SharedValue<boolean>;
|
||||||
cacheProgress: SharedValue<number>;
|
cacheProgress: SharedValue<number>;
|
||||||
@@ -82,7 +81,7 @@ interface Props {
|
|||||||
isVideoLoaded?: boolean;
|
isVideoLoaded?: boolean;
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
seek: (ticks: number) => void;
|
seek: (ticks: number) => void;
|
||||||
startPictureInPicture?: () => Promise<void>;
|
startPictureInPicture: () => Promise<void>;
|
||||||
play: (() => Promise<void>) | (() => void);
|
play: (() => Promise<void>) | (() => void);
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||||
@@ -455,9 +454,9 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const onClose = async () => {
|
const onClose = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
// await ScreenOrientation.lockAsync(
|
await ScreenOrientation.lockAsync(
|
||||||
// ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
// );
|
);
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||||
|
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
@@ -126,15 +128,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
if (getSubtitleTracks) {
|
if (getSubtitleTracks) {
|
||||||
const subtitleData = await 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
|
// Step 1: Move external subs to the end, because VLC puts external subs at the end
|
||||||
const sortedSubs = allSubs.sort(
|
const sortedSubs = allSubs.sort(
|
||||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
|
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 2: Apply VLC indexing logic
|
// Step 2: Apply VLC indexing logic
|
||||||
let textSubIndex = 0;
|
let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1;
|
||||||
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
||||||
// Always increment for non-transcoding subtitles
|
// Always increment for non-transcoding subtitles
|
||||||
// Only increment for text-based subtitles when transcoding
|
// Only increment for text-based subtitles when transcoding
|
||||||
@@ -172,7 +172,6 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
});
|
});
|
||||||
setSubtitleTracks(subtitles);
|
setSubtitleTracks(subtitles);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getAudioTracks) {
|
if (getAudioTracks) {
|
||||||
const audioData = await getAudioTracks();
|
const audioData = await getAudioTracks();
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -12,13 +12,6 @@ import {
|
|||||||
} from "./VlcPlayer.types";
|
} from "./VlcPlayer.types";
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
import VlcPlayerView from "./VlcPlayerView";
|
||||||
|
|
||||||
import {
|
|
||||||
MpvPlayerSource,
|
|
||||||
MpvPlayerViewProps,
|
|
||||||
MpvPlayerViewRef,
|
|
||||||
} from "./MpvPlayer.types";
|
|
||||||
import MpvPlayerView from "./MpvPlayerView";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
VlcPlayerView,
|
VlcPlayerView,
|
||||||
VlcPlayerViewProps,
|
VlcPlayerViewProps,
|
||||||
@@ -31,9 +24,4 @@ export {
|
|||||||
VlcPlayerSource,
|
VlcPlayerSource,
|
||||||
TrackInfo,
|
TrackInfo,
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
// MPV Player exports
|
|
||||||
MpvPlayerView,
|
|
||||||
MpvPlayerViewProps,
|
|
||||||
MpvPlayerViewRef,
|
|
||||||
MpvPlayerSource,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios", "tvos"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["MpvPlayerModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,892 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,831 +0,0 @@
|
|||||||
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, ¶ms) < 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, ¶ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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");
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import TVVLCKit
|
import TVVLCKit
|
||||||
#else
|
#else
|
||||||
import MobileVLCKit
|
import MobileVLCKit
|
||||||
#endif
|
#endif
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class VlcPlayer3View: ExpoView {
|
class VlcPlayer3View: ExpoView {
|
||||||
private var mediaPlayer: VLCMediaPlayer?
|
private var mediaPlayer: VLCMediaPlayer?
|
||||||
@@ -16,7 +16,7 @@ class VlcPlayer3View: ExpoView {
|
|||||||
private var lastReportedIsPlaying: Bool?
|
private var lastReportedIsPlaying: Bool?
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||||
private var startPosition: Int32 = 0
|
private var startPosition: Int32 = 0
|
||||||
private var isMediaReady: Bool = false
|
private var externalSubtitles: [[String: String]]?
|
||||||
private var externalTrack: [String: String]?
|
private var externalTrack: [String: String]?
|
||||||
private var progressTimer: DispatchSourceTimer?
|
private var progressTimer: DispatchSourceTimer?
|
||||||
private var isStopping: Bool = false // Define isStopping here
|
private var isStopping: Bool = false // Define isStopping here
|
||||||
@@ -61,7 +61,7 @@ class VlcPlayer3View: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func startPictureInPicture() { }
|
func startPictureInPicture() {}
|
||||||
|
|
||||||
@objc func play() {
|
@objc func play() {
|
||||||
self.mediaPlayer?.play()
|
self.mediaPlayer?.play()
|
||||||
@@ -109,6 +109,7 @@ class VlcPlayer3View: ExpoView {
|
|||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||||
|
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||||
initOptions.append("--start-time=\(self.startPosition)")
|
initOptions.append("--start-time=\(self.startPosition)")
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||||
@@ -143,8 +144,8 @@ class VlcPlayer3View: ExpoView {
|
|||||||
media.addOptions(mediaOptions)
|
media.addOptions(mediaOptions)
|
||||||
|
|
||||||
self.mediaPlayer?.media = media
|
self.mediaPlayer?.media = media
|
||||||
|
self.setInitialExternalSubtitles()
|
||||||
self.hasSource = true
|
self.hasSource = true
|
||||||
|
|
||||||
if autoplay {
|
if autoplay {
|
||||||
print("Playing...")
|
print("Playing...")
|
||||||
self.play()
|
self.play()
|
||||||
@@ -182,9 +183,9 @@ class VlcPlayer3View: ExpoView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||||
if let result = result {
|
if let result = result {
|
||||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
let internalName = "Track \(self.customSubtitles.count)"
|
||||||
print("Subtitle added with result: \(result) \(internalName)")
|
print("Subtitle added with result: \(result) \(internalName)")
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||||
} else {
|
} else {
|
||||||
@@ -192,6 +193,19 @@ 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]]? {
|
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||||
guard let mediaPlayer = self.mediaPlayer else {
|
guard let mediaPlayer = self.mediaPlayer else {
|
||||||
return nil
|
return nil
|
||||||
@@ -276,16 +290,6 @@ class VlcPlayer3View: ExpoView {
|
|||||||
|
|
||||||
print("Debug: Current time: \(currentTimeMs)")
|
print("Debug: Current time: \(currentTimeMs)")
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
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?([
|
self.onVideoProgress?([
|
||||||
"currentTime": currentTimeMs,
|
"currentTime": currentTimeMs,
|
||||||
"duration": durationMs,
|
"duration": durationMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user