mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 14:52:03 +00:00
Fix macOS permanent black video after player collapse/expand cycles
In separate-window mode, hide() keeps the ordered-out window's SwiftUI hierarchy alive (nilling contentViewController crashes AVKit), and its updateNSView unconditionally re-attached the shared MPVOGLView - stealing it into a window CoreAnimation never composites. The layer's silent skip-render fallback then consumed every frame flag, so video stayed black (climbing vo drops) until app restart. - Decline stealing the shared view from a visible container into a non-visible one; owner-initiated transfers bypass the guard - Only transfer to containers in visible windows or the window still tracked by ExpandedPlayerWindowManager; park the view otherwise and reclaim it when a container gains a window - Log skip-render frame consumption (rate-limited) and track render health counters (draws/skips/dropped draws) - Add a watchdog to the playback stats task that detects a stalled video output and re-attaches the view to a visible container - Don't mount player layouts during zero-size layout passes
This commit is contained in:
@@ -272,6 +272,26 @@ final class MPVOGLView: NSView {
|
||||
videoLayer?.resetFirstFrameTracking()
|
||||
}
|
||||
|
||||
/// Thread-safe snapshot of the layer's render-health counters.
|
||||
func renderHealthSnapshot() -> MPVRenderHealth? {
|
||||
videoLayer?.renderHealthSnapshot()
|
||||
}
|
||||
|
||||
/// Human-readable description of where this shared view is currently
|
||||
/// attached, for render-health diagnostics. Main thread only.
|
||||
var attachmentDescription: String {
|
||||
let superviewDesc: String
|
||||
if let container = superview as? MPVContainerNSView {
|
||||
superviewDesc = "container=\(container.shortID)"
|
||||
} else if let superview {
|
||||
superviewDesc = "superview=\(type(of: superview))"
|
||||
} else {
|
||||
superviewDesc = "superview=nil"
|
||||
}
|
||||
let windowDesc = window.map { "window=#\($0.windowNumber) visible=\($0.isVisible)" } ?? "window=nil"
|
||||
return "\(superviewDesc) \(windowDesc) bounds=\(Int(bounds.width))x\(Int(bounds.height))"
|
||||
}
|
||||
|
||||
/// Clear the view to black.
|
||||
func clearToBlack() {
|
||||
videoLayer?.clearToBlack()
|
||||
|
||||
@@ -49,6 +49,19 @@ private let glFormatSoftware: [CGLPixelFormatAttribute] = [
|
||||
CGLPixelFormatAttribute(0)
|
||||
]
|
||||
|
||||
// MARK: - Render Health
|
||||
|
||||
/// Cumulative render-health counters for the MPV OpenGL layer. `skips` counts
|
||||
/// frames consumed via the skip-render fallback (frame flag cleared without a
|
||||
/// real draw) — a climbing skip count with zero draws means the layer is not
|
||||
/// being composited and the video output is stalled.
|
||||
struct MPVRenderHealth {
|
||||
var draws: UInt64
|
||||
var skips: UInt64
|
||||
var droppedDraws: UInt64
|
||||
var lastDrawAge: TimeInterval?
|
||||
}
|
||||
|
||||
// MARK: - MPVOpenGLLayer
|
||||
|
||||
/// OpenGL layer for MPV rendering on macOS.
|
||||
@@ -94,6 +107,16 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
/// True while a forced draw has been requested but not yet executed.
|
||||
var hasPendingForcedDraw: Bool { forceDrawLock.withLock { forceDraw } }
|
||||
|
||||
/// Render-health counters (see `MPVRenderHealth`). Touched on the render
|
||||
/// queue in `draw`/`display`, read from the main actor by the playback
|
||||
/// stats watchdog.
|
||||
private var successfulDrawCount: UInt64 = 0
|
||||
private var skipRenderCount: UInt64 = 0
|
||||
private var droppedDrawCount: UInt64 = 0
|
||||
private var lastSuccessfulDrawTime: CFTimeInterval?
|
||||
private var skipsSinceLastDraw: UInt64 = 0
|
||||
private let renderHealthLock = NSLock()
|
||||
|
||||
/// Whether the layer has been set up with an MPV client.
|
||||
private var isSetup = false
|
||||
|
||||
@@ -345,6 +368,7 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
category: .mpv
|
||||
)
|
||||
}
|
||||
renderHealthLock.withLock { droppedDrawCount += 1 }
|
||||
scheduleDroppedDrawRetry()
|
||||
return
|
||||
}
|
||||
@@ -353,6 +377,11 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
needsFlipLock.withLock { needsFlip = false }
|
||||
forceDrawLock.withLock { forceDraw = false }
|
||||
droppedDrawRetryLock.withLock { droppedDrawRetries = 0 }
|
||||
renderHealthLock.withLock {
|
||||
successfulDrawCount += 1
|
||||
skipsSinceLastDraw = 0
|
||||
lastSuccessfulDrawTime = CACurrentMediaTime()
|
||||
}
|
||||
|
||||
// Clear the buffer
|
||||
glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
|
||||
@@ -437,6 +466,25 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
guard let mpvClient, let renderContext = mpvClient.mpvRenderContext,
|
||||
mpvClient.shouldRenderUpdateFrame() else { return }
|
||||
|
||||
// A long uninterrupted run of skips means the layer is not being
|
||||
// composited (e.g. the shared view is parented in a non-visible
|
||||
// window) and playback is silently video-less. Log the first skip
|
||||
// after a real draw, then every ~300 (~10s at 30fps).
|
||||
let skips = renderHealthLock.withLock { () -> UInt64 in
|
||||
skipRenderCount += 1
|
||||
skipsSinceLastDraw += 1
|
||||
return skipsSinceLastDraw
|
||||
}
|
||||
if skips == 1 || skips.isMultiple(of: 300) {
|
||||
let boundsSize = bounds.size
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.warning(
|
||||
"MPVOpenGLLayer: skip-render consumed frame without draw (skips=\(skips) since last draw, bounds=\(boundsSize))",
|
||||
category: .mpv
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Must lock OpenGL context before calling mpv render functions
|
||||
mpvClient.lockAndSetOpenGLContext()
|
||||
defer { mpvClient.unlockOpenGLContext() }
|
||||
@@ -648,6 +696,18 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
|
||||
hasRenderedFirstFrame = false
|
||||
}
|
||||
|
||||
/// Thread-safe snapshot of the render-health counters.
|
||||
func renderHealthSnapshot() -> MPVRenderHealth {
|
||||
renderHealthLock.withLock {
|
||||
MPVRenderHealth(
|
||||
draws: successfulDrawCount,
|
||||
skips: skipRenderCount,
|
||||
droppedDraws: droppedDrawCount,
|
||||
lastDrawAge: lastSuccessfulDrawTime.map { CACurrentMediaTime() - $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pixel Format and Context Creation
|
||||
|
||||
/// Create a CGL pixel format, trying 10-bit first, falling back to 8-bit,
|
||||
|
||||
Reference in New Issue
Block a user