mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +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:
@@ -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" : {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user