mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Rework details panel settings: pinned info, per-orientation visibility, section defaults
Pages list - Info is pinned first and always enabled in every layout: normalized pages force it to the front and enabled (older presets self-correct), disabling it is a no-op, and any move that would displace it snaps back. The iOS/macOS list renders it outside the reorderable ForEach (no drag handle, nothing drops above it) and the editors hide its enabled toggle; tvOS also hides its move buttons. The "at least one page must stay enabled" guard and its strings are gone since Info always counts. - iPhone never renders queue or comments as pages, so its Sections list hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes through moveVisiblePages, which keeps hidden entries in their stored slots. Comments get their own group on the Details Panel screen (pill mode + avatars) and the overlay hosts pass the avatars flag. Default visibility - "When Player Opens" is now configured per orientation (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy single value is still decoded and seeds both until set individually; presets that never set it default to portrait Visible, landscape Hidden. macOS shows one picker (no portrait layout). - "Remember Last" no longer leaks across orientations: separate remembered flags, written by the portrait toggles and by the wide and corner layouts respectively, with the old shared flag read once as a fallback. The corner layout seeded from the portrait resolution before; it now uses landscape. Section options (iOS inline-sections hosts) - Chapters and Related editors gain "Expanded by Default" (on by default). The Info editor gains a Description group with "Expanded by Default" (off) and a "Collapsed Lines" stepper (1–10, default 3). Applied when the panel appears and on each video change. - Expanding a collapsed section scrolls its header to the top of the panel: PanelPageScrollView can hand its scroll proxy to page content and each inline section is wrapped in one anchored container. - The portrait panel's bottom fade sized its touch blocker from a stale "expanded comments pill" answer that ignored collapse on scroll, so a collapsed section header (the last item) sat under it and could not be tapped. The predicate now tracks the pill's actual collapse state.
This commit is contained in:
@@ -131,7 +131,9 @@ 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
|
||||
case detailsPanelLastVisible // Legacy shared "remember" value (pre-split); read as fallback only
|
||||
case portraitDetailsPanelLastVisible // Last user-set portrait visibility, for the "remember" default
|
||||
case landscapeDetailsPanelLastVisible // Last user-set landscape visibility, for the "remember" default
|
||||
case cornerPanelColumnWidth // Corner layout - width of the side column
|
||||
|
||||
// Player Controls
|
||||
@@ -168,7 +170,8 @@ enum SettingsKey: String, CaseIterable {
|
||||
// Player details panel — iOS/iPadOS only, different on other platforms
|
||||
.floatingDetailsPanelWidth,
|
||||
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
|
||||
.detailsPanelLastVisible, .cornerPanelColumnWidth,
|
||||
.detailsPanelLastVisible, .portraitDetailsPanelLastVisible, .landscapeDetailsPanelLastVisible,
|
||||
.cornerPanelColumnWidth,
|
||||
// Video swipe actions — touch-gesture feature
|
||||
.videoSwipeActionOrder, .videoSwipeActionVisibility:
|
||||
return true
|
||||
|
||||
@@ -264,16 +264,47 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last user-set details panel visibility, restored when the active
|
||||
/// preset's default visibility is "remember".
|
||||
var detailsPanelLastVisible: Bool {
|
||||
/// Last user-set portrait panel visibility, restored when the active
|
||||
/// preset's portrait default visibility is "remember". Falls back to the
|
||||
/// legacy shared value from before the per-orientation split.
|
||||
var portraitDetailsPanelLastVisible: Bool {
|
||||
get {
|
||||
if let cached = _detailsPanelLastVisible { return cached }
|
||||
return bool(for: .detailsPanelLastVisible, default: false)
|
||||
if let cached = _portraitDetailsPanelLastVisible { return cached }
|
||||
return bool(for: .portraitDetailsPanelLastVisible, default: legacyDetailsPanelLastVisible)
|
||||
}
|
||||
set {
|
||||
_detailsPanelLastVisible = newValue
|
||||
set(newValue, for: .detailsPanelLastVisible)
|
||||
_portraitDetailsPanelLastVisible = newValue
|
||||
set(newValue, for: .portraitDetailsPanelLastVisible)
|
||||
}
|
||||
}
|
||||
|
||||
/// Last user-set landscape panel visibility, restored when the active
|
||||
/// preset's landscape default visibility is "remember". Falls back to the
|
||||
/// legacy shared value from before the per-orientation split.
|
||||
var landscapeDetailsPanelLastVisible: Bool {
|
||||
get {
|
||||
if let cached = _landscapeDetailsPanelLastVisible { return cached }
|
||||
return bool(for: .landscapeDetailsPanelLastVisible, default: legacyDetailsPanelLastVisible)
|
||||
}
|
||||
set {
|
||||
_landscapeDetailsPanelLastVisible = newValue
|
||||
set(newValue, for: .landscapeDetailsPanelLastVisible)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-split shared "remember" value; only read as the fallback for
|
||||
/// the per-orientation values above.
|
||||
private var legacyDetailsPanelLastVisible: Bool {
|
||||
bool(for: .detailsPanelLastVisible, default: false)
|
||||
}
|
||||
|
||||
/// Last user-set visibility for an orientation.
|
||||
func detailsPanelLastVisible(for orientation: PanelOrientation) -> Bool {
|
||||
switch orientation {
|
||||
case .portrait:
|
||||
return portraitDetailsPanelLastVisible
|
||||
case .landscape:
|
||||
return landscapeDetailsPanelLastVisible
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,8 @@ final class SettingsManager {
|
||||
var _floatingDetailsPanelWidth: CGFloat?
|
||||
var _landscapeDetailsPanelVisible: Bool?
|
||||
var _landscapeDetailsPanelPinned: Bool?
|
||||
var _detailsPanelLastVisible: Bool?
|
||||
var _portraitDetailsPanelLastVisible: Bool?
|
||||
var _landscapeDetailsPanelLastVisible: Bool?
|
||||
var _cornerPanelColumnWidth: CGFloat?
|
||||
|
||||
// Notification settings
|
||||
@@ -532,7 +533,8 @@ final class SettingsManager {
|
||||
_floatingDetailsPanelWidth = nil
|
||||
_landscapeDetailsPanelVisible = nil
|
||||
_landscapeDetailsPanelPinned = nil
|
||||
_detailsPanelLastVisible = nil
|
||||
_portraitDetailsPanelLastVisible = nil
|
||||
_landscapeDetailsPanelLastVisible = nil
|
||||
_cornerPanelColumnWidth = nil
|
||||
_backgroundNotificationsEnabled = nil
|
||||
_defaultNotificationsForNewChannels = nil
|
||||
|
||||
@@ -7285,13 +7285,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.defaultVisibility.landscape" : {
|
||||
"comment" : "Details panel default visibility picker for the landscape layout",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Landscape"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.defaultVisibility.portrait" : {
|
||||
"comment" : "Details panel default visibility picker for the portrait layout",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Portrait"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
"value" : "How the details panel appears when the player opens, per orientation. Remember Last restores the panel if it was visible the last time in that orientation."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7318,6 +7340,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.info.description.footer" : {
|
||||
"comment" : "Details panel info editor description section footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "When sections follow the description, it starts collapsed to the chosen number of lines. Tap it to expand."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.info.description.header" : {
|
||||
"comment" : "Details panel info editor description section header",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Description"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.info.descriptionCollapsedLines" : {
|
||||
"comment" : "Details panel info editor stepper label",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Collapsed Lines"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.info.showChannelRow" : {
|
||||
"comment" : "Info page element toggle",
|
||||
"localizations" : {
|
||||
@@ -7406,17 +7461,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.page.region" : {
|
||||
"comment" : "Corner layout page region picker label",
|
||||
"localizations" : {
|
||||
@@ -7516,13 +7560,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.section.lastEnabled.footer" : {
|
||||
"comment" : "Details panel section editor footer (iPhone)",
|
||||
"panel.settings.section.expandedByDefault" : {
|
||||
"comment" : "Details panel chapters/related editor toggle",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "At least one section must stay enabled."
|
||||
"value" : "Expanded by Default"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.section.expandedByDefault.footer" : {
|
||||
"comment" : "Details panel chapters/related editor footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Whether the section starts expanded when it appears below the video details. You can still collapse or expand it by tapping its header."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +108,33 @@ enum PanelLayoutMode: String, Codable, CaseIterable, Sendable {
|
||||
|
||||
// MARK: - Default Visibility
|
||||
|
||||
/// Default panel visibility when the player opens on iPad/macOS.
|
||||
/// Which player layout a panel default applies to: portrait (iPhone/iPad
|
||||
/// upright) or landscape (wide layout: iPhone/iPad rotated, macOS always).
|
||||
enum PanelOrientation: Sendable {
|
||||
case portrait
|
||||
case landscape
|
||||
}
|
||||
|
||||
/// Default panel visibility when the player opens, configured per
|
||||
/// orientation (see `PanelSettings.effectiveDefaultVisibility(for:)`).
|
||||
enum PanelDefaultVisibility: String, Codable, CaseIterable, Sendable {
|
||||
case hidden
|
||||
case visible
|
||||
case remember
|
||||
|
||||
/// Resolves the mode to a concrete initial visibility, given the last
|
||||
/// user-set visibility for the same orientation.
|
||||
func resolve(lastVisible: Bool) -> Bool {
|
||||
switch self {
|
||||
case .hidden:
|
||||
return false
|
||||
case .visible:
|
||||
return true
|
||||
case .remember:
|
||||
return lastVisible
|
||||
}
|
||||
}
|
||||
|
||||
/// Localized name for the settings picker.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
@@ -135,11 +156,22 @@ struct InfoPageSettings: Codable, Hashable, Sendable {
|
||||
var showStatsRow: Bool?
|
||||
var showChannelRow: Bool?
|
||||
var showDescription: Bool?
|
||||
/// Lines shown while the description is collapsed (inline-sections hosts).
|
||||
var descriptionCollapsedLines: Int?
|
||||
/// Whether the description starts expanded instead of collapsed.
|
||||
var descriptionExpandedByDefault: Bool?
|
||||
|
||||
static let defaultDescriptionCollapsedLines = 3
|
||||
static let descriptionCollapsedLinesRange = 1...10
|
||||
|
||||
var effectiveShowStatsRow: Bool { showStatsRow ?? true }
|
||||
var effectiveShowChannelRow: Bool { showChannelRow ?? true }
|
||||
var effectiveShowDescription: Bool { showDescription ?? true }
|
||||
var effectiveDescriptionExpandedByDefault: Bool { descriptionExpandedByDefault ?? false }
|
||||
var effectiveDescriptionCollapsedLines: Int {
|
||||
let range = Self.descriptionCollapsedLinesRange
|
||||
return min(max(descriptionCollapsedLines ?? Self.defaultDescriptionCollapsedLines, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comments Pill Mode
|
||||
@@ -183,8 +215,11 @@ struct CommentsPageSettings: Codable, Hashable, Sendable {
|
||||
/// Element toggles for the related videos page.
|
||||
struct RelatedPageSettings: Codable, Hashable, Sendable {
|
||||
var showThumbnails: Bool?
|
||||
/// Whether the inline section (iPhone, iPad portrait) starts expanded.
|
||||
var expandedByDefault: Bool?
|
||||
|
||||
var effectiveShowThumbnails: Bool { showThumbnails ?? true }
|
||||
var effectiveExpandedByDefault: Bool { expandedByDefault ?? true }
|
||||
}
|
||||
|
||||
/// Element toggles for the queue page.
|
||||
@@ -197,8 +232,11 @@ struct QueuePageSettings: Codable, Hashable, Sendable {
|
||||
/// Element toggles for the chapters page.
|
||||
struct ChaptersPageSettings: Codable, Hashable, Sendable {
|
||||
var showThumbnails: Bool?
|
||||
/// Whether the inline section (iPhone, iPad portrait) starts expanded.
|
||||
var expandedByDefault: Bool?
|
||||
|
||||
var effectiveShowThumbnails: Bool { showThumbnails ?? true }
|
||||
var effectiveExpandedByDefault: Bool { expandedByDefault ?? true }
|
||||
}
|
||||
|
||||
/// Typed per-page settings payload. Uses the same discriminator pattern as
|
||||
@@ -352,10 +390,20 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
/// Layout mode on iPad/macOS. Effective default: `.stacked`.
|
||||
var layoutMode: PanelLayoutMode?
|
||||
|
||||
/// Default panel visibility when the player opens. Effective default:
|
||||
/// `.hidden` (today's behavior).
|
||||
/// Legacy single default visibility from before the per-orientation
|
||||
/// split. Still decoded so older presets keep their choice: it seeds both
|
||||
/// orientations until they are set individually. Not written by the UI.
|
||||
var defaultVisibility: PanelDefaultVisibility?
|
||||
|
||||
/// Default panel visibility when the player opens in portrait. Effective
|
||||
/// default: the legacy value, else `.visible`.
|
||||
var portraitDefaultVisibility: PanelDefaultVisibility?
|
||||
|
||||
/// Default panel visibility when the player opens in landscape (the wide
|
||||
/// layout, including macOS). Effective default: the legacy value, else
|
||||
/// `.hidden`.
|
||||
var landscapeDefaultVisibility: PanelDefaultVisibility?
|
||||
|
||||
/// Whether the page bar shows labels next to icons.
|
||||
var showPageBarLabels: Bool?
|
||||
|
||||
@@ -367,12 +415,16 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
pages: [PanelPageConfiguration],
|
||||
layoutMode: PanelLayoutMode? = nil,
|
||||
defaultVisibility: PanelDefaultVisibility? = nil,
|
||||
portraitDefaultVisibility: PanelDefaultVisibility? = nil,
|
||||
landscapeDefaultVisibility: PanelDefaultVisibility? = nil,
|
||||
showPageBarLabels: Bool? = nil,
|
||||
wideLayoutSide: FloatingPanelSide? = nil
|
||||
) {
|
||||
self.pages = pages
|
||||
self.layoutMode = layoutMode
|
||||
self.defaultVisibility = defaultVisibility
|
||||
self.portraitDefaultVisibility = portraitDefaultVisibility
|
||||
self.landscapeDefaultVisibility = landscapeDefaultVisibility
|
||||
self.showPageBarLabels = showPageBarLabels
|
||||
self.wideLayoutSide = wideLayoutSide
|
||||
}
|
||||
@@ -392,7 +444,27 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
])
|
||||
|
||||
var effectiveLayoutMode: PanelLayoutMode { layoutMode ?? .stacked }
|
||||
var effectiveDefaultVisibility: PanelDefaultVisibility { defaultVisibility ?? .hidden }
|
||||
|
||||
/// The default visibility for an orientation: its own value, else the
|
||||
/// legacy single value, else portrait visible / landscape hidden.
|
||||
func effectiveDefaultVisibility(for orientation: PanelOrientation) -> PanelDefaultVisibility {
|
||||
switch orientation {
|
||||
case .portrait:
|
||||
return portraitDefaultVisibility ?? defaultVisibility ?? .visible
|
||||
case .landscape:
|
||||
return landscapeDefaultVisibility ?? defaultVisibility ?? .hidden
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the default visibility for one orientation.
|
||||
mutating func setDefaultVisibility(_ visibility: PanelDefaultVisibility, for orientation: PanelOrientation) {
|
||||
switch orientation {
|
||||
case .portrait:
|
||||
portraitDefaultVisibility = visibility
|
||||
case .landscape:
|
||||
landscapeDefaultVisibility = visibility
|
||||
}
|
||||
}
|
||||
var effectiveShowPageBarLabels: Bool { showPageBarLabels ?? true }
|
||||
var effectiveWideLayoutSide: FloatingPanelSide { wideLayoutSide ?? .right }
|
||||
|
||||
@@ -406,15 +478,28 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page that is always first and always enabled in every layout:
|
||||
/// the video info is the panel's anchor (title under the drag handle,
|
||||
/// collapsible description, comments pill), so it cannot be moved below
|
||||
/// other pages or switched off. Its element toggles remain configurable.
|
||||
static let pinnedPageType: PanelPageType = .info
|
||||
|
||||
/// The pages list with any page types this app version knows about but
|
||||
/// the stored list is missing appended as disabled, so newly introduced
|
||||
/// pages surface in settings after an update.
|
||||
/// pages surface in settings after an update, and the pinned page forced
|
||||
/// first and enabled (older presets may have stored it elsewhere).
|
||||
var normalizedPages: [PanelPageConfiguration] {
|
||||
var result = pages
|
||||
let known = Set(pages.map(\.pageType))
|
||||
for pageType in PanelPageType.allCases where !known.contains(pageType) {
|
||||
result.append(PanelPageConfiguration(pageType: pageType, isEnabled: false))
|
||||
}
|
||||
|
||||
if let pinnedIndex = result.firstIndex(where: { $0.pageType == Self.pinnedPageType }) {
|
||||
var pinned = result.remove(at: pinnedIndex)
|
||||
pinned.isEnabled = true
|
||||
result.insert(pinned, at: 0)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -432,6 +517,14 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Page types the settings list hides on this device because the panel
|
||||
/// never renders them as pages: iPhone shows only the info page with
|
||||
/// inline sections, so queue has no effect there and comments live on
|
||||
/// the pill/overlay (configured in their own settings group).
|
||||
static var hiddenSettingsPageTypes: Set<PanelPageType> {
|
||||
isSectionsOnlyDevice ? [.queue, .comments] : []
|
||||
}
|
||||
|
||||
/// Enabled pages an inline-sections host renders below the video info,
|
||||
/// in configured order, with their per-page element settings.
|
||||
func inlineSections(in region: PanelRegion? = nil) -> [PanelPageConfiguration] {
|
||||
@@ -448,6 +541,12 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
configuration(for: .comments)?.commentsSettings.effectivePillMode ?? .pill
|
||||
}
|
||||
|
||||
/// Whether comment rows show author avatars (comments page setting),
|
||||
/// honored by the comments page and the pill overlay alike.
|
||||
var commentsShowAvatars: Bool {
|
||||
configuration(for: .comments)?.commentsSettings.effectiveShowAvatars ?? true
|
||||
}
|
||||
|
||||
/// Whether comments should be loaded from the API: needed by the comments
|
||||
/// page (when enabled) and by the pill (unless disabled).
|
||||
var shouldLoadComments: Bool {
|
||||
@@ -467,15 +566,47 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
pages = normalizedPages
|
||||
}
|
||||
|
||||
/// Enables or disables a page. The pinned page ignores this.
|
||||
mutating func setEnabled(_ isEnabled: Bool, for pageType: PanelPageType) {
|
||||
normalize()
|
||||
guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return }
|
||||
guard pageType != Self.pinnedPageType,
|
||||
let index = pages.firstIndex(where: { $0.pageType == pageType })
|
||||
else { return }
|
||||
pages[index].isEnabled = isEnabled
|
||||
}
|
||||
|
||||
/// Reorders pages by index in the normalized list. The pinned page snaps
|
||||
/// back to the front afterwards, so moving it (or moving another page
|
||||
/// above it) has no effect.
|
||||
mutating func movePages(fromOffsets source: IndexSet, toOffset destination: Int) {
|
||||
normalize()
|
||||
pages.move(fromOffsets: source, toOffset: destination)
|
||||
normalize()
|
||||
}
|
||||
|
||||
/// Reorders the subset of pages a settings list displays (offsets are
|
||||
/// relative to `visible`, in its current order). Pages the list hides
|
||||
/// keep their slots; the pinned page stays first.
|
||||
mutating func moveVisiblePages(
|
||||
_ visible: [PanelPageType],
|
||||
fromOffsets source: IndexSet,
|
||||
toOffset destination: Int
|
||||
) {
|
||||
normalize()
|
||||
var reordered = visible
|
||||
reordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
let byType = Dictionary(uniqueKeysWithValues: pages.map { ($0.pageType, $0) })
|
||||
let visibleSet = Set(visible)
|
||||
var next = reordered.makeIterator()
|
||||
pages = pages.map { page in
|
||||
guard visibleSet.contains(page.pageType),
|
||||
let type = next.next(),
|
||||
let replacement = byType[type]
|
||||
else { return page }
|
||||
return replacement
|
||||
}
|
||||
normalize()
|
||||
}
|
||||
|
||||
mutating func updateSettings(_ newSettings: PanelPageSettings?, for pageType: PanelPageType) {
|
||||
@@ -494,6 +625,8 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
case pages
|
||||
case layoutMode
|
||||
case defaultVisibility
|
||||
case portraitDefaultVisibility
|
||||
case landscapeDefaultVisibility
|
||||
case showPageBarLabels
|
||||
case wideLayoutSide
|
||||
}
|
||||
@@ -516,6 +649,8 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
pages = decodedPages
|
||||
layoutMode = try? container.decodeIfPresent(PanelLayoutMode.self, forKey: .layoutMode)
|
||||
defaultVisibility = try? container.decodeIfPresent(PanelDefaultVisibility.self, forKey: .defaultVisibility)
|
||||
portraitDefaultVisibility = try? container.decodeIfPresent(PanelDefaultVisibility.self, forKey: .portraitDefaultVisibility)
|
||||
landscapeDefaultVisibility = try? container.decodeIfPresent(PanelDefaultVisibility.self, forKey: .landscapeDefaultVisibility)
|
||||
showPageBarLabels = try container.decodeIfPresent(Bool.self, forKey: .showPageBarLabels)
|
||||
wideLayoutSide = try? container.decodeIfPresent(FloatingPanelSide.self, forKey: .wideLayoutSide)
|
||||
}
|
||||
@@ -525,6 +660,8 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
try container.encode(pages, forKey: .pages)
|
||||
try container.encodeIfPresent(layoutMode, forKey: .layoutMode)
|
||||
try container.encodeIfPresent(defaultVisibility, forKey: .defaultVisibility)
|
||||
try container.encodeIfPresent(portraitDefaultVisibility, forKey: .portraitDefaultVisibility)
|
||||
try container.encodeIfPresent(landscapeDefaultVisibility, forKey: .landscapeDefaultVisibility)
|
||||
try container.encodeIfPresent(showPageBarLabels, forKey: .showPageBarLabels)
|
||||
try container.encodeIfPresent(wideLayoutSide, forKey: .wideLayoutSide)
|
||||
}
|
||||
|
||||
@@ -794,15 +794,15 @@ final class PlayerControlsSettingsViewModel {
|
||||
panelSettings.enabledPages().count
|
||||
}
|
||||
|
||||
/// Current default panel visibility.
|
||||
var panelDefaultVisibility: PanelDefaultVisibility {
|
||||
panelSettings.effectiveDefaultVisibility
|
||||
/// Current default panel visibility for an orientation.
|
||||
func panelDefaultVisibility(for orientation: PanelOrientation) -> PanelDefaultVisibility {
|
||||
panelSettings.effectiveDefaultVisibility(for: orientation)
|
||||
}
|
||||
|
||||
/// Synchronously updates the default panel visibility.
|
||||
func syncPanelDefaultVisibility(_ visibility: PanelDefaultVisibility) {
|
||||
/// Synchronously updates the default panel visibility for an orientation.
|
||||
func syncPanelDefaultVisibility(_ visibility: PanelDefaultVisibility, for orientation: PanelOrientation) {
|
||||
updatePanelSettingsSync { settings in
|
||||
settings.defaultVisibility = visibility
|
||||
settings.setDefaultVisibility(visibility, for: orientation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,12 +840,6 @@ final class PlayerControlsSettingsViewModel {
|
||||
/// 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)
|
||||
}
|
||||
@@ -858,6 +852,14 @@ final class PlayerControlsSettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorders the pages a settings list displays; offsets are relative to
|
||||
/// `visible` (see `PanelSettings.moveVisiblePages`).
|
||||
func movePanelPages(visible: [PanelPageType], fromOffsets source: IndexSet, toOffset destination: Int) {
|
||||
updatePanelSettingsSync { settings in
|
||||
settings.moveVisiblePages(visible, fromOffsets: source, toOffset: destination)
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronously updates the info page element toggles.
|
||||
func updatePanelInfoSettingsSync(_ mutation: (inout InfoPageSettings) -> Void) {
|
||||
updatePanelSettingsSync { settings in
|
||||
|
||||
@@ -819,7 +819,7 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
isPanelVisible = show
|
||||
panelToggleGeneration += 1
|
||||
settingsManager?.landscapeDetailsPanelVisible = show
|
||||
settingsManager?.detailsPanelLastVisible = show
|
||||
settingsManager?.landscapeDetailsPanelLastVisible = show
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ extension ExpandedPlayerSheet {
|
||||
panelRevealOffset = 0
|
||||
isPortraitPanelVisible = true
|
||||
navigationCoordinator?.isPortraitPanelVisible = true
|
||||
appEnvironment?.settingsManager.detailsPanelLastVisible = true
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = true
|
||||
// Match video position from reveal formula
|
||||
let revealProgress = min(1.0, -currentRevealOffset / pinnedPanelHeight)
|
||||
let maxVideoOffset = hiddenVideoY - topY
|
||||
@@ -518,7 +518,7 @@ extension ExpandedPlayerSheet {
|
||||
withTransaction(transaction) {
|
||||
isPortraitPanelVisible = false
|
||||
navigationCoordinator?.isPortraitPanelVisible = false
|
||||
appEnvironment?.settingsManager.detailsPanelLastVisible = false
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = false
|
||||
isCommentsExpanded = false
|
||||
navigationCoordinator?.isCommentsExpanded = false
|
||||
navigationCoordinator?.commentsFrame = .zero
|
||||
@@ -659,7 +659,7 @@ extension ExpandedPlayerSheet {
|
||||
isPortraitPanelVisible = newValue
|
||||
}
|
||||
navigationCoordinator?.isPortraitPanelVisible = newValue
|
||||
appEnvironment?.settingsManager.detailsPanelLastVisible = newValue
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = newValue
|
||||
}
|
||||
|
||||
/// Hide floating panel with animation
|
||||
@@ -668,7 +668,7 @@ extension ExpandedPlayerSheet {
|
||||
isPortraitPanelVisible = false
|
||||
}
|
||||
navigationCoordinator?.isPortraitPanelVisible = false
|
||||
appEnvironment?.settingsManager.detailsPanelLastVisible = false
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = false
|
||||
|
||||
// Reset main window transform when hiding panel to fix safe area clipping (iOS only)
|
||||
#if os(iOS)
|
||||
@@ -866,7 +866,7 @@ extension ExpandedPlayerSheet {
|
||||
isPortraitPanelVisible.toggle()
|
||||
}
|
||||
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
||||
appEnvironment?.settingsManager.detailsPanelLastVisible = isPortraitPanelVisible
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = isPortraitPanelVisible
|
||||
},
|
||||
onShowSettings: { [self] in
|
||||
showingQualitySheet = true
|
||||
@@ -1139,7 +1139,7 @@ extension ExpandedPlayerSheet {
|
||||
isPortraitPanelVisible.toggle()
|
||||
}
|
||||
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
||||
appEnvironment?.settingsManager.detailsPanelLastVisible = isPortraitPanelVisible
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = isPortraitPanelVisible
|
||||
// Re-enable controls after animation completes (0.35s)
|
||||
// Controls will still be hidden (forceInitialHidden) until user taps
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
@@ -1320,11 +1320,11 @@ extension ExpandedPlayerSheet {
|
||||
func cornerContent(video: Video) -> some View {
|
||||
// Seed the panel visibility before the first frame: once the
|
||||
// session's default visibility is applied the setting is the truth;
|
||||
// until then resolve the preset default (same policy the sheet's
|
||||
// applyPanelDefaultVisibility writes moments later).
|
||||
// until then resolve the preset's landscape default (same policy the
|
||||
// sheet's applyPanelDefaultVisibility writes moments later).
|
||||
let initialPanelVisible = appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true
|
||||
? (appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
: Self.initialPortraitPanelVisibility(settingsManager: appEnvironment?.settingsManager)
|
||||
: Self.initialPanelVisibility(for: .landscape, settingsManager: appEnvironment?.settingsManager)
|
||||
|
||||
CornerPlayerLayout(
|
||||
playerControlsLayout: playerControlsLayout,
|
||||
|
||||
@@ -35,20 +35,20 @@ struct ExpandedPlayerSheet: View {
|
||||
/// Temporarily hide controls during fullscreen transition to avoid animation glitch
|
||||
@State var hideControlsDuringTransition: Bool = false
|
||||
|
||||
/// Resolves the preset's default panel visibility for the portrait panel
|
||||
/// Resolves the preset's default panel visibility for one orientation
|
||||
/// from the cached settings, so creators of this view can seed the initial
|
||||
/// state BEFORE the first frame — applying it in onAppear animates a
|
||||
/// visible top-to-center video jump during player expansion.
|
||||
@MainActor
|
||||
static func initialPanelVisibility(for orientation: PanelOrientation, settingsManager: SettingsManager?) -> Bool {
|
||||
PanelSettings.cached.effectiveDefaultVisibility(for: orientation)
|
||||
.resolve(lastVisible: settingsManager?.detailsPanelLastVisible(for: orientation) ?? false)
|
||||
}
|
||||
|
||||
/// Portrait shorthand for `initialPanelVisibility(for:settingsManager:)`.
|
||||
@MainActor
|
||||
static func initialPortraitPanelVisibility(settingsManager: SettingsManager?) -> Bool {
|
||||
switch PanelSettings.cached.effectiveDefaultVisibility {
|
||||
case .hidden:
|
||||
return false
|
||||
case .visible:
|
||||
return true
|
||||
case .remember:
|
||||
return settingsManager?.detailsPanelLastVisible ?? false
|
||||
}
|
||||
initialPanelVisibility(for: .portrait, settingsManager: settingsManager)
|
||||
}
|
||||
|
||||
init(initialPortraitPanelVisible: Bool = true) {
|
||||
@@ -281,7 +281,8 @@ struct ExpandedPlayerSheet: View {
|
||||
onClose: collapseComments,
|
||||
onDismissOffsetChanged: handleCommentsDismissOffset,
|
||||
onDismissGestureEnded: handleCommentsDismissGestureEnded,
|
||||
dismissThreshold: 30
|
||||
dismissThreshold: 30,
|
||||
showAvatars: playerControlsLayout.effectivePanelSettings.commentsShowAvatars
|
||||
)
|
||||
.frame(height: max(0, commentsHeight))
|
||||
.clipped()
|
||||
@@ -519,6 +520,7 @@ struct ExpandedPlayerSheet: View {
|
||||
CommentsPillView(
|
||||
comment: firstComment,
|
||||
isCollapsed: false,
|
||||
showAvatar: playerControlsLayout.effectivePanelSettings.commentsShowAvatars,
|
||||
onTap: expandComments
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
@@ -581,6 +583,7 @@ struct ExpandedPlayerSheet: View {
|
||||
comment: firstComment,
|
||||
isCollapsed: false,
|
||||
fillWidth: true,
|
||||
showAvatar: playerControlsLayout.effectivePanelSettings.commentsShowAvatars,
|
||||
onTap: expandComments
|
||||
)
|
||||
.frame(maxWidth: 400)
|
||||
@@ -988,26 +991,20 @@ private struct PlayerEventHandlersModifier: ViewModifier {
|
||||
}
|
||||
|
||||
/// 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. Returns the
|
||||
/// resolved visibility so callers can mirror it into the portrait
|
||||
/// panel state, which lives in view @State rather than settings.
|
||||
/// panel mount. The landscape default is written straight to the
|
||||
/// persisted landscape state (the wide layouts read it); the portrait
|
||||
/// default is returned so callers can mirror it into the portrait panel
|
||||
/// state, which lives in view @State rather than settings.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func applyPanelDefaultVisibility(_ settings: PanelSettings, settingsManager: SettingsManager?) -> Bool {
|
||||
guard let settingsManager else { return true }
|
||||
switch settings.effectiveDefaultVisibility {
|
||||
case .hidden:
|
||||
settingsManager.landscapeDetailsPanelVisible = false
|
||||
return false
|
||||
case .visible:
|
||||
settingsManager.landscapeDetailsPanelVisible = true
|
||||
return true
|
||||
case .remember:
|
||||
let lastVisible = settingsManager.detailsPanelLastVisible
|
||||
settingsManager.landscapeDetailsPanelVisible = lastVisible
|
||||
return lastVisible
|
||||
}
|
||||
settingsManager.landscapeDetailsPanelVisible = settings
|
||||
.effectiveDefaultVisibility(for: .landscape)
|
||||
.resolve(lastVisible: settingsManager.landscapeDetailsPanelLastVisible)
|
||||
return settings
|
||||
.effectiveDefaultVisibility(for: .portrait)
|
||||
.resolve(lastVisible: settingsManager.portraitDetailsPanelLastVisible)
|
||||
}
|
||||
|
||||
// MARK: - iOS Event Handlers Modifier
|
||||
|
||||
@@ -811,7 +811,7 @@ final class ExpandedPlayerWindowManager {
|
||||
let initialPanelVisible = ExpandedPlayerSheet.initialPortraitPanelVisibility(
|
||||
settingsManager: appEnvironment.settingsManager
|
||||
)
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: initialPortraitPanelVisible=\(initialPanelVisible), defaultVisibility=\(PanelSettings.cached.effectiveDefaultVisibility)")
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: initialPortraitPanelVisible=\(initialPanelVisible), portraitDefaultVisibility=\(PanelSettings.cached.effectiveDefaultVisibility(for: .portrait))")
|
||||
let expandedView = ExpandedPlayerSheet(initialPortraitPanelVisible: initialPanelVisible)
|
||||
.appEnvironment(appEnvironment)
|
||||
.environment(\.statusBarVisibilityController, statusBarController)
|
||||
|
||||
@@ -346,7 +346,7 @@ struct FloatingDetailsPanel: View {
|
||||
VStack(spacing: 12) {
|
||||
// Expanded comments pill on its own row (when player pill exists and pill not collapsed)
|
||||
if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill, !isPillCollapsed {
|
||||
CommentsPillView(comment: firstComment, isCollapsed: false, onTap: expandComments)
|
||||
CommentsPillView(comment: firstComment, isCollapsed: false, showAvatar: panelSettings.commentsShowAvatars, onTap: expandComments)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
@@ -434,7 +434,7 @@ struct FloatingDetailsPanel: View {
|
||||
HStack {
|
||||
if hasCommentsPill, let firstComment = comments.first {
|
||||
if !shouldShowPlayerPill {
|
||||
CommentsPillView(comment: firstComment, isCollapsed: isPillCollapsed, onTap: expandComments)
|
||||
CommentsPillView(comment: firstComment, isCollapsed: isPillCollapsed, showAvatar: panelSettings.commentsShowAvatars, onTap: expandComments)
|
||||
} else if isPillCollapsed {
|
||||
CommentsPillView(comment: firstComment, isCollapsed: true, onTap: expandComments)
|
||||
}
|
||||
@@ -469,7 +469,8 @@ struct FloatingDetailsPanel: View {
|
||||
ExpandedCommentsView(
|
||||
videoID: video.id.videoID,
|
||||
onClose: collapseComments,
|
||||
onDismissGestureEnded: handleCommentsDismissGestureEnded
|
||||
onDismissGestureEnded: handleCommentsDismissGestureEnded,
|
||||
showAvatars: panelSettings.commentsShowAvatars
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.visualEffect { content, proxy in
|
||||
|
||||
@@ -45,6 +45,18 @@ struct InfoPanelPage: View {
|
||||
inlineSections.first { $0.pageType == .chapters }?.chaptersSettings
|
||||
}
|
||||
|
||||
private var relatedSectionSettings: RelatedPageSettings? {
|
||||
inlineSections.first { $0.pageType == .related }?.relatedSettings
|
||||
}
|
||||
|
||||
/// Seeds the description and section collapse state from the configured
|
||||
/// defaults.
|
||||
private func resetSectionCollapseState() {
|
||||
isDescriptionExpanded = settings.effectiveDescriptionExpandedByDefault
|
||||
isChaptersCollapsed = !(chaptersSectionSettings?.effectiveExpandedByDefault ?? true)
|
||||
isRelatedCollapsed = !(relatedSectionSettings?.effectiveExpandedByDefault ?? true)
|
||||
}
|
||||
|
||||
@State private var showFormattedDate = false
|
||||
@State private var showOriginalTitle = false
|
||||
@State private var isDescriptionExpanded = false
|
||||
@@ -77,7 +89,7 @@ struct InfoPanelPage: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PanelPageScrollView(config: scrollConfig) {
|
||||
PanelPageScrollView(config: scrollConfig) { proxy in
|
||||
videoInfo
|
||||
|
||||
if settings.effectiveShowDescription {
|
||||
@@ -89,14 +101,20 @@ struct InfoPanelPage: View {
|
||||
}
|
||||
|
||||
ForEach(inlineSections) { section in
|
||||
inlineSection(section)
|
||||
// One container per section so the scroll anchor is a single
|
||||
// view (the divider + header + rows), not each child.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
inlineSection(section, scrollProxy: proxy)
|
||||
}
|
||||
.id(Self.sectionScrollID(section.pageType))
|
||||
}
|
||||
}
|
||||
.onChange(of: video.id) { _, _ in
|
||||
showOriginalTitle = false
|
||||
isDescriptionExpanded = false
|
||||
isChaptersCollapsed = false
|
||||
isRelatedCollapsed = false
|
||||
resetSectionCollapseState()
|
||||
}
|
||||
.onAppear {
|
||||
resetSectionCollapseState()
|
||||
}
|
||||
.task(id: context.video.id) {
|
||||
if chaptersSectionSettings?.effectiveShowThumbnails == true {
|
||||
@@ -213,8 +231,9 @@ struct InfoPanelPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsed: 3 lines with an inline "more" cutting into the end of the
|
||||
/// last line; the whole area is tappable to expand. Expanded: full text
|
||||
/// Collapsed: the configured number of lines with an inline "more"
|
||||
/// cutting into the end of the last line; the whole area is tappable to
|
||||
/// expand. Expanded: full text
|
||||
/// with working links and a "less" button below.
|
||||
@ViewBuilder
|
||||
private func collapsibleDescription(_ description: String) -> some View {
|
||||
@@ -223,7 +242,7 @@ struct InfoPanelPage: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.tint(context.accentColor)
|
||||
.lineLimit(isDescriptionExpanded ? nil : 3)
|
||||
.lineLimit(isDescriptionExpanded ? nil : settings.effectiveDescriptionCollapsedLines)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.handleTimestampLinks(using: playerService)
|
||||
// Collapsed: let the container tap win over link taps
|
||||
@@ -250,9 +269,7 @@ struct InfoPanelPage: View {
|
||||
|
||||
if isDescriptionExpanded {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isDescriptionExpanded = false
|
||||
}
|
||||
isDescriptionExpanded = false
|
||||
} label: {
|
||||
Text(String(localized: "common.less"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -265,9 +282,7 @@ struct InfoPanelPage: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if !isDescriptionExpanded {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isDescriptionExpanded = true
|
||||
}
|
||||
isDescriptionExpanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,25 +293,49 @@ struct InfoPanelPage: View {
|
||||
private var storyboard: Storyboard? { playerState?.preferredStoryboard }
|
||||
private var relatedVideos: [Video] { video.relatedVideos ?? [] }
|
||||
|
||||
/// Scroll anchor for a section: its Divider + header, so scrolling to it
|
||||
/// puts the tapped header at the top of the panel.
|
||||
private static func sectionScrollID(_ pageType: PanelPageType) -> String {
|
||||
"inlineSection-\(pageType.rawValue)"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func inlineSection(_ section: PanelPageConfiguration) -> some View {
|
||||
private func inlineSection(_ section: PanelPageConfiguration, scrollProxy: ScrollViewProxy) -> some View {
|
||||
switch section.pageType {
|
||||
case .chapters:
|
||||
chaptersSection(settings: section.chaptersSettings)
|
||||
chaptersSection(settings: section.chaptersSettings, scrollProxy: scrollProxy)
|
||||
case .related:
|
||||
relatedSection(settings: section.relatedSettings)
|
||||
relatedSection(settings: section.relatedSettings, scrollProxy: scrollProxy)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
/// Section header that toggles the section's collapsed state on tap.
|
||||
/// Expanding also scrolls the section to the top of the panel so the
|
||||
/// revealed content starts right under the tapped header, instead of
|
||||
/// growing below the fold.
|
||||
@ViewBuilder
|
||||
private func sectionHeader(_ title: String, isCollapsed: Binding<Bool>) -> some View {
|
||||
private func sectionHeader(
|
||||
_ title: String,
|
||||
isCollapsed: Binding<Bool>,
|
||||
scrollProxy: ScrollViewProxy,
|
||||
scrollID: String
|
||||
) -> some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
let expanding = isCollapsed.wrappedValue
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isCollapsed.wrappedValue.toggle()
|
||||
}
|
||||
if expanding {
|
||||
// Next runloop: the expanded content must be laid out before
|
||||
// the scroll view can reach the section's top.
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
scrollProxy.scrollTo(scrollID, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
@@ -318,13 +357,18 @@ struct InfoPanelPage: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chaptersSection(settings: ChaptersPageSettings) -> some View {
|
||||
private func chaptersSection(settings: ChaptersPageSettings, scrollProxy: ScrollViewProxy) -> some View {
|
||||
if !chapters.isEmpty {
|
||||
Divider()
|
||||
.panelHorizontalPadding()
|
||||
.padding(.bottom, 8)
|
||||
|
||||
sectionHeader(String(localized: "player.chapters"), isCollapsed: $isChaptersCollapsed)
|
||||
sectionHeader(
|
||||
String(localized: "player.chapters"),
|
||||
isCollapsed: $isChaptersCollapsed,
|
||||
scrollProxy: scrollProxy,
|
||||
scrollID: Self.sectionScrollID(.chapters)
|
||||
)
|
||||
|
||||
if !isChaptersCollapsed {
|
||||
LazyVStack(spacing: 0) {
|
||||
@@ -349,13 +393,18 @@ struct InfoPanelPage: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func relatedSection(settings: RelatedPageSettings) -> some View {
|
||||
private func relatedSection(settings: RelatedPageSettings, scrollProxy: ScrollViewProxy) -> some View {
|
||||
if !relatedVideos.isEmpty {
|
||||
Divider()
|
||||
.panelHorizontalPadding()
|
||||
.padding(.bottom, 8)
|
||||
|
||||
sectionHeader(String(localized: "videoInfo.section.relatedVideos"), isCollapsed: $isRelatedCollapsed)
|
||||
sectionHeader(
|
||||
String(localized: "videoInfo.section.relatedVideos"),
|
||||
isCollapsed: $isRelatedCollapsed,
|
||||
scrollProxy: scrollProxy,
|
||||
scrollID: Self.sectionScrollID(.related)
|
||||
)
|
||||
|
||||
if !isRelatedCollapsed {
|
||||
LazyVStack(spacing: 12) {
|
||||
|
||||
@@ -119,7 +119,19 @@ extension View {
|
||||
/// the portrait overscroll gesture when the host provides callbacks.
|
||||
struct PanelPageScrollView<Content: View>: View {
|
||||
let config: PanelScrollConfig
|
||||
@ViewBuilder let content: Content
|
||||
private let content: (ScrollViewProxy) -> Content
|
||||
|
||||
init(config: PanelScrollConfig, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.config = config
|
||||
self.content = { _ in content() }
|
||||
}
|
||||
|
||||
/// Content that needs to scroll programmatically (e.g. to a section it
|
||||
/// just expanded) receives the scroll proxy.
|
||||
init(config: PanelScrollConfig, @ViewBuilder content: @escaping (ScrollViewProxy) -> Content) {
|
||||
self.config = config
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
@@ -140,7 +152,7 @@ struct PanelPageScrollView<Content: View>: View {
|
||||
Color.clear
|
||||
.frame(height: config.topInset)
|
||||
|
||||
content
|
||||
content(proxy)
|
||||
|
||||
Spacer()
|
||||
.frame(height: config.bottomInset)
|
||||
|
||||
@@ -105,9 +105,15 @@ struct PortraitDetailsPanel: View {
|
||||
}
|
||||
|
||||
/// Whether comments pill is shown expanded on its own row (above player pill)
|
||||
/// Whether the comments pill currently occupies its own row above the
|
||||
/// player pill. Must track the pill's actual collapse state (including
|
||||
/// collapse on scroll): the bottom fade sizes its touch blocker from
|
||||
/// this, and a stale "expanded" answer swallows taps on the last rows
|
||||
/// of content, e.g. a collapsed section header.
|
||||
private var hasExpandedCommentsPill: Bool {
|
||||
let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill
|
||||
return hasCommentsPill && shouldShowPlayerPill && !isCommentsPillAlwaysCollapsed && hasVideoDescription
|
||||
let isPillCollapsed = scrollOffset > 20 || isCommentsPillAlwaysCollapsed || !hasVideoDescription
|
||||
return hasCommentsPill && shouldShowPlayerPill && !isPillCollapsed
|
||||
}
|
||||
|
||||
// MARK: - Drag Handle
|
||||
@@ -274,7 +280,8 @@ struct PortraitDetailsPanel: View {
|
||||
onClose: collapseComments,
|
||||
onDismissGestureEnded: handleCommentsDismissGestureEnded,
|
||||
onDragChanged: onDragChanged,
|
||||
onDragEnded: onDragEnded
|
||||
onDragEnded: onDragEnded,
|
||||
showAvatars: panelSettings.commentsShowAvatars
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.visualEffect { content, proxy in
|
||||
@@ -326,6 +333,7 @@ struct PortraitDetailsPanel: View {
|
||||
CommentsPillView(
|
||||
comment: firstComment,
|
||||
isCollapsed: false,
|
||||
showAvatar: panelSettings.commentsShowAvatars,
|
||||
onTap: expandComments
|
||||
)
|
||||
}
|
||||
@@ -384,6 +392,7 @@ struct PortraitDetailsPanel: View {
|
||||
isCollapsed: isPillCollapsed,
|
||||
fillWidth: !isPillCollapsed,
|
||||
compact: isCompact && isPillCollapsed,
|
||||
showAvatar: panelSettings.commentsShowAvatars,
|
||||
onTap: expandComments
|
||||
)
|
||||
.frame(maxWidth: isPillCollapsed ? nil : 400)
|
||||
@@ -394,6 +403,7 @@ struct PortraitDetailsPanel: View {
|
||||
comment: firstComment,
|
||||
isCollapsed: true,
|
||||
compact: isCompact,
|
||||
showAvatar: panelSettings.commentsShowAvatars,
|
||||
onTap: expandComments
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -101,7 +101,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isPanelVisible.toggle()
|
||||
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
|
||||
settingsManager?.detailsPanelLastVisible = isPanelVisible
|
||||
settingsManager?.landscapeDetailsPanelLastVisible = isPanelVisible
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
|
||||
private func hidePanel() {
|
||||
isPanelVisible = false
|
||||
settingsManager?.landscapeDetailsPanelVisible = false
|
||||
settingsManager?.detailsPanelLastVisible = false
|
||||
settingsManager?.landscapeDetailsPanelLastVisible = false
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@@ -359,7 +359,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
|
||||
// Pinning - ensure panel is visible first
|
||||
isPanelVisible = true
|
||||
settingsManager?.landscapeDetailsPanelVisible = true
|
||||
settingsManager?.detailsPanelLastVisible = true
|
||||
settingsManager?.landscapeDetailsPanelLastVisible = true
|
||||
|
||||
// Animate panscan to zero and pin concurrently for smooth transition
|
||||
appEnvironment?.navigationCoordinator.animatePanscanToZero(completion: nil)
|
||||
|
||||
@@ -17,21 +17,25 @@ struct PanelPageEditorView: View {
|
||||
@State private var isEnabled: Bool = true
|
||||
@State private var region: PanelRegion = .primary
|
||||
@State private var showDescription: Bool = true
|
||||
@State private var descriptionExpandedByDefault: Bool = false
|
||||
@State private var descriptionCollapsedLines: Int = InfoPageSettings.defaultDescriptionCollapsedLines
|
||||
@State private var showStatsRow: Bool = true
|
||||
@State private var showChannelRow: Bool = true
|
||||
@State private var showAvatars: Bool = true
|
||||
@State private var commentsPillMode: CommentsPillMode = .pill
|
||||
@State private var showRelatedThumbnails: Bool = true
|
||||
@State private var relatedExpandedByDefault: Bool = true
|
||||
@State private var showChapterThumbnails: Bool = true
|
||||
@State private var chaptersExpandedByDefault: Bool = true
|
||||
@State private var showHistory: 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
|
||||
/// The pinned page (info) is always first and enabled: no toggle.
|
||||
private var isPinnedPage: Bool {
|
||||
pageType == PanelSettings.pinnedPageType
|
||||
}
|
||||
|
||||
/// iPhone configures inline sections rather than pages.
|
||||
@@ -39,7 +43,9 @@ struct PanelPageEditorView: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
enabledSection
|
||||
if !isPinnedPage {
|
||||
enabledSection
|
||||
}
|
||||
|
||||
if PanelSettingsView.supportsCornerLayout, viewModel.panelLayoutMode == .corner {
|
||||
regionSection
|
||||
@@ -83,17 +89,13 @@ struct PanelPageEditorView: View {
|
||||
: String(localized: "panel.settings.page.enabled"),
|
||||
isOn: $isEnabled
|
||||
)
|
||||
.disabled(!viewModel.canEditActivePreset || isLastEnabledPage)
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
guard newValue != configuration?.isEnabled else { return }
|
||||
viewModel.setPanelPageEnabled(pageType, isEnabled: newValue)
|
||||
}
|
||||
} footer: {
|
||||
if isLastEnabledPage {
|
||||
Text(usesSections
|
||||
? String(localized: "panel.settings.section.lastEnabled.footer")
|
||||
: String(localized: "panel.settings.page.lastEnabled.footer"))
|
||||
} else if !viewModel.canEditActivePreset {
|
||||
if !viewModel.canEditActivePreset {
|
||||
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
||||
}
|
||||
}
|
||||
@@ -123,6 +125,7 @@ struct PanelPageEditorView: View {
|
||||
|
||||
// MARK: - Info Elements Section
|
||||
|
||||
@ViewBuilder
|
||||
private var infoElementsSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
@@ -155,6 +158,46 @@ struct PanelPageEditorView: View {
|
||||
Text(String(localized: "panel.settings.elements.header"))
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
|
||||
if Self.hasInlineSections, showDescription {
|
||||
descriptionSection
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse behavior of the description on inline-sections hosts (iPhone,
|
||||
/// iPad portrait), where sections below it clamp it to a few lines.
|
||||
private var descriptionSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "panel.settings.section.expandedByDefault"),
|
||||
isOn: $descriptionExpandedByDefault
|
||||
)
|
||||
.onChange(of: descriptionExpandedByDefault) { _, newValue in
|
||||
guard newValue != infoSettings.effectiveDescriptionExpandedByDefault else { return }
|
||||
viewModel.updatePanelInfoSettingsSync { $0.descriptionExpandedByDefault = newValue }
|
||||
}
|
||||
|
||||
Stepper(
|
||||
value: $descriptionCollapsedLines,
|
||||
in: InfoPageSettings.descriptionCollapsedLinesRange
|
||||
) {
|
||||
HStack {
|
||||
Text(String(localized: "panel.settings.info.descriptionCollapsedLines"))
|
||||
Spacer()
|
||||
Text("\(descriptionCollapsedLines)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: descriptionCollapsedLines) { _, newValue in
|
||||
guard newValue != infoSettings.effectiveDescriptionCollapsedLines else { return }
|
||||
viewModel.updatePanelInfoSettingsSync { $0.descriptionCollapsedLines = newValue }
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "panel.settings.info.description.header"))
|
||||
} footer: {
|
||||
Text(String(localized: "panel.settings.info.description.footer"))
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
|
||||
// MARK: - Comments Elements Section
|
||||
@@ -200,8 +243,23 @@ struct PanelPageEditorView: View {
|
||||
guard newValue != relatedSettings.effectiveShowThumbnails else { return }
|
||||
viewModel.updatePanelRelatedSettingsSync { $0.showThumbnails = newValue }
|
||||
}
|
||||
|
||||
if Self.hasInlineSections {
|
||||
Toggle(
|
||||
String(localized: "panel.settings.section.expandedByDefault"),
|
||||
isOn: $relatedExpandedByDefault
|
||||
)
|
||||
.onChange(of: relatedExpandedByDefault) { _, newValue in
|
||||
guard newValue != relatedSettings.effectiveExpandedByDefault else { return }
|
||||
viewModel.updatePanelRelatedSettingsSync { $0.expandedByDefault = newValue }
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "panel.settings.elements.header"))
|
||||
} footer: {
|
||||
if Self.hasInlineSections {
|
||||
Text(String(localized: "panel.settings.section.expandedByDefault.footer"))
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
@@ -218,12 +276,37 @@ struct PanelPageEditorView: View {
|
||||
guard newValue != chaptersSettings.effectiveShowThumbnails else { return }
|
||||
viewModel.updatePanelChaptersSettingsSync { $0.showThumbnails = newValue }
|
||||
}
|
||||
|
||||
if Self.hasInlineSections {
|
||||
Toggle(
|
||||
String(localized: "panel.settings.section.expandedByDefault"),
|
||||
isOn: $chaptersExpandedByDefault
|
||||
)
|
||||
.onChange(of: chaptersExpandedByDefault) { _, newValue in
|
||||
guard newValue != chaptersSettings.effectiveExpandedByDefault else { return }
|
||||
viewModel.updatePanelChaptersSettingsSync { $0.expandedByDefault = newValue }
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "panel.settings.elements.header"))
|
||||
} footer: {
|
||||
if Self.hasInlineSections {
|
||||
Text(String(localized: "panel.settings.section.expandedByDefault.footer"))
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
|
||||
/// Chapters and related render as collapsible inline sections on iOS
|
||||
/// (iPhone everywhere, iPad portrait); macOS always uses pages.
|
||||
private static var hasInlineSections: Bool {
|
||||
#if os(iOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Queue Elements Section
|
||||
|
||||
private var queueElementsSection: some View {
|
||||
@@ -271,12 +354,16 @@ struct PanelPageEditorView: View {
|
||||
region = configuration?.region ?? .primary
|
||||
let info = infoSettings
|
||||
showDescription = info.effectiveShowDescription
|
||||
descriptionExpandedByDefault = info.effectiveDescriptionExpandedByDefault
|
||||
descriptionCollapsedLines = info.effectiveDescriptionCollapsedLines
|
||||
showStatsRow = info.effectiveShowStatsRow
|
||||
showChannelRow = info.effectiveShowChannelRow
|
||||
showAvatars = commentsSettings.effectiveShowAvatars
|
||||
commentsPillMode = commentsSettings.effectivePillMode
|
||||
showRelatedThumbnails = relatedSettings.effectiveShowThumbnails
|
||||
relatedExpandedByDefault = relatedSettings.effectiveExpandedByDefault
|
||||
showChapterThumbnails = chaptersSettings.effectiveShowThumbnails
|
||||
chaptersExpandedByDefault = chaptersSettings.effectiveExpandedByDefault
|
||||
showHistory = queueSettings.effectiveShowHistory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ struct PanelSettingsView: View {
|
||||
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
|
||||
@State private var portraitDefaultVisibility: PanelDefaultVisibility = .visible
|
||||
@State private var landscapeDefaultVisibility: PanelDefaultVisibility = .hidden
|
||||
@State private var layoutMode: PanelLayoutMode = .stacked
|
||||
@State private var wideLayoutSide: FloatingPanelSide = .right
|
||||
@State private var showPageBarLabels: Bool = true
|
||||
@State private var commentsShowAvatars: Bool = true
|
||||
@State private var commentsPillMode: CommentsPillMode = .pill
|
||||
|
||||
/// Corner layout exists on iPad and macOS only.
|
||||
static var supportsCornerLayout: Bool {
|
||||
@@ -40,6 +43,9 @@ struct PanelSettingsView: View {
|
||||
pageBarSection
|
||||
}
|
||||
pagesSection
|
||||
if usesSections {
|
||||
commentsSection
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
|
||||
#if os(iOS)
|
||||
@@ -106,26 +112,61 @@ struct PanelSettingsView: View {
|
||||
|
||||
// MARK: - Visibility Section
|
||||
|
||||
/// macOS has no portrait player layout: only the landscape default.
|
||||
private static var hasPortraitLayout: Bool {
|
||||
#if os(macOS)
|
||||
return false
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if Self.hasPortraitLayout {
|
||||
visibilityPicker(
|
||||
String(localized: "panel.settings.defaultVisibility.portrait"),
|
||||
selection: $portraitDefaultVisibility,
|
||||
orientation: .portrait
|
||||
)
|
||||
visibilityPicker(
|
||||
String(localized: "panel.settings.defaultVisibility.landscape"),
|
||||
selection: $landscapeDefaultVisibility,
|
||||
orientation: .landscape
|
||||
)
|
||||
} else {
|
||||
visibilityPicker(
|
||||
String(localized: "panel.settings.defaultVisibility"),
|
||||
selection: $landscapeDefaultVisibility,
|
||||
orientation: .landscape
|
||||
)
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: defaultVisibility) { _, newValue in
|
||||
guard newValue != viewModel.panelDefaultVisibility else { return }
|
||||
viewModel.syncPanelDefaultVisibility(newValue)
|
||||
} header: {
|
||||
if Self.hasPortraitLayout {
|
||||
Text(String(localized: "panel.settings.defaultVisibility"))
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "panel.settings.defaultVisibility.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
private func visibilityPicker(
|
||||
_ title: String,
|
||||
selection: Binding<PanelDefaultVisibility>,
|
||||
orientation: PanelOrientation
|
||||
) -> some View {
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(PanelDefaultVisibility.allCases, id: \.self) { visibility in
|
||||
Text(visibility.displayName).tag(visibility)
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: selection.wrappedValue) { _, newValue in
|
||||
guard newValue != viewModel.panelDefaultVisibility(for: orientation) else { return }
|
||||
viewModel.syncPanelDefaultVisibility(newValue, for: orientation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page Bar Section
|
||||
|
||||
private var pageBarSection: some View {
|
||||
@@ -144,15 +185,82 @@ struct PanelSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comments Section (iPhone)
|
||||
|
||||
/// iPhone never renders a comments page: comments live on the pill and
|
||||
/// overlay, so their settings sit here instead of in the sections list.
|
||||
private var commentsSettings: CommentsPageSettings {
|
||||
viewModel.panelSettings.configuration(for: .comments)?.commentsSettings ?? CommentsPageSettings()
|
||||
}
|
||||
|
||||
private var commentsSection: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: "commentsPill.mode.title"),
|
||||
selection: $commentsPillMode
|
||||
) {
|
||||
ForEach(CommentsPillMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.onChange(of: commentsPillMode) { _, newValue in
|
||||
guard newValue != commentsSettings.effectivePillMode else { return }
|
||||
viewModel.updatePanelCommentsSettingsSync { $0.pillMode = newValue }
|
||||
}
|
||||
|
||||
Toggle(
|
||||
String(localized: "panel.settings.comments.showAvatars"),
|
||||
isOn: $commentsShowAvatars
|
||||
)
|
||||
.onChange(of: commentsShowAvatars) { _, newValue in
|
||||
guard newValue != commentsSettings.effectiveShowAvatars else { return }
|
||||
viewModel.updatePanelCommentsSettingsSync { $0.showAvatars = newValue }
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "player.comments"))
|
||||
} footer: {
|
||||
if !viewModel.canEditActivePreset {
|
||||
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
|
||||
// MARK: - Pages Section
|
||||
|
||||
/// The pinned page (info) is rendered outside the reorderable list so it
|
||||
/// gets no drag handle and nothing can be dropped above it.
|
||||
private var pinnedPage: PanelPageConfiguration? {
|
||||
viewModel.panelPages.first { $0.pageType == PanelSettings.pinnedPageType }
|
||||
}
|
||||
|
||||
/// Pages the user can reorder: everything but the pinned page and the
|
||||
/// types this device never renders (iPhone: queue).
|
||||
private var reorderablePages: [PanelPageConfiguration] {
|
||||
let hidden = PanelSettings.hiddenSettingsPageTypes
|
||||
return viewModel.panelPages.filter {
|
||||
$0.pageType != PanelSettings.pinnedPageType && !hidden.contains($0.pageType)
|
||||
}
|
||||
}
|
||||
|
||||
private var pagesSection: some View {
|
||||
Section {
|
||||
ForEach(viewModel.panelPages) { page in
|
||||
if let pinnedPage {
|
||||
pageRow(for: pinnedPage)
|
||||
.moveDisabled(true)
|
||||
}
|
||||
|
||||
ForEach(reorderablePages) { page in
|
||||
pageRow(for: page)
|
||||
}
|
||||
.onMove { source, destination in
|
||||
viewModel.movePanelPages(fromOffsets: source, toOffset: destination)
|
||||
// Offsets are relative to the displayed list; the pinned and
|
||||
// hidden pages keep their slots in the stored list.
|
||||
viewModel.movePanelPages(
|
||||
visible: reorderablePages.map(\.pageType),
|
||||
fromOffsets: source,
|
||||
toOffset: destination
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text(usesSections
|
||||
@@ -202,10 +310,13 @@ struct PanelSettingsView: View {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func syncLocalState() {
|
||||
defaultVisibility = viewModel.panelDefaultVisibility
|
||||
portraitDefaultVisibility = viewModel.panelDefaultVisibility(for: .portrait)
|
||||
landscapeDefaultVisibility = viewModel.panelDefaultVisibility(for: .landscape)
|
||||
layoutMode = viewModel.panelLayoutMode
|
||||
wideLayoutSide = viewModel.panelWideLayoutSide
|
||||
showPageBarLabels = viewModel.panelShowPageBarLabels
|
||||
commentsShowAvatars = commentsSettings.effectiveShowAvatars
|
||||
commentsPillMode = commentsSettings.effectivePillMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,9 +138,17 @@ private struct TVPanelPageEditorView: View {
|
||||
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
|
||||
/// The pinned page (info) is always first and enabled: no toggle, no
|
||||
/// reordering.
|
||||
private var isPinnedPage: Bool {
|
||||
pageType == PanelSettings.pinnedPageType
|
||||
}
|
||||
|
||||
/// Whether the displayed neighbor above is the pinned page (or there is
|
||||
/// none), which blocks moving up.
|
||||
private var isBelowPinnedPage: Bool {
|
||||
guard let displayedIndex else { return true }
|
||||
return displayedIndex <= 1
|
||||
}
|
||||
|
||||
/// Pages as displayed in the tvOS list.
|
||||
@@ -154,35 +162,35 @@ private struct TVPanelPageEditorView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
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)
|
||||
}
|
||||
if !isPinnedPage {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "panel.settings.page.enabled"),
|
||||
isOn: $isEnabled
|
||||
)
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
guard newValue != configuration?.isEnabled else { return }
|
||||
viewModel.setPanelPageEnabled(pageType, isEnabled: newValue)
|
||||
}
|
||||
|
||||
Button {
|
||||
move(up: true)
|
||||
} label: {
|
||||
Label(String(localized: "panel.settings.moveUp"), systemImage: "arrow.up")
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset || displayedIndex == 0)
|
||||
Button {
|
||||
move(up: true)
|
||||
} label: {
|
||||
Label(String(localized: "panel.settings.moveUp"), systemImage: "arrow.up")
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset || isBelowPinnedPage)
|
||||
|
||||
Button {
|
||||
move(up: false)
|
||||
} label: {
|
||||
Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down")
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1)
|
||||
} footer: {
|
||||
if isLastEnabledPage {
|
||||
Text(String(localized: "panel.settings.page.lastEnabled.footer"))
|
||||
} else if !viewModel.canEditActivePreset {
|
||||
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
||||
Button {
|
||||
move(up: false)
|
||||
} label: {
|
||||
Label(String(localized: "panel.settings.moveDown"), systemImage: "arrow.down")
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset || displayedIndex == displayedPages.count - 1)
|
||||
} footer: {
|
||||
if !viewModel.canEditActivePreset {
|
||||
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,19 +28,25 @@ struct PanelSettingsTests {
|
||||
func customizedRoundTrip() throws {
|
||||
var settings = PanelSettings.default
|
||||
settings.layoutMode = .corner
|
||||
settings.defaultVisibility = .remember
|
||||
settings.setDefaultVisibility(.remember, for: .landscape)
|
||||
settings.showPageBarLabels = false
|
||||
settings.setEnabled(false, for: .related)
|
||||
settings.movePages(fromOffsets: IndexSet(integer: 1), toOffset: 0)
|
||||
settings.updateSettings(
|
||||
.info(InfoPageSettings(showStatsRow: false, showDescription: false)),
|
||||
.info(InfoPageSettings(
|
||||
showStatsRow: false,
|
||||
showDescription: false,
|
||||
descriptionCollapsedLines: 5,
|
||||
descriptionExpandedByDefault: true
|
||||
)),
|
||||
for: .info
|
||||
)
|
||||
|
||||
let decoded = try roundTrip(settings)
|
||||
#expect(decoded == settings)
|
||||
#expect(decoded.effectiveLayoutMode == .corner)
|
||||
#expect(decoded.effectiveDefaultVisibility == .remember)
|
||||
#expect(decoded.effectiveDefaultVisibility(for: .landscape) == .remember)
|
||||
#expect(decoded.effectiveDefaultVisibility(for: .portrait) == .visible)
|
||||
#expect(decoded.effectiveShowPageBarLabels == false)
|
||||
#expect(!decoded.isPageEnabled(.related))
|
||||
|
||||
@@ -48,6 +54,10 @@ struct PanelSettingsTests {
|
||||
#expect(info?.effectiveShowStatsRow == false)
|
||||
#expect(info?.effectiveShowDescription == false)
|
||||
#expect(info?.effectiveShowChannelRow == true)
|
||||
#expect(info?.effectiveDescriptionCollapsedLines == 5)
|
||||
#expect(info?.effectiveDescriptionExpandedByDefault == true)
|
||||
#expect(InfoPageSettings(descriptionCollapsedLines: 0).effectiveDescriptionCollapsedLines == 1)
|
||||
#expect(InfoPageSettings().effectiveDescriptionCollapsedLines == 3)
|
||||
}
|
||||
|
||||
@Test("Inline sections follow the configured page order and enablement")
|
||||
@@ -72,7 +82,7 @@ struct PanelSettingsTests {
|
||||
var settings = PanelSettings.default
|
||||
settings.updateSettings(.comments(CommentsPageSettings(showAvatars: false)), for: .comments)
|
||||
settings.updateSettings(.related(RelatedPageSettings(showThumbnails: false)), for: .related)
|
||||
settings.updateSettings(.chapters(ChaptersPageSettings(showThumbnails: false)), for: .chapters)
|
||||
settings.updateSettings(.chapters(ChaptersPageSettings(showThumbnails: false, expandedByDefault: false)), for: .chapters)
|
||||
settings.updateSettings(.queue(QueuePageSettings(showHistory: false)), for: .queue)
|
||||
|
||||
let decoded = try roundTrip(settings)
|
||||
@@ -80,6 +90,8 @@ struct PanelSettingsTests {
|
||||
#expect(decoded.configuration(for: .comments)?.commentsSettings.effectiveShowAvatars == false)
|
||||
#expect(decoded.configuration(for: .related)?.relatedSettings.effectiveShowThumbnails == false)
|
||||
#expect(decoded.configuration(for: .chapters)?.chaptersSettings.effectiveShowThumbnails == false)
|
||||
#expect(decoded.configuration(for: .chapters)?.chaptersSettings.effectiveExpandedByDefault == false)
|
||||
#expect(decoded.configuration(for: .related)?.relatedSettings.effectiveExpandedByDefault == true)
|
||||
#expect(decoded.configuration(for: .queue)?.queueSettings.effectiveShowHistory == false)
|
||||
}
|
||||
|
||||
@@ -120,7 +132,8 @@ struct PanelSettingsTests {
|
||||
#expect(decoded.pages.allSatisfy { $0.isEnabled })
|
||||
#expect(decoded.pages.allSatisfy { $0.region == .primary })
|
||||
#expect(decoded.effectiveLayoutMode == .stacked)
|
||||
#expect(decoded.effectiveDefaultVisibility == .hidden)
|
||||
#expect(decoded.effectiveDefaultVisibility(for: .portrait) == .visible)
|
||||
#expect(decoded.effectiveDefaultVisibility(for: .landscape) == .hidden)
|
||||
}
|
||||
|
||||
// MARK: - Forward Compatibility
|
||||
@@ -152,6 +165,27 @@ struct PanelSettingsTests {
|
||||
#expect(decoded.pages.first?.infoSettings.effectiveShowDescription == true)
|
||||
}
|
||||
|
||||
@Test("Legacy single default visibility seeds both orientations until set individually")
|
||||
func legacyDefaultVisibilitySeedsBothOrientations() throws {
|
||||
let json = """
|
||||
{ "pages": [ { "pageType": "info" } ], "defaultVisibility": "remember" }
|
||||
"""
|
||||
var settings = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
|
||||
#expect(settings.effectiveDefaultVisibility(for: .portrait) == .remember)
|
||||
#expect(settings.effectiveDefaultVisibility(for: .landscape) == .remember)
|
||||
|
||||
settings.setDefaultVisibility(.visible, for: .portrait)
|
||||
#expect(settings.effectiveDefaultVisibility(for: .portrait) == .visible)
|
||||
#expect(settings.effectiveDefaultVisibility(for: .landscape) == .remember)
|
||||
|
||||
let decoded = try roundTrip(settings)
|
||||
#expect(decoded == settings)
|
||||
|
||||
#expect(PanelDefaultVisibility.remember.resolve(lastVisible: true))
|
||||
#expect(!PanelDefaultVisibility.hidden.resolve(lastVisible: true))
|
||||
#expect(PanelDefaultVisibility.visible.resolve(lastVisible: false))
|
||||
}
|
||||
|
||||
@Test("Unknown layoutMode and defaultVisibility decode to nil")
|
||||
func unknownEnumValues() throws {
|
||||
let json = """
|
||||
@@ -163,7 +197,7 @@ struct PanelSettingsTests {
|
||||
#expect(decoded.layoutMode == nil)
|
||||
#expect(decoded.defaultVisibility == nil)
|
||||
#expect(decoded.effectiveLayoutMode == .stacked)
|
||||
#expect(decoded.effectiveDefaultVisibility == .hidden)
|
||||
#expect(decoded.effectiveDefaultVisibility(for: .landscape) == .hidden)
|
||||
}
|
||||
|
||||
// MARK: - Normalization
|
||||
@@ -180,6 +214,48 @@ struct PanelSettingsTests {
|
||||
#expect(normalized.dropFirst().allSatisfy { !$0.isEnabled })
|
||||
}
|
||||
|
||||
@Test("Info is pinned first and always enabled")
|
||||
func infoPinnedFirstAndEnabled() throws {
|
||||
let json = """
|
||||
{ "pages": [
|
||||
{ "pageType": "related" },
|
||||
{ "pageType": "info", "isEnabled": false },
|
||||
{ "pageType": "chapters" }
|
||||
] }
|
||||
"""
|
||||
var settings = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
|
||||
#expect(settings.normalizedPages.first?.pageType == .info)
|
||||
#expect(settings.isPageEnabled(.info))
|
||||
#expect(settings.enabledPages().map(\.pageType) == [.info, .related, .chapters])
|
||||
|
||||
settings.setEnabled(false, for: .info)
|
||||
#expect(settings.isPageEnabled(.info))
|
||||
|
||||
// Moving info down, or another page above it, leaves info first
|
||||
settings.movePages(fromOffsets: IndexSet(integer: 0), toOffset: 3)
|
||||
#expect(settings.pages.first?.pageType == .info)
|
||||
settings.movePages(fromOffsets: IndexSet(integer: 2), toOffset: 0)
|
||||
#expect(settings.pages.map(\.pageType).prefix(3) == [.info, .chapters, .related])
|
||||
}
|
||||
|
||||
@Test("Moving displayed pages keeps hidden pages in their slots")
|
||||
func moveVisiblePagesKeepsHiddenSlots() {
|
||||
var settings = PanelSettings.default
|
||||
// Stored: info, comments, related, chapters, queue. A list that hides
|
||||
// info (pinned) and queue displays comments, related, chapters.
|
||||
let visible: [PanelPageType] = [.comments, .related, .chapters]
|
||||
|
||||
// Move chapters (displayed index 2) to the top of the displayed list
|
||||
settings.moveVisiblePages(visible, fromOffsets: IndexSet(integer: 2), toOffset: 0)
|
||||
#expect(settings.pages.map(\.pageType) == [.info, .chapters, .comments, .related, .queue])
|
||||
|
||||
// Hidden page in the middle keeps its slot
|
||||
settings.movePages(fromOffsets: IndexSet(integer: 4), toOffset: 2)
|
||||
#expect(settings.pages.map(\.pageType) == [.info, .chapters, .queue, .comments, .related])
|
||||
settings.moveVisiblePages([.chapters, .comments, .related], fromOffsets: IndexSet(integer: 0), toOffset: 3)
|
||||
#expect(settings.pages.map(\.pageType) == [.info, .comments, .queue, .related, .chapters])
|
||||
}
|
||||
|
||||
@Test("Enabled pages filter by region")
|
||||
func enabledPagesRegionFilter() {
|
||||
let settings = PanelSettings.default
|
||||
|
||||
Reference in New Issue
Block a user