From 8fe661f30583e322bec2dda4ba48d7b4b4cbd7a3 Mon Sep 17 00:00:00 2001 From: koto9x Date: Tue, 26 May 2026 19:16:26 -0400 Subject: [PATCH 1/2] feat: add "Windowed fullscreen" toggle for borderless on-Space mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-user preference that, when enabled, opens the Fullscreen window as a movable/resizable borderless-style window pinned to the current Space's visible frame instead of macOS native fullscreen (which animates to a new Space and hides the menubar and Dock). - New user default `useWindowedFullscreen` (defaults to false → existing native fullscreen behavior is unchanged). - New "Windowed fullscreen (stays on current Space)" toggle in the in-fullscreen settings popover, alongside the existing Blur/Animate toggles. - LyricFever.swift Fullscreen Window onAppear branches on the flag: - off: existing `collectionBehavior = .fullScreenPrimary` + `toggleFullScreen` - on: borderless styleMask (titled/closable/miniaturizable/resizable + fullSizeContentView, transparent + hidden titlebar), `.managed` collectionBehavior, `setFrame(screen.visibleFrame)`. Defensively exits any restored native-fullscreen state first and disables window restoration so the styleMask change isn't fought by the system. - `.windowFullScreenBehavior` is bound to the flag so the green traffic-light button doesn't pull a windowed session back into native fullscreen. - Drive-by: album art in FullscreenView is now sized from the GeometryReader container (max(120, min(columnWidth*0.85, height*0.55))) instead of the hard-coded 550/700 frame. This is what makes the windowed mode usable when resized; native-fullscreen sizing on a typical display is effectively unchanged. Motivation: native fullscreen forcing a new Space breaks multi-window workflows (e.g. lyrics-while-coding, lyrics-while-browsing). Windowed-fullscreen behaves like the gaming "windowed fullscreen" idiom — fills the screen visually, stays on the current Space, can be moved/resized. --- LyricFever/LyricFever.swift | 31 ++++++++++++++++--- .../Support Files/UserDefaultStorage.swift | 7 +++++ .../Views/FullscreenView/FullscreenView.swift | 17 +++++++--- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/LyricFever/LyricFever.swift b/LyricFever/LyricFever.swift index 6ee4903..17f2434 100644 --- a/LyricFever/LyricFever.swift +++ b/LyricFever/LyricFever.swift @@ -181,7 +181,7 @@ struct LyricFever: App { .menuBarExtraStyle(.window) Window("Lyric Fever: Fullscreen", id: "fullscreen") { FullscreenView() - .windowFullScreenBehavior(.enabled) + .windowFullScreenBehavior(viewmodel.userDefaultStorage.useWindowedFullscreen ? .disabled : .enabled) .preferredColorScheme(.dark) .environment(viewmodel) .onAppear { @@ -193,10 +193,31 @@ struct LyricFever: App { return aEvent } Task { @MainActor in - let window = NSApp.windows.first {$0.identifier?.rawValue == "fullscreen"} - window?.collectionBehavior = .fullScreenPrimary - if window?.styleMask.rawValue != 49167 { - window?.toggleFullScreen(true) + guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "fullscreen" }) else { return } + if viewmodel.userDefaultStorage.useWindowedFullscreen { + // Windowed-fullscreen: a movable/resizable borderless-style window on the + // current Space. If macOS restored prior native-fullscreen state, exit first + // so the styleMask change can take effect. + guard let screen = NSScreen.main else { return } + if window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + window.isRestorable = false + window.collectionBehavior = [.managed] + window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovable = true + window.isMovableByWindowBackground = true + window.level = .normal + window.setFrame(screen.visibleFrame, display: true) + window.makeKeyAndOrderFront(nil) + } else { + // Native fullscreen (default, existing behavior). + window.collectionBehavior = .fullScreenPrimary + if window.styleMask.rawValue != 49167 { + window.toggleFullScreen(true) + } } } } diff --git a/LyricFever/Support Files/UserDefaultStorage.swift b/LyricFever/Support Files/UserDefaultStorage.swift index 5821d02..a665fd8 100644 --- a/LyricFever/Support Files/UserDefaultStorage.swift +++ b/LyricFever/Support Files/UserDefaultStorage.swift @@ -27,6 +27,13 @@ class UserDefaultStorage { @ObservationIgnored var blurFullscreen: Bool @ObservableUserDefault(.init(key: "animateOnStartupFullscreen", defaultValue: true, store: .standard)) @ObservationIgnored var animateOnStartupFullscreen: Bool + #if os(macOS) + // When true, the fullscreen window is a movable/resizable borderless window + // on the current Space instead of macOS native fullscreen (which swipes to a + // new Space and hides the menubar/Dock). + @ObservableUserDefault(.init(key: "useWindowedFullscreen", defaultValue: false, store: .standard)) + @ObservationIgnored var useWindowedFullscreen: Bool + #endif @ObservableUserDefault(.init(key: "romanize", defaultValue: false, store: .standard)) @ObservationIgnored var romanize: Bool @ObservableUserDefault(.init(key: "romanizeMetadata", defaultValue: true, store: .standard)) diff --git a/LyricFever/Views/FullscreenView/FullscreenView.swift b/LyricFever/Views/FullscreenView/FullscreenView.swift index a659b70..3b40bf7 100644 --- a/LyricFever/Views/FullscreenView/FullscreenView.swift +++ b/LyricFever/Views/FullscreenView/FullscreenView.swift @@ -135,6 +135,7 @@ struct FullscreenView: View { VStack(spacing: 7) { Toggle("Blur surrounding lyrics", isOn: $viewmodel.userDefaultStorage.blurFullscreen) Toggle("Animate on startup", isOn: $viewmodel.userDefaultStorage.animateOnStartupFullscreen) + Toggle("Windowed fullscreen (stays on current Space)", isOn: $viewmodel.userDefaultStorage.useWindowedFullscreen) Button("Reset to default") { } @@ -158,7 +159,13 @@ struct FullscreenView: View { .font(.system(size: 12)) } - @ViewBuilder var albumArt: some View { + @ViewBuilder func albumArt(containerSize: CGSize) -> some View { + // Derive the art's side length from the available container so it scales with the + // window — important when "Windowed fullscreen" is on and the user resizes the window. + // The column is half-width when lyrics are visible, full-width otherwise. Cap by height + // so the text/controls stack below the art still fits. + let columnWidth = viewmodel.canDisplayLyrics ? containerSize.width * 0.5 : containerSize.width + let artSide = max(120, min(columnWidth * 0.85, containerSize.height * 0.55)) VStack { Spacer() if let artworkImage = viewmodel.artworkImage { @@ -167,13 +174,13 @@ struct FullscreenView: View { .resizable() .clipShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10, bottomTrailing: 10, topTrailing: 10))) .shadow(radius: 5) - .frame(width: viewmodel.canDisplayLyrics ? 550 : 700, height: viewmodel.canDisplayLyrics ? 550 : 700) + .frame(width: artSide, height: artSide) #else Image(uiImage: artworkImage) .resizable() .clipShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10, bottomTrailing: 10, topTrailing: 10))) .shadow(radius: 5) - .frame(width: canDisplayLyrics ? 550 : 700, height: canDisplayLyrics ? 550 : 700) + .frame(width: artSide, height: artSide) #endif } else { @@ -184,7 +191,7 @@ struct FullscreenView: View { .background(.gray) .clipShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10, bottomTrailing: 10, topTrailing: 10))) .shadow(radius: 5) - .frame(width: viewmodel.canDisplayLyrics ? 550 : 650, height: viewmodel.canDisplayLyrics ? 550 : 650) + .frame(width: artSide, height: artSide) } Group { Text(verbatim: viewmodel.currentlyPlayingName ?? "") @@ -267,7 +274,7 @@ struct FullscreenView: View { var body: some View { GeometryReader { geo in HStack { - albumArt + albumArt(containerSize: geo.size) .frame(minWidth: 0.50*(geo.size.width), maxWidth: viewmodel.canDisplayLyrics ? 0.50*(geo.size.width) : .infinity) if viewmodel.canDisplayLyrics { lyrics(padding: 0.5*(geo.size.height)) From 86ac5007f5b2d180bd89e1a0d8df6ee9978df813 Mon Sep 17 00:00:00 2001 From: koto9x Date: Fri, 29 May 2026 01:52:00 -0400 Subject: [PATCH 2/2] feat: wire Cmd+Ctrl+F to toggle native fullscreen; allow Esc out of native fullscreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up polish changes on top of the windowed-fullscreen feature: 1. `Cmd+Ctrl+F` (the canonical macOS toggle-fullscreen shortcut) now toggles native fullscreen on the Lyric Fever Fullscreen window. Because the app is `LSUIElement = true` and has no standard View menu, the OS shortcut has nothing to fire by default; we wire it explicitly in the existing `NSEvent.addLocalMonitorForEvents` block. This is the escape hatch that makes "Windowed fullscreen" non-trapping — a user can be in windowed mode by default and still opt into native fullscreen when they want it (e.g. on a second display), and back out again. 2. Esc-blocking is now conditional: blocked only while the window is NOT in native fullscreen. When the user has entered native fullscreen (via Cmd+Ctrl+F or otherwise), Esc passes through so the platform-standard exit gesture works. In windowed mode the original "sticky" behavior is preserved. Also flips: - `.windowFullScreenBehavior` is now unconditionally `.enabled` (was conditional on the user default). Native fullscreen is always a reachable mode. - `collectionBehavior` in the windowed-fullscreen branch is `.fullScreenPrimary` (was `[.managed]`), which is what allows the new Cmd+Ctrl+F path to actually enter native fullscreen from a windowed-mode session. Default-off invariant is unchanged: when `useWindowedFullscreen` is false the window opens via the existing `.fullScreenPrimary` + `toggleFullScreen(true)` path exactly as before. --- LyricFever/LyricFever.swift | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/LyricFever/LyricFever.swift b/LyricFever/LyricFever.swift index 17f2434..1fc52d2 100644 --- a/LyricFever/LyricFever.swift +++ b/LyricFever/LyricFever.swift @@ -181,13 +181,26 @@ struct LyricFever: App { .menuBarExtraStyle(.window) Window("Lyric Fever: Fullscreen", id: "fullscreen") { FullscreenView() - .windowFullScreenBehavior(viewmodel.userDefaultStorage.useWindowedFullscreen ? .disabled : .enabled) + .windowFullScreenBehavior(.enabled) .preferredColorScheme(.dark) .environment(viewmodel) .onAppear { - // Block "Esc" button + // Block "Esc" only when NOT in native fullscreen (otherwise Esc is the + // user's standard escape hatch out of native fullscreen). + // Bind Cmd+Ctrl+F to toggle native fullscreen — as an agent app there's no + // standard View menu, so the OS shortcut has nothing to fire unless wired here. + // This shortcut is what lets a user in Windowed-fullscreen mode opt into + // (and back out of) native fullscreen. NSEvent.addLocalMonitorForEvents(matching: .keyDown) { (aEvent) -> NSEvent? in - if aEvent.keyCode == 53 { // if esc pressed + let fullscreenWindow = NSApp.windows.first(where: { $0.identifier?.rawValue == "fullscreen" }) + let inNativeFullscreen = fullscreenWindow?.styleMask.contains(.fullScreen) ?? false + if aEvent.keyCode == 53 && !inNativeFullscreen { // esc, only in windowed mode + return nil + } + if aEvent.keyCode == 3 // F + && aEvent.modifierFlags.contains(.command) + && aEvent.modifierFlags.contains(.control) { + fullscreenWindow?.toggleFullScreen(nil) return nil } return aEvent @@ -197,13 +210,14 @@ struct LyricFever: App { if viewmodel.userDefaultStorage.useWindowedFullscreen { // Windowed-fullscreen: a movable/resizable borderless-style window on the // current Space. If macOS restored prior native-fullscreen state, exit first - // so the styleMask change can take effect. + // so the styleMask change can take effect. .fullScreenPrimary keeps the + // native-fullscreen capability available via Cmd+Ctrl+F as an opt-in. guard let screen = NSScreen.main else { return } if window.styleMask.contains(.fullScreen) { window.toggleFullScreen(nil) } window.isRestorable = false - window.collectionBehavior = [.managed] + window.collectionBehavior = .fullScreenPrimary window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] window.titlebarAppearsTransparent = true window.titleVisibility = .hidden