Files
yattee/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift
Arkadiusz Fal c3de87a12e Add channel avatar to tvOS player controls
Matches the iOS controls by showing the channel avatar next to the
video title and channel name, reusing ChannelAvatarView and the
Yattee Server fallback.
2026-04-18 20:38:01 +02:00

322 lines
12 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 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
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: 110,
yatteeServerURL: yatteeServerURL,
source: video.id.source
)
}
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)
}
}
}
private var yatteeServerURL: URL? {
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
}
// 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 previous button (shown whenever a queue is present; disabled when no history)
if let state = playerState, state.hasNext || state.hasPrevious {
Button {
playPreviousTapCount += 1
Task { await playerService?.playPrevious() }
} label: {
VStack(spacing: 6) {
Image(systemName: "backward.fill")
.font(.system(size: 28))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount)
Text(String(localized: "player.previous"))
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .playPrevious)
.disabled(!state.hasPrevious)
.opacity(state.hasPrevious ? 1.0 : 0.4)
}
// 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)
}
Spacer()
// Queue button (if videos in queue)
if let state = playerState, state.hasNext {
Button {
onShowQueue()
} label: {
VStack(spacing: 6) {
Image(systemName: "list.bullet")
.font(.system(size: 28))
Text(String(localized: "queue.section.count \(state.queue.count)"))
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .queueButton)
}
// 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)
}
}
}
// MARK: - Button Styles
/// 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