diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index f58f35ac..25aeecfa 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index b3e14a72..f6da9bfb 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -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 = [.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 } diff --git a/Yattee/Views/Player/FloatingDetailsPanel.swift b/Yattee/Views/Player/FloatingDetailsPanel.swift index 42ceb5d1..c05bb147 100644 --- a/Yattee/Views/Player/FloatingDetailsPanel.swift +++ b/Yattee/Views/Player/FloatingDetailsPanel.swift @@ -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)) diff --git a/Yattee/Views/Player/Panels/InfoPanelPage.swift b/Yattee/Views/Player/Panels/InfoPanelPage.swift index c2404922..24f61525 100644 --- a/Yattee/Views/Player/Panels/InfoPanelPage.swift +++ b/Yattee/Views/Player/Panels/InfoPanelPage.swift @@ -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) -> 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) } } } diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift index c72b71a3..ff27d584 100644 --- a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -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: diff --git a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift index 1b9e8df7..c6963e9e 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift @@ -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")) } diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift index 2ac14aa4..6eee3402 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -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")) } diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift index 28e43610..3f7e011d 100644 --- a/YatteeTests/PanelSettingsTests.swift +++ b/YatteeTests/PanelSettingsTests.swift @@ -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