import UIKit import MPVKit import CoreMedia import CoreVideo import AVFoundation protocol MPVLayerRendererDelegate: AnyObject { func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) } /// MPV player using vo_avfoundation for video output. /// This renders video directly to AVSampleBufferDisplayLayer for PiP support. final class MPVLayerRenderer { enum RendererError: Error { case mpvCreationFailed case mpvInitialization(Int32) } private let displayLayer: AVSampleBufferDisplayLayer private let queue = DispatchQueue(label: "mpv.avfoundation", qos: .userInitiated) private let stateQueue = DispatchQueue(label: "mpv.avfoundation.state", attributes: .concurrent) private var mpv: OpaquePointer? private var currentPreset: PlayerPreset? private var currentURL: URL? private var currentHeaders: [String: String]? private var pendingExternalSubtitles: [String] = [] private var initialSubtitleId: Int? private var initialAudioId: Int? private var isRunning = false private var isStopping = false // KVO observation for display layer status private var statusObservation: NSKeyValueObservation? weak var delegate: MPVLayerRendererDelegate? // Thread-safe state for playback private var _cachedDuration: Double = 0 private var _cachedPosition: Double = 0 private var _isPaused: Bool = true private var _playbackSpeed: Double = 1.0 private var _isLoading: Bool = false private var _isReadyToSeek: Bool = false // Thread-safe accessors private var cachedDuration: Double { get { stateQueue.sync { _cachedDuration } } set { stateQueue.async(flags: .barrier) { self._cachedDuration = newValue } } } private var cachedPosition: Double { get { stateQueue.sync { _cachedPosition } } set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } } } private var isPaused: Bool { get { stateQueue.sync { _isPaused } } set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } } } private var playbackSpeed: Double { get { stateQueue.sync { _playbackSpeed } } set { stateQueue.async(flags: .barrier) { self._playbackSpeed = newValue } } } private var isLoading: Bool { get { stateQueue.sync { _isLoading } } set { stateQueue.async(flags: .barrier) { self._isLoading = newValue } } } private var isReadyToSeek: Bool { get { stateQueue.sync { _isReadyToSeek } } set { stateQueue.async(flags: .barrier) { self._isReadyToSeek = newValue } } } var isPausedState: Bool { return isPaused } init(displayLayer: AVSampleBufferDisplayLayer) { self.displayLayer = displayLayer observeDisplayLayerStatus() } /// Watches for display layer failures and auto-recovers. /// /// iOS aggressively kills VideoToolbox decoder sessions when the app is /// backgrounded, the screen is locked, or system resources are low. /// This causes the video to go black - especially problematic for PiP. /// /// This KVO observer detects when the display layer status becomes `.failed` /// and automatically reinitializes the hardware decoder to restore video. private func observeDisplayLayerStatus() { statusObservation = displayLayer.observe(\.status, options: [.new]) { [weak self] layer, _ in guard let self else { return } if layer.status == .failed { print("🔧 Display layer failed - auto-resetting decoder") self.queue.async { self.performDecoderReset() } } } } /// Actually performs the decoder reset (called by observer or manually) private func performDecoderReset() { guard let handle = mpv else { return } print("🔧 Resetting decoder: status=\(displayLayer.status.rawValue), requiresFlush=\(displayLayer.requiresFlushToResumeDecoding)") commandSync(handle, ["set", "hwdec", "no"]) commandSync(handle, ["set", "hwdec", "auto"]) } deinit { stop() } func start() throws { guard !isRunning else { return } guard let handle = mpv_create() else { throw RendererError.mpvCreationFailed } mpv = handle // Logging - only warnings and errors in release, verbose in debug #if DEBUG checkError(mpv_request_log_messages(handle, "warn")) #else checkError(mpv_request_log_messages(handle, "no")) #endif // Pass the AVSampleBufferDisplayLayer to mpv via --wid // The vo_avfoundation driver expects this let layerPtrInt = Int(bitPattern: Unmanaged.passUnretained(displayLayer).toOpaque()) var displayLayerPtr = Int64(layerPtrInt) checkError(mpv_set_option(handle, "wid", MPV_FORMAT_INT64, &displayLayerPtr)) // Use AVFoundation video output - required for PiP support checkError(mpv_set_option_string(handle, "vo", "avfoundation")) // Enable composite OSD mode - renders subtitles directly onto video frames using GPU // This is better for PiP as subtitles are baked into the video checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes")) // Hardware decoding with VideoToolbox // On simulator, use software decoding since VideoToolbox is not available // On device, use VideoToolbox with software fallback enabled #if targetEnvironment(simulator) checkError(mpv_set_option_string(handle, "hwdec", "no")) #else checkError(mpv_set_option_string(handle, "hwdec", "videotoolbox")) #endif checkError(mpv_set_option_string(handle, "hwdec-codecs", "all")) checkError(mpv_set_option_string(handle, "hwdec-software-fallback", "yes")) // Subtitle and audio settings checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) // Initialize mpv let initStatus = mpv_initialize(handle) guard initStatus >= 0 else { throw RendererError.mpvInitialization(initStatus) } // Observe properties observeProperties() // Setup wakeup callback mpv_set_wakeup_callback(handle, { ctx in guard let ctx = ctx else { return } let instance = Unmanaged.fromOpaque(ctx).takeUnretainedValue() instance.processEvents() }, Unmanaged.passUnretained(self).toOpaque()) isRunning = true } func stop() { if isStopping { return } if !isRunning, mpv == nil { return } isRunning = false isStopping = true // Stop observing display layer status statusObservation?.invalidate() statusObservation = nil queue.sync { [weak self] in guard let self, let handle = self.mpv else { return } mpv_set_wakeup_callback(handle, nil, nil) mpv_terminate_destroy(handle) self.mpv = nil } DispatchQueue.main.async { [weak self] in guard let self else { return } if #available(iOS 18.0, *) { self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) } else { self.displayLayer.flushAndRemoveImage() } } isStopping = false } func load( url: URL, with preset: PlayerPreset, headers: [String: String]? = nil, startPosition: Double? = nil, externalSubtitles: [String]? = nil, initialSubtitleId: Int? = nil, initialAudioId: Int? = nil ) { currentPreset = preset currentURL = url currentHeaders = headers pendingExternalSubtitles = externalSubtitles ?? [] self.initialSubtitleId = initialSubtitleId self.initialAudioId = initialAudioId queue.async { [weak self] in guard let self else { return } self.isLoading = true self.isReadyToSeek = false DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didChangeLoading: true) } guard let handle = self.mpv else { return } self.apply(commands: preset.commands, on: handle) // Stop previous playback before loading new file self.command(handle, ["stop"]) self.updateHTTPHeaders(headers) // Set start position if let startPos = startPosition, startPos > 0 { self.setProperty(name: "start", value: String(format: "%.2f", startPos)) } else { self.setProperty(name: "start", value: "0") } // Set initial audio track if specified if let audioId = self.initialAudioId, audioId > 0 { self.setAudioTrack(audioId) } // Set initial subtitle track if no external subs if self.pendingExternalSubtitles.isEmpty { if let subId = self.initialSubtitleId { self.setSubtitleTrack(subId) } else { self.disableSubtitles() } } else { self.disableSubtitles() } let target = url.isFileURL ? url.path : url.absoluteString self.command(handle, ["loadfile", target, "replace"]) } } func reloadCurrentItem() { guard let url = currentURL, let preset = currentPreset else { return } load(url: url, with: preset, headers: currentHeaders) } func applyPreset(_ preset: PlayerPreset) { currentPreset = preset guard let handle = mpv else { return } queue.async { [weak self] in guard let self else { return } self.apply(commands: preset.commands, on: handle) } } // MARK: - Property Helpers private func setOption(name: String, value: String) { guard let handle = mpv else { return } checkError(mpv_set_option_string(handle, name, value)) } private func setProperty(name: String, value: String) { guard let handle = mpv else { return } let status = mpv_set_property_string(handle, name, value) if status < 0 { Logger.shared.log("Failed to set property \(name)=\(value) (\(status))", type: "Warn") } } private func clearProperty(name: String) { guard let handle = mpv else { return } let status = mpv_set_property(handle, name, MPV_FORMAT_NONE, nil) if status < 0 { Logger.shared.log("Failed to clear property \(name) (\(status))", type: "Warn") } } private func updateHTTPHeaders(_ headers: [String: String]?) { guard let headers, !headers.isEmpty else { clearProperty(name: "http-header-fields") return } let headerString = headers .map { key, value in "\(key): \(value)" } .joined(separator: "\r\n") setProperty(name: "http-header-fields", value: headerString) } private func observeProperties() { guard let handle = mpv else { return } let properties: [(String, mpv_format)] = [ ("duration", MPV_FORMAT_DOUBLE), ("time-pos", MPV_FORMAT_DOUBLE), ("pause", MPV_FORMAT_FLAG), ("track-list/count", MPV_FORMAT_INT64), ("paused-for-cache", MPV_FORMAT_FLAG) ] for (name, format) in properties { mpv_observe_property(handle, 0, name, format) } } private func apply(commands: [[String]], on handle: OpaquePointer) { for command in commands { guard !command.isEmpty else { continue } self.command(handle, command) } } private func command(_ handle: OpaquePointer, _ args: [String]) { guard !args.isEmpty else { return } _ = withCStringArray(args) { pointer in mpv_command_async(handle, 0, pointer) } } @discardableResult private func commandSync(_ handle: OpaquePointer, _ args: [String]) -> Int32 { guard !args.isEmpty else { return -1 } return withCStringArray(args) { pointer in mpv_command(handle, pointer) } } private func checkError(_ status: CInt) { if status < 0 { Logger.shared.log("MPV API error: \(String(cString: mpv_error_string(status)))", type: "Error") } } // MARK: - Event Handling private func processEvents() { queue.async { [weak self] in guard let self else { return } while self.mpv != nil && !self.isStopping { guard let handle = self.mpv, let eventPointer = mpv_wait_event(handle, 0) else { return } let event = eventPointer.pointee if event.event_id == MPV_EVENT_NONE { break } self.handleEvent(event) if event.event_id == MPV_EVENT_SHUTDOWN { break } } } } private func handleEvent(_ event: mpv_event) { switch event.event_id { case MPV_EVENT_FILE_LOADED: // Add external subtitles now that the file is loaded let hadExternalSubs = !pendingExternalSubtitles.isEmpty if hadExternalSubs, let handle = mpv { for (index, subUrl) in pendingExternalSubtitles.enumerated() { print("🔧 Adding external subtitle [\(index)]: \(subUrl)") // Use commandSync to ensure subs are added in exact order (not async) commandSync(handle, ["sub-add", subUrl]) } pendingExternalSubtitles = [] // Set subtitle after external subs are added if let subId = initialSubtitleId { setSubtitleTrack(subId) } else { disableSubtitles() } } if !isReadyToSeek { isReadyToSeek = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didBecomeReadyToSeek: true) } } // Notify loading ended if isLoading { isLoading = false DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didChangeLoading: false) } } case MPV_EVENT_SEEK: // Seek started - show loading indicator if !isLoading { isLoading = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didChangeLoading: true) } } case MPV_EVENT_PLAYBACK_RESTART: // Video playback has started/restarted (including after seek) if isLoading { isLoading = false DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didChangeLoading: false) } } case MPV_EVENT_PROPERTY_CHANGE: if let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee.name { let name = String(cString: property) refreshProperty(named: name, event: event) } case MPV_EVENT_SHUTDOWN: Logger.shared.log("mpv shutdown", type: "Warn") case MPV_EVENT_LOG_MESSAGE: if let logMessagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { let component = String(cString: logMessagePointer.pointee.prefix) let text = String(cString: logMessagePointer.pointee.text) let lower = text.lowercased() if lower.contains("error") { Logger.shared.log("mpv[\(component)] \(text)", type: "Error") } else if lower.contains("warn") || lower.contains("warning") { Logger.shared.log("mpv[\(component)] \(text)", type: "Warn") } } default: break } } private func refreshProperty(named name: String, event: mpv_event) { guard let handle = mpv else { return } switch name { case "duration": var value = Double(0) let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) if status >= 0 { cachedDuration = value DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) } } case "time-pos": var value = Double(0) let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) if status >= 0 { cachedPosition = value DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration) } } case "pause": var flag: Int32 = 0 let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) if status >= 0 { let newPaused = flag != 0 if newPaused != isPaused { isPaused = newPaused DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didChangePause: self.isPaused) } } } case "paused-for-cache": var flag: Int32 = 0 let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag) if status >= 0 { let buffering = flag != 0 if buffering != isLoading { isLoading = buffering DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didChangeLoading: buffering) } } } case "track-list/count": var trackCount: Int64 = 0 let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_INT64, value: &trackCount) if status >= 0 && trackCount > 0 { Logger.shared.log("Track list updated: \(trackCount) tracks available", type: "Info") DispatchQueue.main.async { [weak self] in guard let self else { return } self.delegate?.renderer(self, didBecomeTracksReady: true) } } default: break } } private func getStringProperty(handle: OpaquePointer, name: String) -> String? { var result: String? if let cString = mpv_get_property_string(handle, name) { result = String(cString: cString) mpv_free(cString) } return result } @discardableResult private func getProperty(handle: OpaquePointer, name: String, format: mpv_format, value: inout T) -> Int32 { return withUnsafeMutablePointer(to: &value) { mutablePointer in return mpv_get_property(handle, name, format, mutablePointer) } } @inline(__always) private func withCStringArray(_ args: [String], body: (UnsafeMutablePointer?>?) -> R) -> R { var cStrings = [UnsafeMutablePointer?]() cStrings.reserveCapacity(args.count + 1) for s in args { cStrings.append(strdup(s)) } cStrings.append(nil) defer { for ptr in cStrings where ptr != nil { free(ptr) } } return cStrings.withUnsafeMutableBufferPointer { buffer in return buffer.baseAddress!.withMemoryRebound(to: UnsafePointer?.self, capacity: buffer.count) { rebound in return body(UnsafeMutablePointer(mutating: rebound)) } } } // MARK: - Playback Controls func play() { setProperty(name: "pause", value: "no") } func pausePlayback() { setProperty(name: "pause", value: "yes") } func togglePause() { if isPaused { play() } else { pausePlayback() } } func seek(to seconds: Double) { guard let handle = mpv else { return } let clamped = max(0, seconds) cachedPosition = clamped commandSync(handle, ["seek", String(clamped), "absolute"]) } func seek(by seconds: Double) { guard let handle = mpv else { return } let newPosition = max(0, cachedPosition + seconds) cachedPosition = newPosition commandSync(handle, ["seek", String(seconds), "relative"]) } /// Sync timebase - no-op for vo_avfoundation (mpv handles timing) func syncTimebase() { // vo_avfoundation manages its own timebase } func setSpeed(_ speed: Double) { playbackSpeed = speed setProperty(name: "speed", value: String(speed)) } func getSpeed() -> Double { guard let handle = mpv else { return 1.0 } var speed: Double = 1.0 getProperty(handle: handle, name: "speed", format: MPV_FORMAT_DOUBLE, value: &speed) return speed } // MARK: - Subtitle Controls func getSubtitleTracks() -> [[String: Any]] { guard let handle = mpv else { Logger.shared.log("getSubtitleTracks: mpv handle is nil", type: "Warn") return [] } var tracks: [[String: Any]] = [] var trackCount: Int64 = 0 getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) for i in 0.. Int { guard let handle = mpv else { return 0 } var sid: Int64 = 0 getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid) return Int(sid) } func addSubtitleFile(url: String, select: Bool = true) { guard let handle = mpv else { return } let flag = select ? "select" : "cached" commandSync(handle, ["sub-add", url, flag]) } // MARK: - Subtitle Positioning func setSubtitlePosition(_ position: Int) { setProperty(name: "sub-pos", value: String(position)) } func setSubtitleScale(_ scale: Double) { setProperty(name: "sub-scale", value: String(scale)) } func setSubtitleMarginY(_ margin: Int) { setProperty(name: "sub-margin-y", value: String(margin)) } func setSubtitleAlignX(_ alignment: String) { setProperty(name: "sub-align-x", value: alignment) } func setSubtitleAlignY(_ alignment: String) { setProperty(name: "sub-align-y", value: alignment) } func setSubtitleFontSize(_ size: Int) { setProperty(name: "sub-font-size", value: String(size)) } // MARK: - Audio Track Controls func getAudioTracks() -> [[String: Any]] { guard let handle = mpv else { Logger.shared.log("getAudioTracks: mpv handle is nil", type: "Warn") return [] } var tracks: [[String: Any]] = [] var trackCount: Int64 = 0 getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount) for i in 0.. 0 { track["channels"] = Int(channels) } var selected: Int32 = 0 getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected) track["selected"] = selected != 0 Logger.shared.log("getAudioTracks: found audio track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info") tracks.append(track) } Logger.shared.log("getAudioTracks: returning \(tracks.count) audio tracks", type: "Info") return tracks } func setAudioTrack(_ trackId: Int) { guard mpv != nil else { Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn") return } Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info") setProperty(name: "aid", value: String(trackId)) } func getCurrentAudioTrack() -> Int { guard let handle = mpv else { return 0 } var aid: Int64 = 0 getProperty(handle: handle, name: "aid", format: MPV_FORMAT_INT64, value: &aid) return Int(aid) } // MARK: - Technical Info func getTechnicalInfo() -> [String: Any] { guard let handle = mpv else { return [:] } var info: [String: Any] = [:] // Video dimensions var videoWidth: Int64 = 0 var videoHeight: Int64 = 0 if getProperty(handle: handle, name: "video-params/w", format: MPV_FORMAT_INT64, value: &videoWidth) >= 0 { info["videoWidth"] = Int(videoWidth) } if getProperty(handle: handle, name: "video-params/h", format: MPV_FORMAT_INT64, value: &videoHeight) >= 0 { info["videoHeight"] = Int(videoHeight) } // Video codec if let videoCodec = getStringProperty(handle: handle, name: "video-format") { info["videoCodec"] = videoCodec } // Audio codec if let audioCodec = getStringProperty(handle: handle, name: "audio-codec-name") { info["audioCodec"] = audioCodec } // FPS (container fps) var fps: Double = 0 if getProperty(handle: handle, name: "container-fps", format: MPV_FORMAT_DOUBLE, value: &fps) >= 0 && fps > 0 { info["fps"] = fps } // Video bitrate (bits per second) var videoBitrate: Int64 = 0 if getProperty(handle: handle, name: "video-bitrate", format: MPV_FORMAT_INT64, value: &videoBitrate) >= 0 && videoBitrate > 0 { info["videoBitrate"] = Int(videoBitrate) } // Audio bitrate (bits per second) var audioBitrate: Int64 = 0 if getProperty(handle: handle, name: "audio-bitrate", format: MPV_FORMAT_INT64, value: &audioBitrate) >= 0 && audioBitrate > 0 { info["audioBitrate"] = Int(audioBitrate) } // Demuxer cache duration (seconds of video buffered) var cacheSeconds: Double = 0 if getProperty(handle: handle, name: "demuxer-cache-duration", format: MPV_FORMAT_DOUBLE, value: &cacheSeconds) >= 0 { info["cacheSeconds"] = cacheSeconds } // Dropped frames var droppedFrames: Int64 = 0 if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 { info["droppedFrames"] = Int(droppedFrames) } return info } }