mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
Yattee v2 rewrite
This commit is contained in:
447
Yattee/Views/Player/macOS/MacOSControlBar.swift
Normal file
447
Yattee/Views/Player/macOS/MacOSControlBar.swift
Normal file
@@ -0,0 +1,447 @@
|
||||
//
|
||||
// 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("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
|
||||
Reference in New Issue
Block a user