mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 14:52:03 +00:00
The play-next and Picture-in-Picture buttons were folded into the view condition (hasNext / isPiPPossible), so they popped in and out of the control bar and shifted the surrounding layout. Always render them (when their callback is wired) and gate availability with .disabled() instead, mirroring the existing play-previous button.
470 lines
17 KiB
Swift
470 lines
17 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 onPlayPrevious: (() async -> Void)? = nil
|
|
var onVolumeChanged: ((Float) -> Void)? = nil
|
|
var onMuteToggled: (() -> Void)? = nil
|
|
var onShowSettings: (() -> Void)? = nil
|
|
var onShowQueue: (() -> 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 playPreviousTapCount = 0
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Whether transport controls should be disabled
|
|
private var isTransportDisabled: Bool {
|
|
playerState.isTransportDisabled
|
|
}
|
|
|
|
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 (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: - 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) {
|
|
// Play previous (disabled when no previous in queue)
|
|
if let onPlayPrevious {
|
|
Button {
|
|
playPreviousTapCount += 1
|
|
Task { await onPlayPrevious() }
|
|
} label: {
|
|
Image(systemName: "backward.fill")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(Rectangle())
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount)
|
|
}
|
|
.buttonStyle(MacOSControlButtonStyle())
|
|
.disabled(!playerState.hasPrevious)
|
|
}
|
|
|
|
// Play/Pause
|
|
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())
|
|
.disabled(isTransportDisabled)
|
|
.opacity(isTransportDisabled ? 0.3 : 1.0)
|
|
|
|
// Play next (disabled when no next in queue)
|
|
if let onPlayNext {
|
|
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())
|
|
.disabled(!playerState.hasNext)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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: - Trailing Action Controls
|
|
|
|
private var trailingActionControls: some View {
|
|
HStack(spacing: 4) {
|
|
// Queue
|
|
if let onShowQueue {
|
|
Button {
|
|
onShowQueue()
|
|
} label: {
|
|
Image(systemName: "list.bullet")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(MacOSControlButtonStyle())
|
|
}
|
|
|
|
// More (context menu)
|
|
if let video = playerState.currentVideo {
|
|
VideoContextMenuView(
|
|
video: video,
|
|
accentColor: .primary,
|
|
buttonSize: 28,
|
|
buttonBackgroundStyle: .none,
|
|
theme: .dark
|
|
)
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
}
|
|
|
|
// 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 {
|
|
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())
|
|
.disabled(!playerState.isPiPPossible)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|