Files
yattee/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift
Arkadiusz Fal 24a728e692 Cancel tvOS scrub with Menu button instead of seeking
Pressing Menu while scrubbing now discards the pending scrub and leaves
playback time unchanged, instead of committing the seek via the
focus-loss path.
2026-04-18 20:38:01 +02:00

281 lines
9.8 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)?
/// 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
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: .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: - 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 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