Split macOS player mode into window toggle + floating control

Replace the three-way macOS Player Mode picker (Separate Window /
Floating Window / Inline) with a single 'Play in a separate window'
toggle in Playback settings, and move the always-on-top choice to a
pin button in the player's top bar (window mode only).

Floating was really a transient window property (NSWindow.level), not
a peer of the window/inline structural choice — so it belongs on a live
control, not in Settings. The pin state persists across sessions.

- Replace MacPlayerMode enum with macPlayerSeparateWindow /
  macPlayerFloating bool settings
- Branch runtime presentation and window level off the two bools
- Add pin.fill/pin toggle to MacOSPlayerControlsView top bar
- Update localization; drop obsolete playerMode strings and enum tests
This commit is contained in:
Arkadiusz Fal
2026-05-30 12:42:18 +02:00
parent 64ca1c10d9
commit 2f2e436fe2
10 changed files with 85 additions and 129 deletions

View File

@@ -61,7 +61,7 @@ struct ContentView: View {
} }
#if os(macOS) #if os(macOS)
.onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in
if appEnvironment.settingsManager.macPlayerMode.usesWindow { if appEnvironment.settingsManager.macPlayerSeparateWindow {
presentExpandedPlayerWindow(appEnvironment: appEnvironment) presentExpandedPlayerWindow(appEnvironment: appEnvironment)
} }
} }
@@ -70,26 +70,33 @@ struct ContentView: View {
ExpandedPlayerWindowManager.shared.hide() ExpandedPlayerWindowManager.shared.hide()
} }
} }
.onChange(of: appEnvironment.settingsManager.macPlayerMode) { oldMode, newMode in .onChange(of: appEnvironment.settingsManager.macPlayerSeparateWindow) { _, separateWindow in
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return } guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
if oldMode.usesWindow && newMode.usesWindow { if separateWindow {
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: newMode.isFloating) // Inline sheet separate window: let the sheet dismiss first, then
} else if oldMode.usesWindow && !newMode.usesWindow { // present the window (mirrors the previous inlinewindow transition).
ExpandedPlayerWindowManager.shared.hide(animated: false)
} else if !oldMode.usesWindow && newMode.usesWindow {
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300)) try? await Task.sleep(for: .milliseconds(300))
if appEnvironment.navigationCoordinator.isPlayerExpanded { if appEnvironment.navigationCoordinator.isPlayerExpanded {
presentExpandedPlayerWindow(appEnvironment: appEnvironment) presentExpandedPlayerWindow(appEnvironment: appEnvironment)
} }
} }
} else {
// Separate window inline sheet: hide the window so the sheet binding
// (isPlayerExpanded && !separateWindow) takes over.
ExpandedPlayerWindowManager.shared.hide(animated: false)
} }
} }
.onChange(of: appEnvironment.settingsManager.macPlayerFloating) { _, floating in
guard appEnvironment.navigationCoordinator.isPlayerExpanded,
appEnvironment.settingsManager.macPlayerSeparateWindow else { return }
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating)
}
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { get: {
appEnvironment.navigationCoordinator.isPlayerExpanded && appEnvironment.navigationCoordinator.isPlayerExpanded &&
!appEnvironment.settingsManager.macPlayerMode.usesWindow !appEnvironment.settingsManager.macPlayerSeparateWindow
}, },
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 } set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
)) { )) {
@@ -164,7 +171,7 @@ struct ContentView: View {
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. In sheet mode the sheet covers the window, so hide it.
let usesWindow = appEnvironment.settingsManager.macPlayerMode.usesWindow let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow
if hasActiveVideo && (!isExpanded || usesWindow) { if hasActiveVideo && (!isExpanded || usesWindow) {
VStack(spacing: 0) { VStack(spacing: 0) {

View File

@@ -45,7 +45,8 @@ enum SettingsKey: String, CaseIterable {
case resolveShortLinksEnabled case resolveShortLinksEnabled
// Platform-specific // Platform-specific
case macPlayerMode case macPlayerSeparateWindow
case macPlayerFloating
case playerSheetAutoResize case playerSheetAutoResize
case listStyle case listStyle
@@ -136,7 +137,8 @@ enum SettingsKey: String, CaseIterable {
/// in both UserDefaults and iCloud, so each platform family syncs independently. /// in both UserDefaults and iCloud, so each platform family syncs independently.
var isPlatformSpecific: Bool { var isPlatformSpecific: Bool {
switch self { switch self {
case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .macPlayerMode, .listStyle, case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats,
.macPlayerSeparateWindow, .macPlayerFloating, .listStyle,
// Home layout different UI paradigms per platform // Home layout different UI paradigms per platform
.homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle, .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle,
.homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors, .homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors,

View File

@@ -68,14 +68,30 @@ extension SettingsManager {
#endif #endif
#if os(macOS) #if os(macOS)
var macPlayerMode: MacPlayerMode { /// Whether the expanded player opens in a separate window (vs. an inline sheet).
/// Default is `true`.
var macPlayerSeparateWindow: Bool {
get { get {
if let cached = _macPlayerMode { return cached } if let cached = _macPlayerSeparateWindow { return cached }
return MacPlayerMode(rawValue: string(for: .macPlayerMode) ?? "") ?? .window return bool(for: .macPlayerSeparateWindow, default: true)
} }
set { set {
_macPlayerMode = newValue _macPlayerSeparateWindow = newValue
set(newValue.rawValue, for: .macPlayerMode) set(newValue, for: .macPlayerSeparateWindow)
}
}
/// Whether the separate player window floats above other windows (always on top).
/// Toggled live from the player's top-bar pin button and remembered across sessions.
/// Default is `false`.
var macPlayerFloating: Bool {
get {
if let cached = _macPlayerFloating { return cached }
return bool(for: .macPlayerFloating, default: false)
}
set {
_macPlayerFloating = newValue
set(newValue, for: .macPlayerFloating)
} }
} }

View File

@@ -201,37 +201,6 @@ enum DownloadQuality: String, CaseIterable, Codable, Sendable {
} }
} }
// MARK: - macOS Player Mode
#if os(macOS)
enum MacPlayerMode: String, CaseIterable, Codable {
case window
case floatingWindow
case inline
var displayName: String {
switch self {
case .window: return String(localized: "settings.playback.macOS.playerMode.window")
case .floatingWindow: return String(localized: "settings.playback.macOS.playerMode.floatingWindow")
case .inline: return String(localized: "settings.playback.macOS.playerMode.inline")
}
}
/// Whether this mode uses a separate window (vs sheet/inline)
var usesWindow: Bool {
switch self {
case .window, .floatingWindow: return true
case .inline: return false
}
}
/// Whether the window should float above other windows
var isFloating: Bool {
self == .floatingWindow
}
}
#endif
// MARK: - Haptic Feedback // MARK: - Haptic Feedback
/// Intensity levels for haptic feedback. /// Intensity levels for haptic feedback.

View File

@@ -72,7 +72,8 @@ final class SettingsManager {
var _preferPortraitBrowsing: Bool? var _preferPortraitBrowsing: Bool?
#endif #endif
#if os(macOS) #if os(macOS)
var _macPlayerMode: MacPlayerMode? var _macPlayerSeparateWindow: Bool?
var _macPlayerFloating: Bool?
var _playerSheetAutoResize: Bool? var _playerSheetAutoResize: Bool?
#endif #endif
@@ -456,7 +457,8 @@ final class SettingsManager {
_syncSearchHistory = nil _syncSearchHistory = nil
_searchHistoryLimit = nil _searchHistoryLimit = nil
#if os(macOS) #if os(macOS)
_macPlayerMode = nil _macPlayerSeparateWindow = nil
_macPlayerFloating = nil
_playerSheetAutoResize = nil _playerSheetAutoResize = nil
#endif #endif
// miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset // miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset

View File

@@ -7234,6 +7234,17 @@
} }
} }
}, },
"player.controls.keepOnTop" : {
"comment" : "Toggles whether the player window floats above other windows",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keep on Top"
}
}
}
},
"player.controls.pause" : { "player.controls.pause" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -12442,46 +12453,13 @@
} }
} }
}, },
"settings.playback.macOS.playerMode" : { "settings.playback.macOS.separateWindow" : {
"comment" : "macOS player mode setting", "comment" : "Toggle: play the expanded video in a separate window vs. an inline sheet",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Player Mode" "value" : "Play in a separate window"
}
}
}
},
"settings.playback.macOS.playerMode.floatingWindow" : {
"comment" : "Floating window player mode - stays on top of other windows",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Floating Window"
}
}
}
},
"settings.playback.macOS.playerMode.inline" : {
"comment" : "Inline (sheet) player mode",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Inline (Sheet)"
}
}
}
},
"settings.playback.macOS.playerMode.window" : {
"comment" : "Separate window player mode",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Separate Window"
} }
} }
} }

View File

@@ -70,8 +70,8 @@ final class ExpandedPlayerWindowManager: NSObject {
// Mark expanding state for mini player coordination // Mark expanding state for mini player coordination
appEnvironment.navigationCoordinator.isPlayerExpanding = true appEnvironment.navigationCoordinator.isPlayerExpanding = true
// Get the current player mode for window configuration // Whether the window should float above other windows (always on top).
let mode = appEnvironment.settingsManager.macPlayerMode let floating = appEnvironment.settingsManager.macPlayerFloating
// Host a lightweight two-phase root instead of ExpandedPlayerSheet directly. // Host a lightweight two-phase root instead of ExpandedPlayerSheet directly.
// AppKit won't composite the window to screen until the hosted SwiftUI view // AppKit won't composite the window to screen until the hosted SwiftUI view
@@ -133,8 +133,8 @@ final class ExpandedPlayerWindowManager: NSObject {
// Make ExpandedPlayerWindowManager itself the delegate to avoid lifecycle issues // Make ExpandedPlayerWindowManager itself the delegate to avoid lifecycle issues
window.delegate = self window.delegate = self
// Configure window level based on mode // Configure window level based on the floating preference
configureWindowLevel(window, floating: mode.isFloating) configureWindowLevel(window, floating: floating)
// Center window on screen // Center window on screen
window.center() window.center()

View File

@@ -113,6 +113,24 @@ struct MacOSPlayerControlsView: View {
Spacer(minLength: 12) Spacer(minLength: 12)
// Floating (always-on-top) toggle only meaningful for the separate
// window presentation, so hidden in the inline sheet and in fullscreen.
if let settings = appEnvironment?.settingsManager,
settings.macPlayerSeparateWindow, !isFullscreen {
Button {
settings.macPlayerFloating.toggle()
} label: {
Image(systemName: settings.macPlayerFloating ? "pin.fill" : "pin")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.help(Text("player.controls.keepOnTop"))
.accessibilityLabel(Text("player.controls.keepOnTop"))
}
if onClose != nil { if onClose != nil {
Button { Button {
onClose?() onClose?()

View File

@@ -250,14 +250,10 @@ private struct BehaviorSection: View {
SettingsFormSection("settings.playback.behavior.header", footer: footer) { SettingsFormSection("settings.playback.behavior.header", footer: footer) {
#if os(macOS) #if os(macOS)
PlatformMenuPicker( Toggle(
String(localized: "settings.playback.macOS.playerMode"), String(localized: "settings.playback.macOS.separateWindow"),
selection: $settings.macPlayerMode isOn: $settings.macPlayerSeparateWindow
) { )
ForEach(MacPlayerMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
Toggle( Toggle(
String(localized: "settings.playback.macOS.autoResizePlayer"), String(localized: "settings.playback.macOS.autoResizePlayer"),

View File

@@ -174,38 +174,6 @@ struct SponsorBlockCategoryTests {
} }
} }
// MARK: - MacPlayerMode Tests (macOS only)
#if os(macOS)
@Suite("MacPlayerMode Tests")
@MainActor
struct MacPlayerModeTests {
@Test("MacPlayerMode cases")
func allCases() {
let cases = MacPlayerMode.allCases
#expect(cases.contains(.window))
#expect(cases.contains(.inline))
}
@Test("MacPlayerMode display names")
func displayNames() {
#expect(MacPlayerMode.window.displayName == "Separate Window")
#expect(MacPlayerMode.floatingWindow.displayName == "Floating Window")
#expect(MacPlayerMode.inline.displayName == "Inline (Sheet)")
}
@Test("MacPlayerMode is Codable")
func codable() throws {
for mode in MacPlayerMode.allCases {
let encoded = try JSONEncoder().encode(mode)
let decoded = try JSONDecoder().decode(MacPlayerMode.self, from: encoded)
#expect(mode == decoded)
}
}
}
#endif
// MARK: - UserAgentGenerator Tests // MARK: - UserAgentGenerator Tests
@Suite("UserAgentGenerator Tests") @Suite("UserAgentGenerator Tests")