Files
yattee/Yattee/Views/Components/PlayerPillButtonView.swift
Arkadiusz Fal d22abf6088 Add an option to share videos via privacy frontend URLs
When enabled (the default), share links to YouTube videos point to the
active source's web watch page instead of youtube.com. Invidious serves
its frontend at the API URL; Piped uses its configured frontend URL.
Videos the active source can't serve (PeerTube, extracted sources) and
sources without a web frontend keep their original links.

The toggle lives in Settings > Layout & Navigation > Sharing on iOS and
macOS, and is shown only when an enabled source has a web watch page.
2026-09-18 23:57:34 +02:00

376 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
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
@Binding var showingPlaylistSheet: Bool
// 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 {
showingPlaylistSheet = true
} 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,
showingPlaylistSheet: .constant(false),
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,
showingPlaylistSheet: .constant(false),
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,
showingPlaylistSheet: .constant(false),
onAction: {},
onRateChanged: { _ in },
onTogglePiP: {}
)
}
.padding()
.background(Color.black.opacity(0.8))
}