import AVFoundation import Foundation import Libmpv import QuartzCore #if os(iOS) || os(tvOS) import UIKit #elseif os(macOS) import Cocoa #endif protocol MpvPlayerDelegate: AnyObject { func onPropertyChange(name: String, value: Any?) func onEvent(name: String, data: [String: Any]?) } #if os(macOS) // Workaround for MoltenVK problems that cause flicker. // https://github.com/mpv-player/mpv/pull/13651 class MpvMetalLayer: CAMetalLayer { override var drawableSize: CGSize { get { super.drawableSize } set { if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { super.drawableSize = newValue } } } override var wantsExtendedDynamicRangeContent: Bool { get { super.wantsExtendedDynamicRangeContent } set { if Thread.isMainThread { super.wantsExtendedDynamicRangeContent = newValue } else { DispatchQueue.main.async { super.wantsExtendedDynamicRangeContent = newValue } } } } } #else class MpvVideoLayer: AVSampleBufferDisplayLayer {} #endif /// Safely convert a C string to Swift String with UTF-8 validation. /// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. /// mpv does not guarantee UTF-8 for log messages, error strings, or /// system-encoded paths and Flutter codecs reject invalid UTF-8. func safeString(_ cstr: UnsafePointer) -> String { if let string = String(validatingUTF8: cstr) { return string } let length = strlen(cstr) let buffer = UnsafeBufferPointer( start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), count: length ) return String(buffer.map { Character(Unicode.Scalar($0)) }) } struct ServerDisplayCriteria { let doviProfile: Int64 let doviLevel: Int64 let doviCompatibilityId: Int64? let fps: Double let width: Int32 let height: Int32 let gamma: String? let primaries: String? let colorMatrix: String? } class MpvPlayerCoreBase: NSObject { weak var delegate: MpvPlayerDelegate? #if os(macOS) var metalLayer: MpvMetalLayer? #else var videoLayer: MpvVideoLayer? #endif var mpv: OpaquePointer? var isInitialized = false var isDisposing = false var isPipActive = false var isBackgrounded = false private var wakeupCallbackContext: UnsafeMutableRawPointer? 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 var hdrEnabled: Bool { cacheLock.lock() defer { cacheLock.unlock() } return cachedHDREnabled } var lastSigPeak: Double { cacheLock.lock() defer { cacheLock.unlock() } return cachedLastSigPeak } var doviProfile: Int64 { cacheLock.lock() defer { cacheLock.unlock() } return cachedDoviProfile } var doviLevel: Int64 { cacheLock.lock() defer { cacheLock.unlock() } return cachedDoviLevel } var containerFps: Double { cacheLock.lock() defer { cacheLock.unlock() } return cachedContainerFps } /// Properties that must still flow to Dart while backgrounded (state-critical). private static let criticalProperties: Set = [ "pause", "eof-reached", "paused-for-cache", "time-pos", "duration", "seekable", ] private static let internalSigPeakObserverId: UInt64 = UInt64.max - 1 private static let internalWidthObserverId: UInt64 = UInt64.max - 2 private static let internalHeightObserverId: UInt64 = UInt64.max - 3 private static let internalDoviProfileObserverId: UInt64 = UInt64.max - 4 private static let internalDoviLevelObserverId: UInt64 = UInt64.max - 5 private static let internalContainerFpsObserverId: UInt64 = UInt64.max - 6 private static let internalVideoGammaObserverId: UInt64 = UInt64.max - 7 private static let internalVideoPrimariesObserverId: UInt64 = UInt64.max - 8 private static let internalVideoColorMatrixObserverId: UInt64 = UInt64.max - 9 private static let internalObserverIds: Set = [ internalSigPeakObserverId, internalWidthObserverId, internalHeightObserverId, internalDoviProfileObserverId, internalDoviLevelObserverId, internalContainerFpsObserverId, internalVideoGammaObserverId, internalVideoPrimariesObserverId, internalVideoColorMatrixObserverId, ] let queue = DispatchQueue(label: "mpv", qos: .userInitiated) private let queueKey = DispatchSpecificKey() private enum PendingRequest { case void((Result) -> Void) case getProperty((Result) -> Void) } private var pendingRequests: [UInt64: PendingRequest] = [:] private let pendingRequestsLock = NSLock() private var nextRequestId: UInt64 = 1 private let cacheLock = NSLock() private var cachedPaused = true private var 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: ()) } func configurePlatformMpvOptions() {} 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 } func scheduleDisplayCriteriaUpdate() { cacheLock.lock() if serverDisplayCriteriaActive { cacheLock.unlock() return } 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?) { cacheLock.lock() serverDisplayCriteriaActive = criteria != nil cacheLock.unlock() let apply = { [weak self] in guard let self else { return } guard let criteria else { _ = self.updateDisplayCriteria( doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil, fps: 0, width: 0, height: 0, sigPeak: 0, gamma: nil, primaries: nil, colorMatrix: nil ) 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.cacheLock.unlock() self.scheduleDisplayCriteriaUpdate() } } if Thread.isMainThread { apply() } else { DispatchQueue.main.async(execute: apply) } } func setupMpv() -> Bool { #if os(macOS) guard let renderLayer = metalLayer else { return false } #else guard let renderLayer = videoLayer else { return false } #endif mpv = mpv_create() guard let mpv else { print("[MpvPlayerCore] Failed to create MPV context") return false } #if DEBUG checkError(mpv_request_log_messages(mpv, "info")) #else checkError(mpv_request_log_messages(mpv, "warn")) #endif var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque())) checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) applySharedMpvOptions() configurePlatformMpvOptions() let initResult = mpv_initialize(mpv) if initResult < 0 { print("[MpvPlayerCore] mpv_initialize failed: \(safeString(mpv_error_string(initResult)))") mpv_terminate_destroy(mpv) self.mpv = nil return false } // mpv stores this context without retaining it. Retain manually so the // Swift core cannot deallocate while mpv can still fire wakeup callbacks. let wakeupContext = Unmanaged.passRetained(self).toOpaque() wakeupCallbackContext = wakeupContext mpv_set_wakeup_callback( mpv, { context in guard let context else { return } let core = Unmanaged.fromOpaque(context).takeUnretainedValue() core.readEvents() }, wakeupContext ) 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 true } func setLogLevel(_ level: String) { guard let mpv else { return } 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 ) { #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)") completion(.success(())) return } updateVideoGravityIfNeeded(name: name, value: value) if name == "pause" { setCachedPaused(value == "yes" || value == "true" || value == "1") } if name == "hdr-enabled" { let enabled = value == "yes" || value == "true" || value == "1" setHDREnabled(enabled, completion: completion) return } setRawStringPropertyAsync(name, value: value, completion: completion) } func setInt64PropertyAsync( _ name: String, value: Int64, completion: @escaping (Result) -> Void ) { guard let mpv else { completion(.success(())) return } let requestId = registerRequest(.void(completion)) var propertyValue = value let status = name.withCString { namePointer in mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_INT64, &propertyValue) } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } func setHDREnabled(_ enabled: Bool, completion: ((Result) -> Void)? = nil) { cacheLock.lock() cachedHDREnabled = enabled let sigPeak = cachedLastSigPeak cacheLock.unlock() print("[MpvPlayerCore] HDR enabled: \(enabled)") setRawStringPropertyAsync( "target-colorspace-hint", value: enabled ? "yes" : "no", completion: completion ?? { _ in } ) DispatchQueue.main.async { self.updateEDRMode(sigPeak: sigPeak) } } func getPropertyAsync(_ name: String, completion: @escaping (Result) -> Void) { guard let mpv else { completion(.success(nil)) return } let requestId = registerRequest(.getProperty(completion)) let status = name.withCString { namePointer in mpv_get_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING) } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } func observeProperty(_ name: String, format: String) { guard mpv != nil else { return } 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 } mpv_observe_property(mpv, 0, name, mpvFormat) } func command(_ args: [String]) { commandAsync(args) { _ in } } func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { guard let mpv, !args.isEmpty else { completion(.success(())) return } let requestId = registerRequest(.void(completion)) var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } cargs.append(nil) cargs.withUnsafeBufferPointer { buffer in var constPointers = buffer.map { UnsafePointer($0) } let result = mpv_command_async(mpv, requestId, &constPointers) completeRequestIfSubmissionFailed(requestId: requestId, status: result) } for pointer in cargs { free(pointer) } } private func setRawStringPropertyAsync( _ name: String, value: String, completion: @escaping (Result) -> Void ) { guard let mpv else { completion(.success(())) return } let requestId = registerRequest(.void(completion)) let status = name.withCString { namePointer in value.withCString { valuePointer in var propertyValue: UnsafePointer? = valuePointer return mpv_set_property_async(mpv, requestId, namePointer, MPV_FORMAT_STRING, &propertyValue) } } completeRequestIfSubmissionFailed(requestId: requestId, status: status) } 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) { isDisposing = true cancelPendingRequests() cacheLock.lock() cachedDoviProfile = 0 cachedDoviLevel = 0 cachedContainerFps = 0 cachedLastSigPeak = 0 cachedVideoGamma = nil cachedVideoPrimaries = nil cachedVideoColorMatrix = nil serverDisplayCriteriaActive = false cacheLock.unlock() let mpvHandle = mpv let callbackContext = wakeupCallbackContext mpv = nil wakeupCallbackContext = nil let destroy = { if let mpvHandle { mpv_set_wakeup_callback(mpvHandle, nil, nil) mpv_terminate_destroy(mpvHandle) } if let callbackContext { Unmanaged.fromOpaque(callbackContext).release() } } if destroySynchronously { if DispatchQueue.getSpecific(key: queueKey) != nil { destroy() } else { queue.sync(execute: destroy) } } else { queue.async(execute: destroy) } } private func applySharedMpvOptions() { guard let mpv else { return } #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", "yes")) 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", "yes")) } private func isManagedRendererProperty(_ name: String) -> Bool { name == "vo" || name == "wid" || name == "gpu-api" || name == "gpu-context" || name == "avfoundation-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 cancelPendingRequests() { pendingRequestsLock.lock() let pending = pendingRequests pendingRequests.removeAll() pendingRequestsLock.unlock() let error = NSError( domain: "mpv", code: -1, userInfo: [NSLocalizedDescriptionKey: "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 mpvError(_ status: CInt) -> NSError { NSError( domain: "mpv", code: Int(status), userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(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 let request = takeRequest(requestId) else { return } DispatchQueue.main.async { switch request { case .void(let completion): if status < 0 { completion(.failure(self.mpvError(status))) } else { completion(.success(())) } case .getProperty: break } } } private func completeGetPropertyRequest(_ event: mpv_event) { guard let request = takeRequest(event.reply_userdata) else { return } guard case .getProperty(let completion) = request else { return } var value: String? if event.error >= 0, let propertyPointer = event.data?.assumingMemoryBound(to: mpv_event_property.self) { let property = propertyPointer.pointee if property.format == MPV_FORMAT_STRING, let data = property.data { let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee value = cstring.map { safeString($0) } } } DispatchQueue.main.async { completion(.success(value)) } } private func readEvents() { queue.async { [weak self] in guard let self, !self.isDisposing, let mpv = self.mpv else { return } while true { 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) } } } 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_FILE_LOADED: DispatchQueue.main.async { self.delegate?.onEvent(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)) } DispatchQueue.main.async { self.delegate?.onEvent(name: "end-file", data: data) } } else { DispatchQueue.main.async { self.delegate?.onEvent(name: "end-file", data: nil) } } case MPV_EVENT_SHUTDOWN: print("[MpvPlayerCore] MPV shutdown event") case MPV_EVENT_PLAYBACK_RESTART: DispatchQueue.main.async { self.delegate?.onEvent(name: "playback-restart", data: nil) } case MPV_EVENT_LOG_MESSAGE: if isBackgrounded { 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) } ?? "" DispatchQueue.main.async { self.delegate?.onEvent( name: "log-message", data: ["prefix": prefix, "level": level, "text": text] ) } } default: break } } private func handlePropertyChange(name: String, property: mpv_event_property, replyUserdata: UInt64) { var value: Any? switch property.format { case MPV_FORMAT_DOUBLE: if let data = property.data { value = data.assumingMemoryBound(to: Double.self).pointee } case MPV_FORMAT_INT64: if let data = property.data { value = data.assumingMemoryBound(to: Int64.self).pointee } case MPV_FORMAT_FLAG: if let data = property.data { value = data.assumingMemoryBound(to: Int32.self).pointee != 0 } case MPV_FORMAT_NODE: if let data = property.data { let node = data.assumingMemoryBound(to: mpv_node.self).pointee value = convertNode(node) } case MPV_FORMAT_STRING: if let data = property.data { let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee value = cstring.map { safeString($0) } } default: break } updateCachedProperty(name: name, value: value) if name == "video-params/sig-peak", let sigPeak = value as? Double { cacheLock.lock() cachedLastSigPeak = sigPeak cacheLock.unlock() DispatchQueue.main.async { self.updateEDRMode(sigPeak: sigPeak) } scheduleDisplayCriteriaUpdate() } switch name { case "current-tracks/video/dolby-vision-profile": cacheLock.lock() cachedDoviProfile = (value as? Int64) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "current-tracks/video/dolby-vision-level": cacheLock.lock() cachedDoviLevel = (value as? Int64) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "container-fps": cacheLock.lock() cachedContainerFps = (value as? Double) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "video-params/gamma": cacheLock.lock() cachedVideoGamma = value as? String cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "video-params/primaries": cacheLock.lock() cachedVideoPrimaries = value as? String cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "video-params/colormatrix": cacheLock.lock() cachedVideoColorMatrix = value as? String cacheLock.unlock() scheduleDisplayCriteriaUpdate() case "width", "height": scheduleDisplayCriteriaUpdate() default: break } if Self.internalObserverIds.contains(replyUserdata) { return } if isBackgrounded && !Self.criticalProperties.contains(name) { return } DispatchQueue.main.async { self.delegate?.onPropertyChange(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 { 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 } } private func setCachedPaused(_ paused: Bool) { cacheLock.lock() cachedPaused = paused cacheLock.unlock() } private func convertNode(_ node: mpv_node) -> Any? { switch node.format { case MPV_FORMAT_STRING: return node.u.string.map { safeString($0) } 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_NODE_ARRAY: guard let list = node.u.list?.pointee else { return nil } var array = [Any]() for index in 0..