mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
The add-to-playlist, download options and queue sheets were declared in every view that could open them: the root view (twice, mirrored from coordinator flags), the expanded player, its portrait and floating panels, the player controls and the video info view. AppSheet lists the shared sheets, and .appSheetHost() declares them once. The app root and the expanded player each host them, and every presented sheet hosts the next level. Views present through the presentAppSheet environment action, which reaches the nearest host; code without a view (and the expanded player itself, whose host sits in its own body) uses PresentationRouter.request, taken by the top-most host: the player while it is up, the root otherwise. The mini player's queue and playlist sheets now go through the host, replacing the isMiniPlayer*SheetPresented flags.
258 lines
8.4 KiB
Swift
258 lines
8.4 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
|
|
|
|
// 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,
|
|
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.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,
|
|
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
|
|
)
|
|
}
|
|
}
|