Add details panel preset settings UI and default visibility

Phase 2 of the player panels redesign:

- New "Details Panel" screen in Player Controls settings: page
  reorder/enable list with per-page editor (info element toggles),
  and a default panel visibility picker (hidden/visible/remember).
- PlayerControlsSettingsViewModel gains panelSettings bindings with
  the usual debounced-save flow; the last enabled page cannot be
  disabled.
- Built-in presets bumped to v9 with explicit default panelSettings.
- The preset's default visibility now drives the wide-layout panel
  on player open and on entering landscape, replacing the hard
  hidden resets; "remember" restores the new detailsPanelLastVisible
  setting recorded on user toggles. Applied once per expanded player
  session (flag on NavigationCoordinator survives re-mounts).
- WideScreenPlayerLayout picks up external visibility writes so the
  async-loaded default reaches the mounted layout.
- Unit tests for PanelSettings Codable compatibility (round-trip,
  old JSON, unknown page types/discriminators/enum values); removed
  stale PlayerInfoTab tests left from Phase 1.
This commit is contained in:
Arkadiusz Fal
2026-09-21 23:25:10 +02:00
parent d5285b2b83
commit c33d125553
15 changed files with 794 additions and 35 deletions

View File

@@ -353,11 +353,13 @@ struct ExpandedPlayerSheet: View {
isPanelExpanded = false
panelExpandOffset = 0
// Reset panel state to orientation defaults
// Reset panel state to the preset's default visibility
if newValue {
// Entering landscape: reset to hidden/unpinned
appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false
appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false
// Entering landscape: apply the preset's default
applyPanelDefaultVisibility(
playerControlsLayout.effectivePanelSettings,
settingsManager: appEnvironment?.settingsManager
)
// Portrait panel doesn't exist in wide layout - clear flag so gesture handler allows pinch
navigationCoordinator?.isPortraitPanelVisible = false
}
@@ -371,13 +373,19 @@ struct ExpandedPlayerSheet: View {
isInWideScreenLayout = wideScreen
#if os(iOS)
previousIsLandscape = wideScreen
// Reset panel state on fresh player open (prevents stale state from previous session)
appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false
appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false
if wideScreen {
navigationCoordinator?.isPortraitPanelVisible = false
}
#endif
// Provisional pass on fresh player open (prevents stale panel
// state from a previous session). Uses the cached settings;
// loadPlayerControlsLayout re-applies the authoritative value.
if navigationCoordinator?.hasAppliedPanelDefaultVisibility != true {
applyPanelDefaultVisibility(
PanelSettings.cached,
settingsManager: appEnvironment?.settingsManager
)
}
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState == .loaded && !comments.isEmpty)
@@ -883,10 +891,38 @@ private struct PlayerEventHandlersModifier: ViewModifier {
playerControlsLayout = layout
GlobalLayoutSettings.cached = layout.globalSettings
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
PanelSettings.cached = layout.effectivePanelSettings
// Apply the preset's default panel visibility once per expanded
// player session, now that the authoritative settings are loaded.
if navigationCoordinator?.hasAppliedPanelDefaultVisibility != true {
applyPanelDefaultVisibility(
layout.effectivePanelSettings,
settingsManager: appEnvironment?.settingsManager
)
navigationCoordinator?.hasAppliedPanelDefaultVisibility = true
}
}
}
}
/// Applies the preset's default details panel visibility for a fresh
/// panel mount. "Hidden" mirrors the historical reset (unpinned too);
/// the other modes leave the pinned state as persisted.
@MainActor
private func applyPanelDefaultVisibility(_ settings: PanelSettings, settingsManager: SettingsManager?) {
guard let settingsManager else { return }
switch settings.effectiveDefaultVisibility {
case .hidden:
settingsManager.landscapeDetailsPanelVisible = false
settingsManager.landscapeDetailsPanelPinned = false
case .visible:
settingsManager.landscapeDetailsPanelVisible = true
case .remember:
settingsManager.landscapeDetailsPanelVisible = settingsManager.detailsPanelLastVisible
}
}
// MARK: - iOS Event Handlers Modifier
#if os(iOS)

View File

@@ -95,6 +95,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
withAnimation(.easeInOut(duration: 0.3)) {
isPanelVisible.toggle()
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
settingsManager?.detailsPanelLastVisible = isPanelVisible
}
}
@@ -102,6 +103,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
private func hidePanel() {
isPanelVisible = false
settingsManager?.landscapeDetailsPanelVisible = false
settingsManager?.detailsPanelLastVisible = false
}
/// Get safe area insets from window scene (geometry reader insets are 0 when .ignoresSafeArea is used)
@@ -296,6 +298,14 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
// Persist panel width changes to settings
settingsManager?.floatingDetailsPanelWidth = newWidth
}
.onChange(of: settingsManager?.landscapeDetailsPanelVisible) { _, newValue in
// External writes (e.g. the preset's default visibility applied
// after the async layout load) must reach the local state too.
guard let newValue, newValue != isPanelVisible else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isPanelVisible = newValue
}
}
.ignoresSafeArea(.all)
#if os(iOS)
.persistentSystemOverlays(.hidden)
@@ -337,6 +347,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
// Pinning - ensure panel is visible first
isPanelVisible = true
settingsManager?.landscapeDetailsPanelVisible = true
settingsManager?.detailsPanelLastVisible = true
// Animate panscan to zero and pin concurrently for smooth transition
appEnvironment?.navigationCoordinator.animatePanscanToZero(completion: nil)

View File

@@ -0,0 +1,127 @@
//
// PanelPageEditorView.swift
// Yattee
//
// Settings editor for a single details panel page.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct PanelPageEditorView: 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
}
var body: some View {
Form {
enabledSection
if pageType == .info {
infoElementsSection
}
}
.navigationTitle(pageType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Enabled Section
private var enabledSection: some View {
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)
}
} footer: {
if isLastEnabledPage {
Text(String(localized: "panel.settings.page.lastEnabled.footer"))
} else if !viewModel.canEditActivePreset {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
}
// 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

@@ -0,0 +1,110 @@
//
// PanelSettingsView.swift
// Yattee
//
// Settings editor for the player details panel pages.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct PanelSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
var body: some View {
List {
visibilitySection
pagesSection
}
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.toolbar {
#if os(iOS)
EditButton()
#endif
}
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Visibility Section
private var visibilitySection: some View {
Section {
Picker(
String(localized: "panel.settings.defaultVisibility"),
selection: $defaultVisibility
) {
ForEach(PanelDefaultVisibility.allCases, id: \.self) { visibility in
Text(visibility.displayName).tag(visibility)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: defaultVisibility) { _, newValue in
guard newValue != viewModel.panelDefaultVisibility else { return }
viewModel.syncPanelDefaultVisibility(newValue)
}
} footer: {
Text(String(localized: "panel.settings.defaultVisibility.footer"))
}
}
// MARK: - Pages Section
private var pagesSection: some View {
Section {
ForEach(viewModel.panelPages) { page in
pageRow(for: page)
}
.onMove { source, destination in
viewModel.movePanelPages(fromOffsets: source, toOffset: destination)
}
} header: {
Text(String(localized: "panel.settings.pages.header"))
} footer: {
if viewModel.canEditActivePreset {
Text(String(localized: "panel.settings.pages.footer"))
} else {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
.disabled(!viewModel.canEditActivePreset)
}
@ViewBuilder
private func pageRow(for page: PanelPageConfiguration) -> some View {
NavigationLink {
PanelPageEditorView(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)
}
}
}
// MARK: - Helpers
private func syncLocalState() {
defaultVisibility = viewModel.panelDefaultVisibility
}
}
#endif

View File

@@ -398,6 +398,24 @@ private struct LayoutSectionsSection: View {
)
}
.disabled(!viewModel.canEditActivePreset)
// The details panel exists in the iOS and macOS players.
#if os(iOS) || os(macOS)
NavigationLink {
PanelSettingsView(viewModel: viewModel)
} label: {
HStack {
Label(
String(localized: "settings.playerControls.detailsPanel"),
systemImage: "sidebar.trailing"
)
Spacer()
Text("\(viewModel.enabledPanelPageCount)")
.foregroundStyle(.secondary)
}
}
.disabled(!viewModel.canEditActivePreset)
#endif
} header: {
Text(String(localized: "settings.playerControls.layoutSections"))
} footer: {