Files
yattee/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift
Arkadiusz Fal b6c3f0e71b Keep tvOS player controls visible on pause via on-screen button
Route the on-screen play/pause button through handlePlayPause() so it
follows the same visibility and auto-hide timer logic as the Siri Remote
hardware button: timer stops when paused (controls stay pinned) and
restarts on resume.
2026-05-09 11:00:04 +02:00

413 lines
16 KiB
Swift

//
// TVPlayerControlsView.swift
// Yattee
//
// AVKit-style player controls overlay for tvOS with focus-based navigation.
//
#if os(tvOS)
import SwiftUI
/// AVKit-style player controls overlay for tvOS.
struct TVPlayerControlsView: View {
@Environment(\.appEnvironment) private var appEnvironment
let playerState: PlayerState?
let playerService: PlayerService?
@FocusState.Binding var focusedControl: TVPlayerFocusTarget?
let onShowSettings: () -> Void
let onShowQueue: () -> Void
let onShowDetails: () -> Void
let onShowComments: () -> Void
let onShowDebug: () -> Void
let onClose: () -> Void
/// Called when the on-screen play/pause button is tapped. Parent routes
/// through `handlePlayPause()` so visibility/auto-hide timer behave
/// consistently with the Siri Remote's hardware Play/Pause button.
let onTogglePlayPause: () -> Void
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
var onScrubbingChanged: ((Bool) -> Void)?
/// Pending target time for the bar's accumulating remote-seek flow (arrow
/// presses while focused but not in SELECT scrub mode).
var remoteSeekTime: TimeInterval? = nil
/// Called when user presses left/right on the focused bar outside SELECT scrub.
var onRemoteSeek: ((Bool) -> Void)? = nil
/// Bumped by the parent to cancel any in-progress scrub without seeking
/// (used when the Menu button is pressed while scrubbing).
var cancelScrubTrigger: UUID? = nil
/// Whether the Debug button should be visible (user-toggled in Developer settings).
private var showDebugButton: Bool {
appEnvironment?.settingsManager.showTVDebugButton ?? false
}
@State private var playNextTapCount = 0
@State private var playPreviousTapCount = 0
@State private var playPauseTapCount = 0
private var isPlaying: Bool {
playerState?.playbackState == .playing
}
private var isTransportDisabled: Bool {
playerState?.isTransportDisabled ?? true
}
private var playPauseIcon: String {
isPlaying ? "pause.fill" : "play.fill"
}
var body: some View {
ZStack {
// Gradient overlay for readability
gradientOverlay
VStack(spacing: 0) {
// Top bar with title and channel
topBar
.padding(.top, 60)
.padding(.horizontal, 88)
Spacer()
// Progress bar - its own focus section
TVPlayerProgressBar(
currentTime: playerState?.currentTime ?? 0,
duration: playerState?.duration ?? 0,
bufferedTime: playerState?.bufferedTime ?? 0,
storyboard: playerState?.preferredStoryboard,
chapters: playerState?.chapters ?? [],
onSeek: { time in
Task {
await playerService?.seek(to: time)
}
},
onScrubbingChanged: onScrubbingChanged,
isLive: playerState?.isLive ?? false,
sponsorSegments: playerState?.sponsorSegments ?? [],
remoteSeekTime: remoteSeekTime,
onRemoteSeek: onRemoteSeek,
cancelScrubTrigger: cancelScrubTrigger
)
.focusSection()
.padding(.horizontal, 88)
.padding(.bottom, 20)
// DEBUG: Uncomment to see focus section boundaries
// .border(.green, width: 2)
// Action buttons row - focus section for horizontal nav
actionButtons
.focusSection()
.padding(.horizontal, 88)
.padding(.bottom, 60)
// DEBUG: Uncomment to see focus section boundaries
// .border(.red, width: 2)
}
}
}
// MARK: - Gradient Overlay
private var gradientOverlay: some View {
VStack(spacing: 0) {
// Top gradient
LinearGradient(
colors: [.black.opacity(0.8), .black.opacity(0.4), .clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 200)
Spacer()
// Bottom gradient
LinearGradient(
colors: [.clear, .black.opacity(0.4), .black.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 300)
}
.ignoresSafeArea()
}
// MARK: - Top Bar
private var topBar: some View {
HStack(alignment: .center, spacing: 20) {
// Channel avatar
if let video = playerState?.currentVideo {
ChannelAvatarView(
author: video.author,
size: 80,
yatteeServerURL: yatteeServerURL,
source: video.id.source
)
}
VStack(alignment: .leading, spacing: 8) {
// Video title
Text(playerState?.currentVideo?.title ?? "")
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.foregroundStyle(.white)
// Channel name
if let channelName = playerState?.currentVideo?.author.name {
Text(channelName)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
}
Spacer()
// Loading indicator
if playerState?.playbackState == .loading || playerState?.playbackState == .buffering {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
.padding(.trailing, 8)
}
// Close button stops playback and dismisses.
// Menu button only hides the player (keeps background playback),
// so an explicit Close is kept here, icon-only in the top bar.
// When `tvOSMenuButtonClosesVideo` is enabled, the Menu button
// takes over this role and the explicit button is hidden.
if appEnvironment?.settingsManager.tvOSMenuButtonClosesVideo != true {
Button {
onClose()
} label: {
Image(systemName: "xmark")
.font(.system(size: 26, weight: .semibold))
}
.buttonStyle(TVCloseButtonStyle())
.focused($focusedControl, equals: .closeButton)
.accessibilityLabel(Text("player.controls.close"))
}
}
}
private var yatteeServerURL: URL? {
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
}
// MARK: - Action Buttons
private var actionButtons: some View {
HStack(spacing: 24) {
// MARK: Left cluster info / meta actions
HStack(spacing: 24) {
Button {
onShowSettings()
} label: {
TVActionButtonLabel(
systemImage: "gearshape",
title: String(localized: "player.controls.settings")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .settingsButton)
Button {
onShowDetails()
} label: {
TVActionButtonLabel(
systemImage: "info.circle",
title: String(localized: "player.controls.info")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .infoButton)
if playerState?.currentVideo?.supportsComments == true {
Button {
onShowComments()
} label: {
TVActionButtonLabel(
systemImage: "bubble.left.and.bubble.right",
title: String(localized: "player.controls.comments")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .commentsButton)
}
if showDebugButton {
Button {
onShowDebug()
} label: {
TVActionButtonLabel(
systemImage: "ant.circle",
title: String(localized: "player.debug.titleShort")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .debugButton)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
// MARK: Center cluster transport (circular, icon-only)
HStack(spacing: 20) {
// Previous is always rendered so Play/Next stay in a fixed
// position; disabled + dimmed when unavailable.
let hasPrevious = playerState?.hasPrevious == true
Button {
playPreviousTapCount += 1
Task { await playerService?.playPrevious() }
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 26, weight: .semibold))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount)
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .playPrevious)
.disabled(!hasPrevious)
.opacity(hasPrevious ? 1.0 : 0.3)
.accessibilityLabel(Text("player.previous"))
Button {
playPauseTapCount += 1
onTogglePlayPause()
} label: {
Image(systemName: playPauseIcon)
.font(.system(size: 32, weight: .semibold))
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPauseTapCount)
}
.buttonStyle(TVTransportButtonStyle(isPrimary: true))
.focused($focusedControl, equals: .playPauseButton)
.disabled(isTransportDisabled)
.opacity(isTransportDisabled ? 0.3 : 1.0)
.accessibilityLabel(Text(isPlaying ? "player.controls.pause" : "player.controls.play"))
let hasNext = playerState?.hasNext == true
Button {
playNextTapCount += 1
Task { await playerService?.playNext() }
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 26, weight: .semibold))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount)
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .playNext)
.disabled(!hasNext)
.opacity(hasNext ? 1.0 : 0.3)
.accessibilityLabel(Text("player.next"))
}
// MARK: Right cluster queue
HStack(spacing: 24) {
Spacer(minLength: 0)
if let state = playerState, state.hasNext {
Button {
onShowQueue()
} label: {
TVActionButtonLabel(
systemImage: "list.bullet",
title: String(localized: "queue.section.count \(state.queue.count)")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .queueButton)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
// MARK: - Button Label
/// Shared label for action buttons: icon always visible, title only on focus.
private struct TVActionButtonLabel: View {
let systemImage: String
let title: String
var symbolEffectTrigger: Int = 0
var body: some View {
VStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 28))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: symbolEffectTrigger)
Text(title)
.font(.caption)
}
}
}
// MARK: - Button Styles
/// Button style for action buttons (settings, info, transport, queue).
/// Width is adaptive so localized labels fit when revealed on focus.
struct TVActionButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isFocused ? Color.black : .white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.horizontal, 20)
.frame(minWidth: 100, minHeight: 80)
.glassBackground(
isFocused ? .tinted(.white.opacity(0.35)) : .regular,
in: .rect(cornerRadius: 12),
fallback: isFocused ? .ultraThickMaterial : .ultraThinMaterial,
colorScheme: .light
)
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.05 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// Circular icon-only button style for transport controls (previous / play-pause / next).
/// Primary variant is larger and uses a filled white background when focused.
struct TVTransportButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
var isPrimary: Bool = false
func makeBody(configuration: Configuration) -> some View {
let size: CGFloat = isPrimary ? 88 : 72
let style: GlassStyle = isFocused ? .tinted(.white.opacity(0.35)) : .regular
let fallback: GlassFallbackMaterial = isFocused ? .ultraThickMaterial : .ultraThinMaterial
return configuration.label
.frame(width: size, height: size)
.glassBackground(style, in: .circle, fallback: fallback, colorScheme: .light)
.foregroundStyle(isFocused ? Color.black : .white)
.scaleEffect(configuration.isPressed ? 0.92 : (isFocused ? 1.08 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// Compact circular button style for the top-right close affordance.
struct TVCloseButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isFocused ? Color.black : .white)
.frame(width: 64, height: 64)
.glassBackground(
isFocused ? .tinted(.white.opacity(0.35)) : .regular,
in: .circle,
fallback: isFocused ? .ultraThickMaterial : .ultraThinMaterial,
colorScheme: .light
)
.scaleEffect(configuration.isPressed ? 0.92 : (isFocused ? 1.08 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif