mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 06:42:01 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
702
Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift
Normal file
702
Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user