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.
This commit is contained in:
Arkadiusz Fal
2026-09-21 23:54:14 +02:00
parent a6d989c7af
commit 052cb76702
8 changed files with 864 additions and 238 deletions

View File

@@ -7577,6 +7577,17 @@
}
}
},
"panel.settings.duplicatePreset" : {
"comment" : "tvOS button duplicating the active preset so it can be edited",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Duplicate Preset to Edit"
}
}
}
},
"panel.settings.elements.header" : {
"comment" : "Details panel page editor section header",
"localizations" : {
@@ -7643,6 +7654,28 @@
}
}
},
"panel.settings.moveDown" : {
"comment" : "tvOS page editor button moving the page later in the order",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Move Down"
}
}
}
},
"panel.settings.moveUp" : {
"comment" : "tvOS page editor button moving the page earlier in the order",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Move Up"
}
}
}
},
"panel.settings.page.enabled" : {
"comment" : "Details panel page editor toggle",
"localizations" : {
@@ -15126,6 +15159,17 @@
}
}
},
"settings.playerPanels.title" : {
"comment" : "tvOS settings row for the player details panel pages",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Player Panels"
}
}
}
},
"settings.privacy.history.account.footer %@" : {
"comment" : "Footer telling the user watched videos are mirrored to the selected account's server history",
"localizations" : {

View File

@@ -16,26 +16,6 @@ struct QueueManagementSheet: View {
/// nil, falls back to `@Environment(\.dismiss)`.
var onDismiss: (() -> Void)?
#if os(tvOS)
/// Identifies which row should receive focus when the inline panel appears.
/// Necessary because tvOS doesn't auto-focus an inline overlay the way it
/// does for `fullScreenCover`.
enum InlinePanelFocus: Hashable {
case history(String)
case nowPlaying
case upNext(String)
}
@FocusState var inlinePanelFocus: InlinePanelFocus?
private var inlinePanelInitialFocusTarget: InlinePanelFocus? {
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
}
#endif
private func performDismiss() {
if let onDismiss {
onDismiss()
@@ -98,11 +78,6 @@ struct QueueManagementSheet: View {
.ignoresSafeArea()
)
.onExitCommand { performDismiss() }
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
inlinePanelFocus = inlinePanelInitialFocusTarget
}
}
}
#endif
@@ -199,113 +174,13 @@ struct QueueManagementSheet: View {
#if os(tvOS)
private var tvOSQueueListView: 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($inlinePanelFocus, 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($inlinePanelFocus, 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($inlinePanelFocus, 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, 80)
.padding(.vertical, 24)
TVQueueListView(
autoFocusOnAppear: true,
onDidSelectVideo: {
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
proxy.scrollTo("now-playing", anchor: .center)
}
}
}
}
@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)
}
}
)
}
#endif
@@ -357,21 +232,6 @@ struct QueueManagementSheet: View {
playerState.queueMode = modes[nextIndex]
}
// MARK: - Actions
#if os(tvOS)
private func playVideo(at index: Int) {
QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment)
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
}
private func playFromHistory(at index: Int) {
QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment)
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
}
#endif
}
#Preview {

View File

@@ -10,21 +10,26 @@ import SwiftUI
import NukeUI
/// Details panel that slides up from the bottom showing video information.
/// Pages come from the active tvOS preset's panel settings; the tracks page
/// is not offered on tvOS (the quality panel covers it).
struct TVDetailsPanel: View {
let video: Video?
let initialTab: TVDetailsTab
let initialPage: PanelPageType
let onDismiss: () -> Void
@Environment(\.appEnvironment) private var appEnvironment
/// Tab selection for Info / Comments.
@State private var selectedTab: TVDetailsTab
/// Currently selected page.
@State private var selectedPage: PanelPageType
init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) {
/// Active preset's panel settings; refreshed from the layout service.
@State private var panelSettings: PanelSettings = PanelSettings.cached
init(video: Video?, initialPage: PanelPageType = .info, onDismiss: @escaping () -> Void) {
self.video = video
self.initialTab = initialTab
self.initialPage = initialPage
self.onDismiss = onDismiss
_selectedTab = State(initialValue: initialTab)
_selectedPage = State(initialValue: initialPage)
}
/// Focus state for interactive elements.
@@ -33,36 +38,68 @@ struct TVDetailsPanel: View {
/// Whether description scroll is locked (prevents focus from leaving description).
@State private var isDescriptionScrollLocked = false
/// Comments state
@State private var comments: [Comment] = []
@State private var commentsState: CommentsLoadState = .idle
@State private var commentsContinuation: String?
private var playerState: PlayerState? { appEnvironment?.playerService.state }
// MARK: - Page Visibility
/// Config enables a page; runtime hides pages with nothing to show.
private func isRuntimeVisible(_ pageType: PanelPageType) -> Bool {
switch pageType {
case .info:
return true
case .comments:
return video?.supportsComments == true
&& playerState?.commentsState != .disabled
case .related:
return !(video?.relatedVideos ?? []).isEmpty
case .chapters:
return !(playerState?.chapters ?? []).isEmpty
case .queue:
return appEnvironment?.settingsManager.queueEnabled ?? true
case .tracks:
// The tvOS quality panel covers tracks; never offered here.
return false
}
}
/// Enabled pages that currently have content, in configured order.
/// Falls back to Info so the panel is never empty.
private var visiblePages: [PanelPageType] {
let pages = panelSettings.enabledPages()
.map(\.pageType)
.filter { isRuntimeVisible($0) }
return pages.isEmpty ? [.info] : pages
}
/// The page to render: the selection when visible, else the first page.
private var effectivePage: PanelPageType {
let pages = visiblePages
return pages.contains(selectedPage) ? selectedPage : (pages.first ?? .info)
}
var body: some View {
let pages = visiblePages
VStack(spacing: 0) {
// Tab picker (hidden when description scroll is locked)
if !isDescriptionScrollLocked {
Picker("", selection: $selectedTab) {
Text("player.tab.info").tag(TVDetailsTab.info)
if video?.supportsComments == true {
Text("player.tab.comments").tag(TVDetailsTab.comments)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 40)
.padding(.top, 40)
.padding(.bottom, 20)
.focused($focusedItem, equals: .tabPicker)
.transition(.opacity.combined(with: .move(edge: .top)))
// Page switcher (hidden when description scroll is locked)
if pages.count >= 2, !isDescriptionScrollLocked {
pageSwitcher(pages: pages)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Content based on selected tab
// Content for the selected page
Group {
switch selectedTab {
case .info:
switch effectivePage {
case .info, .tracks:
infoContent
case .comments:
commentsContent
case .related:
TVRelatedPanelList(video: video, onDidSelectVideo: onDismiss)
case .chapters:
TVChaptersPanelList()
case .queue:
TVQueueListView(horizontalPadding: 0)
}
}
.padding(.horizontal, 40)
@@ -87,28 +124,88 @@ struct TVDetailsPanel: View {
.onAppear {
focusedItem = .tabPicker
}
.onChange(of: selectedTab) { _, newTab in
// Reset scroll lock when switching tabs
.task {
await reloadPanelSettings()
}
.onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
Task { await reloadPanelSettings() }
}
.onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
Task { await reloadPanelSettings() }
}
.onChange(of: selectedPage) { _, _ in
// Reset scroll lock when switching pages
if isDescriptionScrollLocked {
isDescriptionScrollLocked = false
}
// Reset comments state when switching to comments tab
if newTab == .comments && commentsState == .idle {
// Comments will load via TVCommentsListView's .task
}
}
.onChange(of: video?.supportsComments) { _, supportsComments in
// If current video doesn't support comments, switch to info tab
if supportsComments == false && selectedTab == .comments {
selectedTab = .info
.onChange(of: video?.id) { _, _ in
// New video: fall back to the first configured page.
if let first = visiblePages.first {
selectedPage = first
}
}
}
// MARK: - Page Switcher
@ViewBuilder
private func pageSwitcher(pages: [PanelPageType]) -> some View {
// Segmented control fits up to 4 pages in the half-screen panel;
// above that a focusable button row keeps labels legible.
if pages.count <= 4 {
Picker("", selection: $selectedPage) {
ForEach(pages, id: \.self) { page in
Text(page.displayName).tag(page)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 40)
.padding(.top, 40)
.padding(.bottom, 20)
.focused($focusedItem, equals: .tabPicker)
} else {
HStack(spacing: 12) {
ForEach(pages, id: \.self) { page in
Button {
selectedPage = page
} label: {
Label(page.displayName, systemImage: page.systemImage)
.font(.callout)
.labelStyle(.titleOnly)
}
.buttonStyle(TVPageSwitcherButtonStyle(isSelected: effectivePage == page))
.focused($focusedItem, equals: .pageButton(page))
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 40)
.padding(.top, 40)
.padding(.bottom, 20)
.focusSection()
}
}
// MARK: - Panel Settings
private func reloadPanelSettings() async {
guard let service = appEnvironment?.playerControlsLayoutService else { return }
let layout = await service.activeLayout()
let settings = layout.effectivePanelSettings
panelSettings = settings
PanelSettings.cached = settings
}
// MARK: - Info Content
private var infoSettings: InfoPageSettings {
panelSettings.configuration(for: .info)?.infoSettings ?? InfoPageSettings()
}
private var infoContent: some View {
VStack(spacing: 0) {
let settings = infoSettings
return VStack(spacing: 0) {
// Top section with title, channel, stats (hidden when description expanded)
if !isDescriptionScrollLocked {
VStack(alignment: .leading, spacing: 24) {
@@ -120,10 +217,14 @@ struct TVDetailsPanel: View {
.foregroundStyle(.white)
// Channel info row
channelRow
if settings.effectiveShowChannelRow {
channelRow
}
// Stats row
statsRow
if settings.effectiveShowStatsRow {
statsRow
}
}
.padding(.vertical, 16)
.fixedSize(horizontal: false, vertical: true)
@@ -131,7 +232,8 @@ struct TVDetailsPanel: View {
}
// Description - expands to fill when locked
if let description = video?.description, !description.isEmpty {
if settings.effectiveShowDescription,
let description = video?.description, !description.isEmpty {
TVScrollableDescription(
description: description,
isScrollLocked: $isDescriptionScrollLocked,
@@ -265,13 +367,8 @@ struct TVDetailsPanel: View {
private var commentsContent: some View {
ScrollView {
if let videoID = video?.id.videoID {
TVCommentsListView(
videoID: videoID,
comments: $comments,
commentsState: $commentsState,
commentsContinuation: $commentsContinuation
)
.padding(.vertical, 16)
TVCommentsListView(videoID: videoID)
.padding(.vertical, 16)
} else {
Text(String(localized: "comments.unavailable"))
.font(.body)
@@ -292,19 +389,37 @@ struct TVDetailsPanel: View {
// MARK: - Supporting Types
/// Tabs for the details panel.
enum TVDetailsTab: String, CaseIterable {
case info
case comments
}
/// Focus items for the details panel.
enum TVDetailsFocusItem: Hashable {
case tabPicker
case pageButton(PanelPageType)
case channelButton
case description
}
/// Button style for the page switcher row used when more than four pages are
/// enabled. Solid focus fill (materials are invisible over video on Apple TV
/// HD); the selected page keeps a subtle persistent fill.
struct TVPageSwitcherButtonStyle: ButtonStyle {
let isSelected: Bool
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isFocused ? Color.black : (isSelected ? .white : .white.opacity(0.6)))
.lineLimit(1)
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(
Capsule()
.fill(isFocused ? Color.white.opacity(0.92) : (isSelected ? Color.white.opacity(0.2) : .clear))
)
.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)
}
}
/// Scrollable description view with click-to-lock scrolling.
/// When locked, expands to fill available space for easier reading.
struct TVScrollableDescription: View {
@@ -430,19 +545,22 @@ struct TVDescriptionButtonStyle: ButtonStyle {
/// tvOS comments list with focusable comment rows.
/// Each comment is focusable to allow one-by-one navigation.
/// Backed by the unified `PlayerState` comments loader shared with iOS/macOS,
/// so a page opened here reuses (and feeds) the same fetched comments.
struct TVCommentsListView: View {
let videoID: String
@Binding var comments: [Comment]
@Binding var commentsState: CommentsLoadState
@Binding var commentsContinuation: String?
@Environment(\.appEnvironment) private var appEnvironment
private var contentService: ContentService? { appEnvironment?.contentService }
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
private var comments: [Comment] { playerState?.comments ?? [] }
private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle }
private var canLoadMore: Bool {
commentsContinuation != nil && commentsState == .loaded
playerState?.commentsContinuation != nil && commentsState == .loaded
}
var body: some View {
@@ -544,39 +662,15 @@ struct TVCommentsListView: View {
}
private func loadComments() async {
guard commentsState == .idle else { return }
guard let contentService, let instancesManager else { return }
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
commentsState = .loading
do {
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil)
comments = page.comments
commentsContinuation = page.continuation
commentsState = .loaded
} catch let error as APIError where error == .commentsDisabled {
commentsState = .disabled
} catch {
commentsState = .error
}
guard commentsState == .idle || commentsState == .error else { return }
guard let playerState, let contentService, let instancesManager else { return }
await playerState.loadComments(contentService: contentService, instancesManager: instancesManager)
}
private func loadMoreComments() async {
guard canLoadMore else { return }
guard let contentService, let instancesManager else { return }
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
commentsState = .loadingMore
do {
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: commentsContinuation)
comments.append(contentsOf: page.comments)
commentsContinuation = page.continuation
commentsState = .loaded
} catch {
commentsState = .loaded
}
guard let playerState, let contentService, let instancesManager else { return }
await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager)
}
}

View File

@@ -0,0 +1,170 @@
//
// TVDetailsPanelPages.swift
// Yattee
//
// Related videos and chapters pages for the tvOS player details panel.
//
#if os(tvOS)
import SwiftUI
// MARK: - Related Videos
/// Focus-based list of videos related to the current one. Selecting a video
/// plays it immediately and dismisses the panel.
struct TVRelatedPanelList: View {
let video: Video?
let onDidSelectVideo: () -> Void
@Environment(\.appEnvironment) private var appEnvironment
private var relatedVideos: [Video] { video?.relatedVideos ?? [] }
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(relatedVideos) { relatedVideo in
TVRelatedVideoRow(video: relatedVideo) {
playVideo(relatedVideo)
}
}
}
.padding(.vertical, 24)
}
}
private func playVideo(_ video: Video) {
guard let playerService = appEnvironment?.playerService else { return }
Task {
await playerService.play(video: video)
}
onDidSelectVideo()
}
}
/// Focusable row for a related video: thumbnail, title, channel.
private struct TVRelatedVideoRow: View {
let video: Video
let onTap: () -> Void
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
Button(action: onTap) {
HStack(spacing: 16) {
DeArrowVideoThumbnail(
video: video,
cornerRadius: 6,
duration: video.formattedDuration
)
.frame(width: 160, height: 90)
VStack(alignment: .leading, spacing: 4) {
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
Text(video.author.name)
.font(.caption)
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
}
}
// MARK: - Chapters
/// Focus-based chapter list; selecting a chapter seeks and keeps the panel
/// open so nearby chapters stay reachable.
struct TVChaptersPanelList: View {
@Environment(\.appEnvironment) private var appEnvironment
private var playerService: PlayerService? { appEnvironment?.playerService }
private var playerState: PlayerState? { playerService?.state }
private var chapters: [VideoChapter] { playerState?.chapters ?? [] }
private var currentTime: TimeInterval { playerState?.currentTime ?? 0 }
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(chapters) { chapter in
TVChapterRow(
chapter: chapter,
isActive: isChapterActive(chapter),
onTap: {
Task {
await playerService?.seek(to: chapter.startTime)
}
}
)
.id(chapter.id)
}
}
.padding(.vertical, 24)
}
.onAppear {
if let active = chapters.first(where: { isChapterActive($0) }) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
proxy.scrollTo(active.id, anchor: .center)
}
}
}
}
}
private func isChapterActive(_ chapter: VideoChapter) -> Bool {
let nextChapter = chapters.first { $0.startTime > chapter.startTime }
let endTime = nextChapter?.startTime ?? .infinity
return currentTime >= chapter.startTime && currentTime < endTime
}
}
/// Focusable row for a chapter: title, start time, and an active indicator.
private struct TVChapterRow: View {
let chapter: VideoChapter
let isActive: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 16) {
Text(chapter.formattedStartTime)
.font(.subheadline)
.monospacedDigit()
.foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.white.opacity(0.6)))
.frame(width: 110, alignment: .leading)
Text(chapter.title)
.font(.subheadline)
.fontWeight(isActive ? .semibold : .regular)
.lineLimit(2)
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
Spacer()
if isActive {
Image(systemName: "play.fill")
.font(.caption)
.foregroundStyle(.tint)
}
}
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
}
}
#endif

View File

@@ -45,8 +45,8 @@ struct TVPlayerView: View {
/// Whether the details panel is shown.
@State private var isDetailsPanelVisible = false
/// Initial tab for the details panel when opened.
@State private var detailsPanelInitialTab: TVDetailsTab = .info
/// Initial page for the details panel when opened.
@State private var detailsPanelInitialPage: PanelPageType = .info
/// Whether user is scrubbing the progress bar.
@State private var isScrubbing = false
@@ -279,7 +279,7 @@ struct TVPlayerView: View {
Spacer(minLength: 0)
TVDetailsPanel(
video: playerState?.currentVideo,
initialTab: detailsPanelInitialTab,
initialPage: detailsPanelInitialPage,
onDismiss: { hideDetailsPanel() }
)
.frame(width: geo.size.width / 2)
@@ -730,9 +730,9 @@ struct TVPlayerView: View {
// MARK: - Details Panel
private func showDetailsPanel(tab: TVDetailsTab = .info) {
private func showDetailsPanel(tab: PanelPageType = .info) {
stopControlsTimer()
detailsPanelInitialTab = tab
detailsPanelInitialPage = tab
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
isDetailsPanelVisible = true
controlsVisible = false

View File

@@ -0,0 +1,180 @@
//
// 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

View File

@@ -0,0 +1,274 @@
//
// TVPlayerPanelsSettingsView.swift
// Yattee
//
// Minimal tvOS settings for the player details panel pages. Edits the
// active tvOS preset via the shared layout service; CloudKit preset sync is
// device-class filtered, so tvOS panels cannot be configured from iOS/macOS.
//
#if os(tvOS)
import SwiftUI
struct TVPlayerPanelsSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var viewModel: PlayerControlsSettingsViewModel?
var body: some View {
Group {
if let viewModel {
TVPlayerPanelsContent(viewModel: viewModel)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task {
if viewModel == nil, let appEnvironment {
let vm = PlayerControlsSettingsViewModel(
layoutService: appEnvironment.playerControlsLayoutService,
settingsManager: appEnvironment.settingsManager
)
viewModel = vm
await vm.loadPresets()
}
}
}
}
// MARK: - Content
private struct TVPlayerPanelsContent: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
/// Pages offered on tvOS; tracks is covered by the quality panel.
private var tvOSPages: [PanelPageConfiguration] {
viewModel.panelPages.filter { $0.pageType != .tracks }
}
var body: some View {
List {
presetSection
pagesSection
}
}
// MARK: - Preset Section
private var presetSection: some View {
Section {
LabeledContent(
String(localized: "settings.playerControls.activePreset"),
value: viewModel.activePreset?.name ?? ""
)
if !viewModel.canEditActivePreset {
Button {
Task { await duplicateActivePreset() }
} label: {
Label(
String(localized: "panel.settings.duplicatePreset"),
systemImage: "doc.on.doc"
)
}
}
} footer: {
if !viewModel.canEditActivePreset {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
}
private func duplicateActivePreset() async {
guard let activePreset = viewModel.activePreset else { return }
// Pick a unique name for the copy.
let existingNames = Set(viewModel.presets.map(\.name))
let base = "\(activePreset.name) Copy"
var name = base
var counter = 2
while existingNames.contains(name) {
name = "\(base) \(counter)"
counter += 1
}
await viewModel.createPreset(name: name, basedOn: activePreset)
}
// MARK: - Pages Section
private var pagesSection: some View {
Section {
ForEach(tvOSPages) { page in
NavigationLink {
TVPanelPageEditorView(pageType: page.pageType, viewModel: viewModel)
} label: {
HStack {
Label(page.pageType.displayName, systemImage: page.pageType.systemImage)
Spacer()
Text(page.isEnabled
? String(localized: "common.on")
: String(localized: "common.off"))
.foregroundStyle(.secondary)
}
}
}
} header: {
Text(String(localized: "panel.settings.pages.header"))
} footer: {
Text(String(localized: "panel.settings.pages.footer"))
}
}
}
// MARK: - Page Editor
private struct TVPanelPageEditorView: View {
let pageType: PanelPageType
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var isEnabled: Bool = true
@State private var showDescription: Bool = true
@State private var showStatsRow: Bool = true
@State private var showChannelRow: Bool = true
private var configuration: PanelPageConfiguration? {
viewModel.panelSettings.configuration(for: pageType)
}
/// Whether this is the only enabled page; the last one cannot be disabled.
private var isLastEnabledPage: Bool {
isEnabled && viewModel.enabledPanelPageCount <= 1
}
/// Pages as displayed in the tvOS list (tracks hidden).
private var displayedPages: [PanelPageConfiguration] {
viewModel.panelPages.filter { $0.pageType != .tracks }
}
private var displayedIndex: Int? {
displayedPages.firstIndex { $0.pageType == pageType }
}
var body: some View {
List {
Section {
Toggle(
String(localized: "panel.settings.page.enabled"),
isOn: $isEnabled
)
.disabled(!viewModel.canEditActivePreset || isLastEnabledPage)
.onChange(of: isEnabled) { _, newValue in
guard newValue != configuration?.isEnabled else { return }
viewModel.setPanelPageEnabled(pageType, isEnabled: newValue)
}
Button {
move(up: true)
} label: {
Label(String(localized: "panel.settings.moveUp"), systemImage: "arrow.up")
}
.disabled(!viewModel.canEditActivePreset || displayedIndex == 0)
Button {
move(up: false)
} label: {
Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down")
}
.disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1)
} footer: {
if isLastEnabledPage {
Text(String(localized: "panel.settings.page.lastEnabled.footer"))
} else if !viewModel.canEditActivePreset {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
if pageType == .info {
infoElementsSection
}
}
.navigationTitle(pageType.displayName)
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Reordering
/// Moves this page past its displayed neighbor. Indices are mapped back
/// to the full pages list, which may contain pages the tvOS UI hides.
private func move(up: Bool) {
let displayed = displayedPages
guard let index = displayed.firstIndex(where: { $0.pageType == pageType }) else { return }
let neighborIndex = up ? index - 1 : index + 1
guard displayed.indices.contains(neighborIndex) else { return }
let allPages = viewModel.panelPages
guard let source = allPages.firstIndex(where: { $0.pageType == pageType }),
let target = allPages.firstIndex(where: { $0.pageType == displayed[neighborIndex].pageType })
else { return }
viewModel.movePanelPages(
fromOffsets: IndexSet(integer: source),
toOffset: up ? target : target + 1
)
}
// MARK: - Info Elements Section
private var infoElementsSection: some View {
Section {
Toggle(
String(localized: "panel.settings.info.showStatsRow"),
isOn: $showStatsRow
)
.onChange(of: showStatsRow) { _, newValue in
guard newValue != infoSettings.effectiveShowStatsRow else { return }
viewModel.updatePanelInfoSettingsSync { $0.showStatsRow = newValue }
}
Toggle(
String(localized: "panel.settings.info.showChannelRow"),
isOn: $showChannelRow
)
.onChange(of: showChannelRow) { _, newValue in
guard newValue != infoSettings.effectiveShowChannelRow else { return }
viewModel.updatePanelInfoSettingsSync { $0.showChannelRow = newValue }
}
Toggle(
String(localized: "panel.settings.info.showDescription"),
isOn: $showDescription
)
.onChange(of: showDescription) { _, newValue in
guard newValue != infoSettings.effectiveShowDescription else { return }
viewModel.updatePanelInfoSettingsSync { $0.showDescription = newValue }
}
} header: {
Text(String(localized: "panel.settings.elements.header"))
}
.disabled(!viewModel.canEditActivePreset)
}
// MARK: - Helpers
private var infoSettings: InfoPageSettings {
configuration?.infoSettings ?? InfoPageSettings()
}
private func syncLocalState() {
isEnabled = configuration?.isEnabled ?? true
let info = infoSettings
showDescription = info.effectiveShowDescription
showStatsRow = info.effectiveShowStatsRow
showChannelRow = info.effectiveShowChannelRow
}
}
#endif

View File

@@ -195,6 +195,10 @@ struct SettingsView: View {
Label(String(localized: "settings.playback.sectionTitle"), systemImage: "play.circle")
}
NavigationLink { TVSidebarDetailContainer(systemImage: "list.bullet.rectangle", title: String(localized: "settings.playerPanels.title")) { TVPlayerPanelsSettingsView() } } label: {
Label(String(localized: "settings.playerPanels.title"), systemImage: "list.bullet.rectangle")
}
NavigationLink { TVSidebarDetailContainer(systemImage: "hand.raised", title: String(localized: "settings.privacy.title")) { PrivacySettingsView() } } label: {
Label(String(localized: "settings.privacy.title"), systemImage: "hand.raised")
}