From b6198b21bddffa2b9738c7f9301d965334c9cd67 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Mon, 21 Apr 2025 04:38:03 +1000 Subject: [PATCH] MPV Player init --- .vscode/settings.json | 2 +- app/(auth)/player/direct-player.tsx | 20 +- bun.lock | 12 +- components/video-player/controls/Controls.tsx | 5 +- modules/MpvPlayer.types.ts | 98 ++++ modules/MpvPlayerView.tsx | 139 +++++ modules/mpv-player/expo-module.config.json | 6 + modules/mpv-player/ios/MpvPlayer.podspec | 27 + modules/mpv-player/ios/MpvPlayerModule.swift | 71 +++ modules/mpv-player/ios/MpvPlayerView.swift | 551 ++++++++++++++++++ modules/mpv-player/src/MpvPlayerModule.ts | 5 + 11 files changed, 916 insertions(+), 20 deletions(-) create mode 100644 modules/MpvPlayer.types.ts create mode 100644 modules/MpvPlayerView.tsx create mode 100644 modules/mpv-player/expo-module.config.json create mode 100644 modules/mpv-player/ios/MpvPlayer.podspec create mode 100644 modules/mpv-player/ios/MpvPlayerModule.swift create mode 100644 modules/mpv-player/ios/MpvPlayerView.swift create mode 100644 modules/mpv-player/src/MpvPlayerModule.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index b200b485..fffe63ac 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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", diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 7848a597..de658cf4 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -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(null); + const videoRef = useRef(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, }} > - ; + videoRef: MutableRefObject; isPlaying: boolean; isSeeking: SharedValue; cacheProgress: SharedValue; @@ -81,7 +82,7 @@ interface Props { isVideoLoaded?: boolean; mediaSource?: MediaSourceInfo | null; seek: (ticks: number) => void; - startPictureInPicture: () => Promise; + startPictureInPicture?: () => Promise; play: (() => Promise) | (() => void); pause: () => void; getAudioTracks?: (() => Promise) | (() => TrackInfo[]); diff --git a/modules/MpvPlayer.types.ts b/modules/MpvPlayer.types.ts new file mode 100644 index 00000000..726f7024 --- /dev/null +++ b/modules/MpvPlayer.types.ts @@ -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; + play: () => Promise; + pause: () => Promise; + stop: () => Promise; + seekTo: (time: number) => Promise; + setAudioTrack: (trackIndex: number) => Promise; + getAudioTracks: () => Promise; + setSubtitleTrack: (trackIndex: number) => Promise; + getSubtitleTracks: () => Promise; + setSubtitleDelay: (delay: number) => Promise; + setAudioDelay: (delay: number) => Promise; + takeSnapshot: (path: string, width: number, height: number) => Promise; + setRate: (rate: number) => Promise; + nextChapter: () => Promise; + previousChapter: () => Promise; + getChapters: () => Promise; + setVideoCropGeometry: (geometry: string | null) => Promise; + getVideoCropGeometry: () => Promise; + setSubtitleURL: (url: string, name: string) => Promise; +} diff --git a/modules/MpvPlayerView.tsx b/modules/MpvPlayerView.tsx new file mode 100644 index 00000000..bdd4fffc --- /dev/null +++ b/modules/MpvPlayerView.tsx @@ -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) => void; +} + +const MpvViewManager = requireNativeViewManager("MpvPlayer"); + +// Create a forwarded ref version of the native view +const NativeView = React.forwardRef( + (props, ref) => { + return ; + }, +); + +const MpvPlayerView = React.forwardRef( + (props, ref) => { + const nativeRef = React.useRef(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 ( + + ); + }, +); + +export default MpvPlayerView; diff --git a/modules/mpv-player/expo-module.config.json b/modules/mpv-player/expo-module.config.json new file mode 100644 index 00000000..f5bb99d2 --- /dev/null +++ b/modules/mpv-player/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios", "tvos"], + "ios": { + "modules": ["MpvPlayerModule"] + } +} diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec new file mode 100644 index 00000000..75411574 --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -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 \ No newline at end of file diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift new file mode 100644 index 00000000..a67912ce --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -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) + } + } + } +} diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift new file mode 100644 index 00000000..9683d38f --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -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(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(dataOpaquePtr)?.pointee { + let propertyName = String(cString: property.name) + + switch propertyName { + case MpvProperty.pausedForCache: + let buffering = + UnsafePointer(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(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(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(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( + 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 + } + } +} diff --git a/modules/mpv-player/src/MpvPlayerModule.ts b/modules/mpv-player/src/MpvPlayerModule.ts new file mode 100644 index 00000000..563d0256 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerModule.ts @@ -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");