mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
374 lines
11 KiB
Swift
374 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 {
|
|
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: 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))
|
|
}
|