Files
yattee/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.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

275 lines
9.2 KiB
Swift

//
// 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