mirror of
https://github.com/yattee/yattee.git
synced 2026-11-05 03:17:19 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
127
Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift
Normal file
127
Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift
Normal 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
|
||||
110
Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift
Normal file
110
Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift
Normal 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
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user