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