mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
Add native fullscreen for macOS sheet-mode player via in-window overlay
The fullscreen button did nothing in sheet mode: AppKit ignores toggleFullScreen on a window attached as a sheet, and the old fallback targeted the key window — the sheet itself. Two other strategies were ruled out first: fullscreening the sheet's parent is silently refused while any sheet is attached, and resizing the sheet's backing window to screen size NaN-asserts inside AppKit's sheet positioning even without animation. Working approach: swap the sheet for a plain overlay in the main window and native-fullscreen the now sheet-free main window. - isMacPlayerFullScreenOverlay on NavigationCoordinator dismisses the sheet (presentation binding excludes it; the setter ignores the dismissal write-back) and mounts ExpandedPlayerSheet as a black-backed overlay in ContentView - ExpandedPlayerWindowManager fullscreens the parent after the sheet detaches, verifies AppKit accepted it (rolls back if refused), hides the window toolbar (NSWindow keeps it as a strip above the overlay otherwise) and restores it on exit - a didExitFullScreen observer restores the sheet on any exit path (button, Esc, green button, Window menu); closing the player while fullscreen exits the main window's fullscreen too - scheduleSharedViewAdoptionRetry on entry re-homes the shared render view once the dismissing sheet's window loses visibility — its steal is declined mid-dismissal and nothing else retried, leaving the overlay black until the next video load
This commit is contained in:
@@ -57,6 +57,17 @@ struct ContentView: View {
|
||||
// Mini player overlay (macOS only)
|
||||
#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 {
|
||||
ZStack {
|
||||
Color.black
|
||||
ExpandedPlayerSheet()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
@@ -67,6 +78,9 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -96,9 +110,17 @@ struct ContentView: View {
|
||||
.sheet(isPresented: Binding(
|
||||
get: {
|
||||
appEnvironment.navigationCoordinator.isPlayerExpanded &&
|
||||
!appEnvironment.settingsManager.macPlayerSeparateWindow
|
||||
!appEnvironment.settingsManager.macPlayerSeparateWindow &&
|
||||
!appEnvironment.navigationCoordinator.isMacPlayerFullScreenOverlay
|
||||
},
|
||||
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
|
||||
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)
|
||||
|
||||
@@ -115,6 +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
|
||||
|
||||
/// Whether the player is currently collapsing (for animation coordination with mini player).
|
||||
var isPlayerCollapsing = false
|
||||
|
||||
|
||||
@@ -1575,7 +1575,12 @@ extension ExpandedPlayerSheet {
|
||||
// config first so a pinned window can enter fullscreen)
|
||||
ExpandedPlayerWindowManager.shared.toggleFullScreen()
|
||||
},
|
||||
isFullscreen: NSApp.keyWindow?.styleMask.contains(.fullScreen) == true,
|
||||
// The coordinator flag covers overlay fullscreen (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
|
||||
|| NSApp.keyWindow?.styleMask.contains(.fullScreen) == true,
|
||||
onClose: { [self] in
|
||||
closeVideo()
|
||||
},
|
||||
|
||||
@@ -19,6 +19,15 @@ 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?
|
||||
|
||||
/// 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
|
||||
@@ -284,8 +293,9 @@ final class ExpandedPlayerWindowManager: NSObject {
|
||||
/// 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)
|
||||
// Inline-sheet presentation: swap the sheet for an in-window
|
||||
// overlay and native-fullscreen the main window.
|
||||
toggleSheetFullScreen()
|
||||
return
|
||||
}
|
||||
// A floating (pinned) window carries .fullScreenAuxiliary, which AppKit
|
||||
@@ -297,6 +307,133 @@ final class ExpandedPlayerWindowManager: NSObject {
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
|
||||
// MARK: - Inline-Sheet Fullscreen
|
||||
|
||||
/// 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 {
|
||||
LoggingService.shared.debug(
|
||||
"ExpandedPlayerWindowManager: toggleSheetFullScreen found no attached sheet (keyWindow=\(NSApp.keyWindow.map { String(describing: type(of: $0)) } ?? "nil"))",
|
||||
category: .player
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.debug("ExpandedPlayerWindowManager: entering overlay fullscreen", category: .player)
|
||||
coordinator.isMacPlayerFullScreenOverlay = true // dismisses sheet, mounts overlay
|
||||
overlayFullScreenParent = parent
|
||||
observeOverlayFullScreenExit(of: parent)
|
||||
|
||||
// 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.
|
||||
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 }
|
||||
|
||||
LoggingService.shared.debug(
|
||||
"ExpandedPlayerWindowManager: ending overlay fullscreen (exitParent=\(exitParentFullScreen))",
|
||||
category: .player
|
||||
)
|
||||
coordinator.isMacPlayerFullScreenOverlay = false
|
||||
if let observer = overlayFullScreenExitObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
overlayFullScreenExitObserver = nil
|
||||
}
|
||||
if exitParentFullScreen,
|
||||
let parent = overlayFullScreenParent,
|
||||
parent.styleMask.contains(.fullScreen) {
|
||||
parent.toggleFullScreen(nil)
|
||||
}
|
||||
if let wasVisible = overlayFullScreenToolbarWasVisible {
|
||||
overlayFullScreenParent?.toolbar?.isVisible = wasVisible
|
||||
overlayFullScreenToolbarWasVisible = nil
|
||||
}
|
||||
overlayFullScreenParent = 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 {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
overlayFullScreenExitObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didExitFullScreenNotification,
|
||||
object: parent,
|
||||
queue: .main
|
||||
) { _ in
|
||||
Task { @MainActor in
|
||||
ExpandedPlayerWindowManager.shared.endOverlayFullScreen(exitParentFullScreen: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
Reference in New Issue
Block a user