mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
Matches the iOS controls by showing the channel avatar next to the video title and channel name, reusing ChannelAvatarView and the Yattee Server fallback.
322 lines
12 KiB
Swift
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
|