mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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:
@@ -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" : {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user