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.
This commit is contained in:
Arkadiusz Fal
2026-09-22 23:59:54 +02:00
parent ad01e113fc
commit 4d52209404
3 changed files with 233 additions and 17 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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