From c33d1255537a76a57ef3d8562d65c30a2b10f2b0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 21 Sep 2026 23:25:10 +0200 Subject: [PATCH] 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. --- Yattee/Core/Settings/SettingsKey.swift | 2 + .../Settings/SettingsManager+Advanced.swift | 13 ++ Yattee/Core/SettingsManager.swift | 2 + Yattee/Localizable.xcstrings | 154 +++++++++++++++++ .../PlayerControls/BuiltInPresets.swift | 14 +- .../Models/PlayerControls/PanelSettings.swift | 51 +++++- .../Navigation/NavigationCoordinator.swift | 15 +- .../PlayerControlsSettingsViewModel.swift | 78 +++++++++ Yattee/Views/Player/ExpandedPlayerSheet.swift | 50 +++++- .../Views/Player/WideScreenPlayerLayout.swift | 11 ++ .../PlayerControls/PanelPageEditorView.swift | 127 ++++++++++++++ .../PlayerControls/PanelSettingsView.swift | 110 ++++++++++++ .../PlayerControlsSettingsView.swift | 18 ++ YatteeTests/ModelTests.swift | 21 --- YatteeTests/PanelSettingsTests.swift | 163 ++++++++++++++++++ 15 files changed, 794 insertions(+), 35 deletions(-) create mode 100644 Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift create mode 100644 Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift create mode 100644 YatteeTests/PanelSettingsTests.swift diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 7ce3e53d..86e9a345 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -132,6 +132,7 @@ enum SettingsKey: String, CaseIterable { case floatingDetailsPanelWidth // Resizable panel width in wide layout case landscapeDetailsPanelVisible case landscapeDetailsPanelPinned + case detailsPanelLastVisible // Last user-set visibility, for the "remember" default // Player Controls case activeControlsPresetID @@ -167,6 +168,7 @@ enum SettingsKey: String, CaseIterable { // Player details panel — iOS/iPadOS only, different on other platforms .floatingDetailsPanelSide, .floatingDetailsPanelWidth, .landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned, + .detailsPanelLastVisible, // Video swipe actions — touch-gesture feature .videoSwipeActionOrder, .videoSwipeActionVisibility: return true diff --git a/Yattee/Core/Settings/SettingsManager+Advanced.swift b/Yattee/Core/Settings/SettingsManager+Advanced.swift index 88ca2129..f61773d2 100644 --- a/Yattee/Core/Settings/SettingsManager+Advanced.swift +++ b/Yattee/Core/Settings/SettingsManager+Advanced.swift @@ -277,6 +277,19 @@ extension SettingsManager { } } + /// Last user-set details panel visibility, restored when the active + /// preset's default visibility is "remember". + var detailsPanelLastVisible: Bool { + get { + if let cached = _detailsPanelLastVisible { return cached } + return bool(for: .detailsPanelLastVisible, default: false) + } + set { + _detailsPanelLastVisible = newValue + set(newValue, for: .detailsPanelLastVisible) + } + } + /// Whether the details panel is pinned in landscape layout. /// Default is false (floating mode). var landscapeDetailsPanelPinned: Bool { diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index b701ec05..fd4a9ece 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -182,6 +182,7 @@ final class SettingsManager { var _floatingDetailsPanelWidth: CGFloat? var _landscapeDetailsPanelVisible: Bool? var _landscapeDetailsPanelPinned: Bool? + var _detailsPanelLastVisible: Bool? // Notification settings var _backgroundNotificationsEnabled: Bool? @@ -532,6 +533,7 @@ final class SettingsManager { _floatingDetailsPanelWidth = nil _landscapeDetailsPanelVisible = nil _landscapeDetailsPanelPinned = nil + _detailsPanelLastVisible = nil _backgroundNotificationsEnabled = nil _defaultNotificationsForNewChannels = nil _lastBackgroundCheck = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 85b9edc5..200a9f22 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7434,6 +7434,39 @@ } } }, + "panel.defaultVisibility.hidden" : { + "comment" : "Details panel default visibility option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hidden" + } + } + } + }, + "panel.defaultVisibility.remember" : { + "comment" : "Details panel default visibility option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remember Last" + } + } + } + }, + "panel.defaultVisibility.visible" : { + "comment" : "Details panel default visibility option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visible" + } + } + } + }, "panel.page.info" : { "comment" : "Details panel page name", "localizations" : { @@ -7478,6 +7511,116 @@ } } }, + "panel.settings.defaultVisibility" : { + "comment" : "Details panel settings picker label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When Player Opens" + } + } + } + }, + "panel.settings.defaultVisibility.footer" : { + "comment" : "Details panel settings picker footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How the details panel appears when the player opens. Remember Last restores the panel if it was visible last time." + } + } + } + }, + "panel.settings.elements.header" : { + "comment" : "Details panel page editor section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elements" + } + } + } + }, + "panel.settings.info.showChannelRow" : { + "comment" : "Info page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Channel" + } + } + } + }, + "panel.settings.info.showDescription" : { + "comment" : "Info page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Description" + } + } + } + }, + "panel.settings.info.showStatsRow" : { + "comment" : "Info page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistics" + } + } + } + }, + "panel.settings.page.enabled" : { + "comment" : "Details panel page editor toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Page" + } + } + } + }, + "panel.settings.page.lastEnabled.footer" : { + "comment" : "Details panel page editor footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "At least one page must stay enabled." + } + } + } + }, + "panel.settings.pages.header" : { + "comment" : "Details panel settings section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pages" + } + } + } + }, + "panel.settings.pages.footer" : { + "comment" : "Details panel settings section footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically." + } + } + } + }, "peertube.explore.added" : { "localizations" : { "en" : { @@ -14242,6 +14385,17 @@ } } }, + "settings.playerControls.detailsPanel" : { + "comment" : "Player controls settings row", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details Panel" + } + } + } + }, "settings.playerControls.duplicateToEdit" : { "localizations" : { "en" : { diff --git a/Yattee/Models/PlayerControls/BuiltInPresets.swift b/Yattee/Models/PlayerControls/BuiltInPresets.swift index 283e4caa..72f367c5 100644 --- a/Yattee/Models/PlayerControls/BuiltInPresets.swift +++ b/Yattee/Models/PlayerControls/BuiltInPresets.swift @@ -11,7 +11,7 @@ extension LayoutPreset { /// Bump this version whenever any built-in preset definition changes. /// On launch, the app compares this against the last-applied version /// and replaces stale built-in presets with fresh copies from code. - static let builtInPresetsVersion = 8 + static let builtInPresetsVersion = 9 // MARK: - Built-in Preset IDs @@ -131,7 +131,8 @@ extension LayoutPreset { progressBarSettings: ProgressBarSettings(), gesturesSettings: gesturesSettings, playerPillSettings: playerPillSettings, - miniPlayerSettings: miniPlayerSettings + miniPlayerSettings: miniPlayerSettings, + panelSettings: .default ) } @@ -192,7 +193,8 @@ extension LayoutPreset { progressBarSettings: ProgressBarSettings(), gesturesSettings: nil, playerPillSettings: nil, - miniPlayerSettings: MiniPlayerSettings() + miniPlayerSettings: MiniPlayerSettings(), + panelSettings: .default ) } @@ -285,7 +287,8 @@ extension LayoutPreset { progressBarSettings: progressBarSettings, gesturesSettings: gesturesSettings, playerPillSettings: playerPillSettings, - miniPlayerSettings: miniPlayerSettings + miniPlayerSettings: miniPlayerSettings, + panelSettings: .default ) } @@ -336,7 +339,8 @@ extension LayoutPreset { progressBarSettings: progressBarSettings, gesturesSettings: nil, playerPillSettings: nil, - miniPlayerSettings: MiniPlayerSettings() + miniPlayerSettings: MiniPlayerSettings(), + panelSettings: .default ) } diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index 7dc037b5..1207b1b3 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -6,6 +6,7 @@ // import Foundation +import SwiftUI // MARK: - Panel Page Type @@ -77,10 +78,22 @@ enum PanelLayoutMode: String, Codable, Sendable { // MARK: - Default Visibility /// Default panel visibility when the player opens on iPad/macOS. -enum PanelDefaultVisibility: String, Codable, Sendable { +enum PanelDefaultVisibility: String, Codable, CaseIterable, Sendable { case hidden case visible case remember + + /// Localized name for the settings picker. + var displayName: String { + switch self { + case .hidden: + return String(localized: "panel.defaultVisibility.hidden") + case .visible: + return String(localized: "panel.defaultVisibility.visible") + case .remember: + return String(localized: "panel.defaultVisibility.remember") + } + } } // MARK: - Per-Page Settings @@ -288,6 +301,12 @@ struct PanelSettings: Codable, Hashable, Sendable { self.showPageBarLabels = showPageBarLabels } + /// In-memory cache of the active preset's panel settings, refreshed by the + /// player whenever the layout loads. Lets synchronous appear paths (e.g. + /// default-visibility resets) read the last known value before the async + /// layout fetch completes. + nonisolated(unsafe) static var cached: PanelSettings = .default + static let `default` = PanelSettings(pages: [ PanelPageConfiguration(pageType: .info, region: .belowVideo), PanelPageConfiguration(pageType: .comments, region: .belowVideo), @@ -328,6 +347,36 @@ struct PanelSettings: Codable, Hashable, Sendable { enabledPages().contains { $0.pageType == pageType } } + /// The stored configuration for a page type, if present. + func configuration(for pageType: PanelPageType) -> PanelPageConfiguration? { + normalizedPages.first { $0.pageType == pageType } + } + + // MARK: - Mutations (settings UI) + + /// Replaces the stored pages with the normalized list so mutations by + /// index operate on the same list the UI displays. + mutating func normalize() { + pages = normalizedPages + } + + mutating func setEnabled(_ isEnabled: Bool, for pageType: PanelPageType) { + normalize() + guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return } + pages[index].isEnabled = isEnabled + } + + mutating func movePages(fromOffsets source: IndexSet, toOffset destination: Int) { + normalize() + pages.move(fromOffsets: source, toOffset: destination) + } + + mutating func updateSettings(_ newSettings: PanelPageSettings?, for pageType: PanelPageType) { + normalize() + guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return } + pages[index].settings = newSettings + } + private enum CodingKeys: String, CodingKey { case pages case layoutMode diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 04b8e029..84f89d33 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -61,7 +61,20 @@ final class NavigationCoordinator { var nowPlaying: Video? /// Whether the mini player is expanded to full screen. - var isPlayerExpanded = false + var isPlayerExpanded = false { + didSet { + // Each expanded player session applies the preset's default panel + // visibility once; closing the player arms the next application. + if !isPlayerExpanded { + hasAppliedPanelDefaultVisibility = false + } + } + } + + /// Whether the current expanded player session already applied the active + /// preset's default details panel visibility. Lives here (not view @State) + /// so window-mode switches and layout re-mounts don't re-apply it. + var hasAppliedPanelDefaultVisibility = false /// Whether the mini player queue sheet is showing. var isMiniPlayerQueueSheetPresented = false diff --git a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index 4797e672..ddf4c5e2 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -790,6 +790,84 @@ final class PlayerControlsSettingsViewModel { queueDebouncedSave(preset: preset) } + // MARK: - Details Panel Settings + + /// Current details panel settings from active preset, or default. + var panelSettings: PanelSettings { + activePreset?.layout.effectivePanelSettings ?? .default + } + + /// Ordered pages list for the settings UI (includes disabled pages). + var panelPages: [PanelPageConfiguration] { + panelSettings.normalizedPages + } + + /// Number of enabled panel pages. + var enabledPanelPageCount: Int { + panelSettings.enabledPages().count + } + + /// Current default panel visibility. + var panelDefaultVisibility: PanelDefaultVisibility { + panelSettings.effectiveDefaultVisibility + } + + /// Synchronously updates the default panel visibility. + func syncPanelDefaultVisibility(_ visibility: PanelDefaultVisibility) { + updatePanelSettingsSync { settings in + settings.defaultVisibility = visibility + } + } + + /// Enables or disables a panel page. The last enabled page cannot be + /// disabled — the panel always needs at least one page. + func setPanelPageEnabled(_ pageType: PanelPageType, isEnabled: Bool) { + if !isEnabled { + let enabled = panelSettings.enabledPages() + if enabled.count <= 1, enabled.first?.pageType == pageType { + return + } + } + updatePanelSettingsSync { settings in + settings.setEnabled(isEnabled, for: pageType) + } + } + + /// Moves pages within the panel page order. + func movePanelPages(fromOffsets source: IndexSet, toOffset destination: Int) { + updatePanelSettingsSync { settings in + settings.movePages(fromOffsets: source, toOffset: destination) + } + } + + /// Synchronously updates the info page element toggles. + func updatePanelInfoSettingsSync(_ mutation: (inout InfoPageSettings) -> Void) { + updatePanelSettingsSync { settings in + var info = settings.configuration(for: .info)?.infoSettings ?? InfoPageSettings() + mutation(&info) + settings.updateSettings(.info(info), for: .info) + } + } + + /// Synchronously updates details panel settings with immediate UI feedback. + /// Saves are debounced to avoid rapid disk writes. + /// - Parameter mutation: A closure that mutates the panel settings. + func updatePanelSettingsSync(_ mutation: (inout PanelSettings) -> Void) { + guard var preset = activePreset, !preset.isBuiltIn else { + error = "Cannot edit built-in presets. Duplicate it first." + return + } + + var layout = preset.layout + var settings = layout.effectivePanelSettings + mutation(&settings) + layout.panelSettings = settings + preset = preset.withUpdatedLayout(layout) + self.activePreset = preset + + queueDebouncedSave(preset: preset) + } + // MARK: - System Controls & Volume /// Current system controls mode from active preset, or default. diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 0680c4e0..aa8fe9a4 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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) diff --git a/Yattee/Views/Player/WideScreenPlayerLayout.swift b/Yattee/Views/Player/WideScreenPlayerLayout.swift index fae2abb1..d46596c8 100644 --- a/Yattee/Views/Player/WideScreenPlayerLayout.swift +++ b/Yattee/Views/Player/WideScreenPlayerLayout.swift @@ -95,6 +95,7 @@ struct WideScreenPlayerLayout: View { withAnimation(.easeInOut(duration: 0.3)) { isPanelVisible.toggle() settingsManager?.landscapeDetailsPanelVisible = isPanelVisible + settingsManager?.detailsPanelLastVisible = isPanelVisible } } @@ -102,6 +103,7 @@ struct WideScreenPlayerLayout: 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: 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: 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) diff --git a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift new file mode 100644 index 00000000..0e5433fc --- /dev/null +++ b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift @@ -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 diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift new file mode 100644 index 00000000..04699b08 --- /dev/null +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -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 diff --git a/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift index 9aa6844a..8e22cdb6 100644 --- a/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift @@ -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: { diff --git a/YatteeTests/ModelTests.swift b/YatteeTests/ModelTests.swift index 96802682..867fe471 100644 --- a/YatteeTests/ModelTests.swift +++ b/YatteeTests/ModelTests.swift @@ -603,27 +603,6 @@ struct HomeTabTests { } } -// MARK: - PlayerInfoTab Tests - -@Suite("PlayerInfoTab Tests") -struct PlayerInfoTabTests { - - @Test("All cases exist") - func allCases() { - let cases = PlayerInfoTab.allCases - #expect(cases.contains(.description)) - #expect(cases.contains(.comments)) - #expect(cases.count == 2) - } - - @Test("Titles are not empty") - func titles() { - for tab in PlayerInfoTab.allCases { - #expect(!tab.title.isEmpty) - } - } -} - // MARK: - SearchResultType Tests @Suite("SearchResultType Tests") diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift new file mode 100644 index 00000000..c939b020 --- /dev/null +++ b/YatteeTests/PanelSettingsTests.swift @@ -0,0 +1,163 @@ +// +// PanelSettingsTests.swift +// YatteeTests +// +// Tests for details panel settings model (Codable compatibility). +// + +import Foundation +import Testing +@testable import Yattee + +@Suite("Panel Settings Tests") +struct PanelSettingsTests { + private func roundTrip(_ settings: PanelSettings) throws -> PanelSettings { + let encoded = try JSONEncoder().encode(settings) + return try JSONDecoder().decode(PanelSettings.self, from: encoded) + } + + // MARK: - Round Trip + + @Test("Default settings round-trip") + func defaultRoundTrip() throws { + let decoded = try roundTrip(.default) + #expect(decoded == PanelSettings.default) + } + + @Test("Customized settings round-trip") + func customizedRoundTrip() throws { + var settings = PanelSettings.default + settings.layoutMode = .corner + settings.defaultVisibility = .remember + settings.showPageBarLabels = false + settings.setEnabled(false, for: .related) + settings.movePages(fromOffsets: IndexSet(integer: 1), toOffset: 0) + settings.updateSettings( + .info(InfoPageSettings(showStatsRow: false, showDescription: false)), + for: .info + ) + + let decoded = try roundTrip(settings) + #expect(decoded == settings) + #expect(decoded.effectiveLayoutMode == .corner) + #expect(decoded.effectiveDefaultVisibility == .remember) + #expect(decoded.effectiveShowPageBarLabels == false) + #expect(!decoded.isPageEnabled(.related)) + + let info = decoded.configuration(for: .info)?.infoSettings + #expect(info?.effectiveShowStatsRow == false) + #expect(info?.effectiveShowDescription == false) + #expect(info?.effectiveShowChannelRow == true) + } + + // MARK: - Backward Compatibility + + @Test("Layout without panelSettings decodes and falls back to default") + func layoutWithoutPanelSettings() throws { + let layout = PlayerControlsLayout.default + var json = try #require( + try JSONSerialization.jsonObject(with: JSONEncoder().encode(layout)) as? [String: Any] + ) + json.removeValue(forKey: "panelSettings") + let data = try JSONSerialization.data(withJSONObject: json) + + let decoded = try JSONDecoder().decode(PlayerControlsLayout.self, from: data) + #expect(decoded.panelSettings == nil) + #expect(decoded.effectivePanelSettings == .default) + } + + @Test("Minimal page entries decode with defaults") + func minimalPageEntries() throws { + let json = """ + { "pages": [ { "pageType": "info" }, { "pageType": "comments" } ] } + """ + let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8)) + #expect(decoded.pages.count == 2) + #expect(decoded.pages.allSatisfy { $0.isEnabled }) + #expect(decoded.pages.allSatisfy { $0.region == .primary }) + #expect(decoded.effectiveLayoutMode == .stacked) + #expect(decoded.effectiveDefaultVisibility == .hidden) + } + + // MARK: - Forward Compatibility + + @Test("Unknown page type from a future version is dropped") + func unknownPageTypeDropped() throws { + let json = """ + { "pages": [ + { "pageType": "info" }, + { "pageType": "futureHologram", "isEnabled": true }, + { "pageType": "queue" } + ] } + """ + let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8)) + #expect(decoded.pages.map(\.pageType) == [.info, .queue]) + } + + @Test("Unknown settings discriminator decodes to nil settings") + func unknownSettingsDiscriminator() throws { + let json = """ + { "pages": [ + { "pageType": "info", "settings": { "type": "futureKind", "futureKind": { "x": 1 } } } + ] } + """ + let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8)) + #expect(decoded.pages.count == 1) + #expect(decoded.pages.first?.settings == nil) + // nil settings fall back to defaults + #expect(decoded.pages.first?.infoSettings.effectiveShowDescription == true) + } + + @Test("Unknown layoutMode and defaultVisibility decode to nil") + func unknownEnumValues() throws { + let json = """ + { "pages": [ { "pageType": "info" } ], + "layoutMode": "floating", + "defaultVisibility": "sometimes" } + """ + let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8)) + #expect(decoded.layoutMode == nil) + #expect(decoded.defaultVisibility == nil) + #expect(decoded.effectiveLayoutMode == .stacked) + #expect(decoded.effectiveDefaultVisibility == .hidden) + } + + // MARK: - Normalization + + @Test("Normalized pages append missing types as disabled") + func normalizedPagesAppendMissing() throws { + let json = """ + { "pages": [ { "pageType": "info" } ] } + """ + let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8)) + let normalized = decoded.normalizedPages + #expect(normalized.count == PanelPageType.allCases.count) + #expect(normalized.first?.pageType == .info) + #expect(normalized.dropFirst().allSatisfy { !$0.isEnabled }) + } + + @Test("Enabled pages filter by region") + func enabledPagesRegionFilter() { + let settings = PanelSettings.default + let primary = settings.enabledPages(in: .primary).map(\.pageType) + let belowVideo = settings.enabledPages(in: .belowVideo).map(\.pageType) + let all = settings.enabledPages().map(\.pageType) + + #expect(primary == [.related, .chapters, .queue]) + #expect(belowVideo == [.info, .comments]) + #expect(all == [.info, .comments, .related, .chapters, .queue]) + // Tracks is disabled by default + #expect(!all.contains(.tracks)) + } + + // MARK: - Mutations + + @Test("Disabling and re-enabling a page preserves order") + func toggleKeepsOrder() { + var settings = PanelSettings.default + settings.setEnabled(false, for: .comments) + #expect(!settings.isPageEnabled(.comments)) + settings.setEnabled(true, for: .comments) + #expect(settings.pages.map(\.pageType) == PanelSettings.default.pages.map(\.pageType)) + } +}