import UIKit import Libmpv import CoreMedia import CoreVideo import AVFoundation protocol MPVSoftwareRendererDelegate: AnyObject { func renderer(_ renderer: MPVSoftwareRenderer, didUpdatePosition position: Double, duration: Double) func renderer(_ renderer: MPVSoftwareRenderer, didChangePause isPaused: Bool) func renderer(_ renderer: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVSoftwareRenderer, didBecomeTracksReady: Bool) } final class MPVSoftwareRenderer { enum RendererError: Error { case mpvCreationFailed case mpvInitialization(Int32) case renderContextCreation(Int32) } private let displayLayer: AVSampleBufferDisplayLayer private let renderQueue = DispatchQueue(label: "mpv.software.render", qos: .userInitiated) private let eventQueue = DispatchQueue(label: "mpv.software.events", qos: .utility) private let stateQueue = DispatchQueue(label: "mpv.software.state", attributes: .concurrent) private let eventQueueGroup = DispatchGroup() private let renderQueueKey = DispatchSpecificKey() private var dimensionsArray = [Int32](repeating: 0, count: 2) private var renderParams = [mpv_render_param](repeating: mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil), count: 5) private var mpv: OpaquePointer? private var renderContext: OpaquePointer? private var videoSize: CGSize = .zero private var pixelBufferPool: CVPixelBufferPool? private var pixelBufferPoolAuxAttributes: CFDictionary? private var formatDescription: CMVideoFormatDescription? private var didFlushForFormatChange = false private var poolWidth: Int = 0 private var poolHeight: Int = 0 private var preAllocatedBuffers: [CVPixelBuffer] = [] private let maxPreAllocatedBuffers = 12 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 disposeBag: [() -> Void] = [] private var isRunning = false private var isStopping = false private var shouldClearPixelBuffer = false private let bgraFormatCString: [CChar] = Array("bgra\0".utf8CString) private let maxInFlightBuffers = 3 private var inFlightBufferCount = 0 private let inFlightLock = NSLock() weak var delegate: MPVSoftwareRendererDelegate? // Thread-safe state for playback (uses existing stateQueue to prevent races causing stutter) private var _cachedDuration: Double = 0 private var _cachedPosition: Double = 0 private var _isPaused: Bool = true private var _playbackSpeed: Double = 1.0 private var _isSeeking: Bool = false private var _positionUpdateTime: CFTimeInterval = 0 // Host time when position was last updated private var _lastPTS: Double = 0 // Last presentation timestamp (ensures monotonic increase) // 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 isSeeking: Bool { get { stateQueue.sync { _isSeeking } } set { stateQueue.async(flags: .barrier) { self._isSeeking = newValue } } } private var positionUpdateTime: CFTimeInterval { get { stateQueue.sync { _positionUpdateTime } } set { stateQueue.async(flags: .barrier) { self._positionUpdateTime = newValue } } } private var lastPTS: Double { get { stateQueue.sync { _lastPTS } } set { stateQueue.async(flags: .barrier) { self._lastPTS = newValue } } } /// Get next monotonically increasing PTS based on video position /// This ensures frames always have increasing timestamps (prevents stutter from drops) private func nextMonotonicPTS() -> Double { let currentPos = interpolatedPosition() let last = lastPTS // Ensure PTS always increases (by at least 1ms) to prevent frame drops let pts = max(currentPos, last + 0.001) lastPTS = pts return pts } /// Calculate smooth interpolated position based on last known position + elapsed time private func interpolatedPosition() -> Double { let basePosition = cachedPosition let lastUpdate = positionUpdateTime let paused = isPaused let speed = playbackSpeed guard !paused, lastUpdate > 0 else { return basePosition } let elapsed = CACurrentMediaTime() - lastUpdate return basePosition + (elapsed * speed) } private var isLoading: Bool = false private var isRenderScheduled = false private var lastRenderTime: CFTimeInterval = 0 private var minRenderInterval: CFTimeInterval private var isReadyToSeek: Bool = false private var lastRenderDimensions: CGSize = .zero var isPausedState: Bool { return isPaused } init(displayLayer: AVSampleBufferDisplayLayer) { guard let screen = UIApplication.shared.connectedScenes .compactMap({ ($0 as? UIWindowScene)?.screen }) .first else { fatalError("⚠️ No active screen found — app may not have a visible window yet.") } self.displayLayer = displayLayer let maxFPS = screen.maximumFramesPerSecond let cappedFPS = min(maxFPS, 60) self.minRenderInterval = 1.0 / CFTimeInterval(cappedFPS) renderQueue.setSpecific(key: renderQueueKey, value: ()) } deinit { stop() } func start() throws { guard !isRunning else { return } guard let handle = mpv_create() else { throw RendererError.mpvCreationFailed } mpv = handle setOption(name: "terminal", value: "yes") setOption(name: "msg-level", value: "status") setOption(name: "keep-open", value: "yes") setOption(name: "idle", value: "yes") setOption(name: "vo", value: "libmpv") setOption(name: "hwdec", value: "videotoolbox-copy") setOption(name: "gpu-api", value: "metal") setOption(name: "gpu-context", value: "metal") setOption(name: "demuxer-thread", value: "yes") setOption(name: "ytdl", value: "yes") setOption(name: "profile", value: "fast") setOption(name: "vd-lavc-threads", value: "8") setOption(name: "cache", value: "yes") setOption(name: "demuxer-max-bytes", value: "150M") setOption(name: "demuxer-readahead-secs", value: "20") // Subtitle options - use vf=sub to burn subtitles into video frames // This happens at the filter level, BEFORE the software renderer setOption(name: "vf", value: "sub") setOption(name: "sub-visibility", value: "yes") let initStatus = mpv_initialize(handle) guard initStatus >= 0 else { throw RendererError.mpvInitialization(initStatus) } mpv_request_log_messages(handle, "warn") try createRenderContext() observeProperties() installWakeupHandler() isRunning = true } func stop() { if isStopping { return } if !isRunning, mpv == nil { return } isRunning = false isStopping = true var handleForShutdown: OpaquePointer? renderQueue.sync { [weak self] in guard let self else { return } if let ctx = self.renderContext { mpv_render_context_set_update_callback(ctx, nil, nil) mpv_render_context_free(ctx) self.renderContext = nil } handleForShutdown = self.mpv if let handle = handleForShutdown { mpv_set_wakeup_callback(handle, nil, nil) self.command(handle, ["quit"]) mpv_wakeup(handle) } self.formatDescription = nil self.preAllocatedBuffers.removeAll() self.pixelBufferPool = nil self.poolWidth = 0 self.poolHeight = 0 self.lastRenderDimensions = .zero } eventQueueGroup.wait() renderQueue.sync { [weak self] in guard let self else { return } if let handle = handleForShutdown { mpv_destroy(handle) } self.mpv = nil self.preAllocatedBuffers.removeAll() self.pixelBufferPool = nil self.pixelBufferPoolAuxAttributes = nil self.formatDescription = nil self.poolWidth = 0 self.poolHeight = 0 self.lastRenderDimensions = .zero self.disposeBag.forEach { $0() } self.disposeBag.removeAll() } 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 renderQueue.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) // Sync stop to ensure previous playback is stopped before loading new file self.commandSync(handle, ["stop"]) self.updateHTTPHeaders(headers) // Set start position using property (setOption only works before mpv_initialize) 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 (external subs change track IDs) if self.pendingExternalSubtitles.isEmpty { if let subId = self.initialSubtitleId { self.setSubtitleTrack(subId) } else { self.disableSubtitles() } } else { // External subs will be added after file loads, set sid then self.disableSubtitles() } var finalURL = url if !url.isFileURL { finalURL = url } let target = finalURL.isFileURL ? finalURL.path : finalURL.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 } renderQueue.async { [weak self] in guard let self else { return } self.apply(commands: preset.commands, on: handle) } } private func setOption(name: String, value: String) { guard let handle = mpv else { return } _ = value.withCString { valuePointer in name.withCString { namePointer in mpv_set_option_string(handle, namePointer, valuePointer) } } } private func setProperty(name: String, value: String) { guard let handle = mpv else { return } let status = value.withCString { valuePointer in name.withCString { namePointer in mpv_set_property_string(handle, namePointer, valuePointer) } } 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 = name.withCString { namePointer in mpv_set_property(handle, namePointer, 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 createRenderContext() throws { guard let handle = mpv else { return } var apiType = MPV_RENDER_API_TYPE_SW let status = withUnsafePointer(to: &apiType) { apiTypePtr in var params = [ mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: UnsafeMutableRawPointer(mutating: apiTypePtr)), mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil) ] return params.withUnsafeMutableBufferPointer { pointer -> Int32 in pointer.baseAddress?.withMemoryRebound(to: mpv_render_param.self, capacity: pointer.count) { parameters in return mpv_render_context_create(&renderContext, handle, parameters) } ?? -1 } } guard status >= 0, renderContext != nil else { throw RendererError.renderContextCreation(status) } mpv_render_context_set_update_callback(renderContext, { context in guard let context = context else { return } let instance = Unmanaged.fromOpaque(context).takeUnretainedValue() instance.scheduleRender() }, Unmanaged.passUnretained(self).toOpaque()) } private func observeProperties() { guard let handle = mpv else { return } let properties: [(String, mpv_format)] = [ ("dwidth", MPV_FORMAT_INT64), ("dheight", MPV_FORMAT_INT64), ("duration", MPV_FORMAT_DOUBLE), ("time-pos", MPV_FORMAT_DOUBLE), ("pause", MPV_FORMAT_FLAG), ("track-list/count", MPV_FORMAT_INT64) // Notify when tracks are available ] for (name, format) in properties { _ = name.withCString { pointer in mpv_observe_property(handle, 0, pointer, format) } } } private func installWakeupHandler() { guard let handle = mpv else { return } mpv_set_wakeup_callback(handle, { userdata in guard let userdata else { return } let instance = Unmanaged.fromOpaque(userdata).takeUnretainedValue() instance.processEvents() }, Unmanaged.passUnretained(self).toOpaque()) renderQueue.async { [weak self] in guard let self else { return } self.disposeBag.append { [weak self] in guard let self, let handle = self.mpv else { return } mpv_set_wakeup_callback(handle, nil, nil) } } } private func scheduleRender() { renderQueue.async { [weak self] in guard let self, self.isRunning, !self.isStopping else { return } let currentTime = CACurrentMediaTime() let timeSinceLastRender = currentTime - self.lastRenderTime if timeSinceLastRender < self.minRenderInterval { let remaining = self.minRenderInterval - timeSinceLastRender if self.isRenderScheduled { return } self.isRenderScheduled = true self.renderQueue.asyncAfter(deadline: .now() + remaining) { [weak self] in guard let self else { return } self.lastRenderTime = CACurrentMediaTime() self.performRenderUpdate() self.isRenderScheduled = false } return } self.isRenderScheduled = true self.lastRenderTime = currentTime self.performRenderUpdate() self.isRenderScheduled = false } } private func performRenderUpdate() { guard let context = renderContext else { return } let status = mpv_render_context_update(context) let updateFlags = UInt32(status) if updateFlags & MPV_RENDER_UPDATE_FRAME.rawValue != 0 { renderFrame() } if status > 0 { scheduleRender() } } private func renderFrame() { guard let context = renderContext else { return } let videoSize = currentVideoSize() guard videoSize.width > 0, videoSize.height > 0 else { return } let targetSize = targetRenderSize(for: videoSize) let width = Int(targetSize.width) let height = Int(targetSize.height) guard width > 0, height > 0 else { return } if lastRenderDimensions != targetSize { lastRenderDimensions = targetSize if targetSize != videoSize { Logger.shared.log("Rendering scaled output at \(width)x\(height) (source \(Int(videoSize.width))x\(Int(videoSize.height)))", type: "Info") } else { Logger.shared.log("Rendering output at native size \(width)x\(height)", type: "Info") } } if poolWidth != width || poolHeight != height { recreatePixelBufferPool(width: width, height: height) } var pixelBuffer: CVPixelBuffer? var status: CVReturn = kCVReturnError if !preAllocatedBuffers.isEmpty { pixelBuffer = preAllocatedBuffers.removeFirst() status = kCVReturnSuccess } else if let pool = pixelBufferPool { status = CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(kCFAllocatorDefault, pool, pixelBufferPoolAuxAttributes, &pixelBuffer) } if status != kCVReturnSuccess || pixelBuffer == nil { let attrs: [CFString: Any] = [ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!, kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue!, kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue!, kCVPixelBufferWidthKey: width, kCVPixelBufferHeightKey: height, kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA ] status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer) } guard status == kCVReturnSuccess, let buffer = pixelBuffer else { Logger.shared.log("Failed to create pixel buffer for rendering (status: \(status))", type: "Error") return } let actualFormat = CVPixelBufferGetPixelFormatType(buffer) if actualFormat != kCVPixelFormatType_32BGRA { Logger.shared.log("Pixel buffer format mismatch: expected BGRA (0x42475241), got \(actualFormat)", type: "Error") } CVPixelBufferLockBaseAddress(buffer, []) guard let baseAddress = CVPixelBufferGetBaseAddress(buffer) else { CVPixelBufferUnlockBaseAddress(buffer, []) return } if shouldClearPixelBuffer { let bufferDataSize = CVPixelBufferGetDataSize(buffer) memset(baseAddress, 0, bufferDataSize) shouldClearPixelBuffer = false } dimensionsArray[0] = Int32(width) dimensionsArray[1] = Int32(height) let stride = Int32(CVPixelBufferGetBytesPerRow(buffer)) let expectedMinStride = Int32(width * 4) if stride < expectedMinStride { Logger.shared.log("Unexpected pixel buffer stride \(stride) < expected \(expectedMinStride) — skipping render to avoid memory corruption", type: "Error") CVPixelBufferUnlockBaseAddress(buffer, []) return } let pointerValue = baseAddress dimensionsArray.withUnsafeMutableBufferPointer { dimsPointer in bgraFormatCString.withUnsafeBufferPointer { formatPointer in withUnsafePointer(to: stride) { stridePointer in renderParams[0] = mpv_render_param(type: MPV_RENDER_PARAM_SW_SIZE, data: UnsafeMutableRawPointer(dimsPointer.baseAddress)) renderParams[1] = mpv_render_param(type: MPV_RENDER_PARAM_SW_FORMAT, data: UnsafeMutableRawPointer(mutating: formatPointer.baseAddress)) renderParams[2] = mpv_render_param(type: MPV_RENDER_PARAM_SW_STRIDE, data: UnsafeMutableRawPointer(mutating: stridePointer)) renderParams[3] = mpv_render_param(type: MPV_RENDER_PARAM_SW_POINTER, data: pointerValue) renderParams[4] = mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil) let rc = mpv_render_context_render(context, &renderParams) if rc < 0 { Logger.shared.log("mpv_render_context_render returned error \(rc)", type: "Error") } } } } CVPixelBufferUnlockBaseAddress(buffer, []) enqueue(buffer: buffer) if preAllocatedBuffers.count < 4 { renderQueue.async { [weak self] in self?.preAllocateBuffers() } } } private func targetRenderSize(for videoSize: CGSize) -> CGSize { guard videoSize.width > 0, videoSize.height > 0 else { return videoSize } guard let screen = UIApplication.shared.connectedScenes .compactMap({ ($0 as? UIWindowScene)?.screen }) .first else { fatalError("⚠️ No active screen found — app may not have a visible window yet.") } var scale = screen.scale if scale <= 0 { scale = 1 } let maxWidth = max(screen.bounds.width * scale, 1.0) let maxHeight = max(screen.bounds.height * scale, 1.0) if maxWidth <= 0 || maxHeight <= 0 { return videoSize } let widthRatio = videoSize.width / maxWidth let heightRatio = videoSize.height / maxHeight let ratio = max(widthRatio, heightRatio, 1) let targetWidth = max(1, Int(videoSize.width / ratio)) let targetHeight = max(1, Int(videoSize.height / ratio)) return CGSize(width: CGFloat(targetWidth), height: CGFloat(targetHeight)) } private func createPixelBufferPool(width: Int, height: Int) { guard width > 0, height > 0 else { return } let pixelFormat = kCVPixelFormatType_32BGRA let attrs: [CFString: Any] = [ kCVPixelBufferPixelFormatTypeKey: pixelFormat, kCVPixelBufferWidthKey: width, kCVPixelBufferHeightKey: height, kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary, kCVPixelBufferMetalCompatibilityKey: kCFBooleanTrue!, kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!, kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue! ] let poolAttrs: [CFString: Any] = [ kCVPixelBufferPoolMinimumBufferCountKey: maxPreAllocatedBuffers, kCVPixelBufferPoolMaximumBufferAgeKey: 0 ] let auxAttrs: [CFString: Any] = [ kCVPixelBufferPoolAllocationThresholdKey: 8 ] var pool: CVPixelBufferPool? let status = CVPixelBufferPoolCreate(kCFAllocatorDefault, poolAttrs as CFDictionary, attrs as CFDictionary, &pool) if status == kCVReturnSuccess, let pool { renderQueueSync { self.pixelBufferPool = pool self.pixelBufferPoolAuxAttributes = auxAttrs as CFDictionary self.poolWidth = width self.poolHeight = height } renderQueue.async { [weak self] in self?.preAllocateBuffers() } } else { Logger.shared.log("Failed to create CVPixelBufferPool (status: \(status))", type: "Error") } } private func recreatePixelBufferPool(width: Int, height: Int) { renderQueueSync { self.preAllocatedBuffers.removeAll() self.pixelBufferPool = nil self.formatDescription = nil self.poolWidth = 0 self.poolHeight = 0 } createPixelBufferPool(width: width, height: height) } private func preAllocateBuffers() { guard DispatchQueue.getSpecific(key: renderQueueKey) != nil else { renderQueue.async { [weak self] in self?.preAllocateBuffers() } return } guard let pool = pixelBufferPool else { return } let targetCount = min(maxPreAllocatedBuffers, 8) let currentCount = preAllocatedBuffers.count guard currentCount < targetCount else { return } let bufferCount = targetCount - currentCount for _ in 0.. Bool { var didChange = false let width = Int32(CVPixelBufferGetWidth(buffer)) let height = Int32(CVPixelBufferGetHeight(buffer)) let pixelFormat = CVPixelBufferGetPixelFormatType(buffer) renderQueueSync { var needsRecreate = false if let description = formatDescription { let currentDimensions = CMVideoFormatDescriptionGetDimensions(description) let currentPixelFormat = CMFormatDescriptionGetMediaSubType(description) if currentDimensions.width != width || currentDimensions.height != height || currentPixelFormat != pixelFormat { needsRecreate = true } } else { needsRecreate = true } if needsRecreate { var newDescription: CMVideoFormatDescription? let status = CMVideoFormatDescriptionCreateForImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: buffer, formatDescriptionOut: &newDescription ) if status == noErr, let newDescription = newDescription { formatDescription = newDescription didChange = true Logger.shared.log("Created new format description: \(width)x\(height), format: \(pixelFormat)", type: "Info") } else { Logger.shared.log("Failed to create format description (status: \(status))", type: "Error") } } } return didChange } private func renderQueueSync(_ block: () -> Void) { if DispatchQueue.getSpecific(key: renderQueueKey) != nil { block() } else { renderQueue.sync(execute: block) } } private func currentVideoSize() -> CGSize { stateQueue.sync { videoSize } } private func updateVideoSize(width: Int, height: Int) { let size = CGSize(width: max(width, 0), height: max(height, 0)) stateQueue.async(flags: .barrier) { self.videoSize = size } renderQueue.async { [weak self] in guard let self else { return } if self.poolWidth != width || self.poolHeight != height { self.recreatePixelBufferPool(width: max(width, 0), height: max(height, 0)) } } } private func apply(commands: [[String]], on handle: OpaquePointer) { for command in commands { guard !command.isEmpty else { continue } self.command(handle, command) } } /// Async command - returns immediately, mpv processes later private func command(_ handle: OpaquePointer, _ args: [String]) { guard !args.isEmpty else { return } _ = withCStringArray(args) { pointer in mpv_command_async(handle, 0, pointer) } } /// Sync command - waits for mpv to process before returning 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 processEvents() { eventQueueGroup.enter() let group = eventQueueGroup eventQueue.async { [weak self] in defer { group.leave() } guard let self else { return } while !self.isStopping { guard let handle = self.mpv else { return } guard let eventPointer = mpv_wait_event(handle, 0) else { return } let event = eventPointer.pointee if event.event_id == MPV_EVENT_NONE { continue } self.handleEvent(event) if event.event_id == MPV_EVENT_SHUTDOWN { break } } } } private func handleEvent(_ event: mpv_event) { switch event.event_id { case MPV_EVENT_VIDEO_RECONFIG: refreshVideoState() case MPV_EVENT_FILE_LOADED: // Add external subtitles now that the file is loaded let hadExternalSubs = !pendingExternalSubtitles.isEmpty if hadExternalSubs, let handle = mpv { for subUrl in pendingExternalSubtitles { command(handle, ["sub-add", subUrl]) } pendingExternalSubtitles = [] // Set subtitle after external subs are added (track IDs have changed) 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) } } 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) } 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") || lower.contains("deprecated") { Logger.shared.log("mpv[\(component)] \(text)", type: "Warn") } } default: break } } private func refreshVideoState() { guard let handle = mpv else { return } var width: Int64 = 0 var height: Int64 = 0 getProperty(handle: handle, name: "dwidth", format: MPV_FORMAT_INT64, value: &width) getProperty(handle: handle, name: "dheight", format: MPV_FORMAT_INT64, value: &height) updateVideoSize(width: Int(width), height: Int(height)) } private func refreshProperty(named name: String) { 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 delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: cachedDuration) } case "time-pos": // Skip updates while seeking to prevent race condition guard !isSeeking else { return } var value = Double(0) let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value) if status >= 0 { cachedPosition = value positionUpdateTime = CACurrentMediaTime() // Record when we got this update delegate?.renderer(self, didUpdatePosition: cachedPosition, duration: 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 // Update timebase rate - use playbackSpeed when playing, 0 when paused let speed = self.playbackSpeed DispatchQueue.main.async { [weak self] in if let timebase = self?.displayLayer.controlTimebase { CMTimebaseSetRate(timebase, rate: newPaused ? 0 : speed) } } delegate?.renderer(self, didChangePause: isPaused) } } 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 = self else { return } self.delegate?.renderer(self, didBecomeTracksReady: true) } } default: break } } private func getStringProperty(handle: OpaquePointer, name: String) -> String? { var result: String? name.withCString { pointer in if let cString = mpv_get_property_string(handle, pointer) { 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 name.withCString { pointer in return withUnsafeMutablePointer(to: &value) { mutablePointer in return mpv_get_property(handle, pointer, 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) let wasPaused = isPaused // Prevent time-pos updates from overwriting during seek isSeeking = true // Update cached position BEFORE seek so new frames get correct timestamp cachedPosition = clamped positionUpdateTime = CACurrentMediaTime() // Reset interpolation base lastPTS = clamped // Reset monotonic PTS to new position // Update timebase to match new position (sets rate to 1 for frame display) syncTimebase(to: clamped) // Sync seek for accurate positioning commandSync(handle, ["seek", String(clamped), "absolute"]) isSeeking = false // Restore paused rate after seek completes if wasPaused { restoreTimebaseRate() } } func seek(by seconds: Double) { guard let handle = mpv else { return } let wasPaused = isPaused // Prevent time-pos updates from overwriting during seek isSeeking = true // Update cached position BEFORE seek let newPosition = max(0, cachedPosition + seconds) cachedPosition = newPosition positionUpdateTime = CACurrentMediaTime() // Reset interpolation base lastPTS = newPosition // Reset monotonic PTS to new position // Update timebase to match new position (sets rate to 1 for frame display) syncTimebase(to: newPosition) // Sync seek for accurate positioning commandSync(handle, ["seek", String(seconds), "relative"]) isSeeking = false // Restore paused rate after seek completes if wasPaused { restoreTimebaseRate() } } private func restoreTimebaseRate() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in guard let self = self, self.isPaused else { return } if let timebase = self.displayLayer.controlTimebase { CMTimebaseSetRate(timebase, rate: 0) } } } private func syncTimebase(to position: Double) { let speed = playbackSpeed let doWork = { [weak self] in guard let self = self else { return } // Flush old frames to avoid "old frames with new clock" mismatches if #available(iOS 17.0, *) { self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: false, completionHandler: nil) } else { self.displayLayer.flush() } if let timebase = self.displayLayer.controlTimebase { // Update timebase to new position CMTimebaseSetTime(timebase, time: CMTime(seconds: position, preferredTimescale: 1000)) // Set rate to playback speed during seek to ensure frame displays // restoreTimebaseRate() will set it back to 0 if paused CMTimebaseSetRate(timebase, rate: speed) } } if Thread.isMainThread { doWork() } else { DispatchQueue.main.sync { doWork() } } } /// Sync timebase with current position without flushing (for smooth PiP transitions) func syncTimebase() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if let timebase = self.displayLayer.controlTimebase { CMTimebaseSetTime(timebase, time: CMTime(seconds: self.cachedPosition, preferredTimescale: 1000)) CMTimebaseSetRate(timebase, rate: self.isPaused ? 0 : self.playbackSpeed) } } } func setSpeed(_ speed: Double) { playbackSpeed = speed setProperty(name: "speed", value: String(speed)) // Sync timebase rate with playback speed DispatchQueue.main.async { [weak self] in guard let self = self, let timebase = self.displayLayer.controlTimebase else { return } let rate = self.isPaused ? 0.0 : speed CMTimebaseSetRate(timebase, rate: rate) } } 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 } // "cached" adds without selecting, "select" adds and selects 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 let handle = mpv else { Logger.shared.log("setAudioTrack: mpv handle is nil", type: "Warn") return } Logger.shared.log("setAudioTrack: setting aid to \(trackId)", type: "Info") // Use setProperty for synchronous behavior 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) } }