MPV Player init

This commit is contained in:
Alex Kim
2025-04-21 04:38:03 +10:00
parent 2ce04b3fd3
commit b6198b21bd
11 changed files with 916 additions and 20 deletions

View File

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

View File

@@ -6,13 +6,18 @@ import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import { MPVPlayerView, VlcPlayerView } from "@/modules";
// import type {
// PipStartedPayload,
// PlaybackStatePayload,
// ProgressUpdatePayload,
// VlcPlayerViewRef,
// } from "@/modules/VlcPlayer.types";
import type {
PipStartedPayload,
MPVPlayerViewRef,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
} from "@/modules/MPVPlayer.types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -50,7 +55,7 @@ const downloadProvider = !Platform.isTV
: null;
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const videoRef = useRef<MPVPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
@@ -443,7 +448,7 @@ export default function page() {
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView
<MPVPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
@@ -487,7 +492,6 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}

View File

@@ -44,14 +44,13 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-sharing": "~13.0.1",
"expo-sharing": "~13.1.0",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
"expo-updates": "~0.26.17",
"expo-web-browser": "~14.0.2",
"ffmpeg-kit-react-native": "^6.0.2",
"i18next": "^24.2.2",
"jotai": "^2.11.3",
"lodash": "^4.17.21",
@@ -87,7 +86,6 @@
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-video": "6.10.0",
"react-native-volume-manager": "^2.0.8",
"react-native-web": "~0.19.13",
"react-native-webview": "13.13.2",
@@ -1174,7 +1172,7 @@
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
"expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="],
"expo-image": ["expo-image@2.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug=="],
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
@@ -1202,7 +1200,7 @@
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
"expo-sharing": ["expo-sharing@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw=="],
"expo-sharing": ["expo-sharing@13.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-7O29Bdm95v6aBXBhrbKx9FBqL5loQcK0nvCMFSbZHMy1r7Z6vb6sTMsaGbvknfOH+tEzn+LIleTw5TreoxNT9g=="],
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
@@ -1250,8 +1248,6 @@
"fetch-retry": ["fetch-retry@4.1.1", "", {}, "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA=="],
"ffmpeg-kit-react-native": ["ffmpeg-kit-react-native@6.0.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-r9uSmahq8TeyIb7fXf3ft+uUXyoeWRFa99+khjo0TAzWO9y0z9wU7eGnab9JLw1MmCB9v64o4yojNluJhVm9nQ=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1916,8 +1912,6 @@
"react-native-uuid": ["react-native-uuid@2.0.3", "", {}, "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw=="],
"react-native-video": ["react-native-video@6.10.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NyDnpSBRJkj7TxMku2dEys54qKynW/l9kSPCVvayyqXWrc24cxHhLvAaxORdJDb6tS4FhUbR8tFIoOY65/XKZg=="],
"react-native-volume-manager": ["react-native-volume-manager@2.0.8", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-aZM47/mYkdQ4CbXpKYO6Ajiczv7fxbQXZ9c0H8gRuQUaS3OCz/MZABer6o9aDWq0KMNsQ7q7GVFLRPnSSeeMmw=="],
"react-native-web": ["react-native-web@0.19.13", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A=="],

View File

@@ -5,6 +5,7 @@ import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { MPVPlayerViewRef } from "@/modules/MPVPlayer.types";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
@@ -65,7 +66,7 @@ import { useControlsTimeout } from "./useControlsTimeout";
interface Props {
item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
videoRef: MutableRefObject<VlcPlayerViewRef | MPVPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
@@ -81,7 +82,7 @@ interface Props {
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
startPictureInPicture?: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);

View File

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

139
modules/MpvPlayerView.tsx Normal file
View File

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

View File

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

View File

@@ -0,0 +1,27 @@
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.dependency 'ExpoModulesCore'
spm_dependency(s,
url: 'https://github.com/mpvkit/MPVKit.git',
requirement: {kind: 'upToNextMajorVersion', minimumVersion: '0.40.0'},
products: ['MPVKit']
)
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

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

View File

@@ -0,0 +1,551 @@
import ExpoModulesCore
import Libmpv
import SwiftUI
import UIKit
// MARK: - Metal Layer
class MetalLayer: CAMetalLayer {
// workaround for a MoltenVK that sets the drawableSize to 1x1 to forcefully complete
// the presentation, this causes flicker and the drawableSize possibly staying at 1x1
// https://github.com/mpv-player/mpv/pull/13651
override var drawableSize: CGSize {
get { return super.drawableSize }
set {
if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
// Hack for fix [target-colorspace-hint] option:
// Update wantsExtendedDynamicRangeContent only available in iOS 16.0+
@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 function to conditionally set HDR content
func setHDRContent(_ enabled: Bool) {
if #available(iOS 16.0, *) {
if Thread.isMainThread {
self.wantsExtendedDynamicRangeContent = enabled
} else {
DispatchQueue.main.sync {
self.wantsExtendedDynamicRangeContent = enabled
}
}
}
}
}
// MARK: - MPV Player View
class MpvPlayerView: ExpoView {
private var mpvViewController: MpvMetalViewController?
private var coordinator: MpvMetalPlayerView.Coordinator?
private var source: [String: Any]?
// 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?
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
}
private func setupView() {
backgroundColor = .black
// Create coordinator
let coordinator = MpvMetalPlayerView.Coordinator()
coordinator.onPropertyChange = { [weak self] _, propertyName, data in
self?.handlePropertyChange(propertyName: propertyName, data: data)
}
self.coordinator = coordinator
// Create MPV controller
let mpvController = MpvMetalViewController()
mpvController.playDelegate = coordinator
coordinator.player = mpvController
mpvViewController = mpvController
// Add to view hierarchy
let hostingController = UIHostingController(
rootView: MpvMetalPlayerView(coordinator: coordinator)
)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
hostingController.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])
if let uri = source["uri"] as? String, let url = URL(string: uri) {
self.coordinator?.playUrl = url
self.coordinator?.play(url)
self.onVideoLoadEnd?(["target": self.reactTag as Any])
} else {
self.onVideoError?(["error": "Invalid or empty URI"])
}
}
}
func startPictureInPicture() {
self.onPipStarted?(["pipStarted": false, "target": self.reactTag as Any])
}
func play() {
mpvViewController?.play()
}
func pause() {
mpvViewController?.pause()
}
func stop() {
mpvViewController?.command("stop", args: [])
}
func seekTo(_ time: Int32) {
let seconds = Double(time) / 1000.0
mpvViewController?.command("seek", args: ["\(seconds)"])
}
func setAudioTrack(_ trackIndex: Int) {
mpvViewController?.command("set", args: ["aid", "\(trackIndex)"])
}
func getAudioTracks() -> [[String: Any]]? {
return []
}
func setSubtitleTrack(_ trackIndex: Int) {
mpvViewController?.command("set", args: ["sid", "\(trackIndex)"])
}
func getSubtitleTracks() -> [[String: Any]]? {
return []
}
func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { return }
mpvViewController?.command("sub-add", args: [url.absoluteString])
}
// MARK: - Private Methods
private func handlePropertyChange(propertyName: String, data: Any?) {
switch propertyName {
case MpvProperty.pausedForCache:
let isBuffering = data as? Bool ?? false
onVideoStateChange?(["isBuffering": isBuffering, "target": reactTag as Any])
case MpvProperty.timePosition:
if let position = data as? Double {
let timeMs = position * 1000
onVideoProgress?([
"currentTime": timeMs,
"duration": getVideoDuration() * 1000,
"isPlaying": !isPaused(),
"isBuffering": isBuffering(),
"target": reactTag as Any,
])
}
case MpvProperty.pause:
if let isPaused = data as? Bool {
let state = isPaused ? "Paused" : "Playing"
onPlaybackStateChanged?([
"state": state,
"isPlaying": !isPaused,
"isBuffering": isBuffering(),
"currentTime": getCurrentTime() * 1000,
"duration": getVideoDuration() * 1000,
"target": reactTag as Any,
])
}
default:
break
}
}
private func isPaused() -> Bool {
return mpvViewController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return mpvViewController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return mpvViewController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return mpvViewController?.getDouble(MpvProperty.duration) ?? 0
}
}
// MARK: - MPV Properties and Protocol
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"
}
protocol MpvPlayerDelegate: AnyObject {
func propertyChange(mpv: OpaquePointer, propertyName: String, data: Any?)
}
// MARK: - SwiftUI Wrapper
struct MpvMetalPlayerView: UIViewControllerRepresentable {
@ObservedObject var coordinator: Coordinator
func makeUIViewController(context: Context) -> some UIViewController {
let mpv = MpvMetalViewController()
mpv.playDelegate = coordinator
mpv.playUrl = coordinator.playUrl
context.coordinator.player = mpv
return mpv
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
public func makeCoordinator() -> Coordinator {
coordinator
}
func play(_ url: URL) -> Self {
coordinator.playUrl = url
return self
}
func onPropertyChange(_ handler: @escaping (MpvMetalViewController, String, Any?) -> Void)
-> Self
{
coordinator.onPropertyChange = handler
return self
}
@MainActor
public final class Coordinator: MpvPlayerDelegate, ObservableObject {
weak var player: MpvMetalViewController?
var playUrl: URL?
var onPropertyChange: ((MpvMetalViewController, String, Any?) -> Void)?
func play(_ url: URL) {
player?.loadFile(url)
}
func propertyChange(mpv: OpaquePointer, propertyName: String, data: Any?) {
guard let player = player else { return }
self.onPropertyChange?(player, propertyName, data)
}
}
}
// MARK: - MPV Metal View Controller
final class MpvMetalViewController: UIViewController {
var metalLayer = MetalLayer()
var mpv: OpaquePointer!
weak var playDelegate: MpvPlayerDelegate?
lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated)
var playUrl: URL?
var hdrAvailable: Bool {
if #available(iOS 16.0, *) {
let maxEDRRange = view.window?.screen.potentialEDRHeadroom ?? 1.0
let sigPeak = getDouble(MpvProperty.videoParamsSigPeak)
// display screen support HDR and current playing HDR video
return maxEDRRange > 1.0 && sigPeak > 1.0
} else {
return false // HDR not available on iOS < 16.0
}
}
var hdrEnabled = false {
didSet {
// FIXME: target-colorspace-hint does not support being changed at runtime.
// this option should be set as early as possible otherwise can cause issues
// not recommended to use this way.
if hdrEnabled {
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
metalLayer.setHDRContent(true)
} else {
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "no"))
metalLayer.setHDRContent(false)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
metalLayer.frame = view.frame
metalLayer.contentsScale = UIScreen.main.nativeScale
metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer)
setupMpv()
if let url = playUrl {
loadFile(url)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
metalLayer.frame = view.frame
}
func setupMpv() {
mpv = mpv_create()
if mpv == nil {
print("failed creating 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
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &metalLayer))
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, "vo", "gpu-next"))
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
checkError(mpv_set_option_string(mpv, "video-rotate", "no"))
checkError(mpv_set_option_string(mpv, "ytdl", "no"))
checkError(mpv_initialize(mpv))
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)
mpv_set_wakeup_callback(
self.mpv,
{ (ctx) in
let client = unsafeBitCast(ctx, to: MpvMetalViewController.self)
client.readEvents()
}, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
}
func loadFile(_ url: URL) {
var args = [url.absoluteString]
args.append("replace")
command("loadfile", args: args)
}
func togglePause() {
getFlag(MpvProperty.pause) ? play() : pause()
}
func play() {
setFlag(MpvProperty.pause, false)
}
func pause() {
setFlag(MpvProperty.pause, true)
}
func getDouble(_ name: String) -> Double {
guard mpv != nil else { return 0.0 }
var data = Double()
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
return data
}
func getString(_ name: String) -> String? {
guard mpv != nil else { return nil }
let cstr = mpv_get_property_string(mpv, name)
let str: String? = cstr == nil ? nil : String(cString: cstr!)
mpv_free(cstr)
return str
}
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
}
func readEvents() {
queue.async { [weak self] in
guard let self = self else { return }
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)
switch propertyName {
case MpvProperty.pausedForCache:
let buffering =
UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee ?? true
DispatchQueue.main.async {
self.playDelegate?.propertyChange(
mpv: self.mpv, propertyName: propertyName, data: buffering)
}
case MpvProperty.timePosition:
if let data = property.data,
let position = UnsafePointer<Double>(OpaquePointer(data))?.pointee
{
DispatchQueue.main.async {
self.playDelegate?.propertyChange(
mpv: self.mpv, propertyName: propertyName, data: position)
}
}
case MpvProperty.pause:
if let data = property.data,
let paused = UnsafePointer<Bool>(OpaquePointer(data))?.pointee
{
DispatchQueue.main.async {
self.playDelegate?.propertyChange(
mpv: self.mpv, propertyName: propertyName, data: paused)
}
}
case MpvProperty.duration:
if let data = property.data,
let duration = UnsafePointer<Double>(OpaquePointer(data))?.pointee
{
DispatchQueue.main.async {
self.playDelegate?.propertyChange(
mpv: self.mpv, propertyName: propertyName, data: duration)
}
}
default:
break
}
}
case MPV_EVENT_SHUTDOWN:
print("event: shutdown\n")
mpv_terminate_destroy(mpv)
mpv = nil
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")
}
}
deinit {
if mpv != nil {
mpv_terminate_destroy(mpv)
mpv = nil
}
}
}

View File

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