Files
plezy/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift

1481 lines
45 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"))
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)))")
}
}
}