Snap macOS player window to final size on open

The expanded player window opened at a seeded 16:9 size and was then
resized with an animated setFrame once the real aspect ratio / video
change landed. Because the video and controls are laid out from the
window's live size, they visibly grew/repositioned as the window
animated.

Force the first resize after each open to snap (no animation) so the
player appears at its final fixed layout, and seed the initial window
size and aspect constraint from the real video aspect ratio when it is
already known (e.g. expanding from the mini bar) to avoid even a jump.
Later resizes (switching to a different-aspect video while the window
stays open) still animate.
This commit is contained in:
Arkadiusz Fal
2026-05-29 18:36:39 +02:00
parent 32f526e05b
commit 64ca1c10d9

View File

@@ -19,6 +19,12 @@ final class ExpandedPlayerWindowManager: NSObject {
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
@@ -77,6 +83,13 @@ final class ExpandedPlayerWindowManager: NSObject {
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
@@ -86,8 +99,8 @@ final class ExpandedPlayerWindowManager: NSObject {
// below and `resizeToFitAspectRatio` once the real video ratio is known.
hostingController.sizingOptions = []
// Calculate initial window size
let initialSize = calculateInitialWindowSize()
// Calculate initial window size at the seeded aspect ratio
let initialSize = calculateInitialWindowSize(aspectRatio: seedAspect)
// Create window with appropriate style
let window = NSWindow(
@@ -104,9 +117,9 @@ final class ExpandedPlayerWindowManager: NSObject {
window.backgroundColor = NSColor.windowBackgroundColor
window.contentViewController = hostingController
// Lock manual resize to the video aspect ratio. Seeded with 16:9 here;
// updated as soon as the real video aspect ratio is known.
Self.applyAspectRatioConstraint(Self.defaultAspectRatio, to: window)
// 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
@@ -129,6 +142,10 @@ final class ExpandedPlayerWindowManager: NSObject {
// 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
@@ -301,8 +318,21 @@ final class ExpandedPlayerWindowManager: NSObject {
// 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: animated)
window.setFrame(adjustedFrame, display: true, animate: effectiveAnimated)
}
/// Locks the window's resize behavior to the given aspect ratio without
@@ -343,30 +373,18 @@ final class ExpandedPlayerWindowManager: NSObject {
}
}
private func calculateInitialWindowSize() -> NSSize {
/// 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 NSSize(width: 1280, height: 720)
return Self.fittedPlayerSize(
for: ratio,
screenFrame: NSRect(x: 0, y: 0, width: 1280, height: 720)
)
}
let screenFrame = screen.visibleFrame
let maxWidth = screenFrame.width * Self.maxScreenRatio
let maxHeight = screenFrame.height * Self.maxScreenRatio
// Start with 16:9 aspect ratio at target height
var width: CGFloat = Self.targetVideoHeight * 16 / 9
var height: CGFloat = Self.targetVideoHeight
// Scale down if needed
if width > maxWidth {
width = maxWidth
height = width * 9 / 16
}
if height > maxHeight {
height = maxHeight
width = height * 16 / 9
}
return NSSize(width: max(width, Self.minWidth), height: max(height, Self.minHeight))
return Self.fittedPlayerSize(for: ratio, screenFrame: screen.visibleFrame)
}
private func calculateWindowSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {