mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +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,
|
||||
|
||||
@@ -2048,15 +2048,64 @@ extension MPVBackend: MPVClientDelegate {
|
||||
guard playbackStatsTask == nil else { return }
|
||||
|
||||
playbackStatsTask = Task { @MainActor [weak self] in
|
||||
#if os(macOS)
|
||||
var previousHealth: MPVRenderHealth?
|
||||
var previousVoDrops: Int?
|
||||
var watchdogFiredLastTick = false
|
||||
#endif
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
guard let self, !Task.isCancelled, let client = self.mpvClient else { return }
|
||||
|
||||
let props = await client.getDebugPropertiesAsync()
|
||||
|
||||
#if os(macOS)
|
||||
// Render watchdog — runs every tick regardless of the verbose
|
||||
// gate. Detects a stalled video output: playback advancing and
|
||||
// frames being consumed (skip-render) or dropped by the VO,
|
||||
// but not a single successful draw since the last tick. That
|
||||
// is the "permanent black video" signature (shared render view
|
||||
// parented in a non-visible window); attempt recovery by
|
||||
// re-attaching the view to a visible container.
|
||||
let health = self._playerView?.renderHealthSnapshot()
|
||||
if let health {
|
||||
let drawsDelta = health.draws &- (previousHealth?.draws ?? health.draws)
|
||||
let skipsDelta = health.skips &- (previousHealth?.skips ?? health.skips)
|
||||
let voDrops = props.frameDropCount
|
||||
let voDropsDelta = (voDrops ?? 0) - (previousVoDrops ?? voDrops ?? 0)
|
||||
|
||||
if watchdogFiredLastTick {
|
||||
let outcome = drawsDelta > 0 ? "succeeded" : "failed"
|
||||
LoggingService.shared.warning(
|
||||
"MPV render watchdog: recovery \(outcome) (draws=+\(drawsDelta), skips=+\(skipsDelta), attach: \(self._playerView?.attachmentDescription ?? "no view"))",
|
||||
category: .mpv
|
||||
)
|
||||
watchdogFiredLastTick = false
|
||||
}
|
||||
|
||||
if self.isPlaying, !self.isPiPActive, previousHealth != nil,
|
||||
drawsDelta == 0, skipsDelta > 20 || voDropsDelta > 50 {
|
||||
LoggingService.shared.warning(
|
||||
"MPV render watchdog: video output stalled (draws=+0, skips=+\(skipsDelta), voDrops=+\(voDropsDelta), attach: \(self._playerView?.attachmentDescription ?? "no view")) - attempting recovery",
|
||||
category: .mpv
|
||||
)
|
||||
MPVContainerNSView.recoverSharedPlayerViewIfNeeded()
|
||||
self._playerView?.resumeRendering()
|
||||
watchdogFiredLastTick = true
|
||||
}
|
||||
|
||||
previousVoDrops = voDrops ?? previousVoDrops
|
||||
}
|
||||
#endif
|
||||
|
||||
// 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()
|
||||
guard MPVLogging.verboseEnabled else {
|
||||
#if os(macOS)
|
||||
previousHealth = health
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
|
||||
func megabytes(_ bytes: Int64) -> String {
|
||||
String(format: "%.1fMiB", Double(bytes) / 1_048_576)
|
||||
@@ -2081,6 +2130,19 @@ extension MPVBackend: MPVClientDelegate {
|
||||
}
|
||||
if !cache.isEmpty { parts.append("cache(\(cache.joined(separator: " ")))") }
|
||||
|
||||
#if os(macOS)
|
||||
if let health {
|
||||
let drawsDelta = health.draws &- (previousHealth?.draws ?? health.draws)
|
||||
let skipsDelta = health.skips &- (previousHealth?.skips ?? health.skips)
|
||||
let droppedDelta = health.droppedDraws &- (previousHealth?.droppedDraws ?? health.droppedDraws)
|
||||
parts.append("render(draws=+\(drawsDelta) skips=+\(skipsDelta) dropped=+\(droppedDelta))")
|
||||
}
|
||||
if let view = self._playerView {
|
||||
parts.append("attach(\(view.attachmentDescription))")
|
||||
}
|
||||
previousHealth = health
|
||||
#endif
|
||||
|
||||
LoggingService.shared.info("MPV playback stats: \(parts.joined(separator: " "))", category: .mpv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +303,14 @@ struct ExpandedPlayerSheet: View {
|
||||
Color.black.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
if wideScreen {
|
||||
if geometry.size.width <= 0 || geometry.size.height <= 0 {
|
||||
// Transient zero-size layout pass (e.g. the deferred macOS
|
||||
// player window before its first real layout). Don't mount
|
||||
// either layout branch — a branch mounted here would grab
|
||||
// the shared render view for one frame and immediately hand
|
||||
// it off when the real size picks the other branch.
|
||||
Color.black.ignoresSafeArea(.all)
|
||||
} else if wideScreen {
|
||||
if let video = playerState?.currentVideo {
|
||||
// Widescreen layout with floating panel
|
||||
// Must ignore safe area to get full screen geometry
|
||||
|
||||
@@ -36,6 +36,15 @@ final class ExpandedPlayerWindowManager: NSObject {
|
||||
playerWindow != nil
|
||||
}
|
||||
|
||||
/// The live player window managed by this instance (nil after hide()).
|
||||
/// A window that exists here but isn't visible is mid-presentation or
|
||||
/// hidden for PiP — both valid homes for the shared render view, unlike a
|
||||
/// stale ordered-out window that is no longer tracked. Used by
|
||||
/// MPVContainerNSView when picking a transfer target.
|
||||
var currentPlayerWindow: NSWindow? {
|
||||
playerWindow
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -167,15 +167,25 @@ struct MPVRenderViewRepresentable: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Container view that properly manages player view swapping on macOS
|
||||
private class MPVContainerNSView: NSView {
|
||||
/// Container view that properly manages player view swapping on macOS.
|
||||
/// Internal (not private) so MPVBackend's render watchdog can call
|
||||
/// `recoverSharedPlayerViewIfNeeded()`.
|
||||
final class MPVContainerNSView: NSView {
|
||||
private weak var currentPlayerView: NSView?
|
||||
private let containerID = UUID()
|
||||
|
||||
/// Short identifier used in logs.
|
||||
var shortID: String { String(containerID.uuidString.prefix(8)) }
|
||||
|
||||
/// Track all living containers so a container that releases the shared
|
||||
/// player view can hand it to another container instead of orphaning it.
|
||||
private static var livingContainers = NSHashTable<MPVContainerNSView>.weakObjects()
|
||||
|
||||
/// The single shared render view most recently attached to any container.
|
||||
/// Lets a container that gains a window reclaim the view when it was
|
||||
/// orphaned or left behind in a non-visible window.
|
||||
private static weak var sharedPlayerView: NSView?
|
||||
|
||||
/// Callback when view is added to a window
|
||||
var onDidMoveToWindow: (() -> Void)?
|
||||
|
||||
@@ -194,9 +204,30 @@ private class MPVContainerNSView: NSView {
|
||||
// Notify when we're added to a window (not removed)
|
||||
if window != nil {
|
||||
onDidMoveToWindow?()
|
||||
reclaimSharedPlayerViewIfStranded()
|
||||
}
|
||||
}
|
||||
|
||||
/// When this container gains a window, adopt the shared player view if it
|
||||
/// is currently orphaned or parented in a container whose window is gone
|
||||
/// or not visible. This is the recovery path for the expand race where the
|
||||
/// mini capsule unmounts before any windowed container exists ("no
|
||||
/// transfer target"), and for a view left behind in a stale ordered-out
|
||||
/// player window.
|
||||
private func reclaimSharedPlayerViewIfStranded() {
|
||||
guard let sharedView = Self.sharedPlayerView, sharedView.superview !== self else { return }
|
||||
let owner = sharedView.superview as? MPVContainerNSView
|
||||
// Parented outside any container (e.g. mid-transfer) — leave it alone.
|
||||
if sharedView.superview != nil, owner == nil { return }
|
||||
let ownerWindowVisible = owner?.window?.isVisible == true
|
||||
guard owner == nil || !ownerWindowVisible else { return }
|
||||
LoggingService.shared.debug(
|
||||
"MPVContainerNSView[\(shortID)]: reclaiming stranded player view on window attach (previous owner: \(owner?.shortID ?? "none"), ownerWindowVisible: \(ownerWindowVisible))",
|
||||
category: .mpv
|
||||
)
|
||||
setPlayerView(sharedView)
|
||||
}
|
||||
|
||||
override func viewWillMove(toSuperview newSuperview: NSView?) {
|
||||
super.viewWillMove(toSuperview: newSuperview)
|
||||
// When being removed from superview, detach the player view first for a clean
|
||||
@@ -219,13 +250,16 @@ private class MPVContainerNSView: NSView {
|
||||
// view can end up with no superview and the player renders only black.
|
||||
if let target = MPVContainerNSView.findTransferTarget(excluding: self) {
|
||||
LoggingService.shared.debug(
|
||||
"MPVContainerNSView[\(containerID.uuidString.prefix(8))]: unmounting - transferring player view to container \(target.containerID.uuidString.prefix(8)) (windowVisible: \(target.window?.isVisible == true))",
|
||||
"MPVContainerNSView[\(shortID)]: unmounting - transferring player view to container \(target.shortID) (windowVisible: \(target.window?.isVisible == true))",
|
||||
category: .mpv
|
||||
)
|
||||
target.setPlayerView(playerView)
|
||||
// The transfer is initiated by the current owner, so bypass
|
||||
// the steal guard (the target may legitimately be in a
|
||||
// not-yet-visible window, e.g. hidden for PiP).
|
||||
target.setPlayerView(playerView, bypassingStealGuard: true)
|
||||
} else {
|
||||
LoggingService.shared.debug(
|
||||
"MPVContainerNSView[\(containerID.uuidString.prefix(8))]: unmounting - no transfer target, removing player view",
|
||||
"MPVContainerNSView[\(shortID)]: unmounting - no transfer target, parking player view (reclaimed when a container gains a window)",
|
||||
category: .mpv
|
||||
)
|
||||
playerView.removeFromSuperview()
|
||||
@@ -236,15 +270,32 @@ private class MPVContainerNSView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Another living container that can host the shared player view, preferring
|
||||
/// one whose window is currently visible (a restoring window may not be
|
||||
/// visible yet at transfer time, so a hidden window is still acceptable).
|
||||
/// Another living container that can host the shared player view.
|
||||
/// Ranking:
|
||||
/// 1. A container whose window is currently visible.
|
||||
/// 2. A container in the window still tracked by ExpandedPlayerWindowManager
|
||||
/// (mid-presentation or hidden for PiP — it will become visible).
|
||||
/// 3. nil — park the view rather than parenting it in a stale ordered-out
|
||||
/// window that CoreAnimation never composites (permanent black video);
|
||||
/// `reclaimSharedPlayerViewIfStranded` re-adopts it when a container
|
||||
/// gains a window.
|
||||
private static func findTransferTarget(excluding source: MPVContainerNSView) -> MPVContainerNSView? {
|
||||
let candidates = livingContainers.allObjects.filter { $0 !== source && $0.window != nil }
|
||||
return candidates.first { $0.window?.isVisible == true } ?? candidates.first
|
||||
if let visible = candidates.first(where: { $0.window?.isVisible == true }) {
|
||||
return visible
|
||||
}
|
||||
if let trackedWindow = ExpandedPlayerWindowManager.shared.currentPlayerWindow,
|
||||
let tracked = candidates.first(where: { $0.window === trackedWindow }) {
|
||||
LoggingService.shared.debug(
|
||||
"MPVContainerNSView.findTransferTarget: no visible candidate, using container \(tracked.shortID) in tracked player window",
|
||||
category: .mpv
|
||||
)
|
||||
return tracked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setPlayerView(_ playerView: NSView) {
|
||||
func setPlayerView(_ playerView: NSView, bypassingStealGuard: Bool = false) {
|
||||
// Skip only if same view AND actually our subview. The weak ref can
|
||||
// point to a view that was stolen by another container — e.g. the mini
|
||||
// player preview takes the shared render view during PiP while the
|
||||
@@ -254,9 +305,27 @@ private class MPVContainerNSView: NSView {
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse to steal the shared view from a container in a visible window
|
||||
// when this container's own window is missing or not visible. The
|
||||
// player window ordered out on collapse keeps its SwiftUI hierarchy
|
||||
// alive until it deallocates, and its updateNSView would otherwise
|
||||
// re-parent the shared view into the dead window where CoreAnimation
|
||||
// never composites it (permanent black video until app restart).
|
||||
if !bypassingStealGuard,
|
||||
let owner = playerView.superview as? MPVContainerNSView,
|
||||
owner !== self,
|
||||
owner.window?.isVisible == true,
|
||||
window?.isVisible != true {
|
||||
LoggingService.shared.debug(
|
||||
"MPVContainerNSView[\(shortID)]: declined steal - view owned by visible container \(owner.shortID) (self window: \(window != nil), windowVisible: false)",
|
||||
category: .mpv
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let previousSuperview = playerView.superview.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
LoggingService.shared.debug(
|
||||
"MPVContainerNSView[\(containerID.uuidString.prefix(8))].setPlayerView: attaching (reclaim: \(playerView === currentPlayerView), previousSuperview: \(previousSuperview), window: \(window != nil), windowVisible: \(window?.isVisible == true))",
|
||||
"MPVContainerNSView[\(shortID)].setPlayerView: attaching (reclaim: \(playerView === currentPlayerView), previousSuperview: \(previousSuperview), window: \(window != nil), windowVisible: \(window?.isVisible == true))",
|
||||
category: .mpv
|
||||
)
|
||||
|
||||
@@ -276,6 +345,34 @@ private class MPVContainerNSView: NSView {
|
||||
])
|
||||
|
||||
currentPlayerView = playerView
|
||||
MPVContainerNSView.sharedPlayerView = playerView
|
||||
}
|
||||
|
||||
/// Re-attach the shared player view to a container in a visible window when
|
||||
/// it is currently orphaned or parented somewhere non-visible. Called by
|
||||
/// MPVBackend's render watchdog when video output stalls (frames consumed
|
||||
/// without a single draw). Returns true when the view was re-parented.
|
||||
@discardableResult
|
||||
static func recoverSharedPlayerViewIfNeeded() -> Bool {
|
||||
guard let sharedView = sharedPlayerView else { return false }
|
||||
let owner = sharedView.superview as? MPVContainerNSView
|
||||
// Healthy: owned by a container in a visible window.
|
||||
if owner?.window?.isVisible == true { return false }
|
||||
// Parented outside any container — not ours to manage.
|
||||
if sharedView.superview != nil, owner == nil { return false }
|
||||
guard let target = livingContainers.allObjects.first(where: { $0.window?.isVisible == true }) else {
|
||||
LoggingService.shared.warning(
|
||||
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: no visible container available (owner: \(owner?.shortID ?? "none"))",
|
||||
category: .mpv
|
||||
)
|
||||
return false
|
||||
}
|
||||
LoggingService.shared.warning(
|
||||
"MPVContainerNSView.recoverSharedPlayerViewIfNeeded: re-attaching player view from \(owner?.shortID ?? "orphaned") to container \(target.shortID)",
|
||||
category: .mpv
|
||||
)
|
||||
target.setPlayerView(sharedView, bypassingStealGuard: true)
|
||||
return sharedView.superview === target
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user