mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
The fullscreen button did nothing in sheet mode: AppKit ignores toggleFullScreen on a window attached as a sheet, and the old fallback targeted the key window — the sheet itself. Two other strategies were ruled out first: fullscreening the sheet's parent is silently refused while any sheet is attached, and resizing the sheet's backing window to screen size NaN-asserts inside AppKit's sheet positioning even without animation. Working approach: swap the sheet for a plain overlay in the main window and native-fullscreen the now sheet-free main window. - isMacPlayerFullScreenOverlay on NavigationCoordinator dismisses the sheet (presentation binding excludes it; the setter ignores the dismissal write-back) and mounts ExpandedPlayerSheet as a black-backed overlay in ContentView - ExpandedPlayerWindowManager fullscreens the parent after the sheet detaches, verifies AppKit accepted it (rolls back if refused), hides the window toolbar (NSWindow keeps it as a strip above the overlay otherwise) and restores it on exit - a didExitFullScreen observer restores the sheet on any exit path (button, Esc, green button, Window menu); closing the player while fullscreen exits the main window's fullscreen too - scheduleSharedViewAdoptionRetry on entry re-homes the shared render view once the dismissing sheet's window loses visibility — its steal is declined mid-dismissal and nothing else retried, leaving the overlay black until the next video load
870 lines
39 KiB
Swift
870 lines
39 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?
|
|
|
|
/// Main app window native-fullscreened for the inline player overlay, the
|
|
/// observer watching for it to exit fullscreen (Esc, green button, menu),
|
|
/// and the toolbar visibility to restore afterwards (the toolbar is hidden
|
|
/// during overlay fullscreen — NSWindow keeps it as a visible strip above
|
|
/// the content otherwise).
|
|
private weak var overlayFullScreenParent: NSWindow?
|
|
private var overlayFullScreenExitObserver: NSObjectProtocol?
|
|
private var overlayFullScreenToolbarWasVisible: Bool?
|
|
|
|
/// 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 0→1
|
|
// 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 {
|
|
// No window means sheet mode (or the window is already gone).
|
|
// A dismissed sheet's hierarchy stays alive holding the shared
|
|
// render view inside a now-invisible window with no container
|
|
// lifecycle event to react to - watch for the dismissal to finish
|
|
// and re-home the view to the mini capsule.
|
|
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
|
|
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 mpvBackend = appEnvironment?.playerService.currentBackend as? MPVBackend
|
|
let isPiPActive = 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)
|
|
// The mini capsule's claim on the shared render view was
|
|
// declined while this window was still visible during the
|
|
// fade-out, and nothing retries after orderOut - hand the
|
|
// view over now instead of leaving the capsule black until
|
|
// the render watchdog recovers it.
|
|
if MPVContainerNSView.recoverSharedPlayerViewIfNeeded() {
|
|
mpvBackend?.resumeRendering()
|
|
}
|
|
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: swap the sheet for an in-window
|
|
// overlay and native-fullscreen the main window.
|
|
toggleSheetFullScreen()
|
|
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)
|
|
}
|
|
|
|
// MARK: - Inline-Sheet Fullscreen
|
|
|
|
/// Toggles fullscreen for the inline-sheet player via the overlay strategy.
|
|
///
|
|
/// Two dead ends were verified before this design (see git history):
|
|
/// AppKit silently refuses native fullscreen while a sheet is attached
|
|
/// (on the sheet AND on its parent), and directly resizing the sheet's
|
|
/// backing window to screen size NaN-asserts inside AppKit's sheet
|
|
/// positioning, even unanimated. So instead: dismiss the sheet (the
|
|
/// `isMacPlayerFullScreenOverlay` flag flips the presentation binding),
|
|
/// show the player as a plain overlay inside the main window (ContentView),
|
|
/// and native-fullscreen the now sheet-free main window.
|
|
private func toggleSheetFullScreen() {
|
|
let coordinator = AppEnvironment.shared.navigationCoordinator
|
|
|
|
if coordinator.isMacPlayerFullScreenOverlay {
|
|
// Exit native fullscreen; the didExitFullScreen observer restores
|
|
// the sheet presentation.
|
|
if let parent = overlayFullScreenParent, parent.styleMask.contains(.fullScreen) {
|
|
parent.toggleFullScreen(nil)
|
|
} else {
|
|
endOverlayFullScreen(exitParentFullScreen: false)
|
|
}
|
|
return
|
|
}
|
|
|
|
// The click comes from inside the sheet, so the key window is the
|
|
// sheet's backing window and its sheetParent is the main app window.
|
|
guard let sheetWindow = NSApp.keyWindow, let parent = sheetWindow.sheetParent else {
|
|
LoggingService.shared.debug(
|
|
"ExpandedPlayerWindowManager: toggleSheetFullScreen found no attached sheet (keyWindow=\(NSApp.keyWindow.map { String(describing: type(of: $0)) } ?? "nil"))",
|
|
category: .player
|
|
)
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: entering overlay fullscreen", category: .player)
|
|
coordinator.isMacPlayerFullScreenOverlay = true // dismisses sheet, mounts overlay
|
|
overlayFullScreenParent = parent
|
|
observeOverlayFullScreenExit(of: parent)
|
|
|
|
// The overlay's render container mounts while the sheet is still
|
|
// visibly dismissing, so its claim on the shared render view is
|
|
// declined ("declined steal") and no later lifecycle event re-triggers
|
|
// it — the view stays parked in the dismissed sheet's invisible window
|
|
// and the overlay shows black. Watch the dismissal and re-home the
|
|
// view the moment the sheet window loses visibility.
|
|
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
|
|
|
|
Task { @MainActor in
|
|
// Fullscreen is refused while ANY sheet is attached, so wait for
|
|
// the dismissal to fully detach the sheet first.
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
guard coordinator.isMacPlayerFullScreenOverlay else { return }
|
|
LoggingService.shared.debug(
|
|
"ExpandedPlayerWindowManager: overlay fullscreen toggling parent (attachedSheet=\(parent.attachedSheet != nil))",
|
|
category: .player
|
|
)
|
|
parent.toggleFullScreen(nil)
|
|
|
|
// Hide the window toolbar or it stays as a strip above the overlay
|
|
// in fullscreen (NSWindow keeps toolbars visible there). Done after
|
|
// the toggle so the windowed layout never visibly loses it.
|
|
overlayFullScreenToolbarWasVisible = parent.toolbar?.isVisible
|
|
parent.toolbar?.isVisible = false
|
|
|
|
// Debug: verify AppKit accepted the transition; restore the sheet
|
|
// presentation if it refused.
|
|
try? await Task.sleep(for: .seconds(1))
|
|
let entered = parent.styleMask.contains(.fullScreen)
|
|
LoggingService.shared.debug(
|
|
"ExpandedPlayerWindowManager: overlay fullscreen post-toggle entered=\(entered)",
|
|
category: .player
|
|
)
|
|
if !entered {
|
|
endOverlayFullScreen(exitParentFullScreen: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ends overlay fullscreen: clearing the flag unmounts the overlay and
|
|
/// re-presents the sheet. Pass `exitParentFullScreen: true` when the player
|
|
/// closes while fullscreen, so the main window doesn't stay fullscreen for
|
|
/// no reason.
|
|
func endOverlayFullScreen(exitParentFullScreen: Bool) {
|
|
let coordinator = AppEnvironment.shared.navigationCoordinator
|
|
guard coordinator.isMacPlayerFullScreenOverlay else { return }
|
|
|
|
LoggingService.shared.debug(
|
|
"ExpandedPlayerWindowManager: ending overlay fullscreen (exitParent=\(exitParentFullScreen))",
|
|
category: .player
|
|
)
|
|
coordinator.isMacPlayerFullScreenOverlay = false
|
|
if let observer = overlayFullScreenExitObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
overlayFullScreenExitObserver = nil
|
|
}
|
|
if exitParentFullScreen,
|
|
let parent = overlayFullScreenParent,
|
|
parent.styleMask.contains(.fullScreen) {
|
|
parent.toggleFullScreen(nil)
|
|
}
|
|
if let wasVisible = overlayFullScreenToolbarWasVisible {
|
|
overlayFullScreenParent?.toolbar?.isVisible = wasVisible
|
|
overlayFullScreenToolbarWasVisible = nil
|
|
}
|
|
overlayFullScreenParent = nil
|
|
}
|
|
|
|
/// Restores the sheet when the main window leaves fullscreen through any
|
|
/// path the player's button doesn't see (Esc, hover-revealed green button,
|
|
/// Window menu, Mission Control).
|
|
private func observeOverlayFullScreenExit(of parent: NSWindow) {
|
|
if let observer = overlayFullScreenExitObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
overlayFullScreenExitObserver = NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didExitFullScreenNotification,
|
|
object: parent,
|
|
queue: .main
|
|
) { _ in
|
|
Task { @MainActor in
|
|
ExpandedPlayerWindowManager.shared.endOverlayFullScreen(exitParentFullScreen: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 minimum content size on
|
|
/// the window so that interactive resize couples width and height
|
|
/// proportionally and can't shrink below a usable minimum.
|
|
/// 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)
|
|
let minContentSize = NSSize(width: derivedMinWidth, height: Self.minHeight)
|
|
|
|
// Best-effort minimum. NSHostingController resets these to zero at runtime,
|
|
// so the real resize floor is enforced in the window delegate's
|
|
// `windowWillResize(_:to:)`; these are kept for any code path that reads
|
|
// the window's stored minimum before the reset happens.
|
|
window.contentMinSize = minContentSize
|
|
window.minSize = minContentSize
|
|
}
|
|
|
|
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 {
|
|
/// Clamp interactive resize to a usable minimum size.
|
|
///
|
|
/// Once `contentAspectRatio` is set, AppKit's aspect-ratio resize handler
|
|
/// takes over and stops enforcing `minSize`/`contentMinSize` during live
|
|
/// drags; NSHostingController also zeroes those stored minimums at runtime.
|
|
/// So the floor is computed here from constants and applied to the proposed
|
|
/// (already aspect-correct) frame, growing any under-sized proposal back up
|
|
/// while preserving its ratio.
|
|
nonisolated func windowWillResize(_: NSWindow, to frameSize: NSSize) -> NSSize {
|
|
MainActor.assumeIsolated {
|
|
// Enforce the floor from constants here rather than from
|
|
// `window.minSize`/`contentMinSize`: NSHostingController resets those
|
|
// to zero at runtime (even with `sizingOptions = []`), so the window's
|
|
// stored minimum can't be trusted. AppKit already applies
|
|
// `contentAspectRatio` to `frameSize`, so we only need to grow an
|
|
// under-sized proposal back up to the minimum while preserving its
|
|
// (already correct) aspect ratio.
|
|
let aspect = frameSize.height > 0
|
|
? frameSize.width / frameSize.height
|
|
: CGFloat(Self.defaultAspectRatio)
|
|
|
|
var height = max(frameSize.height, Self.minHeight)
|
|
var width = height * aspect
|
|
|
|
// Guard the width floor too (for narrow/portrait videos where the
|
|
// height minimum alone would leave the window too thin), then rederive
|
|
// height so the result stays on the proposed aspect ratio.
|
|
let minWidthFloor = max(Self.minHeight * aspect, 320)
|
|
if width < minWidthFloor {
|
|
width = minWidthFloor
|
|
height = aspect > 0 ? width / aspect : height
|
|
}
|
|
|
|
return NSSize(width: width, height: height)
|
|
}
|
|
}
|
|
|
|
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
|
|
let navigationCoordinator = appEnvironment?.navigationCoordinator
|
|
navigationCoordinator?.isPlayerCollapsing = true
|
|
navigationCoordinator?.isPlayerExpanded = false
|
|
|
|
// Unlike hide(), this path has no animation completion to reset the
|
|
// flag. Left stuck true, the mini capsule mounts its video container
|
|
// forever and hijacks the shared render view on the next expand
|
|
// (player window stays black while the capsule renders the video).
|
|
Task { @MainActor in
|
|
navigationCoordinator?.isPlayerCollapsing = 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
|