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

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