mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
261
Yattee/Views/Components/PlayerPillView.swift
Normal file
261
Yattee/Views/Components/PlayerPillView.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user