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.
375 lines
11 KiB
Swift
375 lines
11 KiB
Swift
//
|
|
// PlayerPillButtonView.swift
|
|
// Yattee
|
|
//
|
|
// Renders individual buttons within the PlayerPillView.
|
|
// Specialized component with compact sizing and pill-specific behavior.
|
|
// Some buttons render as Menu or ShareLink instead of Button.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct PlayerPillButtonView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@Environment(\.presentAppSheet) private var presentAppSheet
|
|
|
|
let configuration: ControlButtonConfiguration
|
|
let isPlaying: Bool
|
|
let hasNext: Bool
|
|
let queueCount: Int
|
|
let queueModeIcon: String
|
|
let isPlayPauseDisabled: Bool
|
|
let isWideLayout: Bool
|
|
let isOrientationLocked: Bool
|
|
|
|
// Context for special buttons
|
|
let video: Video?
|
|
let playbackRate: PlaybackRate
|
|
|
|
// Callbacks
|
|
let onAction: () -> Void
|
|
let onRateChanged: (PlaybackRate) -> Void
|
|
let onTogglePiP: () -> Void
|
|
|
|
@State private var tapCount = 0
|
|
|
|
var body: some View {
|
|
// Some buttons need special SwiftUI components instead of Button
|
|
switch configuration.buttonType {
|
|
case .share:
|
|
shareButton
|
|
case .playbackSpeed:
|
|
playbackSpeedMenu
|
|
case .contextMenu:
|
|
contextMenuButton
|
|
case .addToPlaylist:
|
|
addToPlaylistButton
|
|
case .pictureInPicture:
|
|
pipButton
|
|
case .airplay:
|
|
airplayButton
|
|
default:
|
|
standardButton
|
|
}
|
|
}
|
|
|
|
// MARK: - Standard Button (for most button types)
|
|
|
|
private var standardButton: some View {
|
|
Button {
|
|
tapCount += 1
|
|
onAction()
|
|
} label: {
|
|
buttonContent
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isDisabled)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var buttonContent: some View {
|
|
switch configuration.buttonType {
|
|
case .queue:
|
|
queueButton
|
|
case .playPause:
|
|
playPauseButton
|
|
case .playPrevious:
|
|
transportButton(systemName: "backward.fill", size: 16)
|
|
case .playNext:
|
|
transportButton(systemName: "forward.fill", size: 16, dimmed: !hasNext)
|
|
case .seek:
|
|
seekButton(systemName: seekIcon)
|
|
case .close:
|
|
closeButton
|
|
case .fullscreen:
|
|
fullscreenButton
|
|
case .orientationLock:
|
|
orientationLockButton
|
|
default:
|
|
genericButton
|
|
}
|
|
}
|
|
|
|
// MARK: - Queue Button
|
|
|
|
private var queueButton: some View {
|
|
ZStack(alignment: .bottom) {
|
|
Image(systemName: queueModeIcon)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 32, height: 32)
|
|
|
|
// Badge showing queue count
|
|
if queueCount > 0 {
|
|
Text("\(queueCount)")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 1)
|
|
.background(Color.accentColor, in: Capsule())
|
|
.offset(x: 0, y: 4)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Play/Pause Button
|
|
|
|
private var playPauseButton: some View {
|
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 44, height: 36)
|
|
.contentShape(Rectangle())
|
|
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
|
|
}
|
|
|
|
// MARK: - Transport Buttons (Previous/Next)
|
|
|
|
private func transportButton(systemName: String, size: CGFloat, dimmed: Bool = false) -> some View {
|
|
Image(systemName: systemName)
|
|
.font(.system(size: size, weight: .medium))
|
|
.foregroundStyle(dimmed ? .tertiary : .primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: tapCount)
|
|
}
|
|
|
|
// MARK: - Seek Buttons
|
|
|
|
private func seekButton(systemName: String) -> some View {
|
|
Image(systemName: systemName)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: tapCount)
|
|
}
|
|
|
|
/// SF Symbol name for the seek button based on configured direction and seconds.
|
|
private var seekIcon: String {
|
|
let settings = configuration.seekSettings ?? SeekSettings()
|
|
return settings.systemImage
|
|
}
|
|
|
|
// MARK: - Close Button
|
|
|
|
private var closeButton: some View {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Fullscreen Button
|
|
|
|
private var fullscreenButton: some View {
|
|
// Wide layout (landscape) shows portrait rotate icon, and vice versa
|
|
let icon = isWideLayout ? "rectangle.portrait.rotate" : "rectangle.landscape.rotate"
|
|
return Image(systemName: icon)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Orientation Lock Button
|
|
|
|
private var orientationLockButton: some View {
|
|
let icon = isOrientationLocked ? "lock.rotation" : "lock.rotation.open"
|
|
return Image(systemName: icon)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(isOrientationLocked ? Color.accentColor : .primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Generic Button (fallback for other types)
|
|
|
|
private var genericButton: some View {
|
|
Image(systemName: configuration.buttonType.systemImage)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Special Buttons
|
|
|
|
#if !os(tvOS)
|
|
private var shareButton: some View {
|
|
Group {
|
|
if let video {
|
|
ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) {
|
|
genericButtonLabel
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
standardButton
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
private var shareButton: some View {
|
|
standardButton
|
|
}
|
|
#endif
|
|
|
|
private var playbackSpeedMenu: some View {
|
|
Menu {
|
|
ForEach(PlaybackRate.allCases) { rate in
|
|
Button {
|
|
onRateChanged(rate)
|
|
} label: {
|
|
HStack {
|
|
Text(rate.displayText)
|
|
if playbackRate == rate {
|
|
Spacer()
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
playbackSpeedLabel
|
|
}
|
|
.menuIndicator(.hidden)
|
|
.tint(.primary)
|
|
}
|
|
|
|
private var playbackSpeedLabel: some View {
|
|
Text(playbackRate.compactDisplayText)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
@ViewBuilder
|
|
private var contextMenuButton: some View {
|
|
if let video {
|
|
VideoContextMenuView(video: video, accentColor: .primary)
|
|
} else {
|
|
standardButton
|
|
}
|
|
}
|
|
#else
|
|
private var contextMenuButton: some View {
|
|
standardButton
|
|
}
|
|
#endif
|
|
|
|
private var addToPlaylistButton: some View {
|
|
Button {
|
|
if let video {
|
|
presentAppSheet(.addToPlaylist(video))
|
|
}
|
|
} label: {
|
|
genericButtonLabel
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var pipButton: some View {
|
|
Button {
|
|
onTogglePiP()
|
|
} label: {
|
|
genericButtonLabel
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var airplayButton: some View {
|
|
#if os(iOS)
|
|
AirPlayButton(tintColor: .label)
|
|
.frame(width: 36, height: 36)
|
|
#elseif os(macOS)
|
|
AirPlayButton()
|
|
.frame(width: 36, height: 36)
|
|
#else
|
|
standardButton
|
|
#endif
|
|
}
|
|
|
|
private var genericButtonLabel: some View {
|
|
Image(systemName: configuration.buttonType.systemImage)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Disabled State
|
|
|
|
private var isDisabled: Bool {
|
|
switch configuration.buttonType {
|
|
case .playPause:
|
|
return isPlayPauseDisabled
|
|
case .playNext:
|
|
return !hasNext
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview("Queue Button") {
|
|
PlayerPillButtonView(
|
|
configuration: ControlButtonConfiguration(buttonType: .queue),
|
|
isPlaying: false,
|
|
hasNext: true,
|
|
queueCount: 5,
|
|
queueModeIcon: "list.bullet",
|
|
isPlayPauseDisabled: false,
|
|
isWideLayout: false,
|
|
isOrientationLocked: false,
|
|
video: nil,
|
|
playbackRate: .x1,
|
|
onAction: {},
|
|
onRateChanged: { _ in },
|
|
onTogglePiP: {}
|
|
)
|
|
.padding()
|
|
.background(Color.black.opacity(0.8))
|
|
}
|
|
|
|
#Preview("Play/Pause") {
|
|
HStack {
|
|
PlayerPillButtonView(
|
|
configuration: ControlButtonConfiguration(buttonType: .playPause),
|
|
isPlaying: false,
|
|
hasNext: true,
|
|
queueCount: 0,
|
|
queueModeIcon: "list.bullet",
|
|
isPlayPauseDisabled: false,
|
|
isWideLayout: false,
|
|
isOrientationLocked: false,
|
|
video: nil,
|
|
playbackRate: .x1,
|
|
onAction: {},
|
|
onRateChanged: { _ in },
|
|
onTogglePiP: {}
|
|
)
|
|
PlayerPillButtonView(
|
|
configuration: ControlButtonConfiguration(buttonType: .playPause),
|
|
isPlaying: true,
|
|
hasNext: true,
|
|
queueCount: 0,
|
|
queueModeIcon: "list.bullet",
|
|
isPlayPauseDisabled: false,
|
|
isWideLayout: false,
|
|
isOrientationLocked: false,
|
|
video: nil,
|
|
playbackRate: .x1,
|
|
onAction: {},
|
|
onRateChanged: { _ in },
|
|
onTogglePiP: {}
|
|
)
|
|
}
|
|
.padding()
|
|
.background(Color.black.opacity(0.8))
|
|
}
|