From 36655bba439f1dbe9c38e448c1b7a0788b8d3236 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sat, 6 Dec 2025 02:02:26 +1100 Subject: [PATCH] init --- modules/mpv-player/android/build.gradle | 43 + .../android/src/main/AndroidManifest.xml | 2 + .../expo/modules/mpvplayer/MpvPlayerModule.kt | 50 + .../expo/modules/mpvplayer/MpvPlayerView.kt | 30 + modules/mpv-player/expo-module.config.json | 9 + modules/mpv-player/index.ts | 6 + modules/mpv-player/ios/Logger.swift | 161 +++ .../mpv-player/ios/MPVSoftwareRenderer.swift | 1019 +++++++++++++++++ modules/mpv-player/ios/MpvPlayer.podspec | 24 + modules/mpv-player/ios/MpvPlayerModule.swift | 158 +++ modules/mpv-player/ios/MpvPlayerView.swift | 299 +++++ modules/mpv-player/ios/PiPController.swift | 164 +++ modules/mpv-player/ios/PlayerPreset.swift | 47 + .../ios/SampleBufferDisplayView.swift | 70 ++ modules/mpv-player/src/MpvPlayer.types.ts | 78 ++ modules/mpv-player/src/MpvPlayerModule.ts | 11 + modules/mpv-player/src/MpvPlayerModule.web.ts | 19 + modules/mpv-player/src/MpvPlayerView.tsx | 91 ++ modules/mpv-player/src/MpvPlayerView.web.tsx | 14 + modules/mpv-player/src/index.ts | 3 + 20 files changed, 2298 insertions(+) create mode 100644 modules/mpv-player/android/build.gradle create mode 100644 modules/mpv-player/android/src/main/AndroidManifest.xml create mode 100644 modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt create mode 100644 modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt create mode 100644 modules/mpv-player/expo-module.config.json create mode 100644 modules/mpv-player/index.ts create mode 100644 modules/mpv-player/ios/Logger.swift create mode 100644 modules/mpv-player/ios/MPVSoftwareRenderer.swift 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/ios/PiPController.swift create mode 100644 modules/mpv-player/ios/PlayerPreset.swift create mode 100644 modules/mpv-player/ios/SampleBufferDisplayView.swift create mode 100644 modules/mpv-player/src/MpvPlayer.types.ts create mode 100644 modules/mpv-player/src/MpvPlayerModule.ts create mode 100644 modules/mpv-player/src/MpvPlayerModule.web.ts create mode 100644 modules/mpv-player/src/MpvPlayerView.tsx create mode 100644 modules/mpv-player/src/MpvPlayerView.web.tsx create mode 100644 modules/mpv-player/src/index.ts diff --git a/modules/mpv-player/android/build.gradle b/modules/mpv-player/android/build.gradle new file mode 100644 index 00000000..6096354d --- /dev/null +++ b/modules/mpv-player/android/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.mpvplayer' +version = '0.7.6' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 36) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 24) + targetSdkVersion safeExtGet("targetSdkVersion", 36) + } + } +} + +android { + namespace "expo.modules.mpvplayer" + defaultConfig { + versionCode 1 + versionName "0.7.6" + } + lintOptions { + abortOnError false + } +} diff --git a/modules/mpv-player/android/src/main/AndroidManifest.xml b/modules/mpv-player/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..bdae66c8 --- /dev/null +++ b/modules/mpv-player/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt new file mode 100644 index 00000000..7c8a4b00 --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -0,0 +1,50 @@ +package expo.modules.mpvplayer + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.net.URL + +class MpvPlayerModule : Module() { + // Each module class must implement the definition function. The definition consists of components + // that describes the module's functionality and behavior. + // See https://docs.expo.dev/modules/module-api for more details about available components. + override fun definition() = ModuleDefinition { + // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. + // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. + // The module will be accessible from `requireNativeModule('MpvPlayer')` in JavaScript. + Name("MpvPlayer") + + // Defines constant property on the module. + Constant("PI") { + Math.PI + } + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + "Hello world! 👋" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { value: String -> + // Send an event to JavaScript. + sendEvent("onChange", mapOf( + "value" to value + )) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of + // the view definition: Prop, Events. + View(MpvPlayerView::class) { + // Defines a setter for the `url` prop. + Prop("url") { view: MpvPlayerView, url: URL -> + view.webView.loadUrl(url.toString()) + } + // Defines an event that the view can send to JavaScript. + Events("onLoad") + } + } +} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt new file mode 100644 index 00000000..1c35cb5f --- /dev/null +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -0,0 +1,30 @@ +package expo.modules.mpvplayer + +import android.content.Context +import android.webkit.WebView +import android.webkit.WebViewClient +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + // Creates and initializes an event dispatcher for the `onLoad` event. + // The name of the event is inferred from the value and needs to match the event name defined in the module. + private val onLoad by EventDispatcher() + + // Defines a WebView that will be used as the root subview. + internal val webView = WebView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript. + onLoad(mapOf("url" to url)) + } + } + } + + init { + // Adds the WebView to the view hierarchy. + addView(webView) + } +} diff --git a/modules/mpv-player/expo-module.config.json b/modules/mpv-player/expo-module.config.json new file mode 100644 index 00000000..f5092bad --- /dev/null +++ b/modules/mpv-player/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android", "web"], + "apple": { + "modules": ["MpvPlayerModule"] + }, + "android": { + "modules": ["expo.modules.mpvplayer.MpvPlayerModule"] + } +} diff --git a/modules/mpv-player/index.ts b/modules/mpv-player/index.ts new file mode 100644 index 00000000..cab14e34 --- /dev/null +++ b/modules/mpv-player/index.ts @@ -0,0 +1,6 @@ +// Reexport the native module. On web, it will be resolved to MpvPlayerModule.web.ts +// and on native platforms to MpvPlayerModule.ts + +export * from "./src/MpvPlayer.types"; +export { default } from "./src/MpvPlayerModule"; +export { default as MpvPlayerView } from "./src/MpvPlayerView"; diff --git a/modules/mpv-player/ios/Logger.swift b/modules/mpv-player/ios/Logger.swift new file mode 100644 index 00000000..61857489 --- /dev/null +++ b/modules/mpv-player/ios/Logger.swift @@ -0,0 +1,161 @@ +// +// Logging.swift +// Sora +// +// Created by seiike on 16/01/2025. +// + +import Foundation + +class Logger { + static let shared = Logger() + + struct LogEntry { + let message: String + let type: String + let timestamp: Date + } + + private let queue = DispatchQueue(label: "mpvkit.logger", attributes: .concurrent) + private var logs: [LogEntry] = [] + private let logFileURL: URL + + private let maxFileSize = 1024 * 512 + private let maxLogEntries = 1000 + + private init() { + let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + logFileURL = tmpDir.appendingPathComponent("logs.txt") + } + + func log(_ message: String, type: String = "General") { + let entry = LogEntry(message: message, type: type, timestamp: Date()) + + queue.async(flags: .barrier) { + self.logs.append(entry) + + if self.logs.count > self.maxLogEntries { + self.logs.removeFirst(self.logs.count - self.maxLogEntries) + } + + self.saveLogToFile(entry) + self.debugLog(entry) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("LoggerNotification"), object: nil, + userInfo: [ + "message": message, + "type": type, + "timestamp": entry.timestamp + ] + ) + } + } + } + + func getLogs() -> String { + var result = "" + queue.sync { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" } + .joined(separator: "\n----\n") + } + return result + } + + func getLogsAsync() async -> String { + return await withCheckedContinuation { continuation in + queue.async { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + let result = self.logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" } + .joined(separator: "\n----\n") + continuation.resume(returning: result) + } + } + } + + func clearLogs() { + queue.async(flags: .barrier) { + self.logs.removeAll() + try? FileManager.default.removeItem(at: self.logFileURL) + } + } + + func clearLogsAsync() async { + await withCheckedContinuation { continuation in + queue.async(flags: .barrier) { + self.logs.removeAll() + try? FileManager.default.removeItem(at: self.logFileURL) + continuation.resume() + } + } + } + + private func saveLogToFile(_ log: LogEntry) { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + + let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n" + + guard let data = logString.data(using: .utf8) else { + print("Failed to encode log string to UTF-8") + return + } + + do { + if FileManager.default.fileExists(atPath: logFileURL.path) { + let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path) + let fileSize = attributes[.size] as? UInt64 ?? 0 + + if fileSize + UInt64(data.count) > maxFileSize { + self.truncateLogFile() + } + + if let handle = try? FileHandle(forWritingTo: logFileURL) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + } else { + try data.write(to: logFileURL) + } + } catch { + print("Error managing log file: \(error)") + try? data.write(to: logFileURL) + } + } + + private func truncateLogFile() { + do { + guard let content = try? String(contentsOf: logFileURL, encoding: .utf8), + !content.isEmpty else { + return + } + + let entries = content.components(separatedBy: "\n---\n") + guard entries.count > 10 else { return } + + let keepCount = entries.count / 2 + let truncatedEntries = Array(entries.suffix(keepCount)) + let truncatedContent = truncatedEntries.joined(separator: "\n---\n") + + if let truncatedData = truncatedContent.data(using: .utf8) { + try truncatedData.write(to: logFileURL) + } + } catch { + print("Error truncating log file: \(error)") + try? FileManager.default.removeItem(at: logFileURL) + } + } + + private func debugLog(_ entry: LogEntry) { +#if DEBUG + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)" + print(formattedMessage) +#endif + } +} diff --git a/modules/mpv-player/ios/MPVSoftwareRenderer.swift b/modules/mpv-player/ios/MPVSoftwareRenderer.swift new file mode 100644 index 00000000..ae839d1f --- /dev/null +++ b/modules/mpv-player/ios/MPVSoftwareRenderer.swift @@ -0,0 +1,1019 @@ +// +// MPVSoftwareRenderer.swift +// test +// +// Created by Francesco on 28/09/25. +// + +import Libmpv +import CoreMedia +import CoreVideo +import AVFoundation + +protocol MPVSoftwareRendererDelegate: AnyObject { + func renderer(_ renderer: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) + func renderer(_ renderer: MPVSoftwareRenderer, didChangePause isPaused: Bool) + func renderer(_ renderer: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) + func renderer(_ renderer: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) +} + +final class MPVSoftwareRenderer { + enum RendererError: Error { + case mpvCreationFailed + case mpvInitialization(Int32) + case renderContextCreation(Int32) + } + + private let displayLayer: AVSampleBufferDisplayLayer + private let renderQueue = DispatchQueue(label: "mpv.software.render", qos: .userInitiated) + private let eventQueue = DispatchQueue(label: "mpv.software.events", qos: .utility) + private let stateQueue = DispatchQueue(label: "mpv.software.state", attributes: .concurrent) + private let eventQueueGroup = DispatchGroup() + private let renderQueueKey = DispatchSpecificKey() + + private var dimensionsArray = [Int32](repeating: 0, count: 2) + private var renderParams = [mpv_render_param](repeating: mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), count: 5) + + private var mpv: OpaquePointer? + private var renderContext: OpaquePointer? + private var videoSize: CGSize = .zero + private var pixelBufferPool: CVPixelBufferPool? + private var pixelBufferPoolAuxAttributes: CFDictionary? + private var formatDescription: CMVideoFormatDescription? + private var didFlushForFormatChange = false + private var poolWidth: Int = 0 + private var poolHeight: Int = 0 + private var preAllocatedBuffers: [CVPixelBuffer] = [] + private let maxPreAllocatedBuffers = 6 + + private var currentPreset: PlayerPreset? + private var currentURL: URL? + private var currentHeaders: [String: String]? + + private var disposeBag: [() -> Void] = [] + + private var isRunning = false + private var isStopping = false + private var shouldClearPixelBuffer = false + private let bgraFormatCString: [CChar] = Array("bgra\0".utf8CString) + + weak var delegate: MPVSoftwareRendererDelegate? + private var cachedDuration: Double = 0 + private var cachedPosition: Double = 0 + private var isPaused: Bool = true + private var isLoading: Bool = false + private var isRenderScheduled = false + private var lastRenderTime: CFTimeInterval = 0 + private let minRenderInterval: CFTimeInterval = 1.0 / 120.0 + private var isReadyToSeek: Bool = false + + var isPausedState: Bool { + return isPaused + } + + init(displayLayer: AVSampleBufferDisplayLayer) { + self.displayLayer = displayLayer + renderQueue.setSpecific(key: renderQueueKey, value: ()) + } + + deinit { + stop() + } + + func start() throws { + guard !isRunning else { return } + guard let handle = mpv_create() else { + throw RendererError.mpvCreationFailed + } + mpv = handle + setOption(name: "terminal", value: "yes") + setOption(name: "msg-level", value: "status") + setOption(name: "keep-open", value: "yes") + setOption(name: "idle", value: "yes") + setOption(name: "vo", value: "libmpv") + setOption(name: "hwdec", value: "videotoolbox-copy") + setOption(name: "gpu-api", value: "metal") + setOption(name: "gpu-context", value: "metal") + setOption(name: "demuxer-thread", value: "yes") + setOption(name: "ytdl", value: "yes") + + // Subtitle rendering options + setOption(name: "subs-match-os-language", value: "yes") + setOption(name: "subs-fallback", value: "yes") + setOption(name: "sub-auto", value: "no") + + let initStatus = mpv_initialize(handle) + guard initStatus >= 0 else { + throw RendererError.mpvInitialization(initStatus) + } + + mpv_request_log_messages(handle, "warn") + + try createRenderContext() + observeProperties() + installWakeupHandler() + isRunning = true + } + + func stop() { + if isStopping { return } + if !isRunning, mpv == nil { return } + isRunning = false + isStopping = true + + var handleForShutdown: OpaquePointer? + + renderQueue.sync { [weak self] in + guard let self else { return } + + if let ctx = self.renderContext { + mpv_render_context_set_update_callback(ctx, nil, nil) + mpv_render_context_free(ctx) + self.renderContext = nil + } + + handleForShutdown = self.mpv + if let handle = handleForShutdown { + mpv_set_wakeup_callback(handle, nil, nil) + self.command(handle, ["quit"]) + mpv_wakeup(handle) + } + + self.formatDescription = nil + self.preAllocatedBuffers.removeAll() + self.pixelBufferPool = nil + self.poolWidth = 0 + self.poolHeight = 0 + } + + eventQueueGroup.wait() + + renderQueue.sync { [weak self] in + guard let self else { return } + + if let handle = handleForShutdown { + mpv_destroy(handle) + } + self.mpv = nil + + self.preAllocatedBuffers.removeAll() + self.pixelBufferPool = nil + self.pixelBufferPoolAuxAttributes = nil + self.formatDescription = nil + self.poolWidth = 0 + self.poolHeight = 0 + + self.disposeBag.forEach { $0() } + self.disposeBag.removeAll() + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.displayLayer.flushAndRemoveImage() + } + + isStopping = false + } + + func load(url: URL, with preset: PlayerPreset, headers: [String: String]? = nil) { + currentPreset = preset + currentURL = url + currentHeaders = headers + + renderQueue.async { [weak self] in + guard let self else { return } + self.isLoading = true + self.isReadyToSeek = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didChangeLoading: true) + } + } + + guard let handle = mpv else { return } + + renderQueue.async { [weak self] in + guard let self else { return } + self.apply(commands: preset.commands, on: handle) + self.command(handle, ["stop"]) + self.updateHTTPHeaders(headers) + + // Handle file URLs - use path, otherwise use absolute string + let target: String + if url.isFileURL { + // For file URLs, use the path (removes file:// prefix) + target = url.path + Logger.shared.log("Loading file: \(target)", type: "Info") + } else { + // For network URLs, use absolute string + target = url.absoluteString + Logger.shared.log("Loading URL: \(target)", type: "Info") + } + + self.command(handle, ["loadfile", target, "replace"]) + } + } + + func reloadCurrentItem() { + guard let url = currentURL, let preset = currentPreset else { return } + load(url: url, with: preset, headers: currentHeaders) + } + + func applyPreset(_ preset: PlayerPreset) { + currentPreset = preset + guard let handle = mpv else { return } + renderQueue.async { [weak self] in + guard let self else { return } + self.apply(commands: preset.commands, on: handle) + } + } + + private func setOption(name: String, value: String) { + guard let handle = mpv else { return } + _ = value.withCString { valuePointer in + name.withCString { namePointer in + mpv_set_option_string(handle, namePointer, valuePointer) + } + } + } + + private func setProperty(name: String, value: String) { + guard let handle = mpv else { return } + let status = value.withCString { valuePointer in + name.withCString { namePointer in + mpv_set_property_string(handle, namePointer, valuePointer) + } + } + if status < 0 { + Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn") + } + } + + private func clearProperty(name: String) { + guard let handle = mpv else { return } + let status = name.withCString { namePointer in + mpv_set_property(handle, namePointer, MPV_FORMAT_NONE, nil) + } + if status < 0 { + Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn") + } + } + + private func updateHTTPHeaders(_ headers: [String: String]?) { + guard let headers, !headers.isEmpty else { + clearProperty(name: "http-header-fields") + return + } + + let headerString = headers + .map { key, value in + "\(key): \(value)" + } + .joined(separator: "\r\n") + setProperty(name: "http-header-fields", value: headerString) + } + + private func createRenderContext() throws { + guard let handle = mpv else { return } + + var apiType = MPV_RENDER_API_TYPE_SW + let status = withUnsafePointer(to: &apiType) { apiTypePtr in + var params = [ + mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: UnsafeMutableRawPointer(mutating: apiTypePtr)), + mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil) + ] + + return params.withUnsafeMutableBufferPointer { pointer -> Int32 in + pointer.baseAddress?.withMemoryRebound(to: mpv_render_param.self, capacity: pointer.count) { parameters in + return mpv_render_context_create(&renderContext, handle, parameters) + } ?? -1 + } + } + + guard status >= 0, renderContext != nil else { + throw RendererError.renderContextCreation(status) + } + + mpv_render_context_set_update_callback(renderContext, { context in + guard let context = context else { return } + let instance = Unmanaged.fromOpaque(context).takeUnretainedValue() + instance.scheduleRender() + }, Unmanaged.passUnretained(self).toOpaque()) + } + + private func observeProperties() { + guard let handle = mpv else { return } + let properties: [(String, mpv_format)] = [ + ("dwidth", MPV_FORMAT_INT64), + ("dheight", MPV_FORMAT_INT64), + ("duration", MPV_FORMAT_DOUBLE), + ("time-pos", MPV_FORMAT_DOUBLE), + ("pause", MPV_FORMAT_FLAG) + ] + + for (name, format) in properties { + _ = name.withCString { pointer in + mpv_observe_property(handle, 0, pointer, format) + } + } + } + + private func installWakeupHandler() { + guard let handle = mpv else { return } + mpv_set_wakeup_callback(handle, { userdata in + guard let userdata else { return } + let instance = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + instance.processEvents() + }, Unmanaged.passUnretained(self).toOpaque()) + renderQueue.async { [weak self] in + guard let self else { return } + self.disposeBag.append { [weak self] in + guard let self, let handle = self.mpv else { return } + mpv_set_wakeup_callback(handle, nil, nil) + } + } + } + + private func scheduleRender() { + renderQueue.async { [weak self] in + guard let self, self.isRunning, !self.isStopping else { return } + + let currentTime = CACurrentMediaTime() + if self.isRenderScheduled && (currentTime - self.lastRenderTime) < self.minRenderInterval { + return + } + + self.isRenderScheduled = true + self.lastRenderTime = currentTime + self.performRenderUpdate() + self.isRenderScheduled = false + } + } + + private func performRenderUpdate() { + guard let context = renderContext else { return } + let status = mpv_render_context_update(context) + + let updateFlags = UInt32(status) + + if updateFlags & MPV_RENDER_UPDATE_FRAME.rawValue != 0 { + renderFrame() + } + + if status > 0 { + scheduleRender() + } + } + + private func renderFrame() { + guard let context = renderContext else { return } + let size = currentVideoSize() + guard size.width > 0, size.height > 0 else { return } + + let width = Int(size.width) + let height = Int(size.height) + + if poolWidth != width || poolHeight != height { + recreatePixelBufferPool(width: width, height: height) + } + + var pixelBuffer: CVPixelBuffer? + var status: CVReturn = kCVReturnError + + if !preAllocatedBuffers.isEmpty { + pixelBuffer = preAllocatedBuffers.removeFirst() + status = kCVReturnSuccess + } else if let pool = pixelBufferPool { + status = CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(kCFAllocatorDefault, pool, pixelBufferPoolAuxAttributes, &pixelBuffer) + } + + if status != kCVReturnSuccess || pixelBuffer == nil { + let attrs: [CFString: Any] = [ + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, + kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!, + kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue!, + kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue!, + kCVPixelBufferWidthKey: width, + kCVPixelBufferHeightKey: height, + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA + ] + status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer) + } + + guard status == kCVReturnSuccess, let buffer = pixelBuffer else { + Logger.shared.log("Failed to create pixel buffer for rendering (status: \(status))", type: "Error") + return + } + + let actualFormat = CVPixelBufferGetPixelFormatType(buffer) + if actualFormat != kCVPixelFormatType_32BGRA { + Logger.shared.log("Pixel buffer format mismatch: expected BGRA (0x42475241), got \(actualFormat)", type: "Error") + } + + CVPixelBufferLockBaseAddress(buffer, []) + guard let baseAddress = CVPixelBufferGetBaseAddress(buffer) else { + CVPixelBufferUnlockBaseAddress(buffer, []) + return + } + + if shouldClearPixelBuffer { + let bufferDataSize = CVPixelBufferGetDataSize(buffer) + memset(baseAddress, 0, bufferDataSize) + shouldClearPixelBuffer = false + } + + dimensionsArray[0] = Int32(width) + dimensionsArray[1] = Int32(height) + let stride = Int32(CVPixelBufferGetBytesPerRow(buffer)) + let expectedMinStride = Int32(width * 4) + if stride < expectedMinStride { + Logger.shared.log("Unexpected pixel buffer stride \(stride) < expected \(expectedMinStride) — skipping render to avoid memory corruption", type: "Error") + CVPixelBufferUnlockBaseAddress(buffer, []) + return + } + + let pointerValue = baseAddress + dimensionsArray.withUnsafeMutableBufferPointer { dimsPointer in + bgraFormatCString.withUnsafeBufferPointer { formatPointer in + withUnsafePointer(to: stride) { stridePointer in + renderParams[0] = mpv_render_param(type: MPV_RENDER_PARAM_SW_SIZE, data: UnsafeMutableRawPointer(dimsPointer.baseAddress)) + renderParams[1] = mpv_render_param(type: MPV_RENDER_PARAM_SW_FORMAT, data: UnsafeMutableRawPointer(mutating: formatPointer.baseAddress)) + renderParams[2] = mpv_render_param(type: MPV_RENDER_PARAM_SW_STRIDE, data: UnsafeMutableRawPointer(mutating: stridePointer)) + renderParams[3] = mpv_render_param(type: MPV_RENDER_PARAM_SW_POINTER, data: pointerValue) + renderParams[4] = mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil) + + let rc = mpv_render_context_render(context, &renderParams) + if rc < 0 { + Logger.shared.log("mpv_render_context_render returned error \(rc)", type: "Error") + } + } + } + } + + CVPixelBufferUnlockBaseAddress(buffer, []) + enqueue(buffer: buffer) + + if preAllocatedBuffers.count < 2 { + renderQueue.async { [weak self] in + self?.preAllocateBuffers() + } + } + } + + private func createPixelBufferPool(width: Int, height: Int) { + let pixelFormat = kCVPixelFormatType_32BGRA + + let attrs: [CFString: Any] = [ + kCVPixelBufferPixelFormatTypeKey: pixelFormat, + kCVPixelBufferWidthKey: width, + kCVPixelBufferHeightKey: height, + kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, + kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue!, + kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!, + kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue! + ] + + let poolAttrs: [CFString: Any] = [ + kCVPixelBufferPoolMinimumBufferCountKey: maxPreAllocatedBuffers, + kCVPixelBufferPoolMaximumBufferAgeKey: 0 + ] + + let auxAttrs: [CFString: Any] = [ + kCVPixelBufferPoolAllocationThresholdKey: 4 + ] + + var pool: CVPixelBufferPool? + let status = CVPixelBufferPoolCreate(kCFAllocatorDefault, poolAttrs as CFDictionary, attrs as CFDictionary, &pool) + if status == kCVReturnSuccess, let pool { + renderQueueSync { + self.pixelBufferPool = pool + self.pixelBufferPoolAuxAttributes = auxAttrs as CFDictionary + self.poolWidth = width + self.poolHeight = height + } + + renderQueue.async { [weak self] in + self?.preAllocateBuffers() + } + } else { + Logger.shared.log("Failed to create CVPixelBufferPool (status: \(status))", type: "Error") + } + } + + private func recreatePixelBufferPool(width: Int, height: Int) { + renderQueueSync { + self.preAllocatedBuffers.removeAll() + self.pixelBufferPool = nil + self.formatDescription = nil + self.poolWidth = 0 + self.poolHeight = 0 + } + + createPixelBufferPool(width: width, height: height) + } + + private func preAllocateBuffers() { + guard DispatchQueue.getSpecific(key: renderQueueKey) != nil else { + renderQueue.async { [weak self] in + self?.preAllocateBuffers() + } + return + } + + guard let pool = pixelBufferPool else { return } + + let targetCount = min(maxPreAllocatedBuffers, 4) + let currentCount = preAllocatedBuffers.count + + guard currentCount < targetCount else { return } + + let bufferCount = targetCount - currentCount + + for _ in 0.. Bool { + var didChange = false + let width = Int32(CVPixelBufferGetWidth(buffer)) + let height = Int32(CVPixelBufferGetHeight(buffer)) + let pixelFormat = CVPixelBufferGetPixelFormatType(buffer) + + renderQueueSync { + var needsRecreate = false + + if let description = formatDescription { + let currentDimensions = CMVideoFormatDescriptionGetDimensions(description) + let currentPixelFormat = CMFormatDescriptionGetMediaSubType(description) + + if currentDimensions.width != width || + currentDimensions.height != height || + currentPixelFormat != pixelFormat { + needsRecreate = true + } + } else { + needsRecreate = true + } + + if needsRecreate { + var newDescription: CMVideoFormatDescription? + + let status = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: buffer, + formatDescriptionOut: &newDescription + ) + + if status == noErr, let newDescription = newDescription { + formatDescription = newDescription + didChange = true + Logger.shared.log("Created new format description: \(width)x\(height), format: \(pixelFormat)", type: "Info") + } else { + Logger.shared.log("Failed to create format description (status: \(status))", type: "Error") + } + } + } + return didChange + } + + private func renderQueueSync(_ block: () -> Void) { + if DispatchQueue.getSpecific(key: renderQueueKey) != nil { + block() + } else { + renderQueue.sync(execute: block) + } + } + + private func currentVideoSize() -> CGSize { + stateQueue.sync { + videoSize + } + } + + private func updateVideoSize(width: Int, height: Int) { + let size = CGSize(width: max(width, 0), height: max(height, 0)) + stateQueue.async(flags: .barrier) { + self.videoSize = size + } + renderQueue.async { [weak self] in + guard let self else { return } + + if self.poolWidth != width || self.poolHeight != height { + self.recreatePixelBufferPool(width: max(width, 0), height: max(height, 0)) + } + } + } + + private func apply(commands: [[String]], on handle: OpaquePointer) { + for command in commands { + guard !command.isEmpty else { continue } + self.command(handle, command) + } + } + + private func command(_ handle: OpaquePointer, _ args: [String]) { + guard !args.isEmpty else { return } + _ = withCStringArray(args) { pointer in + mpv_command_async(handle, 0, pointer) + } + } + + private func processEvents() { + eventQueueGroup.enter() + let group = eventQueueGroup + eventQueue.async { [weak self] in + defer { group.leave() } + guard let self else { return } + while !self.isStopping { + guard let handle = self.mpv else { return } + guard let eventPointer = mpv_wait_event(handle, 0) else { return } + let event = eventPointer.pointee + if event.event_id == MPV_EVENT_NONE { continue } + self.handleEvent(event) + if event.event_id == MPV_EVENT_SHUTDOWN { break } + } + } + } + + private func handleEvent(_ event: mpv_event) { + switch event.event_id { + case MPV_EVENT_VIDEO_RECONFIG: + refreshVideoState() + case MPV_EVENT_FILE_LOADED: + if !isReadyToSeek { + isReadyToSeek = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.renderer(self, didBecomeReadyToSeek: true) + } + } + case MPV_EVENT_PROPERTY_CHANGE: + if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name { + let name = String(cString: property) + refreshProperty(named: name) + } + case MPV_EVENT_SHUTDOWN: + Logger.shared.log("mpv shutdown", type: "Warn") + case MPV_EVENT_LOG_MESSAGE: + if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { + let component = String(cString: logMessagePointer.pointee.prefix) + let text = String(cString: logMessagePointer.pointee.text) + let lower = text.lowercased() + if lower.contains("error") { + Logger.shared.log("mpv[\(component)] \(text)", type: "Error") + } else if lower.contains("warn") || lower.contains("warning") || lower.contains("deprecated") { + Logger.shared.log("mpv[\(component)] \(text)", type: "Warn") + } + } + default: + break + } + } + + private func refreshVideoState() { + guard let handle = mpv else { return } + var width: Int64 = 0 + var height: Int64 = 0 + getProperty(handle: handle, name: "dwidth", format: MPV_FORMAT_INT64, value: &width) + getProperty(handle: handle, name: "dheight", format: MPV_FORMAT_INT64, value: &height) + updateVideoSize(width: Int(width), height: Int(height)) + } + + private func refreshProperty(named name: String) { + guard let handle = mpv else { return } + switch name { + case "duration": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedDuration = value + delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration) + } + case "time-pos": + var value = Double(0) + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) + if status >= 0 { + cachedPosition = value + delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration) + } + case "pause": + var flag: Int32 = 0 + let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) + if status >= 0 { + let newPaused = flag != 0 + if newPaused != isPaused { + isPaused = newPaused + delegate?.renderer(self, didChangePause: isPaused) + } + } + default: + break + } + } + + private func getStringProperty(handle: OpaquePointer, name: String) -> String? { + var result: String? + name.withCString { pointer in + if let cString = mpv_get_property_string(handle, pointer) { + result = String(cString: cString) + mpv_free(cString) + } + } + return result + } + + @discardableResult + private func getProperty(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 { + return name.withCString { pointer in + return withUnsafeMutablePointer(to: &value) { mutablePointer in + return mpv_get_property(handle, pointer, format, mutablePointer) + } + } + } + + @inline(__always) + private func withCStringArray(_ args: [String], body: (UnsafeMutablePointer?>?) -> R) -> R { + var cStrings = [UnsafeMutablePointer?]() + cStrings.reserveCapacity(args.count + 1) + for s in args { + cStrings.append(strdup(s)) + } + cStrings.append(nil) + defer { + for ptr in cStrings where ptr != nil { + free(ptr) + } + } + + return cStrings.withUnsafeMutableBufferPointer { buffer in + return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer?.self, capacity: buffer.count) { rebound in + return body(UnsafeMutablePointer(mutating: rebound)) + } + } + } + + // MARK: - Playback Controls + func play() { + setProperty(name: "pause", value: "no") + } + + func pausePlayback() { + setProperty(name: "pause", value: "yes") + } + + func togglePause() { + if isPaused { play() } else { pausePlayback() } + } + + func seek(to seconds: Double) { + guard let handle = mpv else { return } + let clamped = max(0, seconds) + command(handle, ["seek", String(clamped), "absolute"]) + } + + func seek(by seconds: Double) { + guard let handle = mpv else { return } + command(handle, ["seek", String(seconds), "relative"]) + } + + func setSpeed(_ speed: Double) { + setProperty(name: "speed", value: String(speed)) + } + + func getSpeed() -> Double { + guard let handle = mpv else { return 1.0 } + var speed: Double = 1.0 + getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed) + return speed + } + + // MARK: - Subtitle Controls + func getSubtitleTracks() -> [[String: Any]] { + guard let handle = mpv else { return [] } + + var node = mpv_node() + let status = "track-list".withCString { pointer in + mpv_get_property(handle, pointer, MPV_FORMAT_NODE, &node) + } + + guard status >= 0 else { return [] } + defer { mpv_free_node_contents(&node) } + + var tracks: [[String: Any]] = [] + + if node.format == MPV_FORMAT_NODE_ARRAY { + let array = node.u.list.pointee + for i in 0.. Int { + guard let handle = mpv else { return 0 } + var trackId: Int64 = 0 + let status = getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &trackId) + print("MPV: Current subtitle track is \(trackId), status: \(status)") + return status >= 0 ? Int(trackId) : 0 + } + + func addSubtitleFile(url: String) { + guard let handle = mpv else { return } + command(handle, ["sub-add", url]) + } + + // MARK: - Subtitle Positioning + + /// Set subtitle vertical position (0-100, where 100 is bottom) + func setSubtitlePosition(_ position: Int) { + let clampedPosition = max(0, min(100, position)) + setProperty(name: "sub-pos", value: String(clampedPosition)) + } + + /// Set subtitle scale (1.0 is normal size) + func setSubtitleScale(_ scale: Double) { + let clampedScale = max(0.1, min(10.0, scale)) + setProperty(name: "sub-scale", value: String(clampedScale)) + } + + /// Set subtitle vertical margin in pixels + func setSubtitleMarginY(_ margin: Int) { + setProperty(name: "sub-margin-y", value: String(margin)) + } + + /// Set subtitle horizontal alignment: "left", "center", "right" + func setSubtitleAlignX(_ alignment: String) { + setProperty(name: "sub-align-x", value: alignment) + } + + /// Set subtitle vertical alignment: "top", "center", "bottom" + func setSubtitleAlignY(_ alignment: String) { + setProperty(name: "sub-align-y", value: alignment) + } + + /// Set subtitle font size + func setSubtitleFontSize(_ size: Int) { + let clampedSize = max(10, min(200, size)) + setProperty(name: "sub-font-size", value: String(clampedSize)) + } +} diff --git a/modules/mpv-player/ios/MpvPlayer.podspec b/modules/mpv-player/ios/MpvPlayer.podspec new file mode 100644 index 00000000..aee9c799 --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayer.podspec @@ -0,0 +1,24 @@ +Pod::Spec.new do |s| + s.name = 'MpvPlayer' + s.version = '1.0.0' + s.summary = 'MPVKit for Expo' + s.description = 'MPVKit for Expo' + s.author = 'mpvkit' + s.homepage = 'https://github.com/mpvkit/MPVKit' + s.platforms = { + :ios => '15.1', + :tvos => '15.1' + } + s.source = { git: 'https://github.com/mpvkit/MPVKit.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.dependency 'MPVKit', '~> 0.40.0' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift new file mode 100644 index 00000000..96e074bf --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -0,0 +1,158 @@ +import ExpoModulesCore + +public class MpvPlayerModule: Module { + public func definition() -> ModuleDefinition { + Name("MpvPlayer") + + // Defines event names that the module can send to JavaScript. + Events("onChange") + + // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. + Function("hello") { + return "Hello from MPV Player! 👋" + } + + // Defines a JavaScript function that always returns a Promise and whose native code + // is by default dispatched on the different thread than the JavaScript runtime runs on. + AsyncFunction("setValueAsync") { (value: String) in + // Send an event to JavaScript. + self.sendEvent("onChange", [ + "value": value + ]) + } + + // Enables the module to be used as a native view. Definition components that are accepted as part of the + // view definition: Prop, Events. + View(MpvPlayerView.self) { + // Defines a setter for the `url` prop. + Prop("url") { (view: MpvPlayerView, url: String) in + if let videoURL = URL(string: url) { + view.loadVideo(url: videoURL, headers: nil) + } + } + + // Defines a setter for headers + Prop("headers") { (view: MpvPlayerView, headers: [String: String]?) in + // Headers will be used when loading the video + } + + // Defines a setter for autoplay + Prop("autoplay") { (view: MpvPlayerView, autoplay: Bool) in + if autoplay { + view.play() + } + } + + // Async function to play video + AsyncFunction("play") { (view: MpvPlayerView) in + view.play() + } + + // Async function to pause video + AsyncFunction("pause") { (view: MpvPlayerView) in + view.pause() + } + + // Async function to seek to position + AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in + view.seekTo(position: position) + } + + // Async function to seek by offset + AsyncFunction("seekBy") { (view: MpvPlayerView, offset: Double) in + view.seekBy(offset: offset) + } + + // Async function to set playback speed + AsyncFunction("setSpeed") { (view: MpvPlayerView, speed: Double) in + view.setSpeed(speed: speed) + } + + // Function to get current speed + AsyncFunction("getSpeed") { (view: MpvPlayerView) -> Double in + return view.getSpeed() + } + + // Function to check if paused + AsyncFunction("isPaused") { (view: MpvPlayerView) -> Bool in + return view.isPaused() + } + + // Function to get current position + AsyncFunction("getCurrentPosition") { (view: MpvPlayerView) -> Double in + return view.getCurrentPosition() + } + + // Function to get duration + AsyncFunction("getDuration") { (view: MpvPlayerView) -> Double in + return view.getDuration() + } + + // Picture in Picture functions + AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in + view.startPictureInPicture() + } + + AsyncFunction("stopPictureInPicture") { (view: MpvPlayerView) in + view.stopPictureInPicture() + } + + AsyncFunction("isPictureInPictureSupported") { (view: MpvPlayerView) -> Bool in + return view.isPictureInPictureSupported() + } + + AsyncFunction("isPictureInPictureActive") { (view: MpvPlayerView) -> Bool in + return view.isPictureInPictureActive() + } + + // Subtitle functions + AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]] in + return view.getSubtitleTracks() + } + + AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackId: Int) in + view.setSubtitleTrack(trackId) + } + + AsyncFunction("disableSubtitles") { (view: MpvPlayerView) in + view.disableSubtitles() + } + + AsyncFunction("getCurrentSubtitleTrack") { (view: MpvPlayerView) -> Int in + return view.getCurrentSubtitleTrack() + } + + AsyncFunction("addSubtitleFile") { (view: MpvPlayerView, url: String) in + view.addSubtitleFile(url: url) + } + + // Subtitle positioning functions + AsyncFunction("setSubtitlePosition") { (view: MpvPlayerView, position: Int) in + view.setSubtitlePosition(position) + } + + AsyncFunction("setSubtitleScale") { (view: MpvPlayerView, scale: Double) in + view.setSubtitleScale(scale) + } + + AsyncFunction("setSubtitleMarginY") { (view: MpvPlayerView, margin: Int) in + view.setSubtitleMarginY(margin) + } + + AsyncFunction("setSubtitleAlignX") { (view: MpvPlayerView, alignment: String) in + view.setSubtitleAlignX(alignment) + } + + AsyncFunction("setSubtitleAlignY") { (view: MpvPlayerView, alignment: String) in + view.setSubtitleAlignY(alignment) + } + + AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in + view.setSubtitleFontSize(size) + } + + // Defines events that the view can send to JavaScript + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError") + } + } +} diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift new file mode 100644 index 00000000..cdf22d13 --- /dev/null +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -0,0 +1,299 @@ +import AVFoundation +import CoreMedia +import ExpoModulesCore +import UIKit + +// This view will be used as a native component. Make sure to inherit from `ExpoView` +// to apply the proper styling (e.g. border radius and shadows). +class MpvPlayerView: ExpoView { + private let displayLayer = AVSampleBufferDisplayLayer() + private var renderer: MPVSoftwareRenderer? + private var videoContainer: UIView! + private var pipController: PiPController? + + let onLoad = EventDispatcher() + let onPlaybackStateChange = EventDispatcher() + let onProgress = EventDispatcher() + let onError = EventDispatcher() + + private var currentURL: URL? + private var cachedPosition: Double = 0 + private var cachedDuration: Double = 0 + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupView() + } + + private func setupView() { + clipsToBounds = true + backgroundColor = .black + + videoContainer = UIView() + videoContainer.translatesAutoresizingMaskIntoConstraints = false + videoContainer.backgroundColor = .black + videoContainer.clipsToBounds = true + addSubview(videoContainer) + + displayLayer.frame = bounds + displayLayer.videoGravity = .resizeAspect + if #available(iOS 17.0, *) { + displayLayer.wantsExtendedDynamicRangeContent = true + } + displayLayer.backgroundColor = UIColor.black.cgColor + videoContainer.layer.addSublayer(displayLayer) + + NSLayoutConstraint.activate([ + videoContainer.topAnchor.constraint(equalTo: topAnchor), + videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + renderer = MPVSoftwareRenderer(displayLayer: displayLayer) + renderer?.delegate = self + + // Setup PiP + pipController = PiPController(sampleBufferDisplayLayer: displayLayer) + pipController?.delegate = self + + do { + try renderer?.start() + } catch { + onError(["error": "Failed to start renderer: \(error.localizedDescription)"]) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(true) + displayLayer.frame = videoContainer.bounds + displayLayer.isHidden = false + displayLayer.opacity = 1.0 + CATransaction.commit() + } + + func loadVideo(url: URL, headers: [String: String]?) { + currentURL = url + + // Create a simple preset with default commands + let preset = PlayerPreset( + id: .sdrRec709, + title: "Default", + summary: "Default playback preset", + stream: nil, + commands: [] + ) + + renderer?.load(url: url, with: preset, headers: headers) + onLoad(["url": url.absoluteString]) + } + + func play() { + renderer?.play() + } + + func pause() { + renderer?.pausePlayback() + } + + func seekTo(position: Double) { + renderer?.seek(to: position) + } + + func seekBy(offset: Double) { + renderer?.seek(by: offset) + } + + func setSpeed(speed: Double) { + renderer?.setSpeed(speed) + } + + func getSpeed() -> Double { + return renderer?.getSpeed() ?? 1.0 + } + + func isPaused() -> Bool { + return renderer?.isPausedState ?? true + } + + func getCurrentPosition() -> Double { + return cachedPosition + } + + func getDuration() -> Double { + return cachedDuration + } + + // MARK: - Picture in Picture + + func startPictureInPicture() { + print("🎬 MpvPlayerView: startPictureInPicture called") + print("🎬 Duration: \(getDuration()), IsPlaying: \(!isPaused())") + pipController?.startPictureInPicture() + } + + func stopPictureInPicture() { + pipController?.stopPictureInPicture() + } + + func isPictureInPictureSupported() -> Bool { + return pipController?.isPictureInPictureSupported ?? false + } + + func isPictureInPictureActive() -> Bool { + return pipController?.isPictureInPictureActive ?? false + } + + // MARK: - Subtitle Controls + + func getSubtitleTracks() -> [[String: Any]] { + return renderer?.getSubtitleTracks() ?? [] + } + + func setSubtitleTrack(_ trackId: Int) { + renderer?.setSubtitleTrack(trackId) + } + + func disableSubtitles() { + renderer?.disableSubtitles() + } + + func getCurrentSubtitleTrack() -> Int { + return renderer?.getCurrentSubtitleTrack() ?? 0 + } + + func addSubtitleFile(url: String) { + renderer?.addSubtitleFile(url: url) + } + + // MARK: - Subtitle Positioning + + func setSubtitlePosition(_ position: Int) { + renderer?.setSubtitlePosition(position) + } + + func setSubtitleScale(_ scale: Double) { + renderer?.setSubtitleScale(scale) + } + + func setSubtitleMarginY(_ margin: Int) { + renderer?.setSubtitleMarginY(margin) + } + + func setSubtitleAlignX(_ alignment: String) { + renderer?.setSubtitleAlignX(alignment) + } + + func setSubtitleAlignY(_ alignment: String) { + renderer?.setSubtitleAlignY(alignment) + } + + func setSubtitleFontSize(_ size: Int) { + renderer?.setSubtitleFontSize(size) + } + + deinit { + pipController?.stopPictureInPicture() + renderer?.stop() + displayLayer.removeFromSuperlayer() + } +} + +// MARK: - MPVSoftwareRendererDelegate + +extension MpvPlayerView: MPVSoftwareRendererDelegate { + func renderer(_: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) { + cachedPosition = position + cachedDuration = duration + + // Only update PiP state when PiP is active (like the working code does) + if pipController?.isPictureInPictureActive == true { + pipController?.updatePlaybackState() + } + + onProgress([ + "position": position, + "duration": duration, + "progress": duration > 0 ? position / duration : 0, + ]) + } + + func renderer(_: MPVSoftwareRenderer, didChangePause isPaused: Bool) { + onPlaybackStateChange([ + "isPaused": isPaused, + "isPlaying": !isPaused, + ]) + // Update PiP state when playback changes (direct call, like working code) + pipController?.updatePlaybackState() + } + + func renderer(_: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) { + onPlaybackStateChange([ + "isLoading": isLoading, + ]) + } + + func renderer(_: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) { + onPlaybackStateChange([ + "isReadyToSeek": didBecomeReadyToSeek, + ]) + } +} + +// MARK: - PiPControllerDelegate + +extension MpvPlayerView: PiPControllerDelegate { + func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) { + print("PiP will start") + DispatchQueue.main.async { [weak self] in + self?.pipController?.updatePlaybackState() + } + } + + func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) { + print("PiP did start: \(didStartPictureInPicture)") + DispatchQueue.main.async { [weak self] in + self?.pipController?.updatePlaybackState() + } + } + + func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { + print("PiP will stop") + } + + func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) { + print("PiP did stop") + } + + func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { + print("PiP restore user interface") + completionHandler(true) + } + + func pipControllerPlay(_ controller: PiPController) { + print("PiP play requested") + play() + } + + func pipControllerPause(_ controller: PiPController) { + print("PiP pause requested") + pause() + } + + func pipController(_ controller: PiPController, skipByInterval interval: CMTime) { + let seconds = CMTimeGetSeconds(interval) + print("PiP skip by interval: \(seconds)") + let target = max(0, cachedPosition + seconds) + seekTo(position: target) + } + + func pipControllerIsPlaying(_ controller: PiPController) -> Bool { + return !isPaused() + } + + func pipControllerDuration(_ controller: PiPController) -> Double { + return getDuration() + } +} diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift new file mode 100644 index 00000000..aff2cc3a --- /dev/null +++ b/modules/mpv-player/ios/PiPController.swift @@ -0,0 +1,164 @@ +// +// PiPController.swift +// test +// +// Created by Francesco on 30/09/25. +// + +import AVKit +import AVFoundation + +protocol PiPControllerDelegate: AnyObject { + func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) + func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) + func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) + func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) + func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) + func pipControllerPlay(_ controller: PiPController) + func pipControllerPause(_ controller: PiPController) + func pipController(_ controller: PiPController, skipByInterval interval: CMTime) + func pipControllerIsPlaying(_ controller: PiPController) -> Bool + func pipControllerDuration(_ controller: PiPController) -> Double +} + +final class PiPController: NSObject { + private var pipController: AVPictureInPictureController? + private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer? + + weak var delegate: PiPControllerDelegate? + + var isPictureInPictureSupported: Bool { + return AVPictureInPictureController.isPictureInPictureSupported() + } + + var isPictureInPictureActive: Bool { + return pipController?.isPictureInPictureActive ?? false + } + + var isPictureInPicturePossible: Bool { + return pipController?.isPictureInPicturePossible ?? false + } + + init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { + self.sampleBufferDisplayLayer = sampleBufferDisplayLayer + super.init() + setupPictureInPicture() + } + + private func setupPictureInPicture() { + guard isPictureInPictureSupported, let displayLayer = sampleBufferDisplayLayer else { + return + } + + let contentSource = AVPictureInPictureController.ContentSource( + sampleBufferDisplayLayer: displayLayer, + playbackDelegate: self + ) + + pipController = AVPictureInPictureController(contentSource: contentSource) + pipController?.delegate = self + pipController?.requiresLinearPlayback = false + pipController?.canStartPictureInPictureAutomaticallyFromInline = true + } + + func startPictureInPicture() { + guard let pipController = pipController, + pipController.isPictureInPicturePossible else { + return + } + + pipController.startPictureInPicture() + } + + func stopPictureInPicture() { + pipController?.stopPictureInPicture() + } + + func invalidate() { + pipController?.invalidatePlaybackState() + } + + func updatePlaybackState() { + pipController?.invalidatePlaybackState() + } +} + +// MARK: - AVPictureInPictureControllerDelegate + +extension PiPController: AVPictureInPictureControllerDelegate { + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, willStartPictureInPicture: true) + } + + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, didStartPictureInPicture: true) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + print("Failed to start PiP: \(error)") + delegate?.pipController(self, didStartPictureInPicture: false) + } + + func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, willStopPictureInPicture: true) + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + delegate?.pipController(self, didStopPictureInPicture: true) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { + delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler) + } +} + +// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate + +extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate { + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { + if playing { + delegate?.pipControllerPlay(self) + } else { + delegate?.pipControllerPause(self) + } + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { + delegate?.pipController(self, skipByInterval: skipInterval) + completionHandler() + } + + var isPlaying: Bool { + return delegate?.pipControllerIsPlaying(self) ?? false + } + + var timeRangeForPlayback: CMTimeRange { + let duration = delegate?.pipControllerDuration(self) ?? 0 + if duration > 0 { + let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000) + return CMTimeRange(start: .zero, duration: cmDuration) + } + return CMTimeRange(start: .zero, duration: .positiveInfinity) + } + + func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { + return timeRangeForPlayback + } + + func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return !isPlaying + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) { + if playing { + delegate?.pipControllerPlay(self) + } else { + delegate?.pipControllerPause(self) + } + completion() + } +} diff --git a/modules/mpv-player/ios/PlayerPreset.swift b/modules/mpv-player/ios/PlayerPreset.swift new file mode 100644 index 00000000..b21ced0a --- /dev/null +++ b/modules/mpv-player/ios/PlayerPreset.swift @@ -0,0 +1,47 @@ +// +// PlayerPreset.swift +// test +// +// Created by Francesco on 28/09/25. +// + +import Foundation + +struct PlayerPreset: Identifiable, Hashable { + enum Identifier: String, CaseIterable { + case sdrRec709 + case hdr10 + case dolbyVisionP5 + case dolbyVisionP8 + } + + struct Stream: Hashable { + enum Source: Hashable { + case remote(URL) + case bundled(resource: String, withExtension: String) + } + + let source: Source + let note: String + + func resolveURL() -> URL? { + switch source { + case .remote(let url): + return url + case .bundled(let resource, let ext): + return Bundle.main.url(forResource: resource, withExtension: ext) + } + } + } + + let id: Identifier + let title: String + let summary: String + let stream: Stream? + let commands: [[String]] + + static var presets: [PlayerPreset] { + var list: [PlayerPreset] = [] + return list + } +} diff --git a/modules/mpv-player/ios/SampleBufferDisplayView.swift b/modules/mpv-player/ios/SampleBufferDisplayView.swift new file mode 100644 index 00000000..94a17c4c --- /dev/null +++ b/modules/mpv-player/ios/SampleBufferDisplayView.swift @@ -0,0 +1,70 @@ +// +// SampleBufferDisplayView.swift +// test +// +// Created by Francesco on 28/09/25. +// + +import UIKit +import AVFoundation + +final class SampleBufferDisplayView: UIView { + override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self } + + var displayLayer: AVSampleBufferDisplayLayer { + return layer as! AVSampleBufferDisplayLayer + } + + private(set) var pipController: PiPController? + + weak var pipDelegate: PiPControllerDelegate? { + didSet { + pipController?.delegate = pipDelegate + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + backgroundColor = .black + displayLayer.videoGravity = .resizeAspect + if #available(iOS 17.0, *) { + displayLayer.wantsExtendedDynamicRangeContent = true + } + setupPictureInPicture() + } + + private func setupPictureInPicture() { + pipController = PiPController(sampleBufferDisplayLayer: displayLayer) + } + + // MARK: - PiP Control Methods + + func startPictureInPicture() { + pipController?.startPictureInPicture() + } + + func stopPictureInPicture() { + pipController?.stopPictureInPicture() + } + + var isPictureInPictureSupported: Bool { + return pipController?.isPictureInPictureSupported ?? false + } + + var isPictureInPictureActive: Bool { + return pipController?.isPictureInPictureActive ?? false + } + + var isPictureInPicturePossible: Bool { + return pipController?.isPictureInPicturePossible ?? false + } +} diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts new file mode 100644 index 00000000..76e4a36d --- /dev/null +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -0,0 +1,78 @@ +import type { StyleProp, ViewStyle } from "react-native"; + +export type OnLoadEventPayload = { + url: string; +}; + +export type OnPlaybackStateChangePayload = { + isPaused?: boolean; + isPlaying?: boolean; + isLoading?: boolean; + isReadyToSeek?: boolean; +}; + +export type OnProgressEventPayload = { + position: number; + duration: number; + progress: number; +}; + +export type OnErrorEventPayload = { + error: string; +}; + +export type MpvPlayerModuleEvents = { + onChange: (params: ChangeEventPayload) => void; +}; + +export type ChangeEventPayload = { + value: string; +}; + +export type MpvPlayerViewProps = { + url?: string; + headers?: Record; + autoplay?: boolean; + style?: StyleProp; + onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void; + onPlaybackStateChange?: (event: { + nativeEvent: OnPlaybackStateChangePayload; + }) => void; + onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void; + onError?: (event: { nativeEvent: OnErrorEventPayload }) => void; +}; + +export interface MpvPlayerViewRef { + play: () => Promise; + pause: () => Promise; + seekTo: (position: number) => Promise; + seekBy: (offset: number) => Promise; + setSpeed: (speed: number) => Promise; + getSpeed: () => Promise; + isPaused: () => Promise; + getCurrentPosition: () => Promise; + getDuration: () => Promise; + startPictureInPicture: () => Promise; + stopPictureInPicture: () => Promise; + isPictureInPictureSupported: () => Promise; + isPictureInPictureActive: () => Promise; + getSubtitleTracks: () => Promise; + setSubtitleTrack: (trackId: number) => Promise; + disableSubtitles: () => Promise; + getCurrentSubtitleTrack: () => Promise; + addSubtitleFile: (url: string) => Promise; + // Subtitle positioning + setSubtitlePosition: (position: number) => Promise; + setSubtitleScale: (scale: number) => Promise; + setSubtitleMarginY: (margin: number) => Promise; + setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise; + setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise; + setSubtitleFontSize: (size: number) => Promise; +} + +export type SubtitleTrack = { + id: number; + title?: string; + lang?: string; + selected?: boolean; +}; diff --git a/modules/mpv-player/src/MpvPlayerModule.ts b/modules/mpv-player/src/MpvPlayerModule.ts new file mode 100644 index 00000000..a1b72af8 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerModule.ts @@ -0,0 +1,11 @@ +import { NativeModule, requireNativeModule } from "expo"; + +import { MpvPlayerModuleEvents } from "./MpvPlayer.types"; + +declare class MpvPlayerModule extends NativeModule { + hello(): string; + setValueAsync(value: string): Promise; +} + +// This call loads the native module object from the JSI. +export default requireNativeModule("MpvPlayer"); diff --git a/modules/mpv-player/src/MpvPlayerModule.web.ts b/modules/mpv-player/src/MpvPlayerModule.web.ts new file mode 100644 index 00000000..47e29e15 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerModule.web.ts @@ -0,0 +1,19 @@ +import { NativeModule, registerWebModule } from "expo"; + +import { ChangeEventPayload } from "./MpvPlayer.types"; + +type MpvPlayerModuleEvents = { + onChange: (params: ChangeEventPayload) => void; +}; + +class MpvPlayerModule extends NativeModule { + PI = Math.PI; + async setValueAsync(value: string): Promise { + this.emit("onChange", { value }); + } + hello() { + return "Hello world! 👋"; + } +} + +export default registerWebModule(MpvPlayerModule, "MpvPlayerModule"); diff --git a/modules/mpv-player/src/MpvPlayerView.tsx b/modules/mpv-player/src/MpvPlayerView.tsx new file mode 100644 index 00000000..e99b77d3 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerView.tsx @@ -0,0 +1,91 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import { useImperativeHandle, useRef } from "react"; + +import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types"; + +const NativeView: React.ComponentType = + requireNativeView("MpvPlayer"); + +export default React.forwardRef( + function MpvPlayerView(props, ref) { + const nativeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + play: async () => { + await nativeRef.current?.play(); + }, + pause: async () => { + await nativeRef.current?.pause(); + }, + seekTo: async (position: number) => { + await nativeRef.current?.seekTo(position); + }, + seekBy: async (offset: number) => { + await nativeRef.current?.seekBy(offset); + }, + setSpeed: async (speed: number) => { + await nativeRef.current?.setSpeed(speed); + }, + getSpeed: async () => { + return await nativeRef.current?.getSpeed(); + }, + isPaused: async () => { + return await nativeRef.current?.isPaused(); + }, + getCurrentPosition: async () => { + return await nativeRef.current?.getCurrentPosition(); + }, + getDuration: async () => { + return await nativeRef.current?.getDuration(); + }, + startPictureInPicture: async () => { + await nativeRef.current?.startPictureInPicture(); + }, + stopPictureInPicture: async () => { + await nativeRef.current?.stopPictureInPicture(); + }, + isPictureInPictureSupported: async () => { + return await nativeRef.current?.isPictureInPictureSupported(); + }, + isPictureInPictureActive: async () => { + return await nativeRef.current?.isPictureInPictureActive(); + }, + getSubtitleTracks: async () => { + return await nativeRef.current?.getSubtitleTracks(); + }, + setSubtitleTrack: async (trackId: number) => { + await nativeRef.current?.setSubtitleTrack(trackId); + }, + disableSubtitles: async () => { + await nativeRef.current?.disableSubtitles(); + }, + getCurrentSubtitleTrack: async () => { + return await nativeRef.current?.getCurrentSubtitleTrack(); + }, + addSubtitleFile: async (url: string) => { + await nativeRef.current?.addSubtitleFile(url); + }, + setSubtitlePosition: async (position: number) => { + await nativeRef.current?.setSubtitlePosition(position); + }, + setSubtitleScale: async (scale: number) => { + await nativeRef.current?.setSubtitleScale(scale); + }, + setSubtitleMarginY: async (margin: number) => { + await nativeRef.current?.setSubtitleMarginY(margin); + }, + setSubtitleAlignX: async (alignment: "left" | "center" | "right") => { + await nativeRef.current?.setSubtitleAlignX(alignment); + }, + setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => { + await nativeRef.current?.setSubtitleAlignY(alignment); + }, + setSubtitleFontSize: async (size: number) => { + await nativeRef.current?.setSubtitleFontSize(size); + }, + })); + + return ; + }, +); diff --git a/modules/mpv-player/src/MpvPlayerView.web.tsx b/modules/mpv-player/src/MpvPlayerView.web.tsx new file mode 100644 index 00000000..5b874cc9 --- /dev/null +++ b/modules/mpv-player/src/MpvPlayerView.web.tsx @@ -0,0 +1,14 @@ +import { MpvPlayerViewProps } from "./MpvPlayer.types"; + +export default function MpvPlayerView(props: MpvPlayerViewProps) { + return ( +
+