mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")
|
||||
|
||||
163
YatteeTests/PanelSettingsTests.swift
Normal file
163
YatteeTests/PanelSettingsTests.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user