Files
yattee/Yattee/Views/Player/macOS/MacOSControlBar.swift
Arkadiusz Fal 96fef3663f Add glass background theme setting for macOS control bar
Rename the macOS "Control Bar Buttons" settings entry to "Control Bar"
and add an Auto/Light/Dark glass background picker on that page. The
choice is stored per-preset as GlobalLayoutSettings.controlBarTheme and
forces the bar's glass color scheme via glassBackground(colorScheme:).
2026-06-02 19:38:46 +02:00

292 lines
11 KiB
Swift

//
// MacOSControlBar.swift
// Yattee
//
// Condensed QuickTime-style control bar for macOS player.
//
#if os(macOS)
import SwiftUI
/// 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 onSeek: (TimeInterval) async -> Void
/// Whether to show chapter markers on the progress bar (default: true)
var showChapters: Bool = true
/// SponsorBlock segments to display on the progress bar.
var sponsorSegments: [SponsorBlockSegment] = []
/// Settings for SponsorBlock segment display.
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
/// Color for the played portion of the progress bar.
var playedColor: Color = .red
/// Called when user starts interacting (scrubbing, adjusting volume)
var onInteractionStarted: (() -> Void)? = nil
/// Called when user stops interacting
var onInteractionEnded: (() -> Void)? = nil
// MARK: - State
@State private var isDragging = false
@State private var dragProgress: Double = 0
@State private var isHoveringProgress = false
@State private var hoverProgress: Double = 0
// MARK: - Computed Properties
/// Whether the controls are locked (playback buttons/gestures disabled except Settings).
private var isLocked: Bool {
playerState.isControlsLocked
}
private var displayProgress: Double {
isDragging ? dragProgress : playerState.progress
}
private var bufferedProgress: Double {
guard playerState.duration > 0 else { return 0 }
return playerState.bufferedTime / playerState.duration
}
// MARK: - Body
var body: some View {
VStack(spacing: 8) {
// Top row: preset-driven button row
MacOSControlsSectionRenderer(
section: section,
actions: actions,
globalSettings: globalSettings,
context: .bar
)
// Bottom row: timeline (expanded width)
timelineControls
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.frame(maxWidth: 500)
.glassBackground(.regular, in: .rect(cornerRadius: 16), colorScheme: globalSettings.controlBarTheme.colorScheme)
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
// Seek preview overlay - positioned above the control bar
.overlay(alignment: .bottom) {
if (isDragging || isHoveringProgress), !playerState.isLive {
GeometryReader { geometry in
let pos = seekPreviewPosition(geometry: geometry, previewWidth: playerState.preferredStoryboard != nil ? 176 : 80)
if let storyboard = playerState.preferredStoryboard {
seekPreviewView(storyboard: storyboard)
.offset(x: pos.clampedX, y: -150)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= pos.progress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: pos.centerX, availableWidth: geometry.size.width)
.offset(y: -176)
}
} else {
SeekTimePreviewView(
seekTime: pos.progress * playerState.duration,
buttonBackground: .none,
theme: .dark
)
.offset(x: pos.clampedX, y: -60)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= pos.progress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: pos.centerX, availableWidth: geometry.size.width)
.offset(y: -86)
}
}
}
}
}
}
// MARK: - Timeline Controls
private var timelineControls: some View {
HStack(spacing: 8) {
if playerState.isLive {
// Live indicator
HStack(spacing: 4) {
Circle()
.fill(.red)
.frame(width: 6, height: 6)
Text(String(localized: "player.live"))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.red)
}
} else {
// Current time
Text(playerState.formattedCurrentTime)
.font(globalSettings.fontStyle.font(size: 11, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
.fixedSize()
// Progress bar (expands to fill available width)
progressBar
// Duration
Text(playerState.formattedDuration)
.font(globalSettings.fontStyle.font(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
}
}
}
private var progressBar: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Progress bar with chapter segments
SegmentedProgressBar(
chapters: showChapters ? playerState.chapters : [],
duration: playerState.duration,
currentTime: isDragging ? dragProgress * playerState.duration : playerState.currentTime,
bufferedTime: playerState.bufferedTime,
height: 4,
gapWidth: 2,
playedColor: playedColor,
bufferedColor: .primary.opacity(0.3),
backgroundColor: .primary.opacity(0.2),
sponsorSegments: sponsorSegments,
sponsorBlockSettings: sponsorBlockSettings
)
// Scrubber handle
Circle()
.fill(.white)
.frame(width: 12, height: 12)
.shadow(color: .black.opacity(0.3), radius: 2, y: 1)
.offset(x: geometry.size.width * displayProgress - 6)
.opacity(isDragging || isHoveringProgress ? 1 : 0)
}
.frame(height: 20)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard !isLocked else { return }
if !isDragging {
isDragging = true
onInteractionStarted?()
}
let progress = max(0, min(1, value.location.x / geometry.size.width))
dragProgress = progress
}
.onEnded { value in
guard !isLocked else { return }
isDragging = false
let progress = max(0, min(1, value.location.x / geometry.size.width))
let seekTime = progress * playerState.duration
Task { await onSeek(seekTime) }
onInteractionEnded?()
}
)
.onContinuousHover { phase in
switch phase {
case .active(let location):
isHoveringProgress = true
hoverProgress = max(0, min(1, location.x / geometry.size.width))
case .ended:
isHoveringProgress = false
}
}
.allowsHitTesting(!isLocked)
}
.frame(height: 20)
.opacity(isLocked ? 0.5 : 1.0)
}
private func seekPreviewPosition(
geometry: GeometryProxy,
previewWidth: CGFloat
) -> (progress: Double, clampedX: CGFloat, centerX: CGFloat) {
let previewProgress = isDragging ? dragProgress : hoverProgress
let horizontalPadding: CGFloat = 16
let timeLabelWidth: CGFloat = 50
let spacing: CGFloat = 8
let progressBarOffset = horizontalPadding + timeLabelWidth + spacing
let progressBarWidth = geometry.size.width - (2 * horizontalPadding) - (2 * timeLabelWidth) - (2 * spacing)
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
return (previewProgress, clampedX, clampedX + previewWidth / 2)
}
@ViewBuilder
private func seekPreviewView(storyboard: Storyboard) -> some View {
let previewProgress = isDragging ? dragProgress : hoverProgress
let seekTime = previewProgress * playerState.duration
SeekPreviewView(
storyboard: storyboard,
seekTime: seekTime,
storyboardService: StoryboardService.shared,
buttonBackground: .none,
theme: .dark
)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
}
}
// MARK: - Preview
#Preview {
let playerState = PlayerState()
ZStack {
Color.gray.opacity(0.3)
MacOSControlBar(
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)
}
.frame(width: 800, height: 200)
}
#endif