diff --git a/Yattee/Services/Player/MPV/MPVLogging.swift b/Yattee/Services/Player/MPV/MPVLogging.swift index 797c7b30..a3235aab 100644 --- a/Yattee/Services/Player/MPV/MPVLogging.swift +++ b/Yattee/Services/Player/MPV/MPVLogging.swift @@ -24,6 +24,10 @@ enum MPVLogging { private static var _lastCheckTime: UInt64 = 0 private static let cacheDurationNanos: UInt64 = 1_000_000_000 // 1 second + /// Whether verbose MPV logging is enabled (cached, safe from any thread). + /// Exposed for callers that gate their own logging on the same setting. + static var verboseEnabled: Bool { isEnabled() } + /// Check if verbose logging is enabled (cached for performance). /// Safe to call from any thread. private static func isEnabled() -> Bool { diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index fddf8204..08b2c6f8 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -232,10 +232,17 @@ final class MPVRenderView: UIView { contentScaleFactor = UIScreen.main.scale // Configure EAGL layer - // Note: retainedBacking=true is required for glReadPixels to work (for PiP frame capture) + // Note: retainedBacking=true is required for glReadPixels to work (PiP frame capture). + // PiP exists only on iOS; on tvOS retained backing just forces the compositor to + // preserve the drawable's IOSurface every frame, adding GPU fence pressure (issue #949). + #if os(iOS) + let retainedBacking = true + #else + let retainedBacking = false + #endif eaglLayer?.isOpaque = true eaglLayer?.drawableProperties = [ - kEAGLDrawablePropertyRetainedBacking: true, + kEAGLDrawablePropertyRetainedBacking: retainedBacking, kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8 ] @@ -318,6 +325,9 @@ final class MPVRenderView: UIView { MPVLogging.warn("EAGLContext.setCurrent failed in appDidEnterBackground") } glFinish() + // This sync block executes on the calling (main) thread — unbind so the + // context isn't left current on two threads. + EAGLContext.setCurrent(nil) MPVLogging.log("glFinish completed in appDidEnterBackground") } } @@ -498,14 +508,14 @@ final class MPVRenderView: UIView { // Back on main thread for UIKit operations await MainActor.run { self.eaglContext = context - - // Make context current - EAGLContext.setCurrent(context) - + // Create framebuffer only if view has valid bounds + // (createFramebuffer makes the context current itself and unbinds on exit) lastStableSize = bounds.size if bounds.size != .zero { - createFramebuffer() + renderQueue.sync { + createFramebuffer() + } } else { MPVLogging.log("setupAsync: deferring framebuffer creation (bounds are zero)") } @@ -596,6 +606,10 @@ final class MPVRenderView: UIView { if !ctxSet { MPVLogging.warn("createFramebuffer: EAGLContext.setCurrent failed") } + // This runs on the main thread (setup/layout paths); unbind on exit so the + // context is never left current on two threads at once (performRender binds + // it on the render queue thread each frame). + defer { EAGLContext.setCurrent(nil) } // Generate framebuffer glGenFramebuffers(1, &framebuffer) @@ -650,6 +664,12 @@ final class MPVRenderView: UIView { contextCurrent: EAGLContext.current() === eaglContext) EAGLContext.setCurrent(eaglContext) + // Unbind on exit — see createFramebuffer. + defer { EAGLContext.setCurrent(nil) } + // Drain all in-flight GPU work before deleting the renderbuffer: a pending + // presentRenderbuffer may still hold an IOFence on the CAEAGLLayer's IOSurface, + // and deleting/reallocating the surface underneath it deadlocks the GPU (issue #949). + glFinish() destroyFramebufferResources() } @@ -807,8 +827,13 @@ final class MPVRenderView: UIView { /// Frame counter for periodic verbose logging (avoid spam) private var renderFrameLogCounter: UInt64 = 0 + /// Slow-frame tracking (GPU stall diagnostics for issues #947/#949) + private var slowFrameCount: UInt64 = 0 + private var lastSlowFrameWarning: Date = .distantPast + private func performRender() { defer { isRendering = false } + let frameStart = Date() guard let eaglContext, let mpvClient, framebuffer != 0 else { // Log when render is skipped due to missing resources (rare but important) @@ -871,6 +896,24 @@ final class MPVRenderView: UIView { if !presented { MPVLogging.warn("performRender: presentRenderbuffer FAILED", details: "fb:\(framebuffer) rb:\(colorRenderbuffer) layer:\(layer.bounds) superview:\(superview != nil)") + } else { + // Feed swap timing back to mpv — required for the display-* video-sync + // modes (tvOS default is display-vdrop) to pace frames correctly. + // macOS render views already do this. + mpvClient.reportSwap() + } + } + + // Warn on GPU stalls: a render+present that takes far longer than a frame + // interval points at fence contention (see issues #947/#949). Rate-limited. + let frameDuration = Date().timeIntervalSince(frameStart) + if frameDuration > 0.05 { + slowFrameCount += 1 + if Date().timeIntervalSince(lastSlowFrameWarning) > 5 { + lastSlowFrameWarning = Date() + MPVLogging.warn("performRender: slow frame", + details: "duration=\(Int(frameDuration * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)") + slowFrameCount = 0 } } diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 41463217..94b05563 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -120,6 +120,10 @@ final class MPVBackend: PlayerBackend { private let bufferStallTimeout: TimeInterval = 30 // Trigger refresh after 30 seconds of stall private var bufferStallCheckTask: Task? + // Periodic playback stats logging so exported user logs can diagnose + // stuttering / cache starvation remotely (GitHub #947/#949) + private var playbackStatsTask: Task? + // Video dimensions for aspect ratio detection private var videoWidth: Int = 0 private var videoHeight: Int = 0 @@ -378,6 +382,7 @@ final class MPVBackend: PlayerBackend { private func cleanup() { // Stop buffer stall detection stopBufferStallDetection() + stopPlaybackStatsLogging() #if os(macOS) // Clear MPV client callbacks before destroying - these reference the render view @@ -749,6 +754,7 @@ final class MPVBackend: PlayerBackend { // Stop buffer stall detection stopBufferStallDetection() + stopPlaybackStatsLogging() mpvClient?.stop() isPlaying = false @@ -1836,6 +1842,7 @@ extension MPVBackend: MPVClientDelegate { switch event { case MPV_EVENT_FILE_LOADED: LoggingService.shared.debug("MPV: File loaded", category: .mpv) + startPlaybackStatsLogging() // Log hwdec diagnostics on tvOS (use cached values to avoid sync fetch) #if os(tvOS) let codec = videoCodec.isEmpty ? "unknown" : videoCodec @@ -1992,4 +1999,57 @@ extension MPVBackend: MPVClientDelegate { bufferStallStartTime = nil LoggingService.shared.debug("MPV: Buffer stall detection stopped", category: .mpv) } + + // MARK: - Playback Stats Logging + + /// Log cache/frame-drop/pacing stats every 10s while a file is loaded, at INFO level + /// so they appear in user-exported logs (diagnostics for GitHub #947/#949). + /// Only logs while verbose MPV logging is enabled in settings. + private func startPlaybackStatsLogging() { + guard playbackStatsTask == nil else { return } + + playbackStatsTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + guard let self, !Task.isCancelled, let client = self.mpvClient else { return } + + // Gate on the verbose setting each tick (not at task start) so + // toggling it mid-playback takes effect without a reload + guard MPVLogging.verboseEnabled else { continue } + + let props = await client.getDebugPropertiesAsync() + + func megabytes(_ bytes: Int64) -> String { + String(format: "%.1fMiB", Double(bytes) / 1_048_576) + } + + var parts: [String] = ["paused=\(!self.isPlaying)"] + if let hwdec = props.hwdecCurrent { parts.append("hwdec=\(hwdec)") } + if let fps = props.estimatedVfFps { parts.append(String(format: "vf-fps=%.2f", fps)) } + if let avsync = props.avsync { parts.append(String(format: "avsync=%.3f", avsync)) } + var drops: [String] = [] + if let vo = props.frameDropCount { drops.append("vo=\(vo)") } + if let dec = props.decoderFrameDropCount { drops.append("dec=\(dec)") } + if let mistimed = props.mistimedFrameCount { drops.append("mistimed=\(mistimed)") } + if let delayed = props.voDelayedFrameCount { drops.append("delayed=\(delayed)") } + if !drops.isEmpty { parts.append("dropped(\(drops.joined(separator: " ")))") } + var cache: [String] = [] + if let duration = props.demuxerCacheDuration { cache.append(String(format: "dur=%.1fs", duration)) } + if let state = props.cacheState { + cache.append("fw=\(megabytes(state.forwardBytes))") + cache.append("rate=\(megabytes(state.inputRate))/s") + cache.append("eof=\(state.eofCached)") + } + if !cache.isEmpty { parts.append("cache(\(cache.joined(separator: " ")))") } + + LoggingService.shared.info("MPV playback stats: \(parts.joined(separator: " "))", category: .mpv) + } + } + } + + /// Stop periodic playback stats logging. + private func stopPlaybackStatsLogging() { + playbackStatsTask?.cancel() + playbackStatsTask = nil + } }