mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Replace macOS inline player sheet with full-window overlay
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
This commit is contained in:
@@ -58,19 +58,31 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
miniPlayerOverlay(appEnvironment: appEnvironment)
|
miniPlayerOverlay(appEnvironment: appEnvironment)
|
||||||
|
|
||||||
// Fullscreen overlay player: replaces the sheet while the main
|
// Inline expanded player: presented as a full-window overlay
|
||||||
// window is native-fullscreen (a window with an attached sheet
|
// (no native sheet — a window with an attached sheet can't go
|
||||||
// can't fullscreen, so the sheet is swapped for this overlay).
|
// fullscreen, so fullscreen is just the window's native toggle).
|
||||||
if appEnvironment.navigationCoordinator.isMacPlayerFullScreenOverlay {
|
if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black
|
Color.black
|
||||||
ExpandedPlayerSheet()
|
ExpandedPlayerSheet()
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#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
|
.onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in
|
||||||
if appEnvironment.settingsManager.macPlayerSeparateWindow {
|
if appEnvironment.settingsManager.macPlayerSeparateWindow {
|
||||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||||
@@ -78,9 +90,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
|
.onChange(of: appEnvironment.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
|
||||||
if !isExpanded {
|
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()
|
ExpandedPlayerWindowManager.shared.hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,16 +97,11 @@ struct ContentView: View {
|
|||||||
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
|
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
|
||||||
|
|
||||||
if separateWindow {
|
if separateWindow {
|
||||||
// Inline sheet → separate window: let the sheet dismiss first, then
|
// Inline overlay → separate window: the overlay unmounts via the
|
||||||
// present the window (mirrors the previous inline→window transition).
|
// presentation condition; present the window directly.
|
||||||
Task { @MainActor in
|
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
||||||
try? await Task.sleep(for: .milliseconds(300))
|
|
||||||
if appEnvironment.navigationCoordinator.isPlayerExpanded {
|
|
||||||
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} 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.
|
// (isPlayerExpanded && !separateWindow) takes over.
|
||||||
ExpandedPlayerWindowManager.shared.hide(animated: false)
|
ExpandedPlayerWindowManager.shared.hide(animated: false)
|
||||||
}
|
}
|
||||||
@@ -107,42 +111,6 @@ struct ContentView: View {
|
|||||||
appEnvironment.settingsManager.macPlayerSeparateWindow else { return }
|
appEnvironment.settingsManager.macPlayerSeparateWindow else { return }
|
||||||
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating)
|
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(
|
.sheet(isPresented: Binding(
|
||||||
get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented },
|
get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented },
|
||||||
set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 }
|
set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 }
|
||||||
@@ -172,30 +140,12 @@ struct ContentView: View {
|
|||||||
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
|
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Size for the expanded-player sheet, derived from the current video aspect
|
/// Whether the inline (non-separate-window) expanded player overlay should
|
||||||
/// ratio (when auto-resize is enabled) so the sheet re-fits like window mode.
|
/// be presented. Shared by the overlay mount and the begin/end onChange so
|
||||||
/// Reading `videoAspectRatio` / `playerSheetAutoResize` here registers the
|
/// the two can't drift apart.
|
||||||
/// @Observable dependency that drives the re-fit.
|
private func isInlinePlayerOverlayActive(appEnvironment: AppEnvironment) -> Bool {
|
||||||
private func expandedSheetSize(appEnvironment: AppEnvironment) -> CGSize {
|
appEnvironment.navigationCoordinator.isPlayerExpanded &&
|
||||||
let settings = appEnvironment.settingsManager
|
!appEnvironment.settingsManager.macPlayerSeparateWindow
|
||||||
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
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -206,7 +156,7 @@ struct ContentView: View {
|
|||||||
let hasActiveVideo = playerState.currentVideo != nil
|
let hasActiveVideo = playerState.currentVideo != nil
|
||||||
let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded
|
let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded
|
||||||
// The expanded player is a separate window in window mode, so keep the capsule
|
// 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
|
let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow
|
||||||
|
|
||||||
if hasActiveVideo && (!isExpanded || usesWindow) {
|
if hasActiveVideo && (!isExpanded || usesWindow) {
|
||||||
|
|||||||
@@ -115,10 +115,11 @@ final class NavigationCoordinator {
|
|||||||
/// Whether the player is currently expanding (for animation coordination with mini player).
|
/// Whether the player is currently expanding (for animation coordination with mini player).
|
||||||
var isPlayerExpanding = false
|
var isPlayerExpanding = false
|
||||||
|
|
||||||
/// macOS only: true while the inline player runs in fullscreen overlay
|
/// macOS only: true while the main window is native-fullscreen for the
|
||||||
/// mode — sheet dismissed, player shown as an overlay in the main window,
|
/// inline player overlay. Mirrors the window's fullscreen state (kept in
|
||||||
/// main window native-fullscreen. Set/cleared by ExpandedPlayerWindowManager.
|
/// sync by ExpandedPlayerWindowManager via NSWindow fullscreen
|
||||||
var isMacPlayerFullScreenOverlay = false
|
/// notifications) so player controls update reactively.
|
||||||
|
var isMacInlinePlayerFullScreen = false
|
||||||
|
|
||||||
/// Whether the player is currently collapsing (for animation coordination with mini player).
|
/// Whether the player is currently collapsing (for animation coordination with mini player).
|
||||||
var isPlayerCollapsing = false
|
var isPlayerCollapsing = false
|
||||||
|
|||||||
@@ -1575,12 +1575,19 @@ extension ExpandedPlayerSheet {
|
|||||||
// config first so a pinned window can enter fullscreen)
|
// config first so a pinned window can enter fullscreen)
|
||||||
ExpandedPlayerWindowManager.shared.toggleFullScreen()
|
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,
|
// main window is the fullscreen one) and, being @Observable,
|
||||||
// refreshes the button icon on toggle; the keyWindow check
|
// refreshes the button icon on toggle; the keyWindow check
|
||||||
// covers the separate-window presentation.
|
// covers the separate-window presentation.
|
||||||
isFullscreen: navigationCoordinator?.isMacPlayerFullScreenOverlay == true
|
isFullscreen: navigationCoordinator?.isMacInlinePlayerFullScreen == true
|
||||||
|| NSApp.keyWindow?.styleMask.contains(.fullScreen) == 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
|
onClose: { [self] in
|
||||||
closeVideo()
|
closeVideo()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,14 +19,30 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
private var playerWindow: NSWindow?
|
private var playerWindow: NSWindow?
|
||||||
private weak var appEnvironment: AppEnvironment?
|
private weak var appEnvironment: AppEnvironment?
|
||||||
|
|
||||||
/// Main app window native-fullscreened for the inline player overlay, the
|
/// Main app window hosting the inline player overlay, the observers that
|
||||||
/// observer watching for it to exit fullscreen (Esc, green button, menu),
|
/// mirror its fullscreen transitions into the coordinator flag (Esc, green
|
||||||
/// and the toolbar visibility to restore afterwards (the toolbar is hidden
|
/// button, menu, Mission Control all bypass the player's own button), the
|
||||||
/// during overlay fullscreen — NSWindow keeps it as a visible strip above
|
/// toolbar visibility to restore when the overlay ends (the toolbar is
|
||||||
/// the content otherwise).
|
/// hidden while the overlay is up so it doesn't sit above the video), and
|
||||||
private weak var overlayFullScreenParent: NSWindow?
|
/// whether the window was already fullscreen before the overlay began (so
|
||||||
private var overlayFullScreenExitObserver: NSObjectProtocol?
|
/// collapsing doesn't exit a fullscreen the user had independently).
|
||||||
private var overlayFullScreenToolbarWasVisible: Bool?
|
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
|
/// Whether the window has performed its first size application since being
|
||||||
/// shown. The first resize after each open snaps (no animation) so the player
|
/// 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
|
/// - completion: Called after the window is hidden
|
||||||
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
|
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||||
guard let window = playerWindow else {
|
guard let window = playerWindow else {
|
||||||
// No window means sheet mode (or the window is already gone).
|
// No window means inline overlay mode (or the window is already
|
||||||
// A dismissed sheet's hierarchy stays alive holding the shared
|
// gone). The overlay's container may unmount without handing the
|
||||||
// render view inside a now-invisible window with no container
|
// shared render view to the mini capsule in the same pass - watch
|
||||||
// lifecycle event to react to - watch for the dismissal to finish
|
// for the teardown to finish and re-home the view.
|
||||||
// and re-home the view to the mini capsule.
|
|
||||||
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
|
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
|
||||||
completion?()
|
completion?()
|
||||||
return
|
return
|
||||||
@@ -293,9 +308,10 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
/// Toggles native fullscreen on the player window.
|
/// Toggles native fullscreen on the player window.
|
||||||
func toggleFullScreen() {
|
func toggleFullScreen() {
|
||||||
guard let window = playerWindow else {
|
guard let window = playerWindow else {
|
||||||
// Inline-sheet presentation: swap the sheet for an in-window
|
// Inline overlay presentation: the player already fills the main
|
||||||
// overlay and native-fullscreen the main window.
|
// window, so fullscreen is just the window's native toggle. The
|
||||||
toggleSheetFullScreen()
|
// fullscreen observers keep the coordinator flag in sync.
|
||||||
|
overlayParent?.toggleFullScreen(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A floating (pinned) window carries .fullScreenAuxiliary, which AppKit
|
// A floating (pinned) window carries .fullScreenAuxiliary, which AppKit
|
||||||
@@ -307,131 +323,94 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
window.toggleFullScreen(nil)
|
window.toggleFullScreen(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Inline-Sheet Fullscreen
|
// MARK: - Inline Overlay Presentation
|
||||||
|
|
||||||
/// Toggles fullscreen for the inline-sheet player via the overlay strategy.
|
/// Called when the inline player overlay mounts in the main window
|
||||||
///
|
/// (separate-window mode off, player expanded). Hides the main window's
|
||||||
/// Two dead ends were verified before this design (see git history):
|
/// toolbar for the overlay's lifetime and starts mirroring the window's
|
||||||
/// AppKit silently refuses native fullscreen while a sheet is attached
|
/// fullscreen state into `isMacInlinePlayerFullScreen`.
|
||||||
/// (on the sheet AND on its parent), and directly resizing the sheet's
|
func beginInlineOverlay() {
|
||||||
/// backing window to screen size NaN-asserts inside AppKit's sheet
|
guard overlayParent == nil else { return }
|
||||||
/// positioning, even unanimated. So instead: dismiss the sheet (the
|
guard let parent = mainContentWindow ?? NSApp.mainWindow ?? NSApp.keyWindow else {
|
||||||
/// `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(
|
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
|
category: .player
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LoggingService.shared.debug("ExpandedPlayerWindowManager: entering overlay fullscreen", category: .player)
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: beginning inline overlay", category: .player)
|
||||||
coordinator.isMacPlayerFullScreenOverlay = true // dismisses sheet, mounts overlay
|
overlayParent = parent
|
||||||
overlayFullScreenParent = parent
|
overlayToolbarWasVisible = parent.toolbar?.isVisible
|
||||||
observeOverlayFullScreenExit(of: parent)
|
parent.toolbar?.isVisible = false
|
||||||
|
overlayWasFullScreenAtBegin = parent.styleMask.contains(.fullScreen)
|
||||||
|
|
||||||
// The overlay's render container mounts while the sheet is still
|
let coordinator = AppEnvironment.shared.navigationCoordinator
|
||||||
// visibly dismissing, so its claim on the shared render view is
|
coordinator.isMacInlinePlayerFullScreen = overlayWasFullScreenAtBegin
|
||||||
// declined ("declined steal") and no later lifecycle event re-triggers
|
observeOverlayFullScreenTransitions(of: parent)
|
||||||
// it — the view stays parked in the dismissed sheet's invisible window
|
|
||||||
// and the overlay shows black. Watch the dismissal and re-home the
|
// The overlay's render container may mount while the mini capsule is
|
||||||
// view the moment the sheet window loses visibility.
|
// still tearing down, leaving the shared render view parked with no
|
||||||
|
// later lifecycle event to re-trigger adoption — retry until it lands.
|
||||||
MPVContainerNSView.scheduleSharedViewAdoptionRetry()
|
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
|
/// Called when the inline player overlay unmounts (collapse, close, or
|
||||||
/// re-presents the sheet. Pass `exitParentFullScreen: true` when the player
|
/// switch to separate-window mode). Restores the main window's toolbar and,
|
||||||
/// closes while fullscreen, so the main window doesn't stay fullscreen for
|
/// if fullscreen was entered for the video, exits it.
|
||||||
/// no reason.
|
func endInlineOverlay() {
|
||||||
func endOverlayFullScreen(exitParentFullScreen: Bool) {
|
guard let parent = overlayParent else { return }
|
||||||
let coordinator = AppEnvironment.shared.navigationCoordinator
|
|
||||||
guard coordinator.isMacPlayerFullScreenOverlay else { return }
|
|
||||||
|
|
||||||
LoggingService.shared.debug(
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: ending inline overlay", category: .player)
|
||||||
"ExpandedPlayerWindowManager: ending overlay fullscreen (exitParent=\(exitParentFullScreen))",
|
for observer in overlayFullScreenObservers {
|
||||||
category: .player
|
|
||||||
)
|
|
||||||
coordinator.isMacPlayerFullScreenOverlay = false
|
|
||||||
if let observer = overlayFullScreenExitObserver {
|
|
||||||
NotificationCenter.default.removeObserver(observer)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
overlayFullScreenExitObserver = nil
|
|
||||||
}
|
}
|
||||||
if exitParentFullScreen,
|
overlayFullScreenObservers = []
|
||||||
let parent = overlayFullScreenParent,
|
|
||||||
parent.styleMask.contains(.fullScreen) {
|
// 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)
|
parent.toggleFullScreen(nil)
|
||||||
}
|
}
|
||||||
if let wasVisible = overlayFullScreenToolbarWasVisible {
|
if let wasVisible = overlayToolbarWasVisible {
|
||||||
overlayFullScreenParent?.toolbar?.isVisible = wasVisible
|
parent.toolbar?.isVisible = wasVisible
|
||||||
overlayFullScreenToolbarWasVisible = nil
|
|
||||||
}
|
}
|
||||||
overlayFullScreenParent = nil
|
|
||||||
|
AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false
|
||||||
|
overlayToolbarWasVisible = nil
|
||||||
|
overlayWasFullScreenAtBegin = false
|
||||||
|
overlayParent = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restores the sheet when the main window leaves fullscreen through any
|
/// Mirrors the parent window's fullscreen transitions into the coordinator
|
||||||
/// path the player's button doesn't see (Esc, hover-revealed green button,
|
/// flag so the player controls stay in sync with exits the player's button
|
||||||
/// Window menu, Mission Control).
|
/// doesn't see (Esc, hover-revealed green button, Window menu, Mission
|
||||||
private func observeOverlayFullScreenExit(of parent: NSWindow) {
|
/// Control).
|
||||||
if let observer = overlayFullScreenExitObserver {
|
private func observeOverlayFullScreenTransitions(of parent: NSWindow) {
|
||||||
|
for observer in overlayFullScreenObservers {
|
||||||
NotificationCenter.default.removeObserver(observer)
|
NotificationCenter.default.removeObserver(observer)
|
||||||
}
|
}
|
||||||
overlayFullScreenExitObserver = NotificationCenter.default.addObserver(
|
let center = NotificationCenter.default
|
||||||
forName: NSWindow.didExitFullScreenNotification,
|
overlayFullScreenObservers = [
|
||||||
object: parent,
|
center.addObserver(
|
||||||
queue: .main
|
forName: NSWindow.didEnterFullScreenNotification,
|
||||||
) { _ in
|
object: parent,
|
||||||
Task { @MainActor in
|
queue: .main
|
||||||
ExpandedPlayerWindowManager.shared.endOverlayFullScreen(exitParentFullScreen: false)
|
) { _ 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.
|
/// 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
|
/// Sets `contentAspectRatio` and a ratio-consistent minimum content size on
|
||||||
/// the window so that interactive resize couples width and height
|
/// the window so that interactive resize couples width and height
|
||||||
/// proportionally and can't shrink below a usable minimum.
|
/// 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) {
|
static func applyAspectRatioConstraint(_ aspectRatio: Double, to window: NSWindow) {
|
||||||
guard aspectRatio > 0 else { return }
|
guard aspectRatio > 0 else { return }
|
||||||
|
|
||||||
@@ -591,7 +568,7 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
Self.fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame)
|
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
|
/// Anchors on `targetVideoHeight`, derives width from the aspect ratio, then
|
||||||
/// clamps to `maxScreenRatio` of the screen and the minimum size.
|
/// clamps to `maxScreenRatio` of the screen and the minimum size.
|
||||||
static func fittedPlayerSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
|
static func fittedPlayerSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
|
||||||
@@ -624,15 +601,6 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
return NSSize(width: width, height: height)
|
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 {
|
private func constrainToScreen(_ frame: NSRect, screen: NSScreen) -> NSRect {
|
||||||
let screenFrame = screen.visibleFrame
|
let screenFrame = screen.visibleFrame
|
||||||
var adjustedFrame = frame
|
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.
|
/// Invisible background view that registers ContentView's hosting window with
|
||||||
///
|
/// `ExpandedPlayerWindowManager`, so the inline overlay always targets the real
|
||||||
/// `.presentationSizing(.fitted)` only fits the sheet to its content once, at
|
/// main content window (mainWindow/keyWindow point elsewhere when e.g. the
|
||||||
/// presentation time — it does not re-fit when the content's ideal size changes
|
/// Settings window has focus).
|
||||||
/// later (e.g. when the video aspect ratio is decoded). This reaches the sheet's
|
struct MainContentWindowReader: NSViewRepresentable {
|
||||||
/// backing window directly and resizes it, mirroring how the standalone window
|
func makeNSView(context _: Context) -> ReaderView {
|
||||||
/// path drives `setFrame` in `resizeToFitAspectRatio`. It keeps the window
|
ReaderView()
|
||||||
/// 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 {
|
func updateNSView(_: ReaderView, context _: Context) {}
|
||||||
NSView(frame: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {
|
final class ReaderView: NSView {
|
||||||
let target = targetSize
|
override func viewDidMoveToWindow() {
|
||||||
let aspect = aspectRatio
|
super.viewDidMoveToWindow()
|
||||||
let coordinator = context.coordinator
|
guard let window else { return }
|
||||||
// The window isn't attached during the same runloop tick as the SwiftUI
|
Task { @MainActor in
|
||||||
// update, so defer the resize until the view is in a window.
|
ExpandedPlayerWindowManager.shared.registerMainContentWindow(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
|
#endif
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ struct MacOSPlayerControlsView: View {
|
|||||||
let onSeekBackward: (TimeInterval) async -> Void
|
let onSeekBackward: (TimeInterval) async -> Void
|
||||||
var onToggleFullscreen: (() -> Void)? = nil
|
var onToggleFullscreen: (() -> Void)? = nil
|
||||||
var isFullscreen: Bool = false
|
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 onClose: (() -> Void)? = nil
|
||||||
var onTogglePiP: (() -> Void)? = nil
|
var onTogglePiP: (() -> Void)? = nil
|
||||||
var onPlayNext: (() async -> 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
|
/// 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
|
/// traffic-light buttons in the separate/floating window presentation, while
|
||||||
/// keeping the avatar/title aligned to the leading edge.
|
/// 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
|
@State private var trafficLightInset: CGFloat = 0
|
||||||
|
|
||||||
// MARK: - Control Bar Drag State
|
// MARK: - Control Bar Drag State
|
||||||
@@ -125,7 +128,7 @@ struct MacOSPlayerControlsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the hosting window is in native fullscreen. The `isFullscreen`
|
/// 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.
|
/// reactive — check the tracked host window directly as well.
|
||||||
private var isInNativeFullscreen: Bool {
|
private var isInNativeFullscreen: Bool {
|
||||||
isFullscreen || hostWindow?.styleMask.contains(.fullScreen) == true
|
isFullscreen || hostWindow?.styleMask.contains(.fullScreen) == true
|
||||||
@@ -627,6 +630,12 @@ struct MacOSPlayerControlsView: View {
|
|||||||
onToggleFullscreen?()
|
onToggleFullscreen?()
|
||||||
return nil
|
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
|
return event // Pass through if not fullscreen
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user