mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 00:47:17 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user