diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index ad33a8ee..887461bf 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -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 diff --git a/Yattee/Core/Settings/SettingsManager+Advanced.swift b/Yattee/Core/Settings/SettingsManager+Advanced.swift index 5a9d058a..30eed21a 100644 --- a/Yattee/Core/Settings/SettingsManager+Advanced.swift +++ b/Yattee/Core/Settings/SettingsManager+Advanced.swift @@ -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 } } diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 6b190e80..a23f8987 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -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 diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index dce55d03..8c0abdb4 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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." } } } diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index 808565b1..a53cc232 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -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 { + 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) } diff --git a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index c2d61ab6..b91d3661 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -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 diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index 6bc44998..47dfb473 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -819,7 +819,7 @@ struct CornerPlayerLayout: View { isPanelVisible = show panelToggleGeneration += 1 settingsManager?.landscapeDetailsPanelVisible = show - settingsManager?.detailsPanelLastVisible = show + settingsManager?.landscapeDetailsPanelLastVisible = show } } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index de274800..cce7b704 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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, diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 263db2c0..dfecfe1b 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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 diff --git a/Yattee/Views/Player/ExpandedPlayerWindow.swift b/Yattee/Views/Player/ExpandedPlayerWindow.swift index f0736713..707d68ef 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindow.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindow.swift @@ -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) diff --git a/Yattee/Views/Player/FloatingDetailsPanel.swift b/Yattee/Views/Player/FloatingDetailsPanel.swift index e2a780eb..adc35b0c 100644 --- a/Yattee/Views/Player/FloatingDetailsPanel.swift +++ b/Yattee/Views/Player/FloatingDetailsPanel.swift @@ -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 diff --git a/Yattee/Views/Player/Panels/InfoPanelPage.swift b/Yattee/Views/Player/Panels/InfoPanelPage.swift index d2c52470..5f9f700e 100644 --- a/Yattee/Views/Player/Panels/InfoPanelPage.swift +++ b/Yattee/Views/Player/Panels/InfoPanelPage.swift @@ -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) -> some View { + private func sectionHeader( + _ title: String, + isCollapsed: Binding, + 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) { diff --git a/Yattee/Views/Player/Panels/PanelPageContext.swift b/Yattee/Views/Player/Panels/PanelPageContext.swift index e8625308..07bf6101 100644 --- a/Yattee/Views/Player/Panels/PanelPageContext.swift +++ b/Yattee/Views/Player/Panels/PanelPageContext.swift @@ -119,7 +119,19 @@ extension View { /// the portrait overscroll gesture when the host provides callbacks. struct PanelPageScrollView: 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: View { Color.clear .frame(height: config.topInset) - content + content(proxy) Spacer() .frame(height: config.bottomInset) diff --git a/Yattee/Views/Player/PortraitDetailsPanel.swift b/Yattee/Views/Player/PortraitDetailsPanel.swift index 1a742201..1f6e570f 100644 --- a/Yattee/Views/Player/PortraitDetailsPanel.swift +++ b/Yattee/Views/Player/PortraitDetailsPanel.swift @@ -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 { diff --git a/Yattee/Views/Player/WideScreenPlayerLayout.swift b/Yattee/Views/Player/WideScreenPlayerLayout.swift index 7e0c2168..e23ffcee 100644 --- a/Yattee/Views/Player/WideScreenPlayerLayout.swift +++ b/Yattee/Views/Player/WideScreenPlayerLayout.swift @@ -101,7 +101,7 @@ struct WideScreenPlayerLayout: View { withAnimation(.easeInOut(duration: 0.3)) { isPanelVisible.toggle() settingsManager?.landscapeDetailsPanelVisible = isPanelVisible - settingsManager?.detailsPanelLastVisible = isPanelVisible + settingsManager?.landscapeDetailsPanelLastVisible = isPanelVisible } } @@ -109,7 +109,7 @@ struct WideScreenPlayerLayout: View { private func hidePanel() { isPanelVisible = false settingsManager?.landscapeDetailsPanelVisible = false - settingsManager?.detailsPanelLastVisible = false + settingsManager?.landscapeDetailsPanelLastVisible = false } #if os(iOS) @@ -359,7 +359,7 @@ struct WideScreenPlayerLayout: 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) diff --git a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift index c6963e9e..1c99c892 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift @@ -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 } } diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift index 0649411c..eac62681 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -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, + 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 } } diff --git a/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift b/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift index b0dbb2f4..66c178db 100644 --- a/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/TVPlayerPanelsSettingsView.swift @@ -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")) + } } } diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift index 3f7e011d..019d15db 100644 --- a/YatteeTests/PanelSettingsTests.swift +++ b/YatteeTests/PanelSettingsTests.swift @@ -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