Files
yattee/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift
Arkadiusz Fal 1370f68c37 Rework details panel settings: pinned info, per-orientation visibility, section defaults
Pages list
- Info is pinned first and always enabled in every layout: normalized
  pages force it to the front and enabled (older presets self-correct),
  disabling it is a no-op, and any move that would displace it snaps
  back. The iOS/macOS list renders it outside the reorderable ForEach
  (no drag handle, nothing drops above it) and the editors hide its
  enabled toggle; tvOS also hides its move buttons. The "at least one
  page must stay enabled" guard and its strings are gone since Info
  always counts.
- iPhone never renders queue or comments as pages, so its Sections list
  hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes
  through moveVisiblePages, which keeps hidden entries in their stored
  slots. Comments get their own group on the Details Panel screen
  (pill mode + avatars) and the overlay hosts pass the avatars flag.

Default visibility
- "When Player Opens" is now configured per orientation
  (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy
  single value is still decoded and seeds both until set individually;
  presets that never set it default to portrait Visible, landscape
  Hidden. macOS shows one picker (no portrait layout).
- "Remember Last" no longer leaks across orientations: separate
  remembered flags, written by the portrait toggles and by the wide and
  corner layouts respectively, with the old shared flag read once as a
  fallback. The corner layout seeded from the portrait resolution
  before; it now uses landscape.

Section options (iOS inline-sections hosts)
- Chapters and Related editors gain "Expanded by Default" (on by
  default). The Info editor gains a Description group with "Expanded by
  Default" (off) and a "Collapsed Lines" stepper (1–10, default 3).
  Applied when the panel appears and on each video change.
- Expanding a collapsed section scrolls its header to the top of the
  panel: PanelPageScrollView can hand its scroll proxy to page content
  and each inline section is wrapped in one anchored container.
- The portrait panel's bottom fade sized its touch blocker from a stale
  "expanded comments pill" answer that ignored collapse on scroll, so a
  collapsed section header (the last item) sat under it and could not
  be tapped. The predicate now tracks the pill's actual collapse state.
2026-09-26 23:50:30 +02:00

283 lines
9.3 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.
private var tvOSPages: [PanelPageConfiguration] {
viewModel.panelPages
}
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)
}
/// The pinned page (info) is always first and enabled: no toggle, no
/// reordering.
private var isPinnedPage: Bool {
pageType == PanelSettings.pinnedPageType
}
/// Whether the displayed neighbor above is the pinned page (or there is
/// none), which blocks moving up.
private var isBelowPinnedPage: Bool {
guard let displayedIndex else { return true }
return displayedIndex <= 1
}
/// Pages as displayed in the tvOS list.
private var displayedPages: [PanelPageConfiguration] {
viewModel.panelPages
}
private var displayedIndex: Int? {
displayedPages.firstIndex { $0.pageType == pageType }
}
var body: some View {
List {
if !isPinnedPage {
Section {
Toggle(
String(localized: "panel.settings.page.enabled"),
isOn: $isEnabled
)
.disabled(!viewModel.canEditActivePreset)
.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 || isBelowPinnedPage)
Button {
move(up: false)
} label: {
Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down")
}
.disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1)
} footer: {
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