import AVFoundation import Foundation import Libmpv import QuartzCore #if os(iOS) || os(tvOS) import UIKit #elseif os(macOS) import Cocoa import Metal #endif struct MpvLifecycleUnavailableError: LocalizedError { let errorDescription: String? init(_ description: String) { errorDescription = description } } protocol MpvPlayerDelegate: AnyObject { func onPropertyChange(name: String, value: Any?) func onEvent(name: String, data: [String: Any]?) } #if os(macOS) // Workaround for MoltenVK problems that cause flicker. // https://github.com/mpv-player/mpv/pull/13651 class MpvMetalLayer: CAMetalLayer { override var drawableSize: CGSize { get { super.drawableSize } set { if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { super.drawableSize = newValue } } } override var wantsExtendedDynamicRangeContent: Bool { get { super.wantsExtendedDynamicRangeContent } set { if Thread.isMainThread { super.wantsExtendedDynamicRangeContent = newValue } else { DispatchQueue.main.async { super.wantsExtendedDynamicRangeContent = newValue } } } } } #else class MpvVideoLayer: AVSampleBufferDisplayLayer {} #endif /// Safely convert a C string to Swift String with UTF-8 validation. /// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. /// mpv does not guarantee UTF-8 for log messages, error strings, or /// system-encoded paths and Flutter codecs reject invalid UTF-8. func safeString(_ cstr: UnsafePointer) -> String { if let string = String(validatingUTF8: cstr) { return string } let length = strlen(cstr) let buffer = UnsafeBufferPointer( start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), count: length ) return String(buffer.map { Character(Unicode.Scalar($0)) }) } struct ServerDisplayCriteria { let doviProfile: Int64 let doviLevel: Int64 let doviCompatibilityId: Int64? let fps: Double let width: Int32 let height: Int32 let gamma: String? let primaries: String? let colorMatrix: String? /// Whether the server metadata carried actual color/DoVi information — /// only then may the prime lock out mpv-derived color updates. var hasColorInfo: Bool { doviProfile > 0 || gamma != nil || primaries != nil || colorMatrix != nil } } final class MpvWakeupCallbackContext { private let lock = NSLock() private weak var core: MpvPlayerCoreBase? init(core: MpvPlayerCoreBase) { self.core = core } func dispatchWakeup() { lock.lock() defer { lock.unlock() } core?.readEventsFromCallback() } func detach() { lock.lock() core = nil lock.unlock() } } class MpvPlayerCoreBase: NSObject { weak var delegate: MpvPlayerDelegate? #if os(macOS) var metalLayer: MpvMetalLayer? #else var videoLayer: MpvVideoLayer? #endif var isInitialized = false var isPipActive = false private struct LifecycleState { var mpv: OpaquePointer? var isTerminal = false var isBackgrounded = false var wakeupCallbackContext: UnsafeMutableRawPointer? } private let lifecycleLock = NSLock() private var lifecycleState = LifecycleState() private var cachedHDREnabled = true private var cachedLastSigPeak = 0.0 private var cachedDoviProfile: Int64 = 0 private var cachedDoviLevel: Int64 = 0 private var cachedContainerFps: Double = 0 private var cachedVideoGamma: String? private var cachedVideoPrimaries: String? private var cachedVideoColorMatrix: String? private var serverDisplayCriteriaActive = false private var serverCriteriaLocksColor = false private var lastServerCriteria: ServerDisplayCriteria? private var cachedDvConversionMode = "auto" private var cachedDvConversionLogEnabled = false var hdrEnabled: Bool { cacheLock.lock() defer { cacheLock.unlock() } return cachedHDREnabled } var lastSigPeak: Double { cacheLock.lock() defer { cacheLock.unlock() } return cachedLastSigPeak } var doviProfile: Int64 { cacheLock.lock() defer { cacheLock.unlock() } return cachedDoviProfile } var doviLevel: Int64 { cacheLock.lock() defer { cacheLock.unlock() } return cachedDoviLevel } var containerFps: Double { cacheLock.lock() defer { cacheLock.unlock() } return cachedContainerFps } /// Properties that must still flow to Dart while backgrounded (state-critical). private static let criticalProperties: Set = [ "pause", "eof-reached", "paused-for-cache", "time-pos", "duration", "seekable", ] private static let internalSigPeakObserverId: UInt64 = UInt64.max - 1 private static let internalWidthObserverId: UInt64 = UInt64.max - 2 private static let internalHeightObserverId: UInt64 = UInt64.max - 3 private static let internalDoviProfileObserverId: UInt64 = UInt64.max - 4 private static let internalDoviLevelObserverId: UInt64 = UInt64.max - 5 private static let internalContainerFpsObserverId: UInt64 = UInt64.max - 6 private static let internalVideoGammaObserverId: UInt64 = UInt64.max - 7 private static let internalVideoPrimariesObserverId: UInt64 = UInt64.max - 8 private static let internalVideoColorMatrixObserverId: UInt64 = UInt64.max - 9 private static let internalObserverIds: Set = [ internalSigPeakObserverId, internalWidthObserverId, internalHeightObserverId, internalDoviProfileObserverId, internalDoviLevelObserverId, internalContainerFpsObserverId, internalVideoGammaObserverId, internalVideoPrimariesObserverId, internalVideoColorMatrixObserverId, ] let queue = DispatchQueue(label: "mpv", qos: .userInitiated) private let queueKey = DispatchSpecificKey() private enum PendingRequest { case void((Result) -> Void) case getProperty((Result) -> Void) } private var pendingRequests: [UInt64: PendingRequest] = [:] private let pendingRequestsLock = NSLock() private var nextRequestId: UInt64 = 1 private let cacheLock = NSLock() private var cachedPaused = true private var confirmedPaused = true private var resolvedPauseGeneration: UInt64 = 0 private var pauseIntentGeneration: UInt64 = 0 private var pauseObservationRevision: UInt64 = 0 private var pendingPauseIntents: [UInt64: Bool] = [:] private var cachedDuration = 0.0 private var cachedTimePos = 0.0 private var cachedWidth = 0.0 private var cachedHeight = 0.0 private var currentPanscan = 0.0 private var aspectOverrideActive = false override init() { super.init() queue.setSpecific(key: queueKey, value: ()) } @discardableResult func beginDisposal() -> Bool { lifecycleLock.lock() defer { lifecycleLock.unlock() } guard !lifecycleState.isTerminal else { return false } lifecycleState.isTerminal = true return true } func setBackgrounded(_ backgrounded: Bool) { lifecycleLock.lock() lifecycleState.isBackgrounded = backgrounded lifecycleLock.unlock() } var hasActiveMpv: Bool { lifecycleLock.lock() defer { lifecycleLock.unlock() } return !lifecycleState.isTerminal && lifecycleState.mpv != nil } private var isLifecycleActive: Bool { lifecycleLock.lock() defer { lifecycleLock.unlock() } return !lifecycleState.isTerminal } private var isLifecycleBackgrounded: Bool { lifecycleLock.lock() defer { lifecycleLock.unlock() } return lifecycleState.isBackgrounded } private func withActiveMpv(_ body: (OpaquePointer) -> T) -> T? { lifecycleLock.lock() defer { lifecycleLock.unlock() } guard !lifecycleState.isTerminal, let mpv = lifecycleState.mpv else { return nil } return body(mpv) } func configurePlatformMpvOptions(mpv: OpaquePointer) {} func updateEDRMode(sigPeak: Double) {} @discardableResult func updateDisplayCriteria( doviProfile: Int64, doviLevel: Int64, doviCompatibilityId: Int64?, fps: Double, width: Int32, height: Int32, sigPeak: Double, gamma: String?, primaries: String?, colorMatrix: String? ) -> Bool { false } /// Whether the mpv-derived caches indicate an HDR/DV source — mirrors the /// Dart-side MediaDisplayCriteria.isHdr tag check. Call under cacheLock. private static func looksHdr( doviProfile: Int64, sigPeak: Double, gamma: String?, primaries: String?, colorMatrix: String? ) -> Bool { if doviProfile > 0 || sigPeak > 1 { return true } let tags = [gamma, primaries, colorMatrix] .compactMap { $0?.lowercased() } .joined(separator: " ") .replacingOccurrences(of: "[^a-z0-9]", with: "", options: .regularExpression) return ["hlg", "arib", "pq", "smpte2084", "st2084", "bt2020"].contains { tags.contains($0) } } func scheduleDisplayCriteriaUpdate() { cacheLock.lock() if serverDisplayCriteriaActive { // A color-bearing server prime owns the display mode for the item. An // fps-only prime is just an early hint: demote it once the decoded // stream proves HDR/DV so the real color tags reach the display, // otherwise keep suppressing redundant SDR re-applies. if serverCriteriaLocksColor || !Self.looksHdr( doviProfile: cachedDoviProfile, sigPeak: cachedLastSigPeak, gamma: cachedVideoGamma, primaries: cachedVideoPrimaries, colorMatrix: cachedVideoColorMatrix ) { cacheLock.unlock() return } serverDisplayCriteriaActive = false serverCriteriaLocksColor = false lastServerCriteria = nil } let profile = cachedDoviProfile let level = cachedDoviLevel let fps = cachedContainerFps let width = Int32(cachedWidth) let height = Int32(cachedHeight) let sigPeak = cachedLastSigPeak let gamma = cachedVideoGamma let primaries = cachedVideoPrimaries let colorMatrix = cachedVideoColorMatrix cacheLock.unlock() DispatchQueue.main.async { [weak self] in self?.updateDisplayCriteria( doviProfile: profile, doviLevel: level, doviCompatibilityId: nil, fps: fps, width: width, height: height, sigPeak: sigPeak, gamma: gamma, primaries: primaries, colorMatrix: colorMatrix ) } } func setServerDisplayCriteria(_ criteria: ServerDisplayCriteria?, completion: ((Bool) -> Void)? = nil) { cacheLock.lock() serverDisplayCriteriaActive = criteria != nil serverCriteriaLocksColor = criteria?.hasColorInfo ?? false lastServerCriteria = criteria cacheLock.unlock() let apply = { [weak self] in guard let self else { return } guard let criteria else { let applied = self.updateDisplayCriteria( doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil, fps: 0, width: 0, height: 0, sigPeak: 0, gamma: nil, primaries: nil, colorMatrix: nil ) completion?(applied) return } let applied = self.updateDisplayCriteria( doviProfile: criteria.doviProfile, doviLevel: criteria.doviLevel, doviCompatibilityId: criteria.doviCompatibilityId, fps: criteria.fps, width: criteria.width, height: criteria.height, sigPeak: 0, gamma: criteria.gamma, primaries: criteria.primaries, colorMatrix: criteria.colorMatrix ) if !applied { self.cacheLock.lock() self.serverDisplayCriteriaActive = false self.serverCriteriaLocksColor = false self.cacheLock.unlock() self.scheduleDisplayCriteriaUpdate() } completion?(applied) } if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } } /// Re-evaluate the tvOS HDMI display mode using the most recent criteria. /// On tvOS the HDR toggle only reaches the display through this path, so the /// runtime toggle calls this to switch DV/HDR ⇄ SDR without reloading. func reapplyDisplayCriteria() { cacheLock.lock() let criteria = lastServerCriteria cacheLock.unlock() if let criteria { setServerDisplayCriteria(criteria) } else { scheduleDisplayCriteriaUpdate() } } func setupMpv() -> Bool { #if os(macOS) guard let renderLayer = metalLayer else { return false } configureMoltenVKPlacementHeaps() #else guard let renderLayer = videoLayer else { return false } #endif applyDvConversionModeEnvironment() let created = createMpvContext { [self] mpv in var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque())) checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) applySharedMpvOptions(mpv: mpv) configurePlatformMpvOptions(mpv: mpv) } guard created else { return false } let observed: Void? = withActiveMpv { mpv in mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE) mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE) mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE) mpv_observe_property( mpv, Self.internalDoviProfileObserverId, "current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64) mpv_observe_property( mpv, Self.internalDoviLevelObserverId, "current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64) mpv_observe_property( mpv, Self.internalContainerFpsObserverId, "container-fps", MPV_FORMAT_DOUBLE) mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING) mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING) mpv_observe_property( mpv, Self.internalVideoColorMatrixObserverId, "video-params/colormatrix", MPV_FORMAT_STRING) } return observed != nil } /// Create the mpv context, apply pre-init options via `configure`, run /// `mpv_initialize`, and install the wakeup callback. Everything here is /// instance-scoped (per-instance dispatch queue, request table, and retained /// wakeup context), so the video core and the audio-only core can each own /// an independent context and be created/destroyed at any time. func createMpvContext(configure: (OpaquePointer) -> Void) -> Bool { guard let mpv = mpv_create() else { print("[MpvPlayerCore] Failed to create MPV context") return false } #if DEBUG let defaultLogLevel = "v" #else let defaultLogLevel = "warn" #endif checkError(mpv_request_log_messages(mpv, defaultLogLevel)) configure(mpv) let initResult = mpv_initialize(mpv) if initResult < 0 { print("[MpvPlayerCore] mpv_initialize failed: \(safeString(mpv_error_string(initResult)))") mpv_terminate_destroy(mpv) return false } // mpv stores this context without retaining it. The core owns the context, // while the context keeps only a weak callback target; disposal atomically // detaches it before removing the C callback. A late callback can therefore // neither retain/dereference a dead core nor enqueue work against a replacement. let callbackOwner = MpvWakeupCallbackContext(core: self) let wakeupContext = Unmanaged.passRetained(callbackOwner).toOpaque() lifecycleLock.lock() guard !lifecycleState.isTerminal, lifecycleState.mpv == nil else { lifecycleLock.unlock() mpv_terminate_destroy(mpv) Unmanaged.fromOpaque(wakeupContext).release() return false } lifecycleState.mpv = mpv lifecycleState.wakeupCallbackContext = wakeupContext mpv_set_wakeup_callback( mpv, { context in guard let context else { return } Unmanaged.fromOpaque(context) .takeUnretainedValue() .dispatchWakeup() }, wakeupContext ) lifecycleLock.unlock() return true } func setLogLevel(_ level: String) { _ = withActiveMpv { mpv in mpv_request_log_messages(mpv, level) } } func setProperty(_ name: String, value: String) { setPropertyAsync(name, value: value) { _ in } } func setPropertyAsync( _ name: String, value: String, completion: @escaping (Result) -> Void ) { guard isLifecycleActive else { completeOnMain { completion(.failure(self.lifecycleUnavailableError())) } return } #if targetEnvironment(simulator) if name == "hwdec" { if value != "no" { print("[MpvPlayerCore] Simulator does not support hardware decoding; forcing hwdec=no") } setRawStringPropertyAsync(name, value: "no", completion: completion) return } #endif if isManagedRendererProperty(name) { print("[MpvPlayerCore] Ignoring managed renderer property: \(name)=\(value)") completeOnMain { completion(.success(())) } return } updateVideoGravityIfNeeded(name: name, value: value) if name == "pause" { let paused = parseBoolProperty(value) let intent = beginCachedPauseIntent(paused) setRawStringPropertyAsync(name, value: value) { [weak self] result in self?.finishCachedPauseIntent(intent, result: result) completion(result) } return } if name == "hdr-enabled" { let enabled = value == "yes" || value == "true" || value == "1" setHDREnabled(enabled, completion: completion) return } if name == "dv-conversion-mode" { setDvConversionMode(value) completeOnMain { completion(.success(())) } return } if name == "dv-conversion-log" { setDvConversionLogEnabled(parseBoolProperty(value)) completeOnMain { completion(.success(())) } return } setRawStringPropertyAsync(name, value: value, completion: completion) } private func parseBoolProperty(_ value: String) -> Bool { switch value.lowercased() { case "1", "true", "yes", "on": return true default: return false } } private func normalizeDvConversionMode(_ value: String) -> String { switch value.lowercased() { case "disabled", "native": return "disabled" case "dv81", "p8", "p7_to_p8", "p7-to-p8": return "dv81" case "hevc", "hevc_strip", "p7_to_hevc", "p7-to-hevc": return "hevc_strip" default: return "auto" } } private func applyDvConversionModeEnvironment() { cacheLock.lock() let mode = cachedDvConversionMode let logEnabled = cachedDvConversionLogEnabled cacheLock.unlock() setenv("PLEZY_DV_CONVERSION_MODE", mode, 1) setenv("PLEZY_DV_CONVERSION_LOG", logEnabled ? "1" : "0", 1) } func setDvConversionMode(_ mode: String) { cacheLock.lock() cachedDvConversionMode = normalizeDvConversionMode(mode) let normalized = cachedDvConversionMode let logEnabled = cachedDvConversionLogEnabled cacheLock.unlock() applyDvConversionModeEnvironment() if logEnabled { print("[MpvPlayerCore] DV conversion mode: \(normalized)") } } func setDvConversionLogEnabled(_ enabled: Bool) { cacheLock.lock() cachedDvConversionLogEnabled = enabled let mode = cachedDvConversionMode cacheLock.unlock() applyDvConversionModeEnvironment() if enabled { print("[MpvPlayerCore] DV conversion logging enabled (mode: \(mode))") } } func getDvConversionMode() -> String { cacheLock.lock() defer { cacheLock.unlock() } return cachedDvConversionMode } func getDvConversionLogEnabled() -> Bool { cacheLock.lock() defer { cacheLock.unlock() } return cachedDvConversionLogEnabled } func setInt64PropertyAsync( _ name: String, value: Int64, completion: @escaping (Result) -> Void ) { var propertyValue = value submitAsyncRequest(.void(completion)) { mpv, requestId in name.withCString { namePointer in mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_INT64, &propertyValue) } } } func setHDREnabled(_ enabled: Bool, completion: ((Result) -> Void)? = nil) { cacheLock.lock() cachedHDREnabled = enabled let sigPeak = cachedLastSigPeak cacheLock.unlock() print("[MpvPlayerCore] HDR enabled: \(enabled)") setRawStringPropertyAsync( "target-colorspace-hint", value: enabled ? "auto" : "no", completion: completion ?? { _ in } ) DispatchQueue.main.async { self.updateEDRMode(sigPeak: sigPeak) } // On tvOS the toggle only takes effect through the HDMI display-mode path // (target-colorspace-hint is inert in the avfoundation VO and EDR is // iOS-only), so re-evaluate the display criteria with the new flag. #if os(tvOS) DispatchQueue.main.async { self.reapplyDisplayCriteria() } #endif } /// PiP presents the AVSampleBufferDisplayLayer directly, so subtitles must /// be composited into the video samples instead of the inline OSD layer. func setPipSubtitleCompositing(_ enabled: Bool) { #if os(iOS) let value = enabled ? "yes" : "no" setRawStringPropertyAsync("avfoundation-pip-composite-osd", value: value) { result in if case .failure(let error) = result { print( "[MpvPlayerCore] Failed to set PiP subtitle compositing " + "to \(value): \(error.localizedDescription)" ) } } #endif } func getPropertyAsync(_ name: String, completion: @escaping (Result) -> Void) { guard isLifecycleActive else { completeOnMain { completion(.failure(self.lifecycleUnavailableError())) } return } if name == "dv-conversion-mode" { completeOnMain { completion(.success(self.getDvConversionMode())) } return } if name == "dv-conversion-log" { completeOnMain { completion(.success(self.getDvConversionLogEnabled() ? "yes" : "no")) } return } submitAsyncRequest(.getProperty(completion)) { mpv, requestId in name.withCString { namePointer in mpv_get_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING) } } } func observeProperty(_ name: String, format: String) { let mpvFormat: mpv_format switch format { case "double": mpvFormat = MPV_FORMAT_DOUBLE case "flag": mpvFormat = MPV_FORMAT_FLAG case "node": mpvFormat = MPV_FORMAT_NODE case "string": mpvFormat = MPV_FORMAT_STRING default: return } _ = withActiveMpv { mpv in mpv_observe_property(mpv, 0, name, mpvFormat) } } func command(_ args: [String]) { commandAsync(args) { _ in } } func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { guard !args.isEmpty else { completeOnMain { completion(.success(())) } return } var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } cargs.append(nil) submitAsyncRequest(.void(completion)) { mpv, requestId in cargs.withUnsafeBufferPointer { buffer in var constPointers = buffer.map { UnsafePointer($0) } return mpv_command_async(mpv, requestId, &constPointers) } } for pointer in cargs { free(pointer) } } private func setRawStringPropertyAsync( _ name: String, value: String, completion: @escaping (Result) -> Void ) { submitAsyncRequest(.void(completion)) { mpv, requestId in name.withCString { namePointer in value.withCString { valuePointer in var propertyValue: UnsafePointer? = valuePointer return mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING, &propertyValue) } } } } var isPaused: Bool { cacheLock.lock() defer { cacheLock.unlock() } return cachedPaused } var duration: Double { cacheLock.lock() defer { cacheLock.unlock() } return cachedDuration } var timePos: Double { cacheLock.lock() defer { cacheLock.unlock() } return cachedTimePos } var videoSize: CGSize? { cacheLock.lock() defer { cacheLock.unlock() } guard cachedWidth > 0, cachedHeight > 0 else { return nil } return CGSize(width: cachedWidth, height: cachedHeight) } func disposeSharedState(destroySynchronously: Bool) { cancelPendingRequests() cacheLock.lock() cachedDoviProfile = 0 cachedDoviLevel = 0 cachedContainerFps = 0 cachedLastSigPeak = 0 cachedVideoGamma = nil cachedVideoPrimaries = nil cachedVideoColorMatrix = nil serverDisplayCriteriaActive = false cacheLock.unlock() lifecycleLock.lock() lifecycleState.isTerminal = true let mpvHandle = lifecycleState.mpv let callbackContext = lifecycleState.wakeupCallbackContext lifecycleState.mpv = nil lifecycleState.wakeupCallbackContext = nil lifecycleLock.unlock() if let callbackContext { Unmanaged.fromOpaque(callbackContext) .takeUnretainedValue() .detach() } let destroy = { if let mpvHandle { mpv_set_wakeup_callback(mpvHandle, nil, nil) mpv_terminate_destroy(mpvHandle) } if let callbackContext { Unmanaged.fromOpaque(callbackContext).release() } } if destroySynchronously { if DispatchQueue.getSpecific(key: queueKey) != nil { destroy() } else { queue.sync(execute: destroy) } } else { queue.async(execute: destroy) } } private func applySharedMpvOptions(mpv: OpaquePointer) { #if os(macOS) checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) #else checkError(mpv_set_option_string(mpv, "vo", "avfoundation")) #if targetEnvironment(simulator) checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no")) checkError(mpv_set_option_string(mpv, "hwdec", "no")) #elseif os(tvOS) // tvOS HDR is HDMI mode switching, not EDR — an SDR sibling layer // doesn't dim the video, so skip the per-frame CI composite that // round-trips BT.2020/PQ through linear P3. checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no")) checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) #else checkError(mpv_set_option_string(mpv, "avfoundation-composite-osd", "no")) checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) #endif #endif checkError(mpv_set_option_string(mpv, "hwdec-codecs", "all")) checkError(mpv_set_option_string(mpv, "hwdec-software-fallback", "yes")) checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "auto")) // Pause on the last frame at EOF instead of unloading the file, so seeking // back after the video ends still works (matches Linux/Windows). checkError(mpv_set_option_string(mpv, "keep-open", "yes")) } #if os(macOS) private func configureMoltenVKPlacementHeaps() { guard let device = MTLCreateSystemDefaultDevice() else { return } let supportsPlacementHeaps = device.supportsFamily(.apple2) || device.supportsFamily(.mac2) if !supportsPlacementHeaps { setenv("MVK_CONFIG_USE_MTLHEAP", "0", 1) } } #endif private func isManagedRendererProperty(_ name: String) -> Bool { name == "vo" || name == "wid" || name == "gpu-api" || name == "gpu-context" || name == "avfoundation-composite-osd" || name == "avfoundation-pip-composite-osd" } private func updateVideoGravityIfNeeded(name: String, value: String) { #if os(macOS) return #else let gravity: AVLayerVideoGravity cacheLock.lock() switch name { case "panscan": currentPanscan = Double(value) ?? 0 case "video-aspect-override": aspectOverrideActive = value != "no" && value != "-1" && value != "0" default: cacheLock.unlock() return } if aspectOverrideActive { gravity = .resize } else if currentPanscan > 0 { gravity = .resizeAspectFill } else { gravity = .resizeAspect } cacheLock.unlock() DispatchQueue.main.async { [weak self] in self?.videoLayer?.videoGravity = gravity } #endif } private func completeOnMain(_ completion: @escaping () -> Void) { if Thread.isMainThread { completion() } else { DispatchQueue.main.async(execute: completion) } } private func cancelPendingRequests() { pendingRequestsLock.lock() let pending = pendingRequests pendingRequests.removeAll() pendingRequestsLock.unlock() let error = MpvLifecycleUnavailableError("Player disposed") for (_, request) in pending { DispatchQueue.main.async { switch request { case .void(let completion): completion(.failure(error)) case .getProperty(let completion): completion(.failure(error)) } } } } private func registerRequest(_ request: PendingRequest) -> UInt64 { pendingRequestsLock.lock() defer { pendingRequestsLock.unlock() } let requestId = nextRequestId nextRequestId += 1 pendingRequests[requestId] = request return requestId } private func takeRequest(_ requestId: UInt64) -> PendingRequest? { pendingRequestsLock.lock() defer { pendingRequestsLock.unlock() } return pendingRequests.removeValue(forKey: requestId) } private func lifecycleUnavailableError() -> MpvLifecycleUnavailableError { MpvLifecycleUnavailableError("Player is not initialized or has been disposed") } private func mpvError(_ status: CInt) -> NSError { NSError( domain: "mpv", code: Int(status), userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(status))] ) } private func submitAsyncRequest( _ request: PendingRequest, submission: (OpaquePointer, UInt64) -> CInt ) { var requestId: UInt64? guard let status = withActiveMpv({ mpv in let id = registerRequest(request) requestId = id return submission(mpv, id) }), let requestId else { let error = lifecycleUnavailableError() completeOnMain { switch request { case .void(let completion): completion(.failure(error)) case .getProperty(let completion): completion(.failure(error)) } } return } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } private func completeRequestIfSubmissionFailed(requestId: UInt64, status: CInt) { guard status < 0, let request = takeRequest(requestId) else { return } let error = mpvError(status) DispatchQueue.main.async { switch request { case .void(let completion): completion(.failure(error)) case .getProperty(let completion): completion(.failure(error)) } } } private func completeVoidRequest(requestId: UInt64, error status: CInt) { guard case .void(let completion) = takeRequest(requestId) else { return } let result: Result = status < 0 ? .failure(mpvError(status)) : .success(()) DispatchQueue.main.async { completion(result) } } private func completeGetPropertyRequest(_ event: mpv_event) { guard case .getProperty(let completion) = takeRequest(event.reply_userdata) else { return } if event.error < 0 { let error = mpvError(event.error) DispatchQueue.main.async { completion(.failure(error)) } return } var value: String? if let propertyPointer = event.data?.assumingMemoryBound(to: mpv_event_property.self) { let property = propertyPointer.pointee if property.format == MPV_FORMAT_STRING, let data = property.data { let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee value = cstring.map { safeString($0) } } } DispatchQueue.main.async { completion(.success(value)) } } fileprivate func readEventsFromCallback() { queue.async { [weak self] in guard let self else { return } while let mpv = self.withActiveMpv({ $0 }) { let event = mpv_wait_event(mpv, 0) guard let event else { break } if event.pointee.event_id == MPV_EVENT_NONE { break } self.handleEvent(event.pointee) } } } func dispatchDelegateEvent(name: String, data: [String: Any]?) { DispatchQueue.main.async { [weak self] in guard let self, self.isLifecycleActive else { return } self.delegate?.onEvent(name: name, data: data) } } func dispatchDelegateProperty(name: String, value: Any?) { DispatchQueue.main.async { [weak self] in guard let self, self.isLifecycleActive else { return } self.delegate?.onPropertyChange(name: name, value: value) } } private func handleEvent(_ event: mpv_event) { switch event.event_id { case MPV_EVENT_PROPERTY_CHANGE: guard let data = event.data else { break } let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee let name = safeString(property.name) handlePropertyChange(name: name, property: property, replyUserdata: event.reply_userdata) case MPV_EVENT_COMMAND_REPLY: completeVoidRequest(requestId: event.reply_userdata, error: event.error) case MPV_EVENT_SET_PROPERTY_REPLY: completeVoidRequest(requestId: event.reply_userdata, error: event.error) case MPV_EVENT_GET_PROPERTY_REPLY: completeGetPropertyRequest(event) case MPV_EVENT_START_FILE: dispatchDelegateEvent(name: "start-file", data: nil) case MPV_EVENT_FILE_LOADED: dispatchDelegateEvent(name: "file-loaded", data: nil) case MPV_EVENT_END_FILE: if let endFilePtr = event.data?.assumingMemoryBound(to: mpv_event_end_file.self) { let endFile = endFilePtr.pointee var data: [String: Any] = ["reason": Int(endFile.reason.rawValue)] if endFile.reason == MPV_END_FILE_REASON_ERROR { data["error"] = Int(endFile.error) data["message"] = safeString(mpv_error_string(endFile.error)) } dispatchDelegateEvent(name: "end-file", data: data) } else { dispatchDelegateEvent(name: "end-file", data: nil) } case MPV_EVENT_SHUTDOWN: print("[MpvPlayerCore] MPV shutdown event") case MPV_EVENT_PLAYBACK_RESTART: dispatchDelegateEvent(name: "playback-restart", data: nil) case MPV_EVENT_LOG_MESSAGE: if isLifecycleBackgrounded { break } if let messagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { let message = messagePointer.pointee let prefix = message.prefix.map { safeString($0) } ?? "" let level = message.level.map { safeString($0) } ?? "" let text = message.text.map { safeString($0) } ?? "" dispatchDelegateEvent( name: "log-message", data: ["prefix": prefix, "level": level, "text": text] ) } default: break } } private func handlePropertyChange(name: String, property: mpv_event_property, replyUserdata: UInt64) { var value: Any? switch property.format { case MPV_FORMAT_DOUBLE: if let data = property.data { value = data.assumingMemoryBound(to: Double.self).pointee } case MPV_FORMAT_INT64: if let data = property.data { value = data.assumingMemoryBound(to: Int64.self).pointee } case MPV_FORMAT_FLAG: if let data = property.data { value = data.assumingMemoryBound(to: Int32.self).pointee != 0 } case MPV_FORMAT_NODE: if let data = property.data { let node = data.assumingMemoryBound(to: mpv_node.self).pointee value = convertNode(node) } case MPV_FORMAT_STRING: if let data = property.data { let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee value = cstring.map { safeString($0) } } default: break } updateCachedProperty(name: name, value: value) if name == "video-params/sig-peak", let sigPeak = value as? Double { cacheLock.lock() cachedLastSigPeak = sigPeak cacheLock.unlock() DispatchQueue.main.async { self.updateEDRMode(sigPeak: sigPeak) } scheduleDisplayCriteriaUpdate() } switch name { case "current-tracks/video/dolby-vision-profile": cacheLock.lock() cachedDoviProfile = (value as? Int64) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "current-tracks/video/dolby-vision-level": cacheLock.lock() cachedDoviLevel = (value as? Int64) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "container-fps": cacheLock.lock() cachedContainerFps = (value as? Double) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "video-params/gamma": cacheLock.lock() cachedVideoGamma = value as? String cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "video-params/primaries": cacheLock.lock() cachedVideoPrimaries = value as? String cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "video-params/colormatrix": cacheLock.lock() cachedVideoColorMatrix = value as? String cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "width", "height": scheduleDisplayCriteriaUpdate() default: break } if Self.internalObserverIds.contains(replyUserdata) { return } if isLifecycleBackgrounded && !Self.criticalProperties.contains(name) { return } dispatchDelegateProperty(name: name, value: value) } private func updateCachedProperty(name: String, value: Any?) { cacheLock.lock() defer { cacheLock.unlock() } switch name { case "pause": if let paused = value as? Bool { pauseObservationRevision &+= 1 confirmedPaused = paused if pendingPauseIntents.isEmpty { cachedPaused = paused } } case "duration": if let duration = value as? Double { cachedDuration = duration } case "time-pos": if let timePos = value as? Double { cachedTimePos = timePos } case "width": if let width = value as? Double { cachedWidth = width } case "height": if let height = value as? Double { cachedHeight = height } default: break } } #if DEBUG func observeCachedPauseForTesting(_ paused: Bool) { updateCachedProperty(name: "pause", value: paused) } #endif private func retirePauseIntents(through generation: UInt64) { pendingPauseIntents = pendingPauseIntents.filter { $0.key > generation } } func beginCachedPauseIntent( _ paused: Bool ) -> (generation: UInt64, observationRevision: UInt64, paused: Bool) { cacheLock.lock() pauseIntentGeneration &+= 1 let intent = ( generation: pauseIntentGeneration, observationRevision: pauseObservationRevision, paused: paused ) pendingPauseIntents[intent.generation] = paused cachedPaused = paused cacheLock.unlock() return intent } func finishCachedPauseIntent( _ intent: (generation: UInt64, observationRevision: UInt64, paused: Bool), result: Result ) { cacheLock.lock() pendingPauseIntents.removeValue(forKey: intent.generation) if intent.generation >= resolvedPauseGeneration { resolvedPauseGeneration = intent.generation if case .success = result, intent.observationRevision == pauseObservationRevision { confirmedPaused = intent.paused } retirePauseIntents(through: intent.generation) } if let latest = pendingPauseIntents.lazy .filter({ $0.key > self.resolvedPauseGeneration }) .max(by: { $0.key < $1.key }) { cachedPaused = latest.value } else { cachedPaused = confirmedPaused } cacheLock.unlock() } private static let maximumNodeDepth = 32 private static let maximumNodeEntries = 4_096 private static let maximumNodeByteCount = 16 * 1_024 * 1_024 private static let maximumSideDataDimension: Int64 = 16_384 private static let maximumSideDataPixels: Int64 = 64 * 1_024 * 1_024 private struct NodeConversionBudget { var remainingEntries = MpvPlayerCoreBase.maximumNodeEntries var remainingBytes = MpvPlayerCoreBase.maximumNodeByteCount } func convertNode(_ node: mpv_node) -> Any? { var budget = NodeConversionBudget() return convertNode(node, depth: 0, budget: &budget) } func validateSideDataDimensions(width: Int64, height: Int64) -> Bool { guard width > 0, height > 0, width <= Self.maximumSideDataDimension, height <= Self.maximumSideDataDimension else { return false } let (pixels, overflow) = width.multipliedReportingOverflow(by: height) return !overflow && pixels <= Self.maximumSideDataPixels } private func dimensionValue(_ node: mpv_node) -> Int64? { switch node.format { case MPV_FORMAT_INT64: return node.u.int64 case MPV_FORMAT_DOUBLE: let value = node.u.double_ guard value.isFinite, value.rounded() == value, value >= Double(Int64.min), value <= Double(Int64.max) else { return nil } return Int64(value) default: return nil } } private func convertNodeString( _ pointer: UnsafePointer, budget: inout NodeConversionBudget ) -> String? { let length = strnlen(pointer, budget.remainingBytes + 1) guard length <= budget.remainingBytes else { return nil } budget.remainingBytes -= length if let string = String(validatingUTF8: pointer) { return string } let bytes = UnsafeBufferPointer( start: UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self), count: length ) return String(bytes.map { Character(Unicode.Scalar($0)) }) } private func hasValidSideDataDimensions(_ list: mpv_node_list, count: Int) -> Bool { var hasByteArray = false var widthNode: mpv_node? var heightNode: mpv_node? for index in 0.. Any? { guard depth <= Self.maximumNodeDepth, budget.remainingEntries > 0 else { return nil } budget.remainingEntries -= 1 switch node.format { case MPV_FORMAT_STRING: guard let string = node.u.string else { return nil } return convertNodeString(string, budget: &budget) case MPV_FORMAT_FLAG: return node.u.flag != 0 case MPV_FORMAT_INT64: return node.u.int64 case MPV_FORMAT_DOUBLE: return node.u.double_ case MPV_FORMAT_BYTE_ARRAY: guard let byteArray = node.u.ba?.pointee else { return nil } let byteCount = byteArray.size guard byteCount <= budget.remainingBytes else { return nil } guard byteCount == 0 || byteArray.data != nil else { return nil } budget.remainingBytes -= byteCount guard byteCount > 0, let data = byteArray.data else { return Data() } return Data(bytes: data, count: byteCount) case MPV_FORMAT_NODE_ARRAY: guard let list = node.u.list?.pointee else { return nil } let count = Int(list.num) guard list.num >= 0, count <= budget.remainingEntries else { return nil } guard count == 0 || list.values != nil else { return nil } var array = [Any]() array.reserveCapacity(count) for index in 0..= 0, count <= budget.remainingEntries else { return nil } guard count == 0 || (list.values != nil && list.keys != nil) else { return nil } guard hasValidSideDataDimensions(list, count: count) else { return nil } var dictionary = [String: Any]() dictionary.reserveCapacity(count) for index in 0..