Files
streamyfin/modules/mpv-player/ios/MPVSoftwareRenderer.swift

1390 lines
54 KiB
Swift

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<Void>()
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<MPVSoftwareRenderer>.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<MPVSoftwareRenderer>.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..<bufferCount {
var buffer: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(
kCFAllocatorDefault,
pool,
pixelBufferPoolAuxAttributes,
&buffer
)
if status == kCVReturnSuccess, let buffer = buffer {
if preAllocatedBuffers.count < maxPreAllocatedBuffers {
preAllocatedBuffers.append(buffer)
}
} else {
if status != kCVReturnWouldExceedAllocationThreshold {
Logger.shared.log("Failed to pre-allocate buffer (status: \(status))", type: "Warn")
}
break
}
}
}
private func enqueue(buffer: CVPixelBuffer) {
let needsFlush = updateFormatDescriptionIfNeeded(for: buffer)
var shouldNotifyLoadingEnd = false
renderQueueSync {
if self.isLoading {
self.isLoading = false
shouldNotifyLoadingEnd = true
}
}
var capturedFormatDescription: CMVideoFormatDescription?
renderQueueSync {
capturedFormatDescription = self.formatDescription
}
guard let formatDescription = capturedFormatDescription else {
Logger.shared.log("Missing formatDescription when creating sample buffer — skipping frame", type: "Error")
return
}
// Use interpolated position for smooth PTS (prevents jitter from discrete time-pos updates)
// Use monotonically increasing video position for smooth PTS + working PiP progress
let presentationTime = CMTime(seconds: nextMonotonicPTS(), preferredTimescale: 1000)
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: presentationTime, decodeTimeStamp: .invalid)
var sampleBuffer: CMSampleBuffer?
let result = CMSampleBufferCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: buffer,
dataReady: true,
makeDataReadyCallback: nil,
refcon: nil,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard result == noErr, let sample = sampleBuffer else {
Logger.shared.log("Failed to create sample buffer (error: \(result), -12743 = invalid format)", type: "Error")
let width = CVPixelBufferGetWidth(buffer)
let height = CVPixelBufferGetHeight(buffer)
let pixelFormat = CVPixelBufferGetPixelFormatType(buffer)
Logger.shared.log("Buffer info: \(width)x\(height), format: \(pixelFormat)", type: "Error")
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let (status, error): (AVQueuedSampleBufferRenderingStatus?, Error?) = {
if #available(iOS 18.0, *) {
return (
self.displayLayer.sampleBufferRenderer.status,
self.displayLayer.sampleBufferRenderer.error
)
} else {
return (
self.displayLayer.status,
self.displayLayer.error
)
}
}()
if status == .failed {
if let error = error {
Logger.shared.log("Display layer in failed state: \(error.localizedDescription)", type: "Error")
}
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
}
if needsFlush {
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
self.didFlushForFormatChange = true
} else if self.didFlushForFormatChange {
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: false, completionHandler: nil)
} else {
self.displayLayer.flush()
}
self.didFlushForFormatChange = false
}
if self.displayLayer.controlTimebase == nil {
var timebase: CMTimebase?
if CMTimebaseCreateWithSourceClock(allocator: kCFAllocatorDefault, sourceClock: CMClockGetHostTimeClock(), timebaseOut: &timebase) == noErr, let timebase {
// Set rate based on current pause state and playback speed
CMTimebaseSetRate(timebase, rate: self.isPaused ? 0 : self.playbackSpeed)
CMTimebaseSetTime(timebase, time: presentationTime)
self.displayLayer.controlTimebase = timebase
} else {
Logger.shared.log("Failed to create control timebase", type: "Error")
}
}
if shouldNotifyLoadingEnd {
self.delegate?.renderer(self, didChangeLoading: false)
}
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.enqueue(sample)
} else {
self.displayLayer.enqueue(sample)
}
}
}
private func updateFormatDescriptionIfNeeded(for buffer: CVPixelBuffer) -> 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<T>(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<R>(_ args: [String], body: (UnsafeMutablePointer<UnsafePointer<CChar>?>?) -> R) -> R {
var cStrings = [UnsafeMutablePointer<CChar>?]()
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<CChar>?.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..<trackCount {
var trackType: String?
if let typeStr = getStringProperty(handle: handle, name: "track-list/\(i)/type") {
trackType = typeStr
}
guard trackType == "sub" else { continue }
var trackId: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
track["title"] = title
}
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
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("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
tracks.append(track)
}
Logger.shared.log("getSubtitleTracks: returning \(tracks.count) subtitle tracks", type: "Info")
return tracks
}
func setSubtitleTrack(_ trackId: Int) {
Logger.shared.log("setSubtitleTrack: setting sid to \(trackId)", type: "Info")
guard let handle = mpv else {
Logger.shared.log("setSubtitleTrack: mpv handle is nil!", type: "Error")
return
}
// Use setProperty for synchronous behavior (command is async)
if trackId < 0 {
// Disable subtitles
setProperty(name: "sid", value: "no")
} else {
setProperty(name: "sid", value: String(trackId))
}
}
func disableSubtitles() {
setProperty(name: "sid", value: "no")
}
func getCurrentSubtitleTrack() -> 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..<trackCount {
var trackType: String?
if let typeStr = getStringProperty(handle: handle, name: "track-list/\(i)/type") {
trackType = typeStr
}
guard trackType == "audio" else { continue }
var trackId: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
track["title"] = title
}
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
track["codec"] = codec
}
var channels: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/audio-channels", format: MPV_FORMAT_INT64, value: &channels)
if channels > 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)
}
}