Drive macOS player control bars from presets

Render the macOS control bar button row and top bar from the active
preset's sections via a new MacOSControlsSectionRenderer, replacing the
hardcoded QuickTime-style rows in MacOSControlBar. Add a keepOnTop
button type and macOS-specific button availability lists, and adapt the
player controls settings editors (preset editor, section editor, button
configuration) for macOS.

Also apply the preset font style to the control bar time labels, and
update the built-in macOS Default preset order (queue before transport,
fullscreen at the end), bumping builtInPresetsVersion to 7.
This commit is contained in:
Arkadiusz Fal
2026-05-31 16:55:39 +02:00
parent fa6ad2235c
commit 05bbd449f8
18 changed files with 1348 additions and 404 deletions

View File

@@ -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" : {

View File

@@ -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()
)
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"