Files
plezy/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift
T
edde746 35eef09f72 fix(tvos): present video on the host clock instead of the media timebase
Direct Play on Apple TV develops recurring micro-stutter that worsens
the longer playback runs and clears temporarily on pause/play
(issue #1776). Since 2.4 the avfoundation VO has presented frames
against a media-time CMTimebase anchored and rate-servoed to follow
mpv's audio-slaved schedule; clock skew between the two domains can
only be retired by rate slewing or a re-anchor snap that retimes every
queued frame at once. The media timebase exists for PiP, which tvOS
does not have.

Bump MPVKit to 1.0.22 and opt tvOS into its new
avfoundation-presentation=host mode: samples carry mpv's scheduled
display time against a free-running host-clock timebase, so drift is
absorbed per frame inside mpv and no timing state accumulates in the
VO. The queue lead returns to 128ms; 500ms only dampened media-mode
snaps and quadrupled their blast radius. 1.0.22 also paces the
compressed-passthrough feed off the most-advanced playhead reading,
fixing the periodic audio dropouts passthrough users reported on 2.14.
2026-08-18 08:38:46 +02:00

1486 lines
46 KiB
Swift

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<CChar>) -> 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<String> = [
"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<UInt64> = [
internalSigPeakObserverId,
internalWidthObserverId,
internalHeightObserverId,
internalDoviProfileObserverId,
internalDoviLevelObserverId,
internalContainerFpsObserverId,
internalVideoGammaObserverId,
internalVideoPrimariesObserverId,
internalVideoColorMatrixObserverId,
]
let queue = DispatchQueue(label: "mpv", qos: .userInitiated)
private let queueKey = DispatchSpecificKey<Void>()
private enum PendingRequest {
case void((Result<Void, Error>) -> Void)
case getProperty((Result<String?, Error>) -> 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<T>(_ 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<MpvWakeupCallbackContext>.fromOpaque(wakeupContext).release()
return false
}
lifecycleState.mpv = mpv
lifecycleState.wakeupCallbackContext = wakeupContext
mpv_set_wakeup_callback(
mpv,
{ context in
guard let context else { return }
Unmanaged<MpvWakeupCallbackContext>.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, Error>) -> 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, Error>) -> 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, Error>) -> 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<String?, Error>) -> 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, Error>) -> Void) {
guard !args.isEmpty else {
completeOnMain { completion(.success(())) }
return
}
var cargs: [UnsafeMutablePointer<CChar>?] = 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, Error>) -> Void
) {
submitAsyncRequest(.void(completion)) { mpv, requestId in
name.withCString { namePointer in
value.withCString { valuePointer in
var propertyValue: UnsafePointer<CChar>? = 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<MpvWakeupCallbackContext>.fromOpaque(callbackContext)
.takeUnretainedValue()
.detach()
}
let destroy = {
if let mpvHandle {
mpv_set_wakeup_callback(mpvHandle, nil, nil)
mpv_terminate_destroy(mpvHandle)
}
if let callbackContext {
Unmanaged<MpvWakeupCallbackContext>.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"))
// Host-clock presentation: samples carry mpv's scheduled display
// time against a free-running timebase, so audio-clock drift never
// accumulates in the VO (#1776). Media-time presentation is only
// needed for PiP, which tvOS does not have.
checkError(mpv_set_option_string(mpv, "avfoundation-presentation", "host"))
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<Void, Error> =
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<CChar>?.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<CChar>?.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<Void, Error>
) {
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<CChar>,
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..<count {
let value = list.values[index]
hasByteArray = hasByteArray || value.format == MPV_FORMAT_BYTE_ARRAY
guard let keyPointer = list.keys[index] else { continue }
if strcmp(keyPointer, "width") == 0 || strcmp(keyPointer, "w") == 0 {
widthNode = value
} else if strcmp(keyPointer, "height") == 0 || strcmp(keyPointer, "h") == 0 {
heightNode = value
}
}
guard hasByteArray, widthNode != nil || heightNode != nil else { return true }
guard let widthNode, let heightNode,
let width = dimensionValue(widthNode),
let height = dimensionValue(heightNode)
else { return false }
return validateSideDataDimensions(width: width, height: height)
}
private func convertNode(
_ node: mpv_node,
depth: Int,
budget: inout NodeConversionBudget
) -> 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..<count {
if let item = convertNode(list.values[index], depth: depth + 1, budget: &budget) {
array.append(item)
}
}
return array
case MPV_FORMAT_NODE_MAP:
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 && 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..<count {
if let keyPointer = list.keys[index],
let key = convertNodeString(keyPointer, budget: &budget),
let value = convertNode(list.values[index], depth: depth + 1, budget: &budget)
{
dictionary[key] = value
}
}
return dictionary
default:
return nil
}
}
func checkError(_ status: CInt) {
if status < 0 {
print("[MpvPlayerCore] MPV error: \(safeString(mpv_error_string(status)))")
}
}
}