From 4d522094046c641dc8ceec650936f418f11387eb Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 22 Sep 2026 23:59:54 +0200 Subject: [PATCH] Prototype portrait info panel with inline sections instead of page tabs Hide the page bar in the portrait details panel and fold chapters and related videos into the info page as inline sections below a collapsible description. The description clamps to three lines with an inline baseline-aligned "more" fading into the last line; the whole collapsed area is tappable to expand. The comments pill keeps its previous behavior (teaser row, collapsing to a button on scroll) and opens the comments overlay. --- .../Views/Player/Panels/InfoPanelPage.swift | 194 +++++++++++++++++- .../Panels/PlayerPanelContentView.swift | 45 +++- .../Views/Player/PortraitDetailsPanel.swift | 11 +- 3 files changed, 233 insertions(+), 17 deletions(-) diff --git a/Yattee/Views/Player/Panels/InfoPanelPage.swift b/Yattee/Views/Player/Panels/InfoPanelPage.swift index b7a340b4..aad5753b 100644 --- a/Yattee/Views/Player/Panels/InfoPanelPage.swift +++ b/Yattee/Views/Player/Panels/InfoPanelPage.swift @@ -15,8 +15,13 @@ 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 + @State private var showFormattedDate = false @State private var showOriginalTitle = false + @State private var isDescriptionExpanded = false private var appEnvironment: AppEnvironment? { context.appEnvironment } private var video: Video { context.video } @@ -53,9 +58,20 @@ struct InfoPanelPage: View { descriptionContent .padding(.vertical) } + + if inlineSections { + chaptersSection + relatedSection + } } .onChange(of: video.id) { _, _ in showOriginalTitle = false + isDescriptionExpanded = false + } + .task(id: context.video.id) { + if inlineSections { + await preloadChapterSheets() + } } } @@ -108,12 +124,16 @@ struct InfoPanelPage: View { return VStack(alignment: .leading, spacing: 8) { if !description.isEmpty { - Text(DescriptionText.attributed(description, linkColor: context.accentColor)) - .font(.subheadline) - .foregroundStyle(.secondary) - .tint(context.accentColor) - .padding(.horizontal) - .handleTimestampLinks(using: playerService) + if inlineSections { + collapsibleDescription(description) + } else { + Text(DescriptionText.attributed(description, linkColor: context.accentColor)) + .font(.subheadline) + .foregroundStyle(.secondary) + .tint(context.accentColor) + .padding(.horizontal) + .handleTimestampLinks(using: playerService) + } } else if videoDetailsState == .loading { HStack { Spacer() @@ -129,6 +149,168 @@ struct InfoPanelPage: View { } } } + + // MARK: - Collapsible Description (prototype) + + private var panelBackground: Color { + #if os(iOS) + Color(.systemBackground) + #else + Color(nsColor: .windowBackgroundColor) + #endif + } + + /// 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 + /// with working links and a "less" button below. + @ViewBuilder + private func collapsibleDescription(_ description: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(DescriptionText.attributed(description, linkColor: context.accentColor)) + .font(.subheadline) + .foregroundStyle(.secondary) + .tint(context.accentColor) + .lineLimit(isDescriptionExpanded ? nil : 3) + .handleTimestampLinks(using: playerService) + // Collapsed: let the container tap win over link taps + .allowsHitTesting(isDescriptionExpanded) + .overlay(alignment: Alignment(horizontal: .trailing, vertical: .lastTextBaseline)) { + if !isDescriptionExpanded { + Text(String(localized: "common.more")) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(context.accentColor) + .padding(.leading, 32) + .background( + LinearGradient( + stops: [ + .init(color: panelBackground.opacity(0), location: 0), + .init(color: panelBackground, location: 0.4), + .init(color: panelBackground, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + } + } + + if isDescriptionExpanded { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isDescriptionExpanded = false + } + } label: { + Text(String(localized: "common.less")) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(context.accentColor) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .contentShape(Rectangle()) + .onTapGesture { + if !isDescriptionExpanded { + withAnimation(.easeInOut(duration: 0.2)) { + isDescriptionExpanded = true + } + } + } + } + + // MARK: - Inline Sections (prototype) + + private var chapters: [VideoChapter] { playerState?.chapters ?? [] } + private var currentTime: TimeInterval { playerState?.currentTime ?? 0 } + private var storyboard: Storyboard? { playerState?.preferredStoryboard } + private var relatedVideos: [Video] { video.relatedVideos ?? [] } + + @ViewBuilder + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.headline) + .padding(.horizontal) + .padding(.top, 4) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var chaptersSection: some View { + if !chapters.isEmpty { + Divider() + .padding(.horizontal) + .padding(.bottom, 8) + + sectionHeader(String(localized: "player.chapters")) + + LazyVStack(spacing: 0) { + ForEach(chapters) { chapter in + ChapterRow( + chapter: chapter, + isActive: isChapterActive(chapter), + storyboard: storyboard, + showThumbnail: true, + onTap: { + Task { + await playerService?.seek(to: chapter.startTime) + } + } + ) + } + } + .padding(.horizontal) + } + } + + @ViewBuilder + private var relatedSection: some View { + if !relatedVideos.isEmpty { + Divider() + .padding(.horizontal) + .padding(.vertical, 8) + + sectionHeader(String(localized: "videoInfo.section.relatedVideos")) + + LazyVStack(spacing: 12) { + ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in + VideoRowView( + video: relatedVideo, + style: .regular, + showThumbnail: true + ) + .tappableVideo( + relatedVideo, + queueSource: .manual, + sourceLabel: String(localized: "videoInfo.section.relatedVideos"), + videoList: relatedVideos, + videoIndex: index, + loadMoreVideos: nil + ) + + if index < relatedVideos.count - 1 { + Divider() + .padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12) + } + } + } + .padding(.horizontal) + } + } + + private func isChapterActive(_ chapter: VideoChapter) -> Bool { + let nextChapter = chapters.first { $0.startTime > chapter.startTime } + let endTime = nextChapter?.startTime ?? .infinity + return currentTime >= chapter.startTime && currentTime < endTime + } + + private func preloadChapterSheets() async { + guard let storyboard else { return } + let service = StoryboardService.shared + for chapter in chapters { + await service.loadSheet(for: chapter.startTime, from: storyboard) + } + } } #endif diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift index 11e1eb7f..35402d53 100644 --- a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -24,21 +24,33 @@ struct PlayerPanelContentView: View { /// Extra space above the page bar (e.g. below the portrait drag handle). var pageBarTopPadding: CGFloat = 0 + /// Hosts that provide their own page switcher (portrait bottom-row menu) + /// 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. + var inlineInfoSections: Bool = false + private var appEnvironment: AppEnvironment? { context.appEnvironment } - private var playerState: PlayerState? { appEnvironment?.playerService.state } // MARK: - Page Visibility /// Config enables a page; runtime hides pages with nothing to show. - private func isRuntimeVisible(_ pageType: PanelPageType) -> Bool { + static func isRuntimeVisible( + _ pageType: PanelPageType, + video: Video, + appEnvironment: AppEnvironment? + ) -> Bool { + let playerState = appEnvironment?.playerService.state switch pageType { case .info: return true case .comments: - return !context.video.isFromMediaSource + return !video.isFromMediaSource && playerState?.commentsState != .disabled case .related: - return !(context.video.relatedVideos ?? []).isEmpty + return !(video.relatedVideos ?? []).isEmpty case .chapters: return !(playerState?.chapters ?? []).isEmpty case .queue: @@ -49,8 +61,26 @@ struct PlayerPanelContentView: View { } /// Enabled pages that currently have content, in configured order. + static func visiblePages( + panelSettings: PanelSettings, + region: PanelRegion?, + video: Video, + appEnvironment: AppEnvironment? + ) -> [PanelPageConfiguration] { + panelSettings.enabledPages(in: region).filter { + isRuntimeVisible($0.pageType, video: video, appEnvironment: appEnvironment) + } + } + private var visiblePages: [PanelPageConfiguration] { - panelSettings.enabledPages(in: region).filter { isRuntimeVisible($0.pageType) } + let pages = Self.visiblePages( + panelSettings: panelSettings, + region: region, + video: context.video, + appEnvironment: appEnvironment + ) + guard inlineInfoSections else { return pages } + return pages.filter { $0.pageType != .chapters && $0.pageType != .related } } /// The page to render: the selection when visible, else the first page. @@ -64,7 +94,7 @@ struct PlayerPanelContentView: View { let pages = visiblePages VStack(spacing: 0) { - if pages.count >= 2 { + if showPageBar, pages.count >= 2 { PanelPageBar( pages: pages, selectedPage: $selectedPage, @@ -103,7 +133,8 @@ struct PlayerPanelContentView: View { InfoPanelPage( context: context, settings: page.infoSettings, - scrollConfig: scrollConfig + scrollConfig: scrollConfig, + inlineSections: inlineInfoSections ) .id(PanelPageType.info) case .comments: diff --git a/Yattee/Views/Player/PortraitDetailsPanel.swift b/Yattee/Views/Player/PortraitDetailsPanel.swift index cf7e80d5..cd40543c 100644 --- a/Yattee/Views/Player/PortraitDetailsPanel.swift +++ b/Yattee/Views/Player/PortraitDetailsPanel.swift @@ -79,10 +79,10 @@ struct PortraitDetailsPanel: View { playerControlsLayout.effectivePanelSettings } - /// Whether the comments pill should route to the comments page instead of - /// the overlay. + /// Prototype: portrait has a single info page with inline sections, so the + /// comments pill always opens the comments overlay, never a page. private var isCommentsPageAvailable: Bool { - panelSettings.isPageEnabled(.comments) && !(video?.isFromMediaSource ?? true) + false } /// Whether any pills are currently visible in the overlay @@ -163,8 +163,11 @@ struct PortraitDetailsPanel: View { onDragEnded?(offset, predicted) } ), - pageBarTopPadding: 14 + pageBarTopPadding: 14, + showPageBar: false, + inlineInfoSections: true ) + .padding(.top, 10) .background(Color(.systemBackground)) .overlay(alignment: .bottom) { bottomFadeOverlay