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.
1486 lines
46 KiB
Swift
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)))")
|
|
}
|
|
}
|
|
}
|