From 60222fa5f38742debc4f60b4e3296b791004f2df Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 29 Sep 2026 23:08:16 +0200 Subject: [PATCH] Fix inline macOS player aspect resizing --- .../Player/ExpandedPlayerWindowManager.swift | 163 ++++++++++++++---- 1 file changed, 131 insertions(+), 32 deletions(-) diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index 6b13d5ba..51f227b4 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -30,6 +30,17 @@ final class ExpandedPlayerWindowManager: NSObject { private var overlayFullScreenObservers: [NSObjectProtocol] = [] private var overlayToolbarWasVisible: Bool? private var overlayWasFullScreenAtBegin = false + private var isOverlayParentEnteringFullScreen = false + + /// The main window belongs to the app rather than the player. Preserve its + /// normal sizing rules while the inline overlay temporarily owns resizing. + private struct WindowSizingConstraints { + let contentAspectRatio: NSSize + let contentResizeIncrements: NSSize + let contentMinSize: NSSize + let minSize: NSSize + } + private var overlaySizingConstraints: WindowSizingConstraints? /// Window hosting ContentView, registered by `MainContentWindowReader`. /// `beginInlineOverlay` must not guess via mainWindow/keyWindow: when the @@ -67,8 +78,15 @@ final class ExpandedPlayerWindowManager: NSObject { /// alone fills the screen; restored when fullscreen ends. private var cornerPanelsHiddenForFullScreen = false - private var isPlayerWindowFullScreen: Bool { - isPlayerWindowEnteringFullScreen || (playerWindow?.styleMask.contains(.fullScreen) ?? false) + private var activePlayerWindow: NSWindow? { + playerWindow ?? overlayParent + } + + private var isActivePlayerWindowFullScreen: Bool { + if let playerWindow { + return isPlayerWindowEnteringFullScreen || playerWindow.styleMask.contains(.fullScreen) + } + return isOverlayParentEnteringFullScreen || (overlayParent?.styleMask.contains(.fullScreen) ?? false) } // Configuration @@ -87,9 +105,9 @@ final class ExpandedPlayerWindowManager: NSObject { private enum PendingAspectLock { case lock(Double), unlock } private var pendingLiveResizeAspectLock: PendingAspectLock? - /// Whether the user is currently dragging the player window's edge/corner. + /// Whether the user is currently dragging the active player host window. var isLiveResizing: Bool { - playerWindow?.inLiveResize == true + activePlayerWindow?.inLiveResize == true } var isPresented: Bool { @@ -402,7 +420,11 @@ final class ExpandedPlayerWindowManager: NSObject { // entering flag is raised now so the aspect re-lock the panel hide // triggers parks instead of auto-fitting the window mid-way. if !target.styleMask.contains(.fullScreen) { - if playerWindow != nil { isPlayerWindowEnteringFullScreen = true } + if playerWindow != nil { + isPlayerWindowEnteringFullScreen = true + } else { + isOverlayParentEnteringFullScreen = true + } if hideCornerPanelsForFullScreen() { DispatchQueue.main.asyncAfter(deadline: .now() + Self.cornerPanelHideDuration) { [weak self] in self?.performNativeFullScreenToggle() @@ -452,14 +474,31 @@ final class ExpandedPlayerWindowManager: NSObject { LoggingService.shared.debug("ExpandedPlayerWindowManager: beginning inline overlay", category: .player) overlayParent = parent + overlaySizingConstraints = WindowSizingConstraints( + contentAspectRatio: parent.contentAspectRatio, + contentResizeIncrements: parent.contentResizeIncrements, + contentMinSize: parent.contentMinSize, + minSize: parent.minSize + ) overlayToolbarWasVisible = parent.toolbar?.isVisible parent.toolbar?.isVisible = false overlayWasFullScreenAtBegin = parent.styleMask.contains(.fullScreen) + isOverlayParentEnteringFullScreen = false + hasCompletedInitialSizing = false + pendingFullScreenAspectRatio = nil + pendingLiveResizeAspectLock = nil let coordinator = AppEnvironment.shared.navigationCoordinator coordinator.isMacInlinePlayerFullScreen = overlayWasFullScreenAtBegin observeOverlayFullScreenTransitions(of: parent) + // ExpandedPlayerSheet can appear before ContentView's onChange registers + // the parent window. Re-run the same sizing decision on the next turn so + // the inline presentation cannot miss its initial aspect setup. + DispatchQueue.main.async { [weak self] in + self?.synchronizeActiveWindowAspectRatio() + } + // The overlay's render container may mount while the mini capsule is // still tearing down, leaving the shared render view parked with no // later lifecycle event to re-trigger adoption — retry until it lands. @@ -490,11 +529,16 @@ final class ExpandedPlayerWindowManager: NSObject { if let wasVisible = overlayToolbarWasVisible { parent.toolbar?.isVisible = wasVisible } + restoreOverlaySizingConstraints(on: parent) AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false overlayToolbarWasVisible = nil overlayWasFullScreenAtBegin = false + isOverlayParentEnteringFullScreen = false + pendingFullScreenAspectRatio = nil + pendingLiveResizeAspectLock = nil overlayParent = nil + overlaySizingConstraints = nil } /// Mirrors the parent window's fullscreen transitions into the coordinator @@ -513,7 +557,9 @@ final class ExpandedPlayerWindowManager: NSObject { queue: .main ) { _ in Task { @MainActor in - ExpandedPlayerWindowManager.shared.hideCornerPanelsForFullScreen() + let manager = ExpandedPlayerWindowManager.shared + manager.isOverlayParentEnteringFullScreen = true + manager.hideCornerPanelsForFullScreen() } }, center.addObserver( @@ -522,6 +568,7 @@ final class ExpandedPlayerWindowManager: NSObject { queue: .main ) { _ in Task { @MainActor in + ExpandedPlayerWindowManager.shared.isOverlayParentEnteringFullScreen = false AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = true } }, @@ -531,10 +578,24 @@ final class ExpandedPlayerWindowManager: NSObject { queue: .main ) { _ in Task { @MainActor in + let manager = ExpandedPlayerWindowManager.shared + manager.isOverlayParentEnteringFullScreen = false AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false // Mirror of the enter order: the window is back at its // size first, then the panels slide in. - ExpandedPlayerWindowManager.shared.restoreCornerPanelsAfterFullScreen() + manager.restoreCornerPanelsAfterFullScreen() + if let window = manager.overlayParent { + manager.replayPendingFullScreenAspectRatio(on: window) + } + } + }, + center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: parent, + queue: .main + ) { _ in + Task { @MainActor in + ExpandedPlayerWindowManager.shared.applyPendingLiveResizeAspectLock() } }, ] @@ -623,13 +684,13 @@ final class ExpandedPlayerWindowManager: NSObject { /// - 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 let window = activePlayerWindow else { return } guard aspectRatio > 0 else { return } // In native fullscreen the video must keep the whole screen; constraining // or reframing the window here would letterbox the content inside the // fullscreen space. Park the ratio and replay it on fullscreen exit. - if isPlayerWindowFullScreen { + if isActivePlayerWindowFullScreen { pendingFullScreenAspectRatio = aspectRatio LoggingService.shared.debug( "resizeToFitAspectRatio deferred while fullscreen aspect=\(aspectRatio)", @@ -690,9 +751,9 @@ final class ExpandedPlayerWindowManager: NSObject { /// 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 let window = activePlayerWindow else { return } guard aspectRatio > 0 else { return } - if isPlayerWindowFullScreen { + if isActivePlayerWindowFullScreen { pendingFullScreenAspectRatio = aspectRatio return } @@ -708,7 +769,7 @@ final class ExpandedPlayerWindowManager: NSObject { /// video-plus-panels arrangement rather than the video alone. The size /// floor stays enforced by `windowWillResize(_:to:)`. func unlockAspectRatio() { - guard let window = playerWindow else { return } + guard let window = activePlayerWindow else { return } pendingFullScreenAspectRatio = nil if window.inLiveResize { pendingLiveResizeAspectLock = .unlock @@ -720,7 +781,11 @@ final class ExpandedPlayerWindowManager: NSObject { // ratio, which makes interactive resize a no-op (the window can't be // resized at all). Resetting the increments to 1×1 is the supported way // to return to free resizing. - window.contentResizeIncrements = NSSize(width: 1, height: 1) + if window === overlayParent { + restoreOverlaySizingConstraints(on: window) + } else { + window.contentResizeIncrements = NSSize(width: 1, height: 1) + } LoggingService.shared.debug( "unlockAspectRatio (corner layout) aspect=\(window.contentAspectRatio) increments=\(window.contentResizeIncrements)", category: .player @@ -729,6 +794,55 @@ final class ExpandedPlayerWindowManager: NSObject { // MARK: - Private Helpers + private func synchronizeActiveWindowAspectRatio() { + let appEnvironment = AppEnvironment.shared + if appEnvironment.isCornerPanelLayoutActive { + unlockAspectRatio() + return + } + guard let aspectRatio = appEnvironment.playerService.state.videoAspectRatio, + aspectRatio > 0 else { return } + lockAspectRatio(aspectRatio) + if appEnvironment.settingsManager.playerSheetAutoResize { + resizeToFitAspectRatio(aspectRatio, animated: false) + } + } + + private func restoreOverlaySizingConstraints(on window: NSWindow) { + guard let constraints = overlaySizingConstraints else { return } + window.contentMinSize = constraints.contentMinSize + window.minSize = constraints.minSize + if constraints.contentAspectRatio.width > 0, + constraints.contentAspectRatio.height > 0 { + window.contentAspectRatio = constraints.contentAspectRatio + } else { + // Setting resize increments is AppKit's supported way to clear an + // aspect constraint; assigning a zero contentAspectRatio is not. + window.contentResizeIncrements = constraints.contentResizeIncrements + } + } + + private func applyPendingLiveResizeAspectLock() { + guard let pending = pendingLiveResizeAspectLock else { return } + pendingLiveResizeAspectLock = nil + switch pending { + case let .lock(ratio): + lockAspectRatio(ratio) + case .unlock: + unlockAspectRatio() + } + } + + private func replayPendingFullScreenAspectRatio(on window: NSWindow) { + guard let ratio = pendingFullScreenAspectRatio else { return } + pendingFullScreenAspectRatio = nil + guard !AppEnvironment.shared.isCornerPanelLayoutActive else { return } + Self.applyAspectRatioConstraint(ratio, to: window) + if AppEnvironment.shared.settingsManager.playerSheetAutoResize { + resizeToFitAspectRatio(ratio, animated: false) + } + } + /// 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. @@ -868,7 +982,7 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate { /// 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 { + nonisolated func windowWillResize(_ window: NSWindow, to frameSize: NSSize) -> NSSize { MainActor.assumeIsolated { // Enforce the floor from constants here rather than from // `window.minSize`/`contentMinSize`: NSHostingController resets those @@ -877,7 +991,7 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate { // `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 isAspectLocked = (playerWindow?.contentAspectRatio.width ?? 0) > 0 + let isAspectLocked = window.contentAspectRatio.width > 0 var width: CGFloat var height: CGFloat @@ -911,14 +1025,7 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate { nonisolated func windowDidEndLiveResize(_: Notification) { MainActor.assumeIsolated { - guard let pending = pendingLiveResizeAspectLock else { return } - pendingLiveResizeAspectLock = nil - switch pending { - case let .lock(ratio): - lockAspectRatio(ratio) - case .unlock: - unlockAspectRatio() - } + applyPendingLiveResizeAspectLock() } } @@ -994,15 +1101,7 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate { // advanced to a different-aspect video): restore the manual-resize // ratio lock, and auto-fit the window if that setting is on — // exactly what the aspect-change handler would have done windowed. - if let ratio = pendingFullScreenAspectRatio { - pendingFullScreenAspectRatio = nil - // Corner layout with visible panels keeps the window unconstrained. - guard appEnvironment?.isCornerPanelLayoutActive != true else { return } - Self.applyAspectRatioConstraint(ratio, to: window) - if appEnvironment?.settingsManager.playerSheetAutoResize == true { - resizeToFitAspectRatio(ratio, animated: false) - } - } + replayPendingFullScreenAspectRatio(on: window) } } }