Files
yattee/Yattee/Views/Player/macOS/MacOSControlBar.swift
Arkadiusz Fal 54488aea0f Keep macOS player transport buttons visible but disabled when unavailable
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.
2026-05-24 21:05:29 +02:00

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