From ecb6a9dd7a01c9369980c06abdfdb5c834040405 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 3 Jul 2026 21:57:55 +0200 Subject: [PATCH] Replace macOS inline player sheet with full-window overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When "Play in a separate window" is disabled, the expanded player was a native sheet attached to the main window. AppKit refuses fullscreen with an attached sheet, so pressing F swapped the sheet for an in-window overlay before fullscreening — a visible two-step transition. Present the inline player as the full-window overlay from the start, so fullscreen is just the window's native toggle. This removes the whole sheet-swap machinery (toggleSheetFullScreen with its detach timing, SheetWindowResizer, sheet sizing helpers). - beginInlineOverlay/endInlineOverlay hide/restore the main window toolbar and mirror the window's fullscreen state into the renamed isMacInlinePlayerFullScreen flag via NSWindow notifications, so exits through Esc/green button/Mission Control stay in sync - Collapsing exits fullscreen only if it was entered for the video - Esc in the windowed overlay collapses to the mini bar via a new onCollapse callback (replacing the sheet's default Esc-dismiss) - ContentView registers its hosting window (MainContentWindowReader) so the overlay targets the right window even when the setting is toggled while the Settings window has focus --- Yattee/ContentView.swift | 104 ++---- .../Navigation/NavigationCoordinator.swift | 9 +- .../Player/ExpandedPlayerSheet+Layouts.swift | 11 +- .../Player/ExpandedPlayerWindowManager.swift | 320 +++++++----------- .../macOS/MacOSPlayerControlsView.swift | 13 +- 5 files changed, 167 insertions(+), 290 deletions(-) diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index e5389e79..0a335807 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -58,19 +58,31 @@ struct ContentView: View { #if os(macOS) miniPlayerOverlay(appEnvironment: appEnvironment) - // Fullscreen overlay player: replaces the sheet while the main - // window is native-fullscreen (a window with an attached sheet - // can't fullscreen, so the sheet is swapped for this overlay). - if appEnvironment.navigationCoordinator.isMacPlayerFullScreenOverlay { + // Inline expanded player: presented as a full-window overlay + // (no native sheet — a window with an attached sheet can't go + // fullscreen, so fullscreen is just the window's native toggle). + if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) { ZStack { Color.black ExpandedPlayerSheet() } .ignoresSafeArea() + .transition(.opacity) } #endif } #if os(macOS) + // Registers the hosting window so the inline overlay targets the real + // main window even when another window (e.g. Settings) has focus. + .background(MainContentWindowReader()) + .animation(.easeInOut(duration: 0.2), value: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) + .onChange(of: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) { _, active in + if active { + ExpandedPlayerWindowManager.shared.beginInlineOverlay() + } else { + ExpandedPlayerWindowManager.shared.endInlineOverlay() + } + } .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in if appEnvironment.settingsManager.macPlayerSeparateWindow { presentExpandedPlayerWindow(appEnvironment: appEnvironment) @@ -78,9 +90,6 @@ struct ContentView: View { } .onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in if !isExpanded { - // Closing the player while in overlay fullscreen also exits - // the main window's fullscreen (it was entered for the video). - ExpandedPlayerWindowManager.shared.endOverlayFullScreen(exitParentFullScreen: true) ExpandedPlayerWindowManager.shared.hide() } } @@ -88,16 +97,11 @@ struct ContentView: View { guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return } if separateWindow { - // Inline sheet → separate window: let the sheet dismiss first, then - // present the window (mirrors the previous inline→window transition). - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(300)) - if appEnvironment.navigationCoordinator.isPlayerExpanded { - presentExpandedPlayerWindow(appEnvironment: appEnvironment) - } - } + // Inline overlay → separate window: the overlay unmounts via the + // presentation condition; present the window directly. + presentExpandedPlayerWindow(appEnvironment: appEnvironment) } else { - // Separate window → inline sheet: hide the window so the sheet binding + // Separate window → inline overlay: hide the window so the overlay // (isPlayerExpanded && !separateWindow) takes over. ExpandedPlayerWindowManager.shared.hide(animated: false) } @@ -107,42 +111,6 @@ struct ContentView: View { appEnvironment.settingsManager.macPlayerSeparateWindow else { return } ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating) } - .sheet(isPresented: Binding( - get: { - appEnvironment.navigationCoordinator.isPlayerExpanded && - !appEnvironment.settingsManager.macPlayerSeparateWindow && - !appEnvironment.navigationCoordinator.isMacPlayerFullScreenOverlay - }, - set: { newValue in - // Ignore the dismissal write-back when the sheet is swapped for - // the fullscreen overlay — the player stays expanded there. - if !newValue, appEnvironment.navigationCoordinator.isMacPlayerFullScreenOverlay { - return - } - appEnvironment.navigationCoordinator.isPlayerExpanded = newValue - } - )) { - let size = expandedSheetSize(appEnvironment: appEnvironment) - let lockAspect = sheetLockAspectRatio(appEnvironment: appEnvironment) - ExpandedPlayerSheet() - // Floor keeps the content flexible so it tracks the window's - // animated resize with no gap — do NOT pin an exact (max) size: - // a fixed frame snaps instantly while the window animates, - // exposing the window background as bars. The ideal size makes - // `.presentationSizing(.fitted)` open the sheet at the correct - // aspect immediately when the video size is already known (e.g. - // re-opening while playing), avoiding a small-then-resize flash. - .frame( - minWidth: 640, idealWidth: size.width, - minHeight: 360, idealHeight: size.height - ) - .presentationSizing(.fitted) - // `.presentationSizing(.fitted)` only fits the sheet once, at - // presentation. Resize the backing window directly when the - // aspect-ratio-derived size changes so the sheet tracks the video, - // and lock interactive resize to the video ratio (no black bars). - .sheetWindowSize(size, aspectRatio: lockAspect) - } .sheet(isPresented: Binding( get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented }, set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 } @@ -172,30 +140,12 @@ struct ContentView: View { ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true) } - /// Size for the expanded-player sheet, derived from the current video aspect - /// ratio (when auto-resize is enabled) so the sheet re-fits like window mode. - /// Reading `videoAspectRatio` / `playerSheetAutoResize` here registers the - /// @Observable dependency that drives the re-fit. - private func expandedSheetSize(appEnvironment: AppEnvironment) -> CGSize { - let settings = appEnvironment.settingsManager - let aspect: Double - if settings.playerSheetAutoResize, - let ratio = appEnvironment.playerService.state.videoAspectRatio, ratio > 0 { - aspect = ratio - } else { - aspect = 16.0 / 9.0 // fixed default when auto-resize is off / not yet known - } - return ExpandedPlayerWindowManager.fittedSheetSize(for: aspect) - } - - /// Real video aspect ratio to lock the sheet's interactive resize to, or `0` - /// when unknown. Unlike the sheet *size* (which honors `playerSheetAutoResize`), - /// the resize lock always follows the actual video ratio — mirroring the - /// standalone window, which locks unconditionally. Reading `videoAspectRatio` - /// here registers the @Observable dependency that keeps the lock in sync. - private func sheetLockAspectRatio(appEnvironment: AppEnvironment) -> Double { - let ratio = appEnvironment.playerService.state.videoAspectRatio ?? 0 - return ratio > 0 ? ratio : 0 + /// Whether the inline (non-separate-window) expanded player overlay should + /// be presented. Shared by the overlay mount and the begin/end onChange so + /// the two can't drift apart. + private func isInlinePlayerOverlayActive(appEnvironment: AppEnvironment) -> Bool { + appEnvironment.navigationCoordinator.isPlayerExpanded && + !appEnvironment.settingsManager.macPlayerSeparateWindow } #endif @@ -206,7 +156,7 @@ struct ContentView: View { let hasActiveVideo = playerState.currentVideo != nil let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded // The expanded player is a separate window in window mode, so keep the capsule - // visible alongside it. In sheet mode the sheet covers the window, so hide it. + // visible alongside it. The inline overlay covers the window, so hide it there. let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow if hasActiveVideo && (!isExpanded || usesWindow) { diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 1dd89875..04b8e029 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -115,10 +115,11 @@ final class NavigationCoordinator { /// Whether the player is currently expanding (for animation coordination with mini player). var isPlayerExpanding = false - /// macOS only: true while the inline player runs in fullscreen overlay - /// mode — sheet dismissed, player shown as an overlay in the main window, - /// main window native-fullscreen. Set/cleared by ExpandedPlayerWindowManager. - var isMacPlayerFullScreenOverlay = false + /// macOS only: true while the main window is native-fullscreen for the + /// inline player overlay. Mirrors the window's fullscreen state (kept in + /// sync by ExpandedPlayerWindowManager via NSWindow fullscreen + /// notifications) so player controls update reactively. + var isMacInlinePlayerFullScreen = false /// Whether the player is currently collapsing (for animation coordination with mini player). var isPlayerCollapsing = false diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index cf8c48cf..62d19026 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -1575,12 +1575,19 @@ extension ExpandedPlayerSheet { // config first so a pinned window can enter fullscreen) ExpandedPlayerWindowManager.shared.toggleFullScreen() }, - // The coordinator flag covers overlay fullscreen (where the + // The coordinator flag covers the inline overlay (where the // main window is the fullscreen one) and, being @Observable, // refreshes the button icon on toggle; the keyWindow check // covers the separate-window presentation. - isFullscreen: navigationCoordinator?.isMacPlayerFullScreenOverlay == true + isFullscreen: navigationCoordinator?.isMacInlinePlayerFullScreen == true || NSApp.keyWindow?.styleMask.contains(.fullScreen) == true, + // Esc collapses the inline overlay to the mini bar; in + // separate-window mode Esc keeps its default handling. + onCollapse: appEnvironment?.settingsManager.macPlayerSeparateWindow == true + ? nil + : { [weak navigationCoordinator] in + navigationCoordinator?.isPlayerExpanded = false + }, onClose: { [self] in closeVideo() }, diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index 404e27bf..789b15f6 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -19,14 +19,30 @@ final class ExpandedPlayerWindowManager: NSObject { 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? + /// Main app window hosting the inline player overlay, the observers that + /// mirror its fullscreen transitions into the coordinator flag (Esc, green + /// button, menu, Mission Control all bypass the player's own button), the + /// toolbar visibility to restore when the overlay ends (the toolbar is + /// hidden while the overlay is up so it doesn't sit above the video), and + /// whether the window was already fullscreen before the overlay began (so + /// collapsing doesn't exit a fullscreen the user had independently). + private weak var overlayParent: NSWindow? + private var overlayFullScreenObservers: [NSObjectProtocol] = [] + private var overlayToolbarWasVisible: Bool? + private var overlayWasFullScreenAtBegin = false + + /// Window hosting ContentView, registered by `MainContentWindowReader`. + /// `beginInlineOverlay` must not guess via mainWindow/keyWindow: when the + /// separate-window setting is toggled during playback, the key window is + /// the Settings window (and the separate player window can be "main"), so + /// guessing hides/restores the toolbar on the wrong window. + private weak var mainContentWindow: NSWindow? + + /// Registers the window hosting the app's main content view. + func registerMainContentWindow(_ window: NSWindow?) { + guard let window else { return } + mainContentWindow = window + } /// Whether the window has performed its first size application since being /// shown. The first resize after each open snaps (no animation) so the player @@ -201,11 +217,10 @@ final class ExpandedPlayerWindowManager: NSObject { /// - 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. + // No window means inline overlay mode (or the window is already + // gone). The overlay's container may unmount without handing the + // shared render view to the mini capsule in the same pass - watch + // for the teardown to finish and re-home the view. MPVContainerNSView.scheduleSharedViewAdoptionRetry() completion?() return @@ -293,9 +308,10 @@ final class ExpandedPlayerWindowManager: NSObject { /// 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() + // Inline overlay presentation: the player already fills the main + // window, so fullscreen is just the window's native toggle. The + // fullscreen observers keep the coordinator flag in sync. + overlayParent?.toggleFullScreen(nil) return } // A floating (pinned) window carries .fullScreenAuxiliary, which AppKit @@ -307,131 +323,94 @@ final class ExpandedPlayerWindowManager: NSObject { window.toggleFullScreen(nil) } - // MARK: - Inline-Sheet Fullscreen + // MARK: - Inline Overlay Presentation - /// 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 { + /// Called when the inline player overlay mounts in the main window + /// (separate-window mode off, player expanded). Hides the main window's + /// toolbar for the overlay's lifetime and starts mirroring the window's + /// fullscreen state into `isMacInlinePlayerFullScreen`. + func beginInlineOverlay() { + guard overlayParent == nil else { return } + guard let parent = mainContentWindow ?? NSApp.mainWindow ?? NSApp.keyWindow else { LoggingService.shared.debug( - "ExpandedPlayerWindowManager: toggleSheetFullScreen found no attached sheet (keyWindow=\(NSApp.keyWindow.map { String(describing: type(of: $0)) } ?? "nil"))", + "ExpandedPlayerWindowManager: beginInlineOverlay found no main window", category: .player ) return } - LoggingService.shared.debug("ExpandedPlayerWindowManager: entering overlay fullscreen", category: .player) - coordinator.isMacPlayerFullScreenOverlay = true // dismisses sheet, mounts overlay - overlayFullScreenParent = parent - observeOverlayFullScreenExit(of: parent) + LoggingService.shared.debug("ExpandedPlayerWindowManager: beginning inline overlay", category: .player) + overlayParent = parent + overlayToolbarWasVisible = parent.toolbar?.isVisible + parent.toolbar?.isVisible = false + overlayWasFullScreenAtBegin = parent.styleMask.contains(.fullScreen) - // 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. + let coordinator = AppEnvironment.shared.navigationCoordinator + coordinator.isMacInlinePlayerFullScreen = overlayWasFullScreenAtBegin + observeOverlayFullScreenTransitions(of: parent) + + // 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. 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 } + /// Called when the inline player overlay unmounts (collapse, close, or + /// switch to separate-window mode). Restores the main window's toolbar and, + /// if fullscreen was entered for the video, exits it. + func endInlineOverlay() { + guard let parent = overlayParent else { return } - LoggingService.shared.debug( - "ExpandedPlayerWindowManager: ending overlay fullscreen (exitParent=\(exitParentFullScreen))", - category: .player - ) - coordinator.isMacPlayerFullScreenOverlay = false - if let observer = overlayFullScreenExitObserver { + LoggingService.shared.debug("ExpandedPlayerWindowManager: ending inline overlay", category: .player) + for observer in overlayFullScreenObservers { NotificationCenter.default.removeObserver(observer) - overlayFullScreenExitObserver = nil } - if exitParentFullScreen, - let parent = overlayFullScreenParent, - parent.styleMask.contains(.fullScreen) { + overlayFullScreenObservers = [] + + // Leave the window fullscreen if the user was fullscreen before the + // overlay began; only exit a fullscreen entered for the video. + if parent.styleMask.contains(.fullScreen), !overlayWasFullScreenAtBegin { parent.toggleFullScreen(nil) } - if let wasVisible = overlayFullScreenToolbarWasVisible { - overlayFullScreenParent?.toolbar?.isVisible = wasVisible - overlayFullScreenToolbarWasVisible = nil + if let wasVisible = overlayToolbarWasVisible { + parent.toolbar?.isVisible = wasVisible } - overlayFullScreenParent = nil + + AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false + overlayToolbarWasVisible = nil + overlayWasFullScreenAtBegin = false + overlayParent = 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 { + /// Mirrors the parent window's fullscreen transitions into the coordinator + /// flag so the player controls stay in sync with exits the player's button + /// doesn't see (Esc, hover-revealed green button, Window menu, Mission + /// Control). + private func observeOverlayFullScreenTransitions(of parent: NSWindow) { + for observer in overlayFullScreenObservers { NotificationCenter.default.removeObserver(observer) } - overlayFullScreenExitObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didExitFullScreenNotification, - object: parent, - queue: .main - ) { _ in - Task { @MainActor in - ExpandedPlayerWindowManager.shared.endOverlayFullScreen(exitParentFullScreen: false) - } - } + let center = NotificationCenter.default + overlayFullScreenObservers = [ + center.addObserver( + forName: NSWindow.didEnterFullScreenNotification, + object: parent, + queue: .main + ) { _ in + Task { @MainActor in + AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = true + } + }, + center.addObserver( + forName: NSWindow.didExitFullScreenNotification, + object: parent, + queue: .main + ) { _ in + Task { @MainActor in + AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false + } + }, + ] } /// Restores a window that was hidden for PiP mode. @@ -541,8 +520,6 @@ final class ExpandedPlayerWindowManager: NSObject { /// 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 } @@ -591,7 +568,7 @@ final class ExpandedPlayerWindowManager: NSObject { Self.fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame) } - /// Shared sizing math for both the standalone-window path and the sheet path. + /// Sizing math for the standalone-window 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 { @@ -624,15 +601,6 @@ final class ExpandedPlayerWindowManager: NSObject { 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 @@ -784,86 +752,28 @@ private struct ExpandedPlayerWindowRoot: View { } } -// MARK: - Sheet Window Resizer +// MARK: - Main Content Window Reader -/// 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 +/// Invisible background view that registers ContentView's hosting window with +/// `ExpandedPlayerWindowManager`, so the inline overlay always targets the real +/// main content window (mainWindow/keyWindow point elsewhere when e.g. the +/// Settings window has focus). +struct MainContentWindowReader: NSViewRepresentable { + func makeNSView(context _: Context) -> ReaderView { + ReaderView() } - func makeNSView(context _: Context) -> NSView { - NSView(frame: .zero) - } + func updateNSView(_: ReaderView, context _: Context) {} - 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 + final class ReaderView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { return } + Task { @MainActor in + ExpandedPlayerWindowManager.shared.registerMainContentWindow(window) } - - // 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 diff --git a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift index 71cbe8dc..835d09ed 100644 --- a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift +++ b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift @@ -24,6 +24,9 @@ struct MacOSPlayerControlsView: View { let onSeekBackward: (TimeInterval) async -> Void var onToggleFullscreen: (() -> Void)? = nil var isFullscreen: Bool = false + /// Collapses the expanded player (Esc in the inline overlay presentation). + /// When nil, Esc outside fullscreen passes through to the responder chain. + var onCollapse: (() -> Void)? = nil var onClose: (() -> Void)? = nil var onTogglePiP: (() -> Void)? = nil var onPlayNext: (() async -> Void)? = nil @@ -70,7 +73,7 @@ struct MacOSPlayerControlsView: View { /// Extra top inset for the top bar so its content drops below the window's /// traffic-light buttons in the separate/floating window presentation, while /// keeping the avatar/title aligned to the leading edge. - /// Stays 0 when there is no overlap (inline sheet, side panel, fullscreen). + /// Stays 0 when there is no overlap (inline overlay, side panel, fullscreen). @State private var trafficLightInset: CGFloat = 0 // MARK: - Control Bar Drag State @@ -125,7 +128,7 @@ struct MacOSPlayerControlsView: View { } /// Whether the hosting window is in native fullscreen. The `isFullscreen` - /// parameter covers the sheet-overlay flow, but its key-window half isn't + /// parameter covers the inline overlay flow, but its key-window half isn't /// reactive — check the tracked host window directly as well. private var isInNativeFullscreen: Bool { isFullscreen || hostWindow?.styleMask.contains(.fullScreen) == true @@ -627,6 +630,12 @@ struct MacOSPlayerControlsView: View { onToggleFullscreen?() return nil } + if let onCollapse { + // Inline overlay presentation: Esc collapses to the mini bar + // (the sheet's default Esc-dismiss is gone with the sheet). + onCollapse() + return nil + } return event // Pass through if not fullscreen default: