Configure details panel sections on iPhone and honor their order

iPhone has no page switcher: the portrait panel already rendered one info
page with chapters and related videos as collapsible inline sections. The
landscape floating panel now renders the same panel (no page bar, comments
stay on the pill/overlay), and the settings screen calls the list
"Sections" there, with the page bar labels toggle hidden.

The inline sections were hardcoded to chapters then related and ignored
the configured order, enablement, and thumbnail toggles. The info page now
receives the enabled section pages from PanelSettings.inlineSections(in:)
and renders them in list order with their own settings.
This commit is contained in:
Arkadiusz Fal
2026-09-25 23:54:27 +02:00
parent 9387bdf42c
commit a81e618e74
8 changed files with 181 additions and 38 deletions

View File

@@ -7505,6 +7505,50 @@
}
}
},
"panel.settings.section.enabled" : {
"comment" : "Details panel section editor toggle (iPhone)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show Section"
}
}
}
},
"panel.settings.section.lastEnabled.footer" : {
"comment" : "Details panel section editor footer (iPhone)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "At least one section must stay enabled."
}
}
}
},
"panel.settings.sections.footer" : {
"comment" : "Details panel settings section footer (iPhone)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sections appear in this order below the video details. Sections without content hide automatically."
}
}
}
},
"panel.settings.sections.header" : {
"comment" : "Details panel settings section header (iPhone)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sections"
}
}
}
},
"panel.settings.showThumbnails" : {
"comment" : "Details panel page element toggle",
"localizations" : {

View File

@@ -411,6 +411,26 @@ struct PanelSettings: Codable, Hashable, Sendable {
return result
}
/// Page types the info page can render as collapsible inline sections
/// below the description (iPhone, and the stacked iPad portrait panel).
static let inlineSectionTypes: Set<PanelPageType> = [.chapters, .related]
/// iPhone has no page switcher: every host renders one info page with
/// inline sections, so settings talk about "sections" rather than "pages".
static var isSectionsOnlyDevice: Bool {
#if os(iOS)
return UIDevice.current.userInterfaceIdiom == .phone
#else
return false
#endif
}
/// 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] {
enabledPages(in: region).filter { Self.inlineSectionTypes.contains($0.pageType) }
}
/// Whether a page type is enabled in this configuration.
func isPageEnabled(_ pageType: PanelPageType) -> Bool {
enabledPages().contains { $0.pageType == pageType }

View File

@@ -89,10 +89,18 @@ struct FloatingDetailsPanel: View {
playerControlsLayout.effectivePanelSettings
}
/// iPhone renders the same single info page with inline sections as the
/// portrait panel: no page bar, comments stay on the pill/overlay.
private var usesInlineSections: Bool {
PanelSettings.isSectionsOnlyDevice
}
/// Whether the comments pill should route to the comments page instead of
/// the overlay.
private var isCommentsPageAvailable: Bool {
panelSettings.isPageEnabled(.comments) && !(video?.isFromMediaSource ?? true)
!usesInlineSections
&& panelSettings.isPageEnabled(.comments)
&& !(video?.isFromMediaSource ?? true)
}
/// Whether any pills are currently visible
@@ -142,6 +150,7 @@ struct FloatingDetailsPanel: View {
}
}
.environment(\.colorScheme, isPinned ? systemColorScheme : .dark)
.environment(\.presentationColorScheme, systemColorScheme)
.animation(.easeInOut(duration: 0.25), value: isPinned)
.onChange(of: video.id) { _, _ in
// Comments state is managed by parent - just reset local state
@@ -150,7 +159,6 @@ struct FloatingDetailsPanel: View {
.onChange(of: isCommentsExpanded) { _, newValue in
// Sync with NavigationCoordinator to block sheet dismiss gesture when comments expanded
appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue
.environment(\.presentationColorScheme, systemColorScheme)
}
.onChange(of: maxPanelWidth) { _, _ in
// Re-clamp width when max width changes (e.g., pinned vs floating)
@@ -216,14 +224,16 @@ struct FloatingDetailsPanel: View {
onChannelTap: onChannelTap
),
scrollConfig: PanelScrollConfig(
topInset: 8,
topInset: usesInlineSections ? 20 : 8,
bottomInset: 80,
scrollOffsetChanged: { offset in
scrollOffset = offset
},
scrollToTopTrigger: scrollToTopTrigger
),
pageBarTopPadding: 12
pageBarTopPadding: 12,
showPageBar: !usesInlineSections,
inlineInfoSections: usesInlineSections
)
}
.frame(width: clampWidth(panelWidth))

View File

@@ -15,17 +15,34 @@ struct InfoPanelPage: View {
var settings: InfoPageSettings = InfoPageSettings()
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
/// Prototype: collapse the description to a few lines and show chapters
/// and related videos as inline sections below it.
var inlineSections: Bool = false
/// Enabled pages rendered as collapsible inline sections below the
/// description, in configured order (see `PanelSettings.inlineSections`).
/// Empty means the page shows the video info and description only.
var inlineSections: [PanelPageConfiguration] = []
/// Collapse only while something actually renders below the description —
/// with no chapters and no related videos the description stays expanded.
/// While details are still loading, keep it collapsed to avoid an
/// expand-then-clamp flash when sections arrive.
/// with no section content the description stays expanded. While details
/// are still loading, keep it collapsed to avoid an expand-then-clamp
/// flash when sections arrive.
private var usesCollapsedDescription: Bool {
inlineSections
&& (!chapters.isEmpty || !relatedVideos.isEmpty || videoDetailsState == .loading)
guard !inlineSections.isEmpty else { return false }
return inlineSections.contains { sectionHasContent($0.pageType) }
|| videoDetailsState == .loading
}
private func sectionHasContent(_ pageType: PanelPageType) -> Bool {
switch pageType {
case .chapters:
return !chapters.isEmpty
case .related:
return !relatedVideos.isEmpty
default:
return false
}
}
private var chaptersSectionSettings: ChaptersPageSettings? {
inlineSections.first { $0.pageType == .chapters }?.chaptersSettings
}
@State private var showFormattedDate = false
@@ -70,9 +87,8 @@ struct InfoPanelPage: View {
.padding(.vertical)
}
if inlineSections {
chaptersSection
relatedSection
ForEach(inlineSections) { section in
inlineSection(section)
}
}
.onChange(of: video.id) { _, _ in
@@ -82,7 +98,7 @@ struct InfoPanelPage: View {
isRelatedCollapsed = false
}
.task(id: context.video.id) {
if inlineSections {
if chaptersSectionSettings?.effectiveShowThumbnails == true {
await preloadChapterSheets()
}
}
@@ -232,12 +248,24 @@ struct InfoPanelPage: View {
}
}
// MARK: - Inline Sections (prototype)
// MARK: - Inline Sections
private var chapters: [VideoChapter] { playerState?.chapters ?? [] }
private var storyboard: Storyboard? { playerState?.preferredStoryboard }
private var relatedVideos: [Video] { video.relatedVideos ?? [] }
@ViewBuilder
private func inlineSection(_ section: PanelPageConfiguration) -> some View {
switch section.pageType {
case .chapters:
chaptersSection(settings: section.chaptersSettings)
case .related:
relatedSection(settings: section.relatedSettings)
default:
EmptyView()
}
}
/// Section header that toggles the section's collapsed state on tap.
@ViewBuilder
private func sectionHeader(_ title: String, isCollapsed: Binding<Bool>) -> some View {
@@ -266,7 +294,7 @@ struct InfoPanelPage: View {
}
@ViewBuilder
private var chaptersSection: some View {
private func chaptersSection(settings: ChaptersPageSettings) -> some View {
if !chapters.isEmpty {
Divider()
.panelHorizontalPadding()
@@ -281,7 +309,7 @@ struct InfoPanelPage: View {
chapter: chapter,
isActive: isChapterActive(chapter),
storyboard: storyboard,
showThumbnail: true,
showThumbnail: settings.effectiveShowThumbnails,
onTap: {
Task {
await playerService?.seek(to: chapter.startTime)
@@ -291,16 +319,17 @@ struct InfoPanelPage: View {
}
}
.panelHorizontalPadding()
.padding(.bottom, 8)
}
}
}
@ViewBuilder
private var relatedSection: some View {
private func relatedSection(settings: RelatedPageSettings) -> some View {
if !relatedVideos.isEmpty {
Divider()
.panelHorizontalPadding()
.padding(.vertical, 8)
.padding(.bottom, 8)
sectionHeader(String(localized: "videoInfo.section.relatedVideos"), isCollapsed: $isRelatedCollapsed)
@@ -311,7 +340,7 @@ struct InfoPanelPage: View {
video: relatedVideo,
style: .regular,
disableInternalTapHandling: true,
showThumbnail: true
showThumbnail: settings.effectiveShowThumbnails
)
.tappableVideo(
relatedVideo,
@@ -329,6 +358,7 @@ struct InfoPanelPage: View {
}
}
.panelHorizontalPadding()
.padding(.bottom, 8)
}
}
}

View File

@@ -28,8 +28,9 @@ struct PlayerPanelContentView: View {
/// hide the top page bar.
var showPageBar: Bool = true
/// Prototype: the info page shows chapters and related as inline sections;
/// those pages are then dropped from the page list.
/// The info page shows chapters and related as collapsible inline
/// sections in configured order; those pages are then dropped from the
/// page list (iPhone in every orientation, stacked iPad portrait).
var inlineInfoSections: Bool = false
/// Pages the host renders elsewhere (e.g. corner layout keeps info below
@@ -87,7 +88,12 @@ struct PlayerPanelContentView: View {
pages = pages.filter { !excludedPages.contains($0.pageType) }
}
guard inlineInfoSections else { return pages }
return pages.filter { $0.pageType != .chapters && $0.pageType != .related }
return pages.filter { !PanelSettings.inlineSectionTypes.contains($0.pageType) }
}
/// Sections the info page renders inline, in configured order.
private var inlineSections: [PanelPageConfiguration] {
inlineInfoSections ? panelSettings.inlineSections(in: region) : []
}
private var visiblePages: [PanelPageConfiguration] {
@@ -153,12 +159,6 @@ struct PlayerPanelContentView: View {
// MARK: - Pages
@ViewBuilder
private func pageContent(_ page: PanelPageConfiguration) -> some View {
switch page.pageType {
case .info:
InfoPanelPage(
context: context,
/// The host's scroll config keyed to the current video so pages jump to
/// the top when the video changes.
private var pageScrollConfig: PanelScrollConfig {
@@ -167,10 +167,16 @@ struct PlayerPanelContentView: View {
return config
}
@ViewBuilder
private func pageContent(_ page: PanelPageConfiguration) -> some View {
let scrollConfig = pageScrollConfig
switch page.pageType {
case .info:
InfoPanelPage(
context: context,
settings: page.infoSettings,
scrollConfig: scrollConfig,
let scrollConfig = pageScrollConfig
inlineSections: inlineInfoSections
inlineSections: inlineSections
)
.id(PanelPageType.info)
case .comments:

View File

@@ -34,6 +34,9 @@ struct PanelPageEditorView: View {
isEnabled && viewModel.enabledPanelPageCount <= 1
}
/// iPhone configures inline sections rather than pages.
private var usesSections: Bool { PanelSettings.isSectionsOnlyDevice }
var body: some View {
Form {
enabledSection
@@ -75,7 +78,9 @@ struct PanelPageEditorView: View {
private var enabledSection: some View {
Section {
Toggle(
String(localized: "panel.settings.page.enabled"),
usesSections
? String(localized: "panel.settings.section.enabled")
: String(localized: "panel.settings.page.enabled"),
isOn: $isEnabled
)
.disabled(!viewModel.canEditActivePreset || isLastEnabledPage)
@@ -85,7 +90,9 @@ struct PanelPageEditorView: View {
}
} footer: {
if isLastEnabledPage {
Text(String(localized: "panel.settings.page.lastEnabled.footer"))
Text(usesSections
? String(localized: "panel.settings.section.lastEnabled.footer")
: String(localized: "panel.settings.page.lastEnabled.footer"))
} else if !viewModel.canEditActivePreset {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}

View File

@@ -26,13 +26,18 @@ struct PanelSettingsView: View {
#endif
}
/// iPhone has no page bar: the list configures inline sections instead.
private var usesSections: Bool { PanelSettings.isSectionsOnlyDevice }
var body: some View {
List {
if Self.supportsCornerLayout {
layoutSection
}
visibilitySection
pageBarSection
if !usesSections {
pageBarSection
}
pagesSection
}
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
@@ -133,10 +138,14 @@ struct PanelSettingsView: View {
viewModel.movePanelPages(fromOffsets: source, toOffset: destination)
}
} header: {
Text(String(localized: "panel.settings.pages.header"))
Text(usesSections
? String(localized: "panel.settings.sections.header")
: String(localized: "panel.settings.pages.header"))
} footer: {
if viewModel.canEditActivePreset {
Text(String(localized: "panel.settings.pages.footer"))
Text(usesSections
? String(localized: "panel.settings.sections.footer")
: String(localized: "panel.settings.pages.footer"))
} else {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}

View File

@@ -50,6 +50,23 @@ struct PanelSettingsTests {
#expect(info?.effectiveShowChannelRow == true)
}
@Test("Inline sections follow the configured page order and enablement")
func inlineSectionsFollowOrder() {
var settings = PanelSettings.default
// Default order: info, comments, related, chapters, queue
#expect(settings.inlineSections().map(\.pageType) == [.related, .chapters])
// Move chapters (index 3) above related (index 2)
settings.movePages(fromOffsets: IndexSet(integer: 3), toOffset: 2)
#expect(settings.inlineSections().map(\.pageType) == [.chapters, .related])
settings.setEnabled(false, for: .related)
#expect(settings.inlineSections().map(\.pageType) == [.chapters])
settings.updateSettings(.chapters(ChaptersPageSettings(showThumbnails: false)), for: .chapters)
#expect(settings.inlineSections().first?.chaptersSettings.effectiveShowThumbnails == false)
}
@Test("Per-page element settings round-trip for every page type")
func perPageSettingsRoundTrip() throws {
var settings = PanelSettings.default