Files
yattee/Yattee/Views/Player/tvOS/TVQueueListView.swift
Arkadiusz Fal 052cb76702 Add preset-driven details panel pages on tvOS
TVDetailsPanel now renders the active tvOS preset's panel pages (info,
comments, related, chapters, queue; tracks stays with the quality panel)
with runtime hiding of empty pages, a segmented picker for up to four
pages and a focusable capsule row above that, and Info element toggles
honored. Comments unify on the PlayerState loader; the tvOS queue list
is extracted from QueueManagementSheet into TVQueueListView and reused
as the queue page. New related/chapters focus-based pages: selecting a
related video plays it and dismisses the panel, chapters seek and keep
the panel open.

New minimal Settings > Player Panels screen edits the active tvOS
preset via the existing service: duplicate-to-edit, page toggles with
last-page guard, Move Up/Down reordering, and Info element toggles.
2026-09-21 23:54:14 +02:00

181 lines
7.1 KiB
Swift

//
// TVQueueListView.swift
// Yattee
//
// Focus-based queue list (history / now playing / up next) shared by the
// half-screen queue panel and the details panel queue page.
//
#if os(tvOS)
import SwiftUI
struct TVQueueListView: View {
/// Horizontal content padding. The standalone half-screen panel uses 80;
/// the details panel page passes its own smaller inset.
var horizontalPadding: CGFloat = 80
/// Whether the list should grab focus shortly after appearing. Needed for
/// the inline half-screen panel, which tvOS doesn't auto-focus the way it
/// does a `fullScreenCover`; the details panel page keeps focus on its
/// page switcher instead.
var autoFocusOnAppear = false
/// Called after a row selection triggers playback.
var onDidSelectVideo: (() -> Void)?
@Environment(\.appEnvironment) private var appEnvironment
/// Identifies which row should receive focus when the list appears.
enum QueueListFocus: Hashable {
case history(String)
case nowPlaying
case upNext(String)
}
@FocusState private var listFocus: QueueListFocus?
private var initialFocusTarget: QueueListFocus? {
if let first = history.first { return .history(first.id) }
if playerState?.currentVideo != nil { return .nowPlaying }
if let first = queue.first { return .upNext(first.id) }
return nil
}
private var queueManager: QueueManager? { appEnvironment?.queueManager }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
private var queue: [QueuedVideo] { playerState?.queue ?? [] }
private var history: [QueuedVideo] { playerState?.history ?? [] }
private var isLoadingMore: Bool { queueManager?.isLoadingMore ?? false }
private var hasMoreItems: Bool { queueManager?.hasMoreItems() ?? false }
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if !history.isEmpty {
sectionHeader(
String(localized: "queue.section.previously"),
count: history.count
)
VStack(spacing: 20) {
ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in
QueueItemRow(
queuedVideo: historyItem,
index: nil,
isCurrentlyPlaying: false,
onRemove: { },
onTap: { playFromHistory(at: index) }
)
.focused($listFocus, equals: .history(historyItem.id))
}
}
}
if let currentVideo = playerState?.currentVideo {
sectionHeader(String(localized: "queue.section.nowPlaying"))
.id("now-playing")
let nowPlayingItem = QueuedVideo(
video: currentVideo,
stream: playerState?.currentStream,
queueSource: nil
)
QueueItemRow(
queuedVideo: nowPlayingItem,
index: nil,
isCurrentlyPlaying: true,
onRemove: { },
onTap: { }
)
.focused($listFocus, equals: .nowPlaying)
}
if !queue.isEmpty {
sectionHeader(
String(localized: "queue.section.upNext"),
count: queue.count
)
VStack(spacing: 20) {
ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in
QueueItemRow(
queuedVideo: queuedVideo,
index: index + 1,
isCurrentlyPlaying: false,
onRemove: {
withAnimation {
queueManager?.removeFromQueue(id: queuedVideo.id)
}
},
onTap: { playVideo(at: index) }
)
.focused($listFocus, equals: .upNext(queuedVideo.id))
}
if hasMoreItems {
HStack {
Spacer()
if isLoadingMore {
ProgressView().controlSize(.small)
} else {
Button {
Task { try? await queueManager?.loadMoreQueueItems() }
} label: {
Text(String(localized: "queue.sheet.loadMore"))
.font(.subheadline)
.foregroundStyle(.tint)
}
}
Spacer()
}
.padding(.top, 12)
}
}
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 24)
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
proxy.scrollTo("now-playing", anchor: .center)
}
if autoFocusOnAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
listFocus = initialFocusTarget
}
}
}
}
}
@ViewBuilder
private func sectionHeader(_ title: String, count: Int? = nil) -> some View {
HStack {
Text(title.uppercased())
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let count {
Text(String(localized: "queue.section.count \(count)"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Actions
private func playVideo(at index: Int) {
QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment)
onDidSelectVideo?()
}
private func playFromHistory(at index: Int) {
QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment)
onDidSelectVideo?()
}
}
#endif