mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
A pinned window carries .fullScreenAuxiliary, which AppKit refuses to make a primary fullscreen window, so toggleFullScreen was a silent no-op. Drop the floating config before entering fullscreen and restore it in windowDidExitFullScreen, keeping the pin setting intact.
650 lines
28 KiB
Swift
650 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
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 {
|
|
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
|