diff --git a/Yattee/Services/Player/MPV/MPVOGLView.swift b/Yattee/Services/Player/MPV/MPVOGLView.swift index a6e605b0..8bb32914 100644 --- a/Yattee/Services/Player/MPV/MPVOGLView.swift +++ b/Yattee/Services/Player/MPV/MPVOGLView.swift @@ -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() diff --git a/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift b/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift index 36b43542..d11da7a3 100644 --- a/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift +++ b/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift @@ -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, diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 18e6dd4d..b5c10718 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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) } } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index ec67ab64..bdc42ace 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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 diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index 67735055..3b47cc3d 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -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() } diff --git a/Yattee/Views/Player/MPVRenderViewRepresentable.swift b/Yattee/Views/Player/MPVRenderViewRepresentable.swift index 5315f66d..07f31fe1 100644 --- a/Yattee/Views/Player/MPVRenderViewRepresentable.swift +++ b/Yattee/Views/Player/MPVRenderViewRepresentable.swift @@ -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.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 } }