diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 1ffbdae5..6033cdb1 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -1469,6 +1469,16 @@ } } }, + "controls.button.keepOnTop" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep on Top" + } + } + } + }, "controls.button.more" : { "localizations" : { "en" : { @@ -13214,6 +13224,17 @@ } } }, + "settings.playerControls.center.seekDurationsFooter" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Seek durations are used by the ← and → keyboard shortcuts and by seek buttons added to the player." + } + } + } + }, "settings.playerControls.center.seekForward" : { "localizations" : { "en" : { @@ -13294,6 +13315,17 @@ } } }, + "settings.playerControls.controlBarButtons" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Control Bar Buttons" + } + } + } + }, "settings.playerControls.create" : { "localizations" : { "en" : { @@ -13504,6 +13536,16 @@ } } }, + "settings.playerControls.noButtonSettings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This button has no additional settings." + } + } + } + }, "settings.playerControls.ok" : { "localizations" : { "en" : { @@ -13724,6 +13766,17 @@ } } }, + "settings.playerControls.seekDurations" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Seek Durations" + } + } + } + }, "settings.playerControls.slider.behavior" : { "localizations" : { "en" : { diff --git a/Yattee/Models/PlayerControls/BuiltInPresets.swift b/Yattee/Models/PlayerControls/BuiltInPresets.swift index 7b62fd3f..8deac6f4 100644 --- a/Yattee/Models/PlayerControls/BuiltInPresets.swift +++ b/Yattee/Models/PlayerControls/BuiltInPresets.swift @@ -11,7 +11,7 @@ extension LayoutPreset { /// Bump this version whenever any built-in preset definition changes. /// On launch, the app compares this against the last-applied version /// and replaces stale built-in presets with fresh copies from code. - static let builtInPresetsVersion = 5 + static let builtInPresetsVersion = 7 // MARK: - Built-in Preset IDs @@ -25,6 +25,19 @@ extension LayoutPreset { /// Default preset with balanced controls for general use. static func defaultPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset { + LayoutPreset( + id: BuiltInID.defaultPreset, + name: String(localized: "controls.preset.default"), + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + isBuiltIn: true, + deviceClass: deviceClass, + layout: deviceClass == .macOS ? macOSDefaultLayout() : standardDefaultLayout() + ) + } + + /// Default layout for iOS/tvOS (touch-oriented overlay controls). + private static func standardDefaultLayout() -> PlayerControlsLayout { // Top buttons: titleAuthor (wideOnly), spacer, orientationLock, close let topButtons: [ControlButtonConfiguration] = [ ControlButtonConfiguration( @@ -110,7 +123,7 @@ extension LayoutPreset { // Mini player: show video, tap for PiP let miniPlayerSettings = MiniPlayerSettings() - let layout = PlayerControlsLayout( + return PlayerControlsLayout( topSection: LayoutSection(buttons: topButtons), centerSettings: centerSettings, bottomSection: LayoutSection(buttons: bottomButtons), @@ -120,20 +133,84 @@ extension LayoutPreset { playerPillSettings: playerPillSettings, miniPlayerSettings: miniPlayerSettings ) + } - return LayoutPreset( - id: BuiltInID.defaultPreset, - name: String(localized: "controls.preset.default"), - createdAt: Date(timeIntervalSince1970: 0), - updatedAt: Date(timeIntervalSince1970: 0), - isBuiltIn: true, - deviceClass: deviceClass, - layout: layout + /// Default layout for macOS, mirroring the QuickTime-style control bar: + /// top bar with title/author, keep-on-top pin and close; capsule row with + /// volume slider, transport and trailing actions around flexible spacers. + private static func macOSDefaultLayout() -> PlayerControlsLayout { + let topButtons: [ControlButtonConfiguration] = [ + .defaultConfiguration(for: .titleAuthor), + .flexibleSpacer(), + .defaultConfiguration(for: .keepOnTop), + .defaultConfiguration(for: .close) + ] + + let bottomButtons: [ControlButtonConfiguration] = [ + ControlButtonConfiguration( + buttonType: .volume, + settings: .slider(SliderSettings(sliderBehavior: .alwaysVisible)) + ), + .flexibleSpacer(), + .defaultConfiguration(for: .queue), + .defaultConfiguration(for: .playPrevious), + .defaultConfiguration(for: .playPause), + .defaultConfiguration(for: .playNext), + .flexibleSpacer(), + .defaultConfiguration(for: .contextMenu), + .defaultConfiguration(for: .settings), + .defaultConfiguration(for: .pictureInPicture), + .defaultConfiguration(for: .fullscreen) + ] + + // On macOS center settings only drive seek amounts (keyboard arrows + // and default seek buttons); 5s preserves the historical arrow-key step. + let centerSettings = CenterSectionSettings( + showPlayPause: true, + showSeekBackward: true, + showSeekForward: true, + seekBackwardSeconds: 5, + seekForwardSeconds: 5, + leftSlider: .disabled, + rightSlider: .disabled + ) + + let globalSettings = GlobalLayoutSettings( + style: .plain, + buttonSize: .medium, + fontStyle: .system, + systemControlsMode: .seek, + systemControlsSeekDuration: .tenSeconds, + volumeMode: .mpv + ) + + return PlayerControlsLayout( + topSection: LayoutSection(buttons: topButtons), + centerSettings: centerSettings, + bottomSection: LayoutSection(buttons: bottomButtons), + globalSettings: globalSettings, + progressBarSettings: ProgressBarSettings(), + gesturesSettings: nil, + playerPillSettings: nil, + miniPlayerSettings: MiniPlayerSettings() ) } /// Minimal preset with stripped-down controls for distraction-free playback. static func minimalPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset { + LayoutPreset( + id: BuiltInID.minimalPreset, + name: String(localized: "controls.preset.minimal"), + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + isBuiltIn: true, + deviceClass: deviceClass, + layout: deviceClass == .macOS ? macOSMinimalLayout() : standardMinimalLayout() + ) + } + + /// Minimal layout for iOS/tvOS. + private static func standardMinimalLayout() -> PlayerControlsLayout { let topButtons: [ControlButtonConfiguration] = [ .flexibleSpacer(), .defaultConfiguration(for: .close) @@ -200,7 +277,7 @@ extension LayoutPreset { sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false) ) - let layout = PlayerControlsLayout( + return PlayerControlsLayout( topSection: LayoutSection(buttons: topButtons), centerSettings: centerSettings, bottomSection: LayoutSection(buttons: bottomButtons), @@ -210,15 +287,56 @@ extension LayoutPreset { playerPillSettings: playerPillSettings, miniPlayerSettings: miniPlayerSettings ) + } - return LayoutPreset( - id: BuiltInID.minimalPreset, - name: String(localized: "controls.preset.minimal"), - createdAt: Date(timeIntervalSince1970: 0), - updatedAt: Date(timeIntervalSince1970: 0), - isBuiltIn: true, - deviceClass: deviceClass, - layout: layout + /// Minimal layout for macOS: bare play/pause and settings in the capsule, + /// close-only top bar, no chapters or SponsorBlock markers. + private static func macOSMinimalLayout() -> PlayerControlsLayout { + let topButtons: [ControlButtonConfiguration] = [ + .flexibleSpacer(), + .defaultConfiguration(for: .close) + ] + + let bottomButtons: [ControlButtonConfiguration] = [ + .flexibleSpacer(), + .defaultConfiguration(for: .playPause), + .flexibleSpacer(), + .defaultConfiguration(for: .settings) + ] + + let centerSettings = CenterSectionSettings( + showPlayPause: true, + showSeekBackward: true, + showSeekForward: true, + seekBackwardSeconds: 5, + seekForwardSeconds: 5, + leftSlider: .disabled, + rightSlider: .disabled + ) + + let globalSettings = GlobalLayoutSettings( + style: .plain, + buttonSize: .medium, + fontStyle: .system, + systemControlsMode: .seek, + systemControlsSeekDuration: .tenSeconds, + volumeMode: .mpv + ) + + let progressBarSettings = ProgressBarSettings( + showChapters: false, + sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false) + ) + + return PlayerControlsLayout( + topSection: LayoutSection(buttons: topButtons), + centerSettings: centerSettings, + bottomSection: LayoutSection(buttons: bottomButtons), + globalSettings: globalSettings, + progressBarSettings: progressBarSettings, + gesturesSettings: nil, + playerPillSettings: nil, + miniPlayerSettings: MiniPlayerSettings() ) } diff --git a/Yattee/Models/PlayerControls/ControlButtonType.swift b/Yattee/Models/PlayerControls/ControlButtonType.swift index 93e606b7..01e1aaab 100644 --- a/Yattee/Models/PlayerControls/ControlButtonType.swift +++ b/Yattee/Models/PlayerControls/ControlButtonType.swift @@ -40,6 +40,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { case panscan case autoPlayNext case seek + case keepOnTop // MARK: - Version Tracking @@ -117,6 +118,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { return String(localized: "controls.button.autoPlayNext") case .seek: return String(localized: "controls.button.seek") + case .keepOnTop: + return String(localized: "controls.button.keepOnTop") } } @@ -185,6 +188,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { return "play.square.stack.fill" case .seek: return "goforward.10" // Default icon, actual icon is determined by settings + case .keepOnTop: + return "pin" } } @@ -224,6 +229,30 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { /// Button types available for top/bottom sections. static var availableForHorizontalSections: [ControlButtonType] { + #if os(macOS) + [ + .spacer, + .timeDisplay, + .titleAuthor, + .playPrevious, + .playPause, + .playNext, + .seek, + .queue, + .close, + .keepOnTop, + .volume, + .pictureInPicture, + .fullscreen, + .controlsLock, + .settings, + .playbackSpeed, + .share, + .contextMenu, + .autoPlayNext, + .mpvDebug + ] + #else [ .spacer, .timeDisplay, @@ -255,6 +284,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { .airplay, .mpvDebug ] + #endif } /// Button types for center section (play/pause, seek). @@ -287,6 +317,24 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { /// Button types available for the mini player (curated subset for compact UI). static var availableForMiniPlayer: [ControlButtonType] { + #if os(macOS) + [ + // Transport + .playPause, + .playPrevious, + .playNext, + .seek, + // Queue & Actions + .queue, + .close, + // Player Actions + .share, + .addToPlaylist, + .pictureInPicture, + // Utility + .playbackSpeed + ] + #else [ // Transport .playPause, @@ -304,5 +352,6 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { // Utility .playbackSpeed ] + #endif } } diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index 0d99362e..64951d98 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -279,6 +279,10 @@ struct ControlsSectionRenderer: View { // These are center section only buttons, not rendered here EmptyView() + case .keepOnTop: + // macOS-only window pin button, not applicable on iOS + EmptyView() + case .seek: if let settings = config.seekSettings { seekButton(settings: settings, config: config) diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index e0004554..bdce53b3 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -923,6 +923,10 @@ extension ExpandedPlayerSheet { }, onShowQueue: { [self] in showingQueueSheet = true + }, + onRateChanged: { rate in + playerState.rate = rate + playerService.currentBackend?.rate = Float(rate.rawValue) } ) .frame(width: controlsWidth, height: controlsHeight) @@ -1185,6 +1189,10 @@ extension ExpandedPlayerSheet { }, onShowQueue: { [self] in showingQueueSheet = true + }, + onRateChanged: { rate in + playerState.rate = rate + playerService.currentBackend?.rate = Float(rate.rawValue) } ) .frame(width: controlsWidth, height: controlsHeight) @@ -1595,7 +1603,11 @@ extension ExpandedPlayerSheet { onTitleTap: { onTogglePanel() }, - isDetailsPanelVisible: isPanelVisible + isDetailsPanelVisible: isPanelVisible, + onRateChanged: { rate in + playerState.rate = rate + playerService.currentBackend?.rate = Float(rate.rawValue) + } ) .frame(width: availableWidth, height: availableHeight) .offset(x: controlsOffset) diff --git a/Yattee/Views/Player/MPVVideoView.swift b/Yattee/Views/Player/MPVVideoView.swift index 9366c2c5..003f5bd7 100644 --- a/Yattee/Views/Player/MPVVideoView.swift +++ b/Yattee/Views/Player/MPVVideoView.swift @@ -296,6 +296,10 @@ struct MPVVideoView: View { playerService.currentBackend?.isMuted = newMuted playerState.isMuted = newMuted appEnvironment?.remoteControlCoordinator.broadcastStateUpdate() + }, + onRateChanged: { rate in + playerState.rate = rate + playerService.currentBackend?.rate = Float(rate.rawValue) } ) #else diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index 246cb590..6a86cbf7 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -7,10 +7,11 @@ import SwiftUI -#if os(iOS) +#if os(iOS) || os(macOS) /// Consolidated actions and state for player control buttons. -/// Used by `ControlsSectionRenderer` to render buttons dynamically. +/// Used by `ControlsSectionRenderer` (iOS) and `MacOSControlsSectionRenderer` +/// (macOS) to render buttons dynamically. @MainActor struct PlayerControlsActions { // MARK: - State @@ -27,20 +28,26 @@ struct PlayerControlsActions { /// Whether the video is widescreen aspect ratio let isWidescreenVideo: Bool + #if os(iOS) /// Whether orientation is locked let isOrientationLocked: Bool + #endif /// Whether the side panel is visible let isPanelVisible: Bool + #if os(iOS) /// Whether the side panel is pinned (iPad landscape) let isPanelPinned: Bool + #endif /// Which side the panel is on let panelSide: FloatingPanelSide + #if os(iOS) /// Whether running on iPad let isIPad: Bool + #endif /// Whether volume controls should show (mpv mode) let showVolumeControls: Bool @@ -69,11 +76,13 @@ struct PlayerControlsActions { /// Current audio stream let currentAudioStream: Stream? + #if os(iOS) /// Current panscan value (0.0 = fit, 1.0 = fill) let panscanValue: Double /// Whether panscan change is currently allowed let isPanscanAllowed: Bool + #endif /// Whether auto-play next is enabled let isAutoPlayNextEnabled: Bool @@ -101,14 +110,18 @@ struct PlayerControlsActions { /// Toggle details visibility (portrait fullscreen) var onToggleDetailsVisibility: (() -> Void)? + #if os(iOS) /// Toggle orientation lock var onToggleOrientationLock: (() -> Void)? + #endif /// Toggle side panel var onTogglePanel: (() -> Void)? + #if os(iOS) /// Toggle panscan between 0 and 1 var onTogglePanscan: (() -> Void)? + #endif /// Toggle auto-play next in queue var onToggleAutoPlayNext: (() -> Void)? @@ -213,6 +226,17 @@ struct PlayerControlsActions { playerState.pipState == .active ? "pip.exit" : "pip.enter" } + #if os(macOS) + /// Fullscreen icon based on current state (no rotation semantics on macOS). + var fullscreenIcon: String { + isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" + } + + /// Whether the fullscreen button should be shown + var shouldShowFullscreenButton: Bool { + onToggleFullscreen != nil + } + #else /// Fullscreen icon based on current state. /// Uses rotation icons when tapping will cause device rotation, /// otherwise uses standard fullscreen arrows. @@ -278,6 +302,7 @@ struct PlayerControlsActions { return showFullscreenButton && hasFullscreenAction && !isPinnedPanelActive } + #endif /// Whether PiP button should be enabled var isPiPAvailable: Bool { diff --git a/Yattee/Views/Player/macOS/MacOSControlBar.swift b/Yattee/Views/Player/macOS/MacOSControlBar.swift index 2a9f49e2..90a44780 100644 --- a/Yattee/Views/Player/macOS/MacOSControlBar.swift +++ b/Yattee/Views/Player/macOS/MacOSControlBar.swift @@ -9,25 +9,22 @@ import SwiftUI -/// Condensed QuickTime-style control bar with transport, timeline, and action controls. +/// Condensed QuickTime-style control bar with a preset-driven button row and timeline. struct MacOSControlBar: View { @Bindable var playerState: PlayerState + // MARK: - Layout & Actions + + /// The button row rendered above the timeline, from the active preset's bottom section. + let section: LayoutSection + /// Global appearance settings from the active preset. + let globalSettings: GlobalLayoutSettings + /// Consolidated actions and state for the section renderer. + let actions: PlayerControlsActions + // MARK: - Callbacks - let onPlayPause: () -> Void let onSeek: (TimeInterval) async -> Void - let onSeekForward: (TimeInterval) async -> Void - let onSeekBackward: (TimeInterval) async -> Void - var onToggleFullscreen: (() -> Void)? = nil - var isFullscreen: Bool = false - var onTogglePiP: (() -> Void)? = nil - var onPlayNext: (() async -> Void)? = nil - var onPlayPrevious: (() async -> Void)? = nil - var onVolumeChanged: ((Float) -> Void)? = nil - var onMuteToggled: (() -> Void)? = nil - var onShowSettings: (() -> Void)? = nil - var onShowQueue: (() -> Void)? = nil /// Whether to show chapter markers on the progress bar (default: true) var showChapters: Bool = true /// SponsorBlock segments to display on the progress bar. @@ -48,30 +45,14 @@ struct MacOSControlBar: View { @State private var dragProgress: Double = 0 @State private var isHoveringProgress = false @State private var hoverProgress: Double = 0 - @State private var playNextTapCount = 0 - @State private var playPreviousTapCount = 0 // MARK: - Computed Properties - /// Whether transport controls should be disabled - private var isTransportDisabled: Bool { - playerState.isTransportDisabled - } - /// Whether the controls are locked (playback buttons/gestures disabled except Settings). private var isLocked: Bool { playerState.isControlsLocked } - private var playPauseIcon: String { - switch playerState.playbackState { - case .playing: - return "pause.fill" - default: - return "play.fill" - } - } - private var displayProgress: Double { isDragging ? dragProgress : playerState.progress } @@ -85,8 +66,13 @@ struct MacOSControlBar: View { var body: some View { VStack(spacing: 8) { - // Top row: volume | transport (centered) | actions - topRowControls + // Top row: preset-driven button row + MacOSControlsSectionRenderer( + section: section, + actions: actions, + globalSettings: globalSettings, + context: .bar + ) // Bottom row: timeline (expanded width) timelineControls @@ -132,81 +118,6 @@ struct MacOSControlBar: View { } } - // MARK: - Top Row Controls - - private var topRowControls: some View { - HStack(spacing: 0) { - // Left: Volume controls - volumeControls - .disabled(isLocked) - .opacity(isLocked ? 0.5 : 1.0) - - Spacer() - - // Center: Transport controls - transportControls - .disabled(isLocked) - .opacity(isLocked ? 0.5 : 1.0) - - Spacer() - - // Right: Action buttons (settings, PiP, fullscreen) - trailingActionControls - } - } - - // MARK: - Transport Controls - - private var transportControls: some View { - HStack(spacing: 4) { - // Play previous (disabled when no previous in queue) - if let onPlayPrevious { - Button { - playPreviousTapCount += 1 - Task { await onPlayPrevious() } - } label: { - Image(systemName: "backward.fill") - .font(.system(size: 12, weight: .medium)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount) - } - .buttonStyle(MacOSControlButtonStyle()) - .disabled(!playerState.hasPrevious) - } - - // Play/Pause - Button { - onPlayPause() - } label: { - Image(systemName: playPauseIcon) - .font(.system(size: 16, weight: .medium)) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - .contentTransition(.symbolEffect(.replace, options: .speed(2))) - } - .buttonStyle(MacOSControlButtonStyle()) - .disabled(isTransportDisabled) - .opacity(isTransportDisabled ? 0.3 : 1.0) - - // Play next (disabled when no next in queue) - if let onPlayNext { - Button { - playNextTapCount += 1 - Task { await onPlayNext() } - } label: { - Image(systemName: "forward.fill") - .font(.system(size: 12, weight: .medium)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount) - } - .buttonStyle(MacOSControlButtonStyle()) - .disabled(!playerState.hasNext) - } - } - } - // MARK: - Timeline Controls private var timelineControls: some View { @@ -224,7 +135,7 @@ struct MacOSControlBar: View { } else { // Current time Text(playerState.formattedCurrentTime) - .font(.system(size: 11, weight: .medium, design: .rounded).monospacedDigit()) + .font(globalSettings.fontStyle.font(size: 11, weight: .medium)) .foregroundStyle(.primary) .lineLimit(1) .fixedSize() @@ -234,7 +145,7 @@ struct MacOSControlBar: View { // Duration Text(playerState.formattedDuration) - .font(.system(size: 11, weight: .medium, design: .rounded).monospacedDigit()) + .font(globalSettings.fontStyle.font(size: 11, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) .fixedSize() @@ -336,148 +247,41 @@ struct MacOSControlBar: View { .animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress) } - // MARK: - Trailing Action Controls - - private var trailingActionControls: some View { - HStack(spacing: 4) { - // Queue - if let onShowQueue { - Button { - onShowQueue() - } label: { - Image(systemName: "list.bullet") - .font(.system(size: 13, weight: .medium)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(MacOSControlButtonStyle()) - .disabled(isLocked) - .opacity(isLocked ? 0.5 : 1.0) - } - - // More (context menu) - if let video = playerState.currentVideo { - VideoContextMenuView( - video: video, - accentColor: .primary, - buttonSize: 28, - buttonBackgroundStyle: .none, - theme: .dark - ) - .menuStyle(.borderlessButton) - .fixedSize() - .disabled(isLocked) - .opacity(isLocked ? 0.5 : 1.0) - } - - // Settings - if let onShowSettings { - Button { - onShowSettings() - } label: { - Image(systemName: "gearshape") - .font(.system(size: 13, weight: .medium)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(MacOSControlButtonStyle()) - } - - // PiP - if let onTogglePiP { - Button { - onTogglePiP() - } label: { - Image(systemName: playerState.pipState == .active ? "pip.exit" : "pip.enter") - .font(.system(size: 13, weight: .medium)) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(MacOSControlButtonStyle()) - .disabled(isLocked || !playerState.isPiPPossible) - .opacity(isLocked ? 0.5 : 1.0) - } - } - } - - private var volumeControls: some View { - HStack(spacing: 2) { - // Mute/unmute button - Button { - onMuteToggled?() - } label: { - Image(systemName: volumeIcon) - .font(.system(size: 12, weight: .medium)) - .frame(width: 24, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(MacOSControlButtonStyle()) - - // Volume slider - Slider( - value: Binding( - get: { Double(playerState.volume) }, - set: { newValue in - playerState.volume = Float(newValue) - onVolumeChanged?(Float(newValue)) - } - ), - in: 0...1, - onEditingChanged: { editing in - if editing { - onInteractionStarted?() - } else { - onInteractionEnded?() - } - } - ) - .frame(width: 70) - .controlSize(.mini) - .disabled(playerState.isMuted) - .opacity(playerState.isMuted ? 0.5 : 1.0) - } - } - - private var volumeIcon: String { - if playerState.isMuted || playerState.volume == 0 { - return "speaker.slash.fill" - } else if playerState.volume < 0.33 { - return "speaker.wave.1.fill" - } else if playerState.volume < 0.66 { - return "speaker.wave.2.fill" - } else { - return "speaker.wave.3.fill" - } - } -} - -// MARK: - Button Style - -/// Subtle button style for macOS control bar buttons. -struct MacOSControlButtonStyle: ButtonStyle { - @Environment(\.isEnabled) private var isEnabled - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .foregroundStyle(isEnabled ? .primary : .secondary) - .opacity(configuration.isPressed ? 0.6 : 1.0) - .scaleEffect(configuration.isPressed ? 0.95 : 1.0) - .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) - } } // MARK: - Preview #Preview { + let playerState = PlayerState() + ZStack { Color.gray.opacity(0.3) MacOSControlBar( - playerState: PlayerState(), - onPlayPause: {}, - onSeek: { _ in }, - onSeekForward: { _ in }, - onSeekBackward: { _ in } + playerState: playerState, + section: PlayerControlsLayout.default.bottomSection, + globalSettings: .default, + actions: PlayerControlsActions( + playerState: playerState, + isWideScreenLayout: true, + isFullscreen: false, + isWidescreenVideo: true, + isPanelVisible: false, + panelSide: .right, + showVolumeControls: true, + showDebugButton: false, + showCloseButton: false, + currentVideo: nil, + availableCaptions: [], + currentCaption: nil, + availableStreams: [], + currentStream: nil, + currentAudioStream: nil, + isAutoPlayNextEnabled: true, + yatteeServerURL: nil, + deArrowBrandingProvider: nil + ), + onSeek: { _ in } ) .frame(width: 650) } diff --git a/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift b/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift new file mode 100644 index 00000000..1622a81c --- /dev/null +++ b/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift @@ -0,0 +1,702 @@ +// +// MacOSControlsSectionRenderer.swift +// Yattee +// +// Renders a section of player control buttons dynamically for the macOS player, +// styled to match the QuickTime-like capsule bar and the overlay top bar. +// + +#if os(macOS) + +import SwiftUI + +/// Rendering context for macOS control buttons. +enum MacOSControlsContext { + /// The glass capsule control bar (primary-tinted compact buttons). + case bar + /// The overlay top bar over the video (white icons on gradient, circular material backgrounds). + case overlay +} + +/// Renders a horizontal row of control buttons based on layout configuration. +/// macOS counterpart of the iOS `ControlsSectionRenderer`. +struct MacOSControlsSectionRenderer: View { + @Environment(\.appEnvironment) private var appEnvironment + + let section: LayoutSection + let actions: PlayerControlsActions + let globalSettings: GlobalLayoutSettings + var context: MacOSControlsContext = .bar + + // MARK: - State + + @State private var playNextTapCount = 0 + @State private var playPreviousTapCount = 0 + @State private var seekTapCount = 0 + @State private var isVolumeExpanded = false + @State private var unlockProgress: Double = 0 + @State private var unlockTimer: Timer? + + var body: some View { + HStack(spacing: 4) { + ForEach(section.visibleButtons(isWideLayout: true)) { config in + renderButton(config) + } + } + .animation(.easeInOut(duration: 0.2), value: isVolumeExpanded) + } + + // MARK: - Metrics + + private var isLocked: Bool { + actions.isControlsLocked + } + + private var isTransportDisabled: Bool { + actions.playerState.isTransportDisabled + } + + /// Button frame size derived from the layout's button size setting. + private var frameSize: CGFloat { + switch globalSettings.buttonSize { + case .small: return context == .bar ? 24 : 26 + case .medium: return context == .bar ? 28 : 30 + case .large: return context == .bar ? 34 : 36 + } + } + + /// Icon point size derived from the layout's button size setting. + private var iconSize: CGFloat { + switch globalSettings.buttonSize { + case .small: return context == .bar ? 11 : 13 + case .medium: return context == .bar ? 13 : 15 + case .large: return context == .bar ? 16 : 18 + } + } + + /// Primary tint for the current context. + private var tint: Color { + context == .bar ? .primary : .white + } + + /// Secondary tint for the current context. + private var secondaryTint: Color { + context == .bar ? Color.secondary : .white.opacity(0.7) + } + + private var fontStyle: ControlsFontStyle { + globalSettings.fontStyle + } + + // MARK: - Button Rendering + + @ViewBuilder + private func renderButton(_ config: ControlButtonConfiguration) -> some View { + switch config.buttonType { + case .spacer: + renderSpacer(config) + + case .close: + if actions.showCloseButton, actions.onClose != nil { + controlButton(systemImage: "xmark", help: config.buttonType.displayName) { + actions.onClose?() + } + .accessibilityLabel(Text("player.controls.close")) + } + + case .keepOnTop: + keepOnTopButton + + case .mpvDebug: + if actions.showDebugButton, actions.onToggleDebug != nil { + controlButton(systemImage: "info.circle", help: config.buttonType.displayName) { + actions.onToggleDebug?() + } + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .volume: + if actions.showVolumeControls { + volumeControls(config: config) + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .pictureInPicture: + if actions.onTogglePiP != nil { + controlButton(systemImage: actions.pipIcon, help: config.buttonType.displayName) { + actions.onTogglePiP?() + } + .disabled(isLocked || !actions.isPiPAvailable) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .fullscreen: + if actions.shouldShowFullscreenButton { + controlButton(systemImage: actions.fullscreenIcon, help: config.buttonType.displayName) { + actions.onToggleFullscreen?() + } + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .settings: + if actions.onShowSettings != nil { + controlButton(systemImage: "gearshape", help: config.buttonType.displayName) { + actions.onShowSettings?() + } + } + + case .controlsLock: + if actions.onControlsLockToggled != nil { + controlsLockButton + } + + case .playPrevious: + if actions.onPlayPrevious != nil { + Button { + playPreviousTapCount += 1 + Task { await actions.onPlayPrevious?() } + } label: { + buttonLabel(systemImage: "backward.fill") + .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount) + } + .buttonStyle(buttonStyleForContext) + .help(config.buttonType.displayName) + .disabled(isLocked || !actions.hasPreviousInQueue) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .playNext: + if actions.onPlayNext != nil { + Button { + playNextTapCount += 1 + Task { await actions.onPlayNext?() } + } label: { + buttonLabel(systemImage: "forward.fill") + .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount) + } + .buttonStyle(buttonStyleForContext) + .help(config.buttonType.displayName) + .disabled(isLocked || !actions.hasNextInQueue) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .playPause: + if actions.onPlayPause != nil { + Button { + actions.onPlayPause?() + } label: { + let label = Image(systemName: playPauseIcon) + .font(.system(size: iconSize + 3, weight: .medium)) + .frame(width: frameSize + 4, height: frameSize + 4) + .contentShape(Rectangle()) + .contentTransition(.symbolEffect(.replace, options: .speed(2))) + .modifier(OverlayCircleBackgroundModifier(active: context == .overlay)) + + if context == .overlay { + label.foregroundStyle(.white) + } else { + label + } + } + .buttonStyle(buttonStyleForContext) + .disabled(isTransportDisabled || isLocked) + .opacity(isTransportDisabled ? 0.3 : (isLocked ? 0.5 : 1.0)) + } + + case .queue: + if actions.onShowQueue != nil { + controlButton(systemImage: "list.bullet", help: config.buttonType.displayName) { + actions.onShowQueue?() + } + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .timeDisplay: + timeDisplayView(config) + + case .playbackSpeed: + playbackSpeedMenu + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + + case .share: + shareButton + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + + case .contextMenu: + if let video = actions.currentVideo { + VideoContextMenuView( + video: video, + accentColor: tint, + buttonSize: frameSize, + buttonBackgroundStyle: .none, + theme: .dark + ) + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .titleAuthor: + titleAuthorButton(config: config) + + case .autoPlayNext: + if actions.onToggleAutoPlayNext != nil { + controlButton( + systemImage: "play.square.stack.fill", + tint: actions.isAutoPlayNextEnabled ? .red : tint, + help: config.buttonType.displayName + ) { + actions.onToggleAutoPlayNext?() + } + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + case .seek: + if let settings = config.seekSettings { + seekButton(settings: settings, config: config) + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + + default: + // Button types not supported on macOS (brightness, orientation lock, + // panscan, etc.) degrade gracefully when present in stale presets. + EmptyView() + } + } + + // MARK: - Base Button + + /// Button style matching the rendering context. + private var buttonStyleForContext: MacOSRendererButtonStyle { + MacOSRendererButtonStyle(context: context) + } + + @ViewBuilder + private func controlButton( + systemImage: String, + tint: Color? = nil, + help: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + buttonLabel(systemImage: systemImage, tint: tint) + } + .buttonStyle(buttonStyleForContext) + .help(help) + } + + @ViewBuilder + private func buttonLabel(systemImage: String, tint: Color? = nil) -> some View { + let base = Image(systemName: systemImage) + .font(.system(size: iconSize, weight: context == .bar ? .medium : .semibold)) + .frame(width: frameSize, height: frameSize) + .contentShape(Rectangle()) + .modifier(OverlayCircleBackgroundModifier(active: context == .overlay)) + + if context == .overlay { + base.foregroundStyle(tint ?? .white) + } else if let tint { + base.foregroundStyle(tint) + } else { + // No explicit tint in the bar: the button style drives + // primary/secondary based on the enabled state. + base + } + } + + @ViewBuilder + private func renderSpacer(_ config: ControlButtonConfiguration) -> some View { + if let settings = config.spacerSettings { + if settings.isFlexible { + Spacer() + } else { + Spacer() + .frame(width: CGFloat(settings.fixedWidth)) + } + } else { + Spacer() + } + } + + private var playPauseIcon: String { + switch actions.playerState.playbackState { + case .playing: + return "pause.fill" + default: + return "play.fill" + } + } + + // MARK: - Keep on Top + + @ViewBuilder + private var keepOnTopButton: some View { + // Only meaningful for the separate window presentation, so hidden in + // the inline sheet and in fullscreen. + if let settings = appEnvironment?.settingsManager, + settings.macPlayerSeparateWindow, !actions.isFullscreen { + controlButton( + systemImage: settings.macPlayerFloating ? "pin.fill" : "pin", + help: String(localized: "player.controls.keepOnTop") + ) { + settings.macPlayerFloating.toggle() + } + .accessibilityLabel(Text("player.controls.keepOnTop")) + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + } + + // MARK: - Volume + + @ViewBuilder + private func volumeControls(config: ControlButtonConfiguration) -> some View { + // macOS is always a "wide" layout, so auto-expand behaves as always visible. + let behavior = config.sliderSettings?.sliderBehavior ?? .alwaysVisible + let effectiveBehavior: SliderBehavior = behavior == .autoExpandInLandscape ? .alwaysVisible : behavior + + HStack(spacing: 2) { + Button { + if actions.playerState.isMuted { + actions.onMuteToggled?() + } else if effectiveBehavior == .expandOnTap { + withAnimation(.easeInOut(duration: 0.2)) { + isVolumeExpanded.toggle() + } + } else { + actions.onMuteToggled?() + } + } label: { + buttonLabel(systemImage: volumeIcon, tint: actions.playerState.isMuted ? .red : nil) + } + .buttonStyle(buttonStyleForContext) + .help(ControlButtonType.volume.displayName) + + if effectiveBehavior == .alwaysVisible || isVolumeExpanded { + Slider( + value: Binding( + get: { Double(actions.playerState.volume) }, + set: { newValue in + actions.playerState.volume = Float(newValue) + actions.onVolumeChanged?(Float(newValue)) + } + ), + in: 0...1, + onEditingChanged: { editing in + actions.onSliderAdjustmentChanged?(editing) + if editing { + actions.onCancelHideTimer?() + } else { + actions.onResetHideTimer?() + } + } + ) + .frame(width: 70) + .controlSize(.mini) + .disabled(actions.playerState.isMuted) + .opacity(actions.playerState.isMuted ? 0.5 : 1.0) + .transition(.opacity) + } + } + } + + private var volumeIcon: String { + let playerState = actions.playerState + if playerState.isMuted || playerState.volume == 0 { + return "speaker.slash.fill" + } else if playerState.volume < 0.33 { + return "speaker.wave.1.fill" + } else if playerState.volume < 0.66 { + return "speaker.wave.2.fill" + } else { + return "speaker.wave.3.fill" + } + } + + // MARK: - Controls Lock + + @ViewBuilder + private var controlsLockButton: some View { + if isLocked { + // Locked state: show progress ring, hold to unlock + Button { } label: { + buttonLabel(systemImage: "lock", tint: .red) + .overlay { + Circle() + .trim(from: 0, to: unlockProgress) + .stroke(tint, lineWidth: 2) + .rotationEffect(.degrees(-90)) + .frame(width: frameSize + 4, height: frameSize + 4) + .opacity(unlockProgress > 0 ? 1 : 0) + } + } + .buttonStyle(buttonStyleForContext) + .help(String(localized: "controls.button.controlsLock")) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in startUnlock() } + .onEnded { _ in cancelUnlock() } + ) + } else { + controlButton(systemImage: "lock.open", help: String(localized: "controls.button.controlsLock")) { + actions.onControlsLockToggled?(true) + } + } + } + + private func startUnlock() { + guard unlockTimer == nil else { return } + unlockProgress = 0 + actions.onCancelHideTimer?() + + unlockTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in + Task { @MainActor in + self.unlockProgress += 0.05 / 3.0 // 3 seconds total + if self.unlockProgress >= 1.0 { + self.unlockTimer?.invalidate() + self.unlockTimer = nil + self.actions.onControlsLockToggled?(false) + self.unlockProgress = 0 + self.actions.onResetHideTimer?() + } + } + } + } + + private func cancelUnlock() { + unlockTimer?.invalidate() + unlockTimer = nil + withAnimation(.easeOut(duration: 0.2)) { + unlockProgress = 0 + } + actions.onResetHideTimer?() + } + + // MARK: - Time Display + + @ViewBuilder + private func timeDisplayView(_ config: ControlButtonConfiguration) -> some View { + let playerState = actions.playerState + let timeFont = fontStyle.font(.caption) + + Group { + if playerState.isLive { + HStack(spacing: 4) { + Circle() + .fill(.red) + .frame(width: 6, height: 6) + Text(String(localized: "player.live")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.red) + } + } else { + let format = config.timeDisplaySettings?.format ?? .currentAndTotal + + HStack(spacing: 0) { + Text(playerState.formattedCurrentTime) + .font(timeFont) + .foregroundStyle(tint) + + switch format { + case .currentOnly: + EmptyView() + + case .currentAndTotal, .currentAndTotalExcludingSponsor: + Text(verbatim: " / ") + .font(timeFont) + .foregroundStyle(secondaryTint) + Text(playerState.formattedDuration) + .font(timeFont) + .foregroundStyle(secondaryTint) + + case .currentAndRemaining, .currentAndRemainingExcludingSponsor: + Text(verbatim: " / ") + .font(timeFont) + .foregroundStyle(secondaryTint) + Text(verbatim: "-\(formattedRemainingTime)") + .font(timeFont) + .foregroundStyle(secondaryTint) + } + } + } + } + .lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.middle) + } + + private var formattedRemainingTime: String { + let remaining = max(0, actions.playerState.duration - actions.playerState.currentTime) + return remaining.formattedAsTimestamp + } + + // MARK: - Title / Author + + @ViewBuilder + private func titleAuthorButton(config: ControlButtonConfiguration) -> some View { + let settings = config.titleAuthorSettings ?? TitleAuthorSettings() + + if let video = actions.currentVideo { + Button { + actions.onToggleDetailsVisibility?() + } label: { + HStack(alignment: .center, spacing: context == .overlay ? 12 : 8) { + if settings.showSourceImage { + ChannelAvatarView( + author: video.author, + size: context == .overlay ? 36 : frameSize * 0.9, + yatteeServerURL: actions.yatteeServerURL, + source: video.id.source + ) + } + + if settings.showTitle || settings.showSourceName { + VStack(alignment: .leading, spacing: 2) { + if settings.showTitle { + Text(actions.deArrowBrandingProvider?.title(for: video) ?? video.title) + .font(context == .overlay ? .headline : fontStyle.font(.caption).weight(.medium)) + .foregroundStyle(tint) + .lineLimit(1) + } + + if settings.showSourceName { + Text(video.author.name) + .font(context == .overlay ? .subheadline : fontStyle.font(.caption2)) + .foregroundStyle(secondaryTint) + .lineLimit(1) + } + } + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(actions.onToggleDetailsVisibility == nil || isLocked) + .opacity(isLocked ? 0.5 : 1.0) + .help(Text("player.controls.info")) + } + } + + // MARK: - Playback Speed + + @ViewBuilder + private var playbackSpeedMenu: some View { + Menu { + ForEach(PlaybackRate.allCases) { rate in + Button { + actions.onRateChanged?(rate) + } label: { + HStack { + Text(rate.displayText) + if actions.playerState.rate == rate { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 2) { + Image(systemName: "gauge.with.needle") + .font(.system(size: iconSize, weight: context == .bar ? .medium : .semibold)) + if let rateDisplay = actions.playbackRateDisplay { + Text(rateDisplay) + .font(fontStyle.font(.caption).weight(.semibold)) + } + } + .foregroundStyle(tint) + .frame(minWidth: frameSize, minHeight: frameSize) + .contentShape(Rectangle()) + .modifier(OverlayCircleBackgroundModifier(active: context == .overlay)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(ControlButtonType.playbackSpeed.displayName) + } + + // MARK: - Share + + @ViewBuilder + private var shareButton: some View { + if let video = actions.currentVideo { + ShareLink(item: video.shareURL) { + buttonLabel(systemImage: "square.and.arrow.up") + } + .buttonStyle(buttonStyleForContext) + .help(ControlButtonType.share.displayName) + } + } + + // MARK: - Seek + + @ViewBuilder + private func seekButton(settings: SeekSettings, config: ControlButtonConfiguration) -> some View { + Button { + seekTapCount += 1 + Task { + if settings.direction == .forward { + await actions.onSeekForward?(TimeInterval(settings.seconds)) + } else { + await actions.onSeekBackward?(TimeInterval(settings.seconds)) + } + } + } label: { + buttonLabel(systemImage: settings.systemImage) + .symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekTapCount) + } + .buttonStyle(buttonStyleForContext) + .help(config.buttonType.displayName) + } +} + +// MARK: - Helpers + +/// Applies the top-bar circular material background when rendering in the overlay context. +private struct OverlayCircleBackgroundModifier: ViewModifier { + let active: Bool + + func body(content: Content) -> some View { + if active { + content.background(.ultraThinMaterial, in: Circle()) + } else { + content + } + } +} + +/// Button style for the macOS renderer, switching by rendering context. +/// The bar variant tints primary/secondary by enabled state with subtle press +/// feedback; the overlay variant only adds press feedback since its labels +/// carry explicit white styling. +struct MacOSRendererButtonStyle: ButtonStyle { + let context: MacOSControlsContext + + @Environment(\.isEnabled) private var isEnabled + + @ViewBuilder + func makeBody(configuration: Configuration) -> some View { + switch context { + case .bar: + configuration.label + .foregroundStyle(isEnabled ? .primary : .secondary) + .opacity(configuration.isPressed ? 0.6 : 1.0) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + + case .overlay: + configuration.label + .opacity(configuration.isPressed ? 0.7 : 1.0) + } + } +} + +#endif diff --git a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift index a86ca666..3d39123d 100644 --- a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift +++ b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift @@ -37,6 +37,8 @@ struct MacOSPlayerControlsView: View { /// Whether the floating video details panel is currently visible. When it opens, /// the controls hide immediately instead of waiting for the auto-hide timer. var isDetailsPanelVisible: Bool = false + /// Change playback rate (used by the playback speed button). + var onRateChanged: ((PlaybackRate) -> Void)? = nil // MARK: - State @@ -46,6 +48,10 @@ struct MacOSPlayerControlsView: View { @State private var showControls: Bool? @State private var keyboardMonitor: Any? + /// The active player controls layout (macOS preset). `nil` until loaded, so + /// the bars don't flash a wrong default before the preset arrives. + @State private var layout: PlayerControlsLayout? + /// 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 /// keeping the avatar/title aligned to the leading edge. @@ -77,77 +83,15 @@ struct MacOSPlayerControlsView: View { // MARK: - Top Bar - /// Top row showing channel avatar, video title, author name, and a close button. - /// Mirrors the tvOS `topBar`, scaled down for macOS. - private var topBar: some View { - HStack(alignment: .center, spacing: 12) { - if let video = playerState.currentVideo { - Button { - onTitleTap?() - } label: { - HStack(alignment: .center, spacing: 12) { - ChannelAvatarView( - author: video.author, - size: 36, - yatteeServerURL: yatteeServerURL, - source: video.id.source - ) - - VStack(alignment: .leading, spacing: 2) { - Text(video.title) - .font(.headline) - .lineLimit(1) - .foregroundStyle(.white) - Text(video.author.name) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.7)) - .lineLimit(1) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(onTitleTap == nil || playerState.isControlsLocked) - .opacity(playerState.isControlsLocked ? 0.5 : 1.0) - .help(Text("player.controls.info")) - } - - 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")) - .disabled(playerState.isControlsLocked) - .opacity(playerState.isControlsLocked ? 0.5 : 1.0) - } - - if onClose != nil { - Button { - onClose?() - } label: { - Image(systemName: "xmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .background(.ultraThinMaterial, in: Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Text("player.controls.close")) - } - } + /// Top row rendered from the active preset's top section (title/author, + /// keep-on-top pin, close by default). + private func topBar(layout: PlayerControlsLayout) -> some View { + MacOSControlsSectionRenderer( + section: layout.topSection, + actions: controlsActions(layout: layout), + globalSettings: layout.globalSettings, + context: .overlay + ) .padding(.horizontal, 20) .padding(.top, 16 + trafficLightInset) .padding(.bottom, 24) @@ -165,6 +109,74 @@ struct MacOSPlayerControlsView: View { ) } + // MARK: - Actions + + /// Consolidated actions for the section renderers. + private func controlsActions(layout: PlayerControlsLayout) -> PlayerControlsActions { + PlayerControlsActions( + playerState: playerState, + isWideScreenLayout: true, + isFullscreen: isFullscreen, + isWidescreenVideo: true, + isPanelVisible: isDetailsPanelVisible, + panelSide: .right, + showVolumeControls: layout.globalSettings.volumeMode == .mpv, + showDebugButton: true, + showCloseButton: onClose != nil, + currentVideo: playerState.currentVideo, + availableCaptions: [], + currentCaption: nil, + availableStreams: [], + currentStream: nil, + currentAudioStream: nil, + isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true, + yatteeServerURL: yatteeServerURL, + deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider, + onClose: onClose, + onToggleDebug: { [self] in + withAnimation(.easeInOut(duration: 0.2)) { + playerState.showDebugOverlay.toggle() + } + }, + onTogglePiP: onTogglePiP, + onToggleFullscreen: onToggleFullscreen, + onToggleDetailsVisibility: onTitleTap, + onToggleAutoPlayNext: { [weak appEnvironment] in + appEnvironment?.settingsManager.queueAutoPlayNext.toggle() + }, + onShowSettings: onShowSettings, + onPlayNext: onPlayNext, + onPlayPrevious: onPlayPrevious, + onPlayPause: { [self] in + let wasPaused = playerState.playbackState == .paused + onPlayPause() + showControls = true + if wasPaused { + resetHideTimer() + } + }, + onSeekForward: { seconds in await onSeekForward(seconds) }, + onSeekBackward: { seconds in await onSeekBackward(seconds) }, + onVolumeChanged: onVolumeChanged, + onMuteToggled: onMuteToggled, + onCancelHideTimer: { [self] in cancelHideTimer() }, + onResetHideTimer: { [self] in resetHideTimer() }, + onSliderAdjustmentChanged: { [self] adjusting in + isInteracting = adjusting + if adjusting { + cancelHideTimer() + } else { + resetHideTimer() + } + }, + onRateChanged: onRateChanged, + onShowQueue: onShowQueue, + onControlsLockToggled: { [self] locked in + playerState.isControlsLocked = locked + } + ) + } + // MARK: - Body var body: some View { @@ -178,49 +190,38 @@ struct MacOSPlayerControlsView: View { } .allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed) - // Top bar (title/author/avatar/close) + control bar at bottom center - VStack(spacing: 0) { - topBar + // Top bar (from preset top section) + control bar at bottom center + if let layout { + VStack(spacing: 0) { + topBar(layout: layout) - Spacer() + Spacer() - MacOSControlBar( - playerState: playerState, - onPlayPause: { - let wasPaused = playerState.playbackState == .paused - onPlayPause() - showControls = true - if wasPaused { + MacOSControlBar( + playerState: playerState, + section: layout.bottomSection, + globalSettings: layout.globalSettings, + actions: controlsActions(layout: layout), + onSeek: onSeek, + showChapters: layout.progressBarSettings.showChapters, + sponsorSegments: playerState.sponsorSegments, + sponsorBlockSettings: layout.progressBarSettings.sponsorBlockSettings, + playedColor: layout.progressBarSettings.playedColor.color, + onInteractionStarted: { + isInteracting = true + cancelHideTimer() + }, + onInteractionEnded: { + isInteracting = false resetHideTimer() } - }, - onSeek: onSeek, - onSeekForward: onSeekForward, - onSeekBackward: onSeekBackward, - onToggleFullscreen: onToggleFullscreen, - isFullscreen: isFullscreen, - onTogglePiP: onTogglePiP, - onPlayNext: onPlayNext, - onPlayPrevious: onPlayPrevious, - onVolumeChanged: onVolumeChanged, - onMuteToggled: onMuteToggled, - onShowSettings: onShowSettings, - onShowQueue: onShowQueue, - sponsorSegments: playerState.sponsorSegments, - onInteractionStarted: { - isInteracting = true - cancelHideTimer() - }, - onInteractionEnded: { - isInteracting = false - resetHideTimer() - } - ) - .frame(width: 650) - .padding(.bottom, 20) + ) + .frame(width: 650) + .padding(.bottom, 20) + } + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) } - .opacity(shouldShowControls ? 1 : 0) - .allowsHitTesting(shouldShowControls) } .frame(width: geometry.size.width, height: geometry.size.height) } @@ -267,6 +268,22 @@ struct MacOSPlayerControlsView: View { .onDisappear { removeKeyboardMonitor() } + .task { + await loadLayout() + } + .onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in + Task { await loadLayout() } + } + .onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in + Task { await loadLayout() } + } + } + + // MARK: - Layout Loading + + private func loadLayout() async { + guard let service = appEnvironment?.playerControlsLayoutService else { return } + layout = await service.activeLayout() } // MARK: - Keyboard Shortcuts @@ -301,26 +318,32 @@ struct MacOSPlayerControlsView: View { return nil // Consume event case 123: // Left arrow - Task { await onSeek(max(0, playerState.currentTime - 5)) } + let seconds = TimeInterval(layout?.centerSettings.seekBackwardSeconds ?? 5) + Task { await onSeekBackward(seconds) } return nil case 124: // Right arrow - Task { await onSeek(min(playerState.duration, playerState.currentTime + 5)) } + let seconds = TimeInterval(layout?.centerSettings.seekForwardSeconds ?? 5) + Task { await onSeekForward(seconds) } return nil case 126: // Up arrow + // In system volume mode MPV volume is pinned at 1.0; don't fight it. + guard layout?.globalSettings.volumeMode != .system else { return event } let newVolume = min(1.0, playerState.volume + 0.1) playerState.volume = newVolume onVolumeChanged?(newVolume) return nil case 125: // Down arrow + guard layout?.globalSettings.volumeMode != .system else { return event } let newVolume = max(0, playerState.volume - 0.1) playerState.volume = newVolume onVolumeChanged?(newVolume) return nil case 46: // M key + guard layout?.globalSettings.volumeMode != .system else { return event } onMuteToggled?() return nil diff --git a/Yattee/Views/Settings/PlayerControls/ButtonConfigurationView.swift b/Yattee/Views/Settings/PlayerControls/ButtonConfigurationView.swift index 0b33b12b..3129cf98 100644 --- a/Yattee/Views/Settings/PlayerControls/ButtonConfigurationView.swift +++ b/Yattee/Views/Settings/PlayerControls/ButtonConfigurationView.swift @@ -36,7 +36,9 @@ struct ButtonConfigurationView: View { var body: some View { if let config = configuration { Form { - // Visibility mode (all buttons) + // Visibility mode (all buttons); portrait/wide modes have no + // meaning on macOS, where every button is always shown. + #if os(iOS) Section { Picker( String(localized: "settings.playerControls.visibility"), @@ -54,15 +56,26 @@ struct ButtonConfigurationView: View { } footer: { Text(String(localized: "settings.playerControls.visibilityFooter")) } + #endif // Type-specific settings if config.buttonType.hasSettings { typeSpecificSettings(for: config) } + #if os(macOS) + if !config.buttonType.hasSettings { + Section { + Text(String(localized: "settings.playerControls.noButtonSettings")) + .foregroundStyle(.secondary) + } + } + #endif } .navigationTitle(config.buttonType.displayName) #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #elseif os(macOS) + .formStyle(.grouped) #endif .onAppear { syncFromConfiguration(config) diff --git a/Yattee/Views/Settings/PlayerControls/CenterControlsSettingsView.swift b/Yattee/Views/Settings/PlayerControls/CenterControlsSettingsView.swift index 98d2fc9c..38919600 100644 --- a/Yattee/Views/Settings/PlayerControls/CenterControlsSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/CenterControlsSettingsView.swift @@ -20,8 +20,28 @@ struct CenterControlsSettingsView: View { @State private var leftSlider: SideSliderType = .disabled @State private var rightSlider: SideSliderType = .disabled + /// On macOS the center overlay doesn't exist; the seek durations always + /// apply (keyboard arrows and default seek buttons), so the controls are + /// always visible there. + private var seekBackwardControlsVisible: Bool { + #if os(macOS) + return true + #else + return showSeekBackward + #endif + } + + private var seekForwardControlsVisible: Bool { + #if os(macOS) + return true + #else + return showSeekForward + #endif + } + var body: some View { Form { + #if os(iOS) // Preview Section { CenterPreviewView( @@ -47,9 +67,11 @@ struct CenterControlsSettingsView: View { } header: { Text(String(localized: "settings.playerControls.center.playback")) } + #endif // Seek backward settings Section { + #if os(iOS) Toggle( String(localized: "settings.playerControls.center.showSeekBackward"), isOn: $showSeekBackward @@ -57,8 +79,9 @@ struct CenterControlsSettingsView: View { .onChange(of: showSeekBackward) { _, newValue in viewModel.updateCenterSettingsSync { $0.showSeekBackward = newValue } } + #endif - if showSeekBackward { + if seekBackwardControlsVisible { #if !os(tvOS) HStack { Text(String(localized: "settings.playerControls.center.seekBackwardTime")) @@ -97,6 +120,7 @@ struct CenterControlsSettingsView: View { // Seek forward settings Section { + #if os(iOS) Toggle( String(localized: "settings.playerControls.center.showSeekForward"), isOn: $showSeekForward @@ -104,8 +128,9 @@ struct CenterControlsSettingsView: View { .onChange(of: showSeekForward) { _, newValue in viewModel.updateCenterSettingsSync { $0.showSeekForward = newValue } } + #endif - if showSeekForward { + if seekForwardControlsVisible { #if !os(tvOS) HStack { Text(String(localized: "settings.playerControls.center.seekForwardTime")) @@ -140,6 +165,13 @@ struct CenterControlsSettingsView: View { } } header: { Text(String(localized: "settings.playerControls.center.seekForward")) + } footer: { + #if os(macOS) + Text(String( + localized: "settings.playerControls.center.seekDurationsFooter", + defaultValue: "Seek durations are used by the ← and → keyboard shortcuts and by seek buttons added to the player." + )) + #endif } #if os(iOS) @@ -175,7 +207,12 @@ struct CenterControlsSettingsView: View { } #endif } + #if os(macOS) + .formStyle(.grouped) + .navigationTitle(String(localized: "settings.playerControls.seekDurations", defaultValue: "Seek Durations")) + #else .navigationTitle(String(localized: "settings.playerControls.centerControls")) + #endif #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif diff --git a/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift b/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift index b1323cd2..a2e15605 100644 --- a/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift @@ -30,6 +30,8 @@ struct MiniPlayerEditorView: View { .navigationTitle(String(localized: "settings.playerControls.miniPlayer")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #elseif os(macOS) + .formStyle(.grouped) #endif .onAppear { syncLocalState() @@ -274,6 +276,8 @@ struct MiniPlayerButtonConfigurationView: View { .navigationTitle(config.buttonType.displayName) #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #elseif os(macOS) + .formStyle(.grouped) #endif .onAppear { syncFromConfiguration(config) diff --git a/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift index 373b3b38..579516ab 100644 --- a/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift @@ -74,7 +74,10 @@ private struct PlayerControlsSettingsContent: View { var body: some View { Form { + // The phone-shaped preview and comments pill don't apply to the macOS player. + #if os(iOS) PreviewSection(viewModel: viewModel, layout: previewLayout) + #endif PresetSection( viewModel: viewModel, trackedPresetName: $trackedActivePresetName, @@ -87,13 +90,16 @@ private struct PlayerControlsSettingsContent: View { fontStyle: $fontStyle ) LayoutSectionsSection(viewModel: viewModel) - CommentsPillSection(viewModel: viewModel) #if os(iOS) + CommentsPillSection(viewModel: viewModel) GesturesSectionsSection(viewModel: viewModel) #endif SystemControlsSection(viewModel: viewModel) VolumeSection(viewModel: viewModel) } + #if os(macOS) + .formStyle(.grouped) + #endif .id(refreshID) .alert( String(localized: "settings.playerControls.error"), @@ -237,6 +243,8 @@ private struct AppearanceSection: View { var body: some View { Section { + // The macOS bar draws its own glass capsule; the per-button style has no effect there. + #if os(iOS) Picker( String(localized: "settings.playerControls.style"), selection: $style @@ -250,6 +258,7 @@ private struct AppearanceSection: View { guard newStyle != viewModel.currentLayout.globalSettings.style else { return } viewModel.updateGlobalSettingsSync { $0.style = newStyle } } + #endif Picker( String(localized: "settings.playerControls.buttonSize"), @@ -312,10 +321,17 @@ private struct LayoutSectionsSection: View { NavigationLink { CenterControlsSettingsView(viewModel: viewModel) } label: { + #if os(macOS) + Label( + String(localized: "settings.playerControls.seekDurations", defaultValue: "Seek Durations"), + systemImage: "arrow.trianglehead.clockwise" + ) + #else Label( String(localized: "settings.playerControls.centerControls"), systemImage: "play.circle" ) + #endif } .disabled(!viewModel.canEditActivePreset) @@ -336,10 +352,17 @@ private struct LayoutSectionsSection: View { ) } label: { HStack { + #if os(macOS) + Label( + String(localized: "settings.playerControls.controlBarButtons", defaultValue: "Control Bar Buttons"), + systemImage: "rectangle.bottomthird.inset.filled" + ) + #else Label( String(localized: "settings.playerControls.bottomButtons"), systemImage: "rectangle.bottomthird.inset.filled" ) + #endif Spacer() Text("\(viewModel.currentLayout.bottomSection.buttons.count)") .foregroundStyle(.secondary) @@ -347,6 +370,8 @@ private struct LayoutSectionsSection: View { } .disabled(!viewModel.canEditActivePreset) + // The player pill only exists in the iOS player. + #if os(iOS) NavigationLink { PlayerPillEditorView(viewModel: viewModel) } label: { @@ -361,6 +386,7 @@ private struct LayoutSectionsSection: View { } } .disabled(!viewModel.canEditActivePreset) + #endif NavigationLink { MiniPlayerEditorView(viewModel: viewModel) diff --git a/Yattee/Views/Settings/PlayerControls/PresetEditorView.swift b/Yattee/Views/Settings/PlayerControls/PresetEditorView.swift index 4991ad77..ce487592 100644 --- a/Yattee/Views/Settings/PlayerControls/PresetEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PresetEditorView.swift @@ -85,39 +85,22 @@ struct PresetEditorView: View { } var body: some View { + #if os(macOS) + macOSDialog + #else NavigationStack { Form { // Base Layout Picker (only for create mode) if case .create(_, _) = mode { Section { - Picker( - String(localized: "settings.playerControls.baseLayout"), - selection: $selectedBaseLayoutID - ) { - ForEach(baseLayouts) { preset in - Text(preset.name).tag(preset.id as UUID?) - } - } + basePicker } } Section { - TextField(mode.placeholder, text: $name) - .focused($isNameFocused) - .submitLabel(.done) - .onSubmit(saveIfValid) + nameField } footer: { - HStack { - if trimmedName.count > LayoutPreset.maxNameLength { - Text(String(localized: "settings.playerControls.nameTooLong")) - .foregroundStyle(.red) - } - Spacer() - Text("\(trimmedName.count)/\(LayoutPreset.maxNameLength)") - .foregroundStyle( - trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary - ) - } + nameFooter } } .navigationTitle(mode.title) @@ -145,11 +128,87 @@ struct PresetEditorView: View { #if os(iOS) .presentationDetents([.medium]) #endif - #if os(macOS) - .frame(minWidth: 500, minHeight: 350) #endif } + // MARK: - Shared Controls + + private var basePicker: some View { + Picker( + String(localized: "settings.playerControls.baseLayout"), + selection: $selectedBaseLayoutID + ) { + ForEach(baseLayouts) { preset in + Text(preset.name).tag(preset.id as UUID?) + } + } + } + + private var nameField: some View { + TextField(mode.placeholder, text: $name) + .focused($isNameFocused) + .submitLabel(.done) + .onSubmit(saveIfValid) + } + + private var nameFooter: some View { + HStack { + if trimmedName.count > LayoutPreset.maxNameLength { + Text(String(localized: "settings.playerControls.nameTooLong")) + .foregroundStyle(.red) + } + Spacer() + Text("\(trimmedName.count)/\(LayoutPreset.maxNameLength)") + .foregroundStyle( + trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary + ) + } + } + + // MARK: - macOS Dialog + + #if os(macOS) + /// Compact dialog layout matching native macOS sheets (title, fields, + /// trailing action buttons) instead of the iOS navigation-bar form. + private var macOSDialog: some View { + VStack(alignment: .leading, spacing: 16) { + Text(mode.title) + .font(.headline) + + if case .create(_, _) = mode { + basePicker + } + + VStack(alignment: .leading, spacing: 4) { + nameField + .textFieldStyle(.roundedBorder) + + nameFooter + .font(.caption) + } + + HStack { + Spacer() + Button(String(localized: "settings.playerControls.cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(mode.saveButtonTitle) { + saveIfValid() + } + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + } + .padding(20) + .frame(width: 400) + .onAppear { + isNameFocused = true + } + } + #endif + private func saveIfValid() { guard isValid else { return } onSave(trimmedName, selectedBaseLayoutID) diff --git a/Yattee/Views/Settings/PlayerControls/ProgressBarSettingsView.swift b/Yattee/Views/Settings/PlayerControls/ProgressBarSettingsView.swift index 040ebea9..fd813d96 100644 --- a/Yattee/Views/Settings/PlayerControls/ProgressBarSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/ProgressBarSettingsView.swift @@ -29,6 +29,8 @@ struct ProgressBarSettingsView: View { .navigationTitle(String(localized: "settings.playerControls.progressBar")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #elseif os(macOS) + .formStyle(.grouped) #endif .onAppear { loadSettings() diff --git a/Yattee/Views/Settings/PlayerControls/SectionEditorView.swift b/Yattee/Views/Settings/PlayerControls/SectionEditorView.swift index ca24e9b5..5a21e24a 100644 --- a/Yattee/Views/Settings/PlayerControls/SectionEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/SectionEditorView.swift @@ -30,7 +30,8 @@ struct SectionEditorView: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) - // Orientation toggle + // Orientation toggle; macOS has no portrait/landscape distinction + #if os(iOS) Picker( String(localized: "settings.playerControls.previewOrientation"), selection: $viewModel.isPreviewingLandscape @@ -42,6 +43,7 @@ struct SectionEditorView: View { } .pickerStyle(.segmented) .listRowBackground(Color.clear) + #endif // Added buttons Section { @@ -182,11 +184,13 @@ private struct ButtonRow: View { VStack(alignment: .leading, spacing: 2) { Text(configuration.buttonType.displayName) + #if os(iOS) if configuration.visibilityMode != .both { Text(configuration.visibilityMode.displayName) .font(.caption) .foregroundStyle(.secondary) } + #endif } Spacer() diff --git a/Yattee/Views/Settings/SettingsView.swift b/Yattee/Views/Settings/SettingsView.swift index 7e3a81f7..a8ed218e 100644 --- a/Yattee/Views/Settings/SettingsView.swift +++ b/Yattee/Views/Settings/SettingsView.swift @@ -59,6 +59,8 @@ struct SettingsView: View { LayoutNavigationSettingsView() case .playback: PlaybackSettingsView() + case .playerControls: + PlayerControlsSettingsView() case .notifications: NotificationSettingsView() case .downloads: @@ -355,6 +357,7 @@ enum SettingsSection: String, CaseIterable, Identifiable { case appearance case layoutNavigation case playback + case playerControls case notifications case downloads case privacy @@ -371,6 +374,7 @@ enum SettingsSection: String, CaseIterable, Identifiable { case .appearance: return String(localized: "settings.appearance.sectionTitle") case .layoutNavigation: return String(localized: "settings.layoutNavigation.title") case .playback: return String(localized: "settings.playback.sectionTitle") + case .playerControls: return String(localized: "settings.playerControls.title") case .notifications: return String(localized: "settings.notifications.title") case .downloads: return String(localized: "settings.downloads.title") case .privacy: return String(localized: "settings.privacy.title") @@ -387,6 +391,7 @@ enum SettingsSection: String, CaseIterable, Identifiable { case .appearance: return "paintbrush" case .layoutNavigation: return "hand.tap" case .playback: return "play.circle" + case .playerControls: return "slider.horizontal.below.rectangle" case .notifications: return "bell.badge" case .downloads: return "arrow.down.circle" case .privacy: return "hand.raised"