Files
yattee/Yattee/Views/Player/macOS/MacOSControlBar.swift
Arkadiusz Fal 71eb2aacc7 Position macOS seek preview just above the progress bar
The storyboard seek preview was anchored to the top of the control-bar
pill and floated well above it. Anchor the preview and chapter capsule up
from the pill bottom (via geometry height) so they overlap the button row
and sit just above the progress bar, matching iOS proximity.
2026-06-20 22:49:22 +02:00

303 lines
12 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 just above the progress bar (overlapping the bar)
.overlay(alignment: .bottom) {
if (isDragging || isHoveringProgress), !playerState.isLive {
GeometryReader { geometry in
let pos = seekPreviewPosition(geometry: geometry, previewWidth: playerState.preferredStoryboard != nil ? 176 : 80)
// Position previews just above the progress bar so they overlap the control
// bar (button row) rather than floating above the whole pill. Measured up from
// the pill bottom, so it stays correct regardless of button-row height.
// Bottom inset = pill bottom padding (10) + timeline row height (~20).
let progressBarTop = geometry.size.height - 30
let previewGap: CGFloat = 6
let capsuleDelta: CGFloat = 26
if let storyboard = playerState.preferredStoryboard {
let previewY = progressBarTop - 102 - previewGap // storyboard preview height ~102
seekPreviewView(storyboard: storyboard)
.offset(x: pos.clampedX, y: previewY)
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: previewY - capsuleDelta)
}
} else {
let previewY = progressBarTop - 32 - previewGap // time pill height ~32
SeekTimePreviewView(
seekTime: pos.progress * playerState.duration,
buttonBackground: .none,
theme: .dark
)
.offset(x: pos.clampedX, y: previewY)
.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: previewY - capsuleDelta)
}
}
}
}
}
}
// 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