Files
yattee/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift
Arkadiusz Fal c7942ef555 Rework tvOS player controls and settings sheet
Replace the tvOS bottom action bar with Settings / Info / Comments /
Next / Close. Settings reuses QualitySelectorView (video, audio,
subtitles, speed); Comments opens TVDetailsPanel directly on the
comments tab; Close stops playback and dismisses.

Debug button is hidden by default and can be re-enabled via a new
tvOS-only Advanced Settings > Developer toggle.

Present the settings sheet as a fullScreenCover with a centered
material card, fix the "Normal" hyphenation, and restyle row selection
throughout the quality selector on tvOS: per-row rounded backgrounds
with focus tint + stroke, vertical spacing instead of dividers, and a
focusable speed-rate menu.
2026-04-18 20:38:01 +02:00

362 lines
13 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 onShowDetails: () -> Void
let onShowComments: () -> Void
let onShowDebug: () -> Void
let onClose: () -> Void
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
var onScrubbingChanged: ((Bool) -> Void)?
/// 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 seekBackwardTrigger = 0
@State private var seekForwardTrigger = 0
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()
// Center transport controls - focus section for horizontal nav
transportControls
.focusSection()
// DEBUG: Uncomment to see focus section boundaries
// .border(.blue, width: 2)
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 ?? []
)
.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: .top) {
VStack(alignment: .leading, spacing: 8) {
// Video title
Text(playerState?.currentVideo?.title ?? "")
.font(.title2)
.fontWeight(.semibold)
.lineLimit(2)
.foregroundStyle(.white)
// Channel name
if let channelName = playerState?.currentVideo?.author.name {
Text(channelName)
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
}
}
Spacer()
// Loading indicator
if playerState?.playbackState == .loading || playerState?.playbackState == .buffering {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
}
}
}
// MARK: - Transport Controls
private var transportControls: some View {
HStack(spacing: 80) {
// Skip backward
Button {
seekBackwardTrigger += 1
playerService?.seekBackward(by: 10)
} label: {
Image(systemName: "10.arrow.trianglehead.counterclockwise")
.font(.system(size: 52, weight: .medium))
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekBackwardTrigger)
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .skipBackward)
.disabled(isTransportDisabled)
// Play/Pause - hide when transport disabled, show spacer to maintain layout
if !isTransportDisabled {
Button {
playerService?.togglePlayPause()
} label: {
Image(systemName: playPauseIcon)
.font(.system(size: 72, weight: .medium))
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .playPause)
} else {
// Invisible spacer maintains layout stability
Color.clear
.frame(width: 72, height: 72)
.allowsHitTesting(false)
}
// Skip forward
Button {
seekForwardTrigger += 1
playerService?.seekForward(by: 10)
} label: {
Image(systemName: "10.arrow.trianglehead.clockwise")
.font(.system(size: 52, weight: .medium))
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekForwardTrigger)
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .skipForward)
.disabled(isTransportDisabled)
}
}
/// Whether transport controls should be disabled (during loading/buffering or buffer not ready)
private var isTransportDisabled: Bool {
playerState?.playbackState == .loading ||
playerState?.playbackState == .buffering ||
!(playerState?.isFirstFrameReady ?? false) ||
!(playerState?.isBufferReady ?? false)
}
private var playPauseIcon: String {
switch playerState?.playbackState {
case .playing:
return "pause.fill"
default:
return "play.fill"
}
}
// MARK: - Action Buttons
private var actionButtons: some View {
HStack(spacing: 40) {
// Settings (video / audio / subtitles / speed)
Button {
onShowSettings()
} label: {
VStack(spacing: 6) {
Image(systemName: "gearshape")
.font(.system(size: 28))
Text("player.controls.settings")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .settingsButton)
// Info / Details
Button {
onShowDetails()
} label: {
VStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.system(size: 28))
Text("player.controls.info")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .infoButton)
// Comments (opens details panel on Comments tab)
if playerState?.currentVideo?.supportsComments == true {
Button {
onShowComments()
} label: {
VStack(spacing: 6) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 28))
Text("player.controls.comments")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .commentsButton)
}
// Debug overlay (only when enabled in Developer settings)
if showDebugButton {
Button {
onShowDebug()
} label: {
VStack(spacing: 6) {
Image(systemName: "ant.circle")
.font(.system(size: 28))
Text(String(localized: "player.debug.titleShort"))
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .debugButton)
}
// Play next button (when queue has items)
if let state = playerState, state.hasNext {
Button {
playNextTapCount += 1
Task { await playerService?.playNext() }
} label: {
VStack(spacing: 6) {
Image(systemName: "forward.fill")
.font(.system(size: 28))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount)
Text(String(localized: "player.next"))
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .playNext)
}
// Close (stops playback and dismisses)
Button {
onClose()
} label: {
VStack(spacing: 6) {
Image(systemName: "xmark.circle")
.font(.system(size: 28))
Text("player.controls.close")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .closeButton)
Spacer()
// Queue indicator (if videos in queue)
if let state = playerState, state.hasNext {
HStack(spacing: 8) {
Image(systemName: "list.bullet")
.font(.system(size: 20))
Text(String(localized: "queue.section.count \(state.queue.count)"))
.font(.subheadline)
}
.foregroundStyle(.white.opacity(0.6))
}
}
}
}
// MARK: - Button Styles
/// Button style for transport controls (play/pause, skip).
struct TVTransportButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.opacity(configuration.isPressed ? 0.6 : 1.0)
.scaleEffect(configuration.isPressed ? 0.9 : (isFocused ? 1.15 : 1.0))
.shadow(color: isFocused ? .white.opacity(0.5) : .clear, radius: 20)
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// Button style for action buttons (quality, captions, info).
struct TVActionButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(width: 140, height: 80)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1))
)
.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)
}
}
#endif