mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
262 lines
8.6 KiB
Swift
262 lines
8.6 KiB
Swift
//
|
|
// PlayerPillView.swift
|
|
// Yattee
|
|
//
|
|
// A rounded pill showing customizable player controls.
|
|
// Supports dynamic buttons and horizontal scroll when space is limited.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - Content Width Preference Key
|
|
|
|
private struct ContentWidthKey: PreferenceKey {
|
|
static var defaultValue: CGFloat = 0
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = max(value, nextValue())
|
|
}
|
|
}
|
|
|
|
struct PlayerPillView: View {
|
|
let settings: PlayerPillSettings
|
|
let maxWidth: CGFloat?
|
|
let isWideLayout: Bool
|
|
let isPlaying: Bool
|
|
let hasNext: Bool
|
|
let queueCount: Int
|
|
let queueModeIcon: String
|
|
let isPlayPauseDisabled: Bool
|
|
let isOrientationLocked: Bool
|
|
|
|
// Video context for special buttons
|
|
let video: Video?
|
|
let playbackRate: PlaybackRate
|
|
@Binding var showingPlaylistSheet: Bool
|
|
|
|
// Action callbacks
|
|
let onQueueTap: () -> Void
|
|
let onPrevious: () -> Void
|
|
let onPlayPause: () -> Void
|
|
let onNext: () -> Void
|
|
/// Callback for seek actions. Positive seconds = forward, negative = backward.
|
|
let onSeek: ((TimeInterval) async -> Void)?
|
|
let onClose: () -> Void
|
|
let onAirPlay: () -> Void
|
|
let onPiP: () -> Void
|
|
let onOrientationLock: () -> Void
|
|
let onFullscreen: () -> Void
|
|
let onRateChanged: (PlaybackRate) -> Void
|
|
|
|
// Measured content width for hug-content behavior
|
|
@State private var contentWidth: CGFloat = 0
|
|
|
|
init(
|
|
settings: PlayerPillSettings,
|
|
maxWidth: CGFloat? = nil,
|
|
isWideLayout: Bool = false,
|
|
isPlaying: Bool,
|
|
hasNext: Bool,
|
|
queueCount: Int = 0,
|
|
queueModeIcon: String = "list.bullet",
|
|
isPlayPauseDisabled: Bool = false,
|
|
isOrientationLocked: Bool = false,
|
|
video: Video? = nil,
|
|
playbackRate: PlaybackRate = .x1,
|
|
showingPlaylistSheet: Binding<Bool> = .constant(false),
|
|
onQueueTap: @escaping () -> Void = {},
|
|
onPrevious: @escaping () -> Void = {},
|
|
onPlayPause: @escaping () -> Void = {},
|
|
onNext: @escaping () -> Void = {},
|
|
onSeek: ((TimeInterval) async -> Void)? = nil,
|
|
onClose: @escaping () -> Void = {},
|
|
onAirPlay: @escaping () -> Void = {},
|
|
onPiP: @escaping () -> Void = {},
|
|
onOrientationLock: @escaping () -> Void = {},
|
|
onFullscreen: @escaping () -> Void = {},
|
|
onRateChanged: @escaping (PlaybackRate) -> Void = { _ in }
|
|
) {
|
|
self.settings = settings
|
|
self.maxWidth = maxWidth
|
|
self.isWideLayout = isWideLayout
|
|
self.isPlaying = isPlaying
|
|
self.hasNext = hasNext
|
|
self.queueCount = queueCount
|
|
self.queueModeIcon = queueModeIcon
|
|
self.isPlayPauseDisabled = isPlayPauseDisabled
|
|
self.isOrientationLocked = isOrientationLocked
|
|
self.video = video
|
|
self.playbackRate = playbackRate
|
|
self._showingPlaylistSheet = showingPlaylistSheet
|
|
self.onQueueTap = onQueueTap
|
|
self.onPrevious = onPrevious
|
|
self.onPlayPause = onPlayPause
|
|
self.onNext = onNext
|
|
self.onSeek = onSeek
|
|
self.onClose = onClose
|
|
self.onAirPlay = onAirPlay
|
|
self.onPiP = onPiP
|
|
self.onOrientationLock = onOrientationLock
|
|
self.onFullscreen = onFullscreen
|
|
self.onRateChanged = onRateChanged
|
|
}
|
|
|
|
var body: some View {
|
|
// Hide pill if no buttons configured
|
|
if settings.buttons.isEmpty {
|
|
EmptyView()
|
|
} else {
|
|
pillContent
|
|
}
|
|
}
|
|
|
|
// MARK: - Pill Content
|
|
|
|
/// Calculates the width for the pill based on content and max constraints
|
|
private var pillWidth: CGFloat? {
|
|
guard contentWidth > 0 else { return nil } // Wait for measurement
|
|
if let maxWidth {
|
|
return min(contentWidth, maxWidth)
|
|
}
|
|
return contentWidth // No max, use content width
|
|
}
|
|
|
|
/// True when all buttons fit in available width; scrolling should be disabled.
|
|
private var contentFitsInAvailableWidth: Bool {
|
|
guard let maxWidth else { return true }
|
|
guard contentWidth > 0 else { return false }
|
|
return contentWidth <= maxWidth
|
|
}
|
|
|
|
private var pillContent: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(settings.buttons) { config in
|
|
PlayerPillButtonView(
|
|
configuration: config,
|
|
isPlaying: isPlaying,
|
|
hasNext: hasNext,
|
|
queueCount: queueCount,
|
|
queueModeIcon: queueModeIcon,
|
|
isPlayPauseDisabled: isPlayPauseDisabled,
|
|
isWideLayout: isWideLayout,
|
|
isOrientationLocked: isOrientationLocked,
|
|
video: video,
|
|
playbackRate: playbackRate,
|
|
showingPlaylistSheet: $showingPlaylistSheet,
|
|
onAction: { handleAction(for: config) },
|
|
onRateChanged: onRateChanged,
|
|
onTogglePiP: onPiP
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
GeometryReader { geo in
|
|
Color.clear.preference(key: ContentWidthKey.self, value: geo.size.width)
|
|
}
|
|
)
|
|
}
|
|
.onPreferenceChange(ContentWidthKey.self) { contentWidth = $0 }
|
|
.scrollDisabled(contentFitsInAvailableWidth)
|
|
.frame(width: pillWidth)
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.fixedSize(horizontal: false, vertical: true) // Pill sizes to content height
|
|
.glassBackground(.regular, in: .capsule, fallback: .thinMaterial)
|
|
.clipShape(Capsule())
|
|
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
|
|
}
|
|
|
|
// MARK: - Action Routing
|
|
|
|
private func handleAction(for config: ControlButtonConfiguration) {
|
|
switch config.buttonType {
|
|
case .queue:
|
|
onQueueTap()
|
|
case .playPrevious:
|
|
onPrevious()
|
|
case .playPause:
|
|
onPlayPause()
|
|
case .playNext:
|
|
onNext()
|
|
case .seek:
|
|
let settings = config.seekSettings ?? SeekSettings()
|
|
// Pass signed seconds: positive for forward, negative for backward
|
|
let signedSeconds = settings.direction == .forward ? TimeInterval(settings.seconds) : -TimeInterval(settings.seconds)
|
|
Task {
|
|
await onSeek?(signedSeconds)
|
|
}
|
|
case .close:
|
|
onClose()
|
|
case .airplay:
|
|
onAirPlay()
|
|
case .orientationLock:
|
|
onOrientationLock()
|
|
case .fullscreen:
|
|
onFullscreen()
|
|
// These are handled directly by PlayerPillButtonView:
|
|
// .share, .playbackSpeed, .contextMenu, .addToPlaylist, .pictureInPicture
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview("Playing") {
|
|
ZStack {
|
|
Color.black.opacity(0.8)
|
|
PlayerPillView(
|
|
settings: .default,
|
|
isPlaying: true,
|
|
hasNext: true,
|
|
queueCount: 5
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Paused") {
|
|
ZStack {
|
|
Color.black.opacity(0.8)
|
|
PlayerPillView(
|
|
settings: .default,
|
|
isPlaying: false,
|
|
hasNext: true,
|
|
queueCount: 5
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Many Buttons") {
|
|
ZStack {
|
|
Color.black.opacity(0.8)
|
|
PlayerPillView(
|
|
settings: PlayerPillSettings(
|
|
visibility: .both,
|
|
buttons: [
|
|
ControlButtonConfiguration(buttonType: .queue),
|
|
ControlButtonConfiguration(buttonType: .playPrevious),
|
|
ControlButtonConfiguration(buttonType: .playPause),
|
|
ControlButtonConfiguration(buttonType: .playNext),
|
|
ControlButtonConfiguration(
|
|
buttonType: .seek,
|
|
settings: .seek(SeekSettings(seconds: 10, direction: .backward))
|
|
),
|
|
ControlButtonConfiguration(
|
|
buttonType: .seek,
|
|
settings: .seek(SeekSettings(seconds: 10, direction: .forward))
|
|
),
|
|
ControlButtonConfiguration(buttonType: .airplay),
|
|
ControlButtonConfiguration(buttonType: .pictureInPicture),
|
|
ControlButtonConfiguration(buttonType: .close)
|
|
]
|
|
),
|
|
maxWidth: 300,
|
|
isPlaying: true,
|
|
hasNext: true,
|
|
queueCount: 3
|
|
)
|
|
}
|
|
}
|