From 2f2e436fe238aa8ed20d195bd8d31cb24162143d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 30 May 2026 12:42:18 +0200 Subject: [PATCH] Split macOS player mode into window toggle + floating control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Yattee/ContentView.swift | 25 ++++++---- Yattee/Core/Settings/SettingsKey.swift | 6 ++- .../Settings/SettingsManager+Player.swift | 26 ++++++++-- Yattee/Core/Settings/SettingsTypes.swift | 31 ------------ Yattee/Core/SettingsManager.swift | 6 ++- Yattee/Localizable.xcstrings | 50 ++++++------------- .../Player/ExpandedPlayerWindowManager.swift | 8 +-- .../macOS/MacOSPlayerControlsView.swift | 18 +++++++ .../Views/Settings/PlaybackSettingsView.swift | 12 ++--- YatteeTests/SettingsTests.swift | 32 ------------ 10 files changed, 85 insertions(+), 129 deletions(-) diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index b3531fc6..8e55d2df 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -61,7 +61,7 @@ struct ContentView: View { } #if os(macOS) .onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in - if appEnvironment.settingsManager.macPlayerMode.usesWindow { + if appEnvironment.settingsManager.macPlayerSeparateWindow { presentExpandedPlayerWindow(appEnvironment: appEnvironment) } } @@ -70,26 +70,33 @@ struct ContentView: View { ExpandedPlayerWindowManager.shared.hide() } } - .onChange(of: appEnvironment.settingsManager.macPlayerMode) { oldMode, newMode in + .onChange(of: appEnvironment.settingsManager.macPlayerSeparateWindow) { _, separateWindow in guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return } - if oldMode.usesWindow && newMode.usesWindow { - ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: newMode.isFloating) - } else if oldMode.usesWindow && !newMode.usesWindow { - ExpandedPlayerWindowManager.shared.hide(animated: false) - } else if !oldMode.usesWindow && newMode.usesWindow { + 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) } } + } 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( get: { appEnvironment.navigationCoordinator.isPlayerExpanded && - !appEnvironment.settingsManager.macPlayerMode.usesWindow + !appEnvironment.settingsManager.macPlayerSeparateWindow }, set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 } )) { @@ -164,7 +171,7 @@ struct ContentView: View { 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. - let usesWindow = appEnvironment.settingsManager.macPlayerMode.usesWindow + let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow if hasActiveVideo && (!isExpanded || usesWindow) { VStack(spacing: 0) { diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 09e6ca6c..6babf6fe 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -45,7 +45,8 @@ enum SettingsKey: String, CaseIterable { case resolveShortLinksEnabled // Platform-specific - case macPlayerMode + case macPlayerSeparateWindow + case macPlayerFloating case playerSheetAutoResize case listStyle @@ -136,7 +137,8 @@ enum SettingsKey: String, CaseIterable { /// in both UserDefaults and iCloud, so each platform family syncs independently. var isPlatformSpecific: Bool { switch self { - case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .macPlayerMode, .listStyle, + case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, + .macPlayerSeparateWindow, .macPlayerFloating, .listStyle, // Home layout — different UI paradigms per platform .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle, .homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors, diff --git a/Yattee/Core/Settings/SettingsManager+Player.swift b/Yattee/Core/Settings/SettingsManager+Player.swift index e364c081..fdcb09d6 100644 --- a/Yattee/Core/Settings/SettingsManager+Player.swift +++ b/Yattee/Core/Settings/SettingsManager+Player.swift @@ -68,14 +68,30 @@ extension SettingsManager { #endif #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 { - if let cached = _macPlayerMode { return cached } - return MacPlayerMode(rawValue: string(for: .macPlayerMode) ?? "") ?? .window + if let cached = _macPlayerSeparateWindow { return cached } + return bool(for: .macPlayerSeparateWindow, default: true) } set { - _macPlayerMode = newValue - set(newValue.rawValue, for: .macPlayerMode) + _macPlayerSeparateWindow = newValue + 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) } } diff --git a/Yattee/Core/Settings/SettingsTypes.swift b/Yattee/Core/Settings/SettingsTypes.swift index 97fb6f36..8de0d154 100644 --- a/Yattee/Core/Settings/SettingsTypes.swift +++ b/Yattee/Core/Settings/SettingsTypes.swift @@ -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 /// Intensity levels for haptic feedback. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 59ff69f5..9a6ad2f1 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -72,7 +72,8 @@ final class SettingsManager { var _preferPortraitBrowsing: Bool? #endif #if os(macOS) - var _macPlayerMode: MacPlayerMode? + var _macPlayerSeparateWindow: Bool? + var _macPlayerFloating: Bool? var _playerSheetAutoResize: Bool? #endif @@ -456,7 +457,8 @@ final class SettingsManager { _syncSearchHistory = nil _searchHistoryLimit = nil #if os(macOS) - _macPlayerMode = nil + _macPlayerSeparateWindow = nil + _macPlayerFloating = nil _playerSheetAutoResize = nil #endif // miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index eeae1ca4..1ffbdae5 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { "localizations" : { "en" : { @@ -12442,46 +12453,13 @@ } } }, - "settings.playback.macOS.playerMode" : { - "comment" : "macOS player mode setting", + "settings.playback.macOS.separateWindow" : { + "comment" : "Toggle: play the expanded video in a separate window vs. an inline sheet", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Player Mode" - } - } - } - }, - "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" + "value" : "Play in a separate window" } } } diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index 574ff79e..fd5e591c 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -70,8 +70,8 @@ final class ExpandedPlayerWindowManager: NSObject { // Mark expanding state for mini player coordination appEnvironment.navigationCoordinator.isPlayerExpanding = true - // Get the current player mode for window configuration - let mode = appEnvironment.settingsManager.macPlayerMode + // Whether the window should float above other windows (always on top). + let floating = appEnvironment.settingsManager.macPlayerFloating // Host a lightweight two-phase root instead of ExpandedPlayerSheet directly. // 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 window.delegate = self - // Configure window level based on mode - configureWindowLevel(window, floating: mode.isFloating) + // Configure window level based on the floating preference + configureWindowLevel(window, floating: floating) // Center window on screen window.center() diff --git a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift index a98bac09..eed02ca3 100644 --- a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift +++ b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift @@ -113,6 +113,24 @@ struct MacOSPlayerControlsView: View { 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 { Button { onClose?() diff --git a/Yattee/Views/Settings/PlaybackSettingsView.swift b/Yattee/Views/Settings/PlaybackSettingsView.swift index 3702a7d8..2420fabb 100644 --- a/Yattee/Views/Settings/PlaybackSettingsView.swift +++ b/Yattee/Views/Settings/PlaybackSettingsView.swift @@ -250,14 +250,10 @@ private struct BehaviorSection: View { SettingsFormSection("settings.playback.behavior.header", footer: footer) { #if os(macOS) - PlatformMenuPicker( - String(localized: "settings.playback.macOS.playerMode"), - selection: $settings.macPlayerMode - ) { - ForEach(MacPlayerMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } + Toggle( + String(localized: "settings.playback.macOS.separateWindow"), + isOn: $settings.macPlayerSeparateWindow + ) Toggle( String(localized: "settings.playback.macOS.autoResizePlayer"), diff --git a/YatteeTests/SettingsTests.swift b/YatteeTests/SettingsTests.swift index 7d939e58..67955836 100644 --- a/YatteeTests/SettingsTests.swift +++ b/YatteeTests/SettingsTests.swift @@ -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 @Suite("UserAgentGenerator Tests")