mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 06:42:01 +00:00
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.
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))
|
|
.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
|