mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
448 lines
16 KiB
Swift
448 lines
16 KiB
Swift
//
|
|
// MacOSControlBar.swift
|
|
// Yattee
|
|
//
|
|
// Condensed QuickTime-style control bar for macOS player.
|
|
//
|
|
|
|
#if os(macOS)
|
|
|
|
import SwiftUI
|
|
|
|
/// Condensed QuickTime-style control bar with transport, timeline, and action controls.
|
|
struct MacOSControlBar: View {
|
|
@Bindable var playerState: PlayerState
|
|
|
|
// 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 onVolumeChanged: ((Float) -> Void)? = nil
|
|
var onMuteToggled: (() -> Void)? = nil
|
|
var onShowSettings: (() -> 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.
|
|
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
|
|
@State private var playNextTapCount = 0
|
|
@State private var seekBackwardTrigger = 0
|
|
@State private var seekForwardTrigger = 0
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Whether transport controls should be disabled
|
|
private var isTransportDisabled: Bool {
|
|
playerState.playbackState == .loading ||
|
|
playerState.playbackState == .buffering ||
|
|
!playerState.isFirstFrameReady ||
|
|
!playerState.isBufferReady
|
|
}
|
|
|
|
private var playPauseIcon: String {
|
|
switch playerState.playbackState {
|
|
case .playing:
|
|
return "pause.fill"
|
|
default:
|
|
return "play.fill"
|
|
}
|
|
}
|
|
|
|
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: volume | transport (centered) | actions
|
|
topRowControls
|
|
|
|
// 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 let storyboard = playerState.preferredStoryboard,
|
|
(isDragging || isHoveringProgress),
|
|
!playerState.isLive {
|
|
GeometryReader { geometry in
|
|
let previewProgress = isDragging ? dragProgress : hoverProgress
|
|
// Progress bar spans full width minus padding and time labels
|
|
// Time labels ~50px each, spacing ~16px = ~116px total for labels
|
|
let horizontalPadding: CGFloat = 16
|
|
let timeLabelWidth: CGFloat = 50
|
|
let spacing: CGFloat = 8
|
|
let progressBarOffset: CGFloat = horizontalPadding + timeLabelWidth + spacing
|
|
let progressBarWidth: CGFloat = geometry.size.width - (2 * horizontalPadding) - (2 * timeLabelWidth) - (2 * spacing)
|
|
let previewWidth: CGFloat = 176 // 160 + 16 padding
|
|
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
|
|
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
|
|
|
|
seekPreviewView(storyboard: storyboard)
|
|
.offset(x: clampedX, y: -150)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Top Row Controls
|
|
|
|
private var topRowControls: some View {
|
|
HStack(spacing: 0) {
|
|
// Left: Volume controls
|
|
volumeControls
|
|
|
|
Spacer()
|
|
|
|
// Center: Transport controls
|
|
transportControls
|
|
|
|
Spacer()
|
|
|
|
// Right: Action buttons (settings, PiP, fullscreen)
|
|
trailingActionControls
|
|
}
|
|
}
|
|
|
|
// MARK: - Transport Controls
|
|
|
|
private var transportControls: some View {
|
|
HStack(spacing: 4) {
|
|
// Skip backward
|
|
Button {
|
|
seekBackwardTrigger += 1
|
|
Task { await onSeekBackward(10) }
|
|
} label: {
|
|
Image(systemName: "10.arrow.trianglehead.counterclockwise")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(Rectangle())
|
|
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekBackwardTrigger)
|
|
}
|
|
.buttonStyle(MacOSControlButtonStyle())
|
|
.disabled(isTransportDisabled)
|
|
|
|
// Play/Pause
|
|
if !isTransportDisabled {
|
|
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())
|
|
} else {
|
|
// Spacer to maintain layout
|
|
Color.clear
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
|
|
// Skip forward
|
|
Button {
|
|
seekForwardTrigger += 1
|
|
Task { await onSeekForward(10) }
|
|
} label: {
|
|
Image(systemName: "10.arrow.trianglehead.clockwise")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(Rectangle())
|
|
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekForwardTrigger)
|
|
}
|
|
.buttonStyle(MacOSControlButtonStyle())
|
|
.disabled(isTransportDisabled)
|
|
|
|
// Play next (if queue has items)
|
|
if let onPlayNext, playerState.hasNext {
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(.system(size: 11, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
.fixedSize()
|
|
|
|
// Progress bar (expands to fill available width)
|
|
progressBar
|
|
|
|
// Duration
|
|
Text(playerState.formattedDuration)
|
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
.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
|
|
if !isDragging {
|
|
isDragging = true
|
|
onInteractionStarted?()
|
|
}
|
|
let progress = max(0, min(1, value.location.x / geometry.size.width))
|
|
dragProgress = progress
|
|
}
|
|
.onEnded { value in
|
|
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
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 20)
|
|
}
|
|
|
|
@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,
|
|
chapters: showChapters ? playerState.chapters : []
|
|
)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
|
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
|
|
}
|
|
|
|
// MARK: - Trailing Action Controls
|
|
|
|
private var trailingActionControls: some View {
|
|
HStack(spacing: 4) {
|
|
// 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, playerState.isPiPPossible {
|
|
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())
|
|
}
|
|
|
|
// Fullscreen
|
|
if let onToggleFullscreen {
|
|
Button {
|
|
onToggleFullscreen()
|
|
} label: {
|
|
Image(systemName: isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(MacOSControlButtonStyle())
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
ZStack {
|
|
Color.gray.opacity(0.3)
|
|
|
|
MacOSControlBar(
|
|
playerState: PlayerState(),
|
|
onPlayPause: {},
|
|
onSeek: { _ in },
|
|
onSeekForward: { _ in },
|
|
onSeekBackward: { _ in }
|
|
)
|
|
.frame(width: 650)
|
|
}
|
|
.frame(width: 800, height: 200)
|
|
}
|
|
|
|
#endif
|