Files
yattee/Yattee/Views/Player/ExpandedPlayerWindowManager.swift
Arkadiusz Fal b82241564d 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
2026-06-21 21:55:37 +02:00

664 lines
28 KiB
Swift

//
// ExpandedPlayerWindowManager.swift
// Yattee
//
// Manages expanded player window on macOS.
// Uses a separate NSWindow for better control over presentation and floating behavior.
//
#if os(macOS)
import AppKit
import SwiftUI
/// Manages the expanded player window on macOS.
/// Supports both normal window and floating (always-on-top) modes.
@MainActor
final class ExpandedPlayerWindowManager: NSObject {
static let shared = ExpandedPlayerWindowManager()
private var playerWindow: NSWindow?
private weak var appEnvironment: AppEnvironment?
/// Whether the window has performed its first size application since being
/// shown. The first resize after each open snaps (no animation) so the player
/// appears at its final fixed layout; later resizes (e.g. switching to a
/// different-aspect video while the window stays open) animate normally.
private var hasCompletedInitialSizing = false
// Configuration
private static let minWidth: CGFloat = 640
private static let minHeight: CGFloat = 360
private static let maxScreenRatio: CGFloat = 0.7
private static let targetVideoHeight: CGFloat = 720
private static let defaultAspectRatio: Double = 16.0 / 9.0
var isPresented: Bool {
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()
}
// MARK: - Public API
/// Shows the expanded player in a separate window.
/// - Parameters:
/// - appEnvironment: The app environment for state and services
/// - animated: Whether to animate the window appearance
func show(with appEnvironment: AppEnvironment, animated: Bool = true) {
// If window already exists (hidden for PiP), restore it instead of creating new one
if let existingWindow = playerWindow {
// Already on screen (e.g. "Play Now" while playing) just bring it
// forward; resetting alphaValue here would make the window blink.
if existingWindow.isVisible {
LoggingService.shared.debug("ExpandedPlayerWindowManager: show() - window already visible, bringing to front", category: .player)
existingWindow.makeKeyAndOrderFront(nil)
return
}
LoggingService.shared.debug("ExpandedPlayerWindowManager: show() - restoring existing window (was hidden for PiP)", category: .player)
if animated {
existingWindow.alphaValue = 0
existingWindow.makeKeyAndOrderFront(nil)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
existingWindow.animator().alphaValue = 1
}
} else {
existingWindow.alphaValue = 1
existingWindow.makeKeyAndOrderFront(nil)
}
// Any forced draw requested while the window was ordered out was
// dropped (no drawable); the layer is pull-based, so repaint now
// that the window is on screen otherwise a paused video stays
// black until the next MPV frame (never, while paused).
(appEnvironment.playerService.currentBackend as? MPVBackend)?.resumeRendering()
return
}
self.appEnvironment = appEnvironment
// Mark expanding state for mini player coordination
appEnvironment.navigationCoordinator.isPlayerExpanding = true
// Whether the window should float above other windows (always on top).
let floating = appEnvironment.settingsManager.macPlayerFloating
// Host a lightweight two-phase root instead of ExpandedPlayerSheet directly.
// AppKit won't composite the window to screen until the hosted SwiftUI view
// finishes its first layout pass; ExpandedPlayerSheet is heavy (MPV setup,
// full controls, layout math), so building it inline leaves the alpha 01
// fade with nothing to draw and the window only pops in after the render.
// ExpandedPlayerWindowRoot paints black + spinner immediately, then defers
// building ExpandedPlayerSheet by one runloop so the window appears at once.
let playerView = ExpandedPlayerWindowRoot()
.appEnvironment(appEnvironment)
// Open at the real video aspect ratio when it's already known (e.g.
// expanding a video already playing in the mini bar) so the window appears
// at its final size with no snap-jump. Falls back to 16:9 when the ratio
// isn't decoded yet; the first-resize snap below removes any later grow.
let knownAspect = appEnvironment.playerService.state.videoAspectRatio ?? 0
let seedAspect = knownAspect > 0 ? knownAspect : Self.defaultAspectRatio
// Create hosting controller
let hostingController = NSHostingController(rootView: playerView)
// Don't let the hosting controller drive the window size from the SwiftUI
// content's fitting size. The lightweight loading root (just a spinner) has a
// tiny ideal size, which would otherwise shrink the window and then grow it
// when ExpandedPlayerSheet builds. Window sizing is owned by `initialSize`
// below and `resizeToFitAspectRatio` once the real video ratio is known.
hostingController.sizingOptions = []
// Calculate initial window size at the seeded aspect ratio
let initialSize = calculateInitialWindowSize(aspectRatio: seedAspect)
// Create window with appropriate style
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: initialSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
// Configure window appearance
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.backgroundColor = NSColor.windowBackgroundColor
window.contentViewController = hostingController
// Lock manual resize to the video aspect ratio. Seeded with the real ratio
// when known (else 16:9); updated as soon as the real ratio is known.
Self.applyAspectRatioConstraint(seedAspect, to: window)
// Force the intended size. Assigning `contentViewController` above resizes
// the window to the hosting controller's fitting size and the lightweight
// loading root has a tiny fitting size, which produced a tiny window that
// only grew once `resizeToFitAspectRatio` fired after streams loaded. Set
// the content size back to `initialSize` so the window opens full-sized
// during the loading phase too.
window.setContentSize(initialSize)
// Set up window delegate for close handling
// Make ExpandedPlayerWindowManager itself the delegate to avoid lifecycle issues
window.delegate = self
// Configure window level based on the floating preference
configureWindowLevel(window, floating: floating)
// Center window on screen
window.center()
// Store reference
self.playerWindow = window
// Fresh window: the next resize is the initial sizing and must snap
// (no animation) so the player appears at its final fixed layout.
hasCompletedInitialSizing = false
// Show window
if animated {
window.alphaValue = 0
window.makeKeyAndOrderFront(nil)
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().alphaValue = 1
}, completionHandler: {
Task { @MainActor in
appEnvironment.navigationCoordinator.isPlayerExpanding = false
}
})
} else {
window.makeKeyAndOrderFront(nil)
appEnvironment.navigationCoordinator.isPlayerExpanding = false
}
}
/// Hides and cleans up the player window.
/// - Parameters:
/// - animated: Whether to animate the dismissal
/// - completion: Called after the window is hidden
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
guard let window = playerWindow else {
completion?()
return
}
// Mark collapsing state for mini player coordination
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
// Check if PiP is active - if so, just hide the window without destroying content
// The AVSampleBufferDisplayLayer needs to stay alive while PiP is active
let isPiPActive = (appEnvironment?.playerService.currentBackend as? MPVBackend)?.isPiPActive ?? false
LoggingService.shared.debug("ExpandedPlayerWindowManager: hide() called, isPiPActive=\(isPiPActive)", category: .player)
// Capture navigationCoordinator before closures to avoid Swift 6 concurrency warnings
let navigationCoordinator = appEnvironment?.navigationCoordinator
if isPiPActive {
// PiP is active - just hide the window, keep content alive
// Don't clear playerWindow reference so we can restore it later
let hideWindow: @Sendable () -> Void = {
Task { @MainActor in
navigationCoordinator?.isPlayerCollapsing = false
window.orderOut(nil)
completion?()
}
}
if animated {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().alphaValue = 0
}, completionHandler: hideWindow)
} else {
hideWindow()
}
} else {
// PiP is not active - fully clean up the window
// Clear reference immediately to prevent re-entry
playerWindow = nil
let cleanup: @Sendable () -> Void = {
Task { @MainActor in
navigationCoordinator?.isPlayerCollapsing = false
window.delegate = nil
// Don't set contentViewController to nil or call close() - just order out
// This lets SwiftUI views deallocate naturally rather than being forcibly torn down
window.orderOut(nil)
completion?()
}
}
if animated {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().alphaValue = 0
}, completionHandler: cleanup)
} else {
cleanup()
}
}
}
/// Updates the window level based on floating preference.
/// Call this when the user changes the player mode setting.
func updateWindowLevel(floating: Bool) {
guard let window = playerWindow else { return }
// Don't touch level/collectionBehavior mid-fullscreen; the setting is
// re-read in windowDidExitFullScreen.
guard !window.styleMask.contains(.fullScreen) else { return }
configureWindowLevel(window, floating: floating)
}
/// Toggles native fullscreen on the player window.
func toggleFullScreen() {
guard let window = playerWindow else {
// Inline-sheet presentation: fullscreen the main app window
NSApp.keyWindow?.toggleFullScreen(nil)
return
}
// A floating (pinned) window carries .fullScreenAuxiliary, which AppKit
// refuses to make a primary fullscreen window. Switch to the primary
// config for the transition; windowDidExitFullScreen restores floating.
if !window.styleMask.contains(.fullScreen) {
configureWindowLevel(window, floating: false)
}
window.toggleFullScreen(nil)
}
/// Restores a window that was hidden for PiP mode.
/// Call this when returning from PiP to show the player window again.
func restoreFromPiP(animated: Bool = true) {
guard let window = playerWindow else {
LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP - no window to restore", category: .player)
return
}
LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP called", category: .player)
if animated {
window.alphaValue = 0
window.makeKeyAndOrderFront(nil)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().alphaValue = 1
}
} else {
window.alphaValue = 1
window.makeKeyAndOrderFront(nil)
}
}
/// Cleans up a window that was hidden for PiP when PiP ends without restoring.
/// Call this when PiP is closed via the X button (not restore).
func cleanupAfterPiP() {
guard let window = playerWindow else { return }
LoggingService.shared.debug("ExpandedPlayerWindowManager: cleanupAfterPiP called", category: .player)
// Clear our reference immediately to prevent further use
playerWindow = nil
window.delegate = nil
// Don't forcefully destroy the contentViewController or close the window immediately.
// This causes crashes because AVKit's PiP implementation adds internal views to the
// window hierarchy (via NSHostingController), and forcefully tearing down the view
// hierarchy while AVKit still has references causes use-after-free crashes.
//
// Instead, just order the window out and let it deallocate naturally when all
// references are released.
window.orderOut(nil)
LoggingService.shared.debug("ExpandedPlayerWindowManager: window ordered out", category: .player)
}
/// Resizes the player window to fit the given video aspect ratio.
/// - Parameters:
/// - aspectRatio: Video width / height ratio
/// - animated: Whether to animate the resize
func resizeToFitAspectRatio(_ aspectRatio: Double, animated: Bool = true) {
guard let window = playerWindow else { return }
guard aspectRatio > 0 else { return }
// Always update the aspect-ratio lock, even if we end up not resizing here.
Self.applyAspectRatioConstraint(aspectRatio, to: window)
// Get screen bounds
guard let screen = window.screen ?? NSScreen.main else { return }
let screenFrame = screen.visibleFrame
// Calculate target size
let targetSize = calculateWindowSize(for: aspectRatio, screenFrame: screenFrame)
// Calculate new frame centered on current position
let currentFrame = window.frame
let newOrigin = NSPoint(
x: currentFrame.midX - targetSize.width / 2,
y: currentFrame.midY - targetSize.height / 2
)
let newFrame = NSRect(origin: newOrigin, size: targetSize)
// Ensure frame stays on screen
let adjustedFrame = constrainToScreen(newFrame, screen: screen)
// The first resize after each open snaps regardless of the requested
// animation, so the player appears at its final fixed layout instead of
// animating (the video/controls track the window's live size). Later
// resizes e.g. switching to a different-aspect video while the window
// stays open honor `animated`.
let effectiveAnimated = animated && hasCompletedInitialSizing
hasCompletedInitialSizing = true
LoggingService.shared.debug(
"resizeToFitAspectRatio aspect=\(aspectRatio) requested=\(animated) effective=\(effectiveAnimated) from=\(window.frame.size) to=\(adjustedFrame.size)",
category: .player
)
// Apply the new frame
window.setFrame(adjustedFrame, display: true, animate: effectiveAnimated)
}
/// Locks the window's resize behavior to the given aspect ratio without
/// changing the current frame. Use this when the auto-resize setting is
/// disabled but we still want manual resizing to be ratio-locked.
func lockAspectRatio(_ aspectRatio: Double) {
guard let window = playerWindow else { return }
guard aspectRatio > 0 else { return }
Self.applyAspectRatioConstraint(aspectRatio, to: window)
}
// MARK: - Private Helpers
/// Sets `contentAspectRatio` and a ratio-consistent `minSize` on the window
/// so that interactive resize couples width and height proportionally.
/// Static so the inline-sheet path (`SheetWindowResizer`) can lock the sheet's
/// backing window to the same ratio the standalone window uses.
static func applyAspectRatioConstraint(_ aspectRatio: Double, to window: NSWindow) {
guard aspectRatio > 0 else { return }
// contentAspectRatio is expressed as a ratio; using (aspectRatio, 1) keeps it exact.
window.contentAspectRatio = NSSize(width: aspectRatio, height: 1)
// Derive a minimum size that lies on the same ratio so the lower bound
// doesn't force the window off-ratio (which would re-introduce bars).
// Anchor on minHeight and scale width by aspect.
let derivedMinWidth = max(Self.minHeight * CGFloat(aspectRatio), 320)
window.minSize = NSSize(width: derivedMinWidth, height: Self.minHeight)
}
private func configureWindowLevel(_ window: NSWindow, floating: Bool) {
if floating {
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
} else {
window.level = .normal
window.collectionBehavior = [.managed, .fullScreenPrimary]
}
}
/// Initial window size for a given aspect ratio. Delegates to the shared
/// `fittedPlayerSize` so the opening size and the later aspect-fitted size use
/// identical math (no mismatch no grow when the two are compared).
private func calculateInitialWindowSize(aspectRatio: Double) -> NSSize {
let ratio = aspectRatio > 0 ? aspectRatio : Self.defaultAspectRatio
guard let screen = NSScreen.main else {
return Self.fittedPlayerSize(
for: ratio,
screenFrame: NSRect(x: 0, y: 0, width: 1280, height: 720)
)
}
return Self.fittedPlayerSize(for: ratio, screenFrame: screen.visibleFrame)
}
private func calculateWindowSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
Self.fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame)
}
/// Shared sizing math for both the standalone-window path and the sheet path.
/// Anchors on `targetVideoHeight`, derives width from the aspect ratio, then
/// clamps to `maxScreenRatio` of the screen and the minimum size.
static func fittedPlayerSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
let maxWidth = screenFrame.width * maxScreenRatio
let maxHeight = screenFrame.height * maxScreenRatio
var width: CGFloat
var height: CGFloat
// Start with target video height and calculate width from aspect ratio
height = targetVideoHeight
width = height * aspectRatio
// Scale down if too wide for screen
if width > maxWidth {
width = maxWidth
height = width / aspectRatio
}
// Scale down if too tall for screen
if height > maxHeight {
height = maxHeight
width = height * aspectRatio
}
// Apply minimum constraints
width = max(width, minWidth)
height = max(height, minHeight)
return NSSize(width: width, height: height)
}
/// Fitted sheet content size for a given aspect ratio, resolving the screen
/// internally so callers (e.g. `ContentView`) don't need AppKit. Returns a
/// plain `CGSize` (`NSSize == CGSize` on macOS).
static func fittedSheetSize(for aspectRatio: Double) -> CGSize {
let screenFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame
?? NSRect(x: 0, y: 0, width: 1280, height: 800)
return fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame)
}
private func constrainToScreen(_ frame: NSRect, screen: NSScreen) -> NSRect {
let screenFrame = screen.visibleFrame
var adjustedFrame = frame
// Ensure width/height don't exceed screen
adjustedFrame.size.width = min(adjustedFrame.size.width, screenFrame.width)
adjustedFrame.size.height = min(adjustedFrame.size.height, screenFrame.height)
// Adjust origin to keep on screen
if adjustedFrame.minX < screenFrame.minX {
adjustedFrame.origin.x = screenFrame.minX
}
if adjustedFrame.maxX > screenFrame.maxX {
adjustedFrame.origin.x = screenFrame.maxX - adjustedFrame.width
}
if adjustedFrame.minY < screenFrame.minY {
adjustedFrame.origin.y = screenFrame.minY
}
if adjustedFrame.maxY > screenFrame.maxY {
adjustedFrame.origin.y = screenFrame.maxY - adjustedFrame.height
}
return adjustedFrame
}
}
// MARK: - NSWindowDelegate
extension ExpandedPlayerWindowManager: NSWindowDelegate {
nonisolated func windowShouldClose(_ sender: NSWindow) -> Bool {
// Handle close ourselves to avoid deallocation race conditions
MainActor.assumeIsolated {
// Clear reference first so hide() becomes a no-op
playerWindow = nil
// Clear queue so closing the window fully ends the session,
// matching the close button behavior
appEnvironment?.queueManager.clearQueue()
// Stop player BEFORE cleaning up window to avoid crash
// The player must be stopped while views still exist to ensure
// proper cleanup of render resources
appEnvironment?.playerService.stop()
// Clean up window
sender.delegate = nil
sender.contentViewController = nil
sender.orderOut(nil)
// Update navigation state
// Set collapsing first so mini player shows video immediately
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
}
// Return false - we've already hidden the window with orderOut
return false
}
nonisolated func windowDidExitFullScreen(_ notification: Notification) {
MainActor.assumeIsolated {
guard let window = playerWindow else { return }
// Restore the pinned (floating) config that toggleFullScreen
// dropped so the window could enter primary fullscreen.
let floating = appEnvironment?.settingsManager.macPlayerFloating ?? false
configureWindowLevel(window, floating: floating)
}
}
}
// MARK: - Two-Phase Window Root
/// Root view hosted in the expanded player window.
///
/// Shows a cheap black background + spinner on the first frame so the window
/// composites and fades in immediately, then defers building the heavy
/// `ExpandedPlayerSheet` by one runloop after the window is already on screen.
/// This is the macOS equivalent of the instant loading feedback iOS shows while
/// its player window renders, and removes the perceptible "dead gap" between the
/// click and the window appearing.
private struct ExpandedPlayerWindowRoot: View {
@State private var showFullPlayer = false
var body: some View {
ZStack {
// Fills the window and matches the player's black background so the
// swap to ExpandedPlayerSheet is seamless.
Color.black
if showFullPlayer {
ExpandedPlayerSheet()
} else {
ProgressView()
.controlSize(.large)
.tint(.white)
}
}
.ignoresSafeArea()
.onAppear {
// Defer so the cheap branch composites first, then build the real
// player while the window is already visible.
DispatchQueue.main.async { showFullPlayer = true }
}
}
}
// MARK: - Sheet Window Resizer
/// Resizes the hosting sheet's `NSWindow` to `targetSize` whenever it changes.
///
/// `.presentationSizing(.fitted)` only fits the sheet to its content once, at
/// presentation time it does not re-fit when the content's ideal size changes
/// later (e.g. when the video aspect ratio is decoded). This reaches the sheet's
/// backing window directly and resizes it, mirroring how the standalone window
/// path drives `setFrame` in `resizeToFitAspectRatio`. It keeps the window
/// horizontally centered and top-anchored so the sheet grows/shrinks the way an
/// attached sheet naturally sits.
struct SheetWindowResizer: NSViewRepresentable {
let targetSize: CGSize
/// Real video aspect ratio (width / height) to lock interactive resize to, or
/// `0` when unknown. Mirrors the standalone window's `contentAspectRatio` lock
/// so dragging the sheet edge keeps the video's ratio instead of adding bars.
let aspectRatio: Double
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
var hasApplied = false
}
func makeNSView(context _: Context) -> NSView {
NSView(frame: .zero)
}
func updateNSView(_ nsView: NSView, context: Context) {
let target = targetSize
let aspect = aspectRatio
let coordinator = context.coordinator
// The window isn't attached during the same runloop tick as the SwiftUI
// update, so defer the resize until the view is in a window.
DispatchQueue.main.async {
guard target.width > 0, target.height > 0 else { return }
guard let window = nsView.window else { return }
// Paint the window/content black so that if the content ever lags the
// window frame mid-resize, the exposed area is black (matching the
// player background) rather than the system's light window color.
if window.backgroundColor != .black {
window.backgroundColor = .black
window.isOpaque = true
}
// Lock interactive resize to the video ratio so dragging the sheet's
// edge keeps the aspect (no black bars), matching the standalone
// window. Applied every pass so it tracks aspect-ratio changes.
if aspect > 0 {
ExpandedPlayerWindowManager.applyAspectRatioConstraint(aspect, to: window)
}
let current = window.frame
guard abs(current.width - target.width) > 1
|| abs(current.height - target.height) > 1 else { return }
// Snap to the correct size the first time (no animation) so the sheet
// opens at the right aspect; animate later aspect-ratio changes.
let shouldAnimate = coordinator.hasApplied
coordinator.hasApplied = true
// Keep horizontally centered; anchor the top edge so the sheet grows
// downward (AppKit y-origin is bottom-left, so hold `maxY`).
let newOrigin = NSPoint(
x: current.midX - target.width / 2,
y: current.maxY - target.height
)
let newFrame = NSRect(origin: newOrigin, size: target)
window.setFrame(newFrame, display: true, animate: shouldAnimate)
}
}
}
extension View {
/// Resizes the hosting sheet window to `size` when it changes and locks its
/// interactive resize to `aspectRatio` (macOS sheets). Pass `aspectRatio == 0`
/// to leave resize unconstrained.
func sheetWindowSize(_ size: CGSize, aspectRatio: Double) -> some View {
background(SheetWindowResizer(targetSize: size, aspectRatio: aspectRatio))
}
}
#endif