mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 23:02:04 +00:00
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:).
292 lines
11 KiB
Swift
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
|