Files
yattee/Yattee/Views/Components/PlayerPillView.swift
Arkadiusz Fal 28a75792e8 Present shared sheets through one host per window
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.
2026-10-03 23:45:08 +02:00

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
)
}
}