mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 21:52:51 +00:00
Pages list - Info is pinned first and always enabled in every layout: normalized pages force it to the front and enabled (older presets self-correct), disabling it is a no-op, and any move that would displace it snaps back. The iOS/macOS list renders it outside the reorderable ForEach (no drag handle, nothing drops above it) and the editors hide its enabled toggle; tvOS also hides its move buttons. The "at least one page must stay enabled" guard and its strings are gone since Info always counts. - iPhone never renders queue or comments as pages, so its Sections list hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes through moveVisiblePages, which keeps hidden entries in their stored slots. Comments get their own group on the Details Panel screen (pill mode + avatars) and the overlay hosts pass the avatars flag. Default visibility - "When Player Opens" is now configured per orientation (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy single value is still decoded and seeds both until set individually; presets that never set it default to portrait Visible, landscape Hidden. macOS shows one picker (no portrait layout). - "Remember Last" no longer leaks across orientations: separate remembered flags, written by the portrait toggles and by the wide and corner layouts respectively, with the old shared flag read once as a fallback. The corner layout seeded from the portrait resolution before; it now uses landscape. Section options (iOS inline-sections hosts) - Chapters and Related editors gain "Expanded by Default" (on by default). The Info editor gains a Description group with "Expanded by Default" (off) and a "Collapsed Lines" stepper (1–10, default 3). Applied when the panel appears and on each video change. - Expanding a collapsed section scrolls its header to the top of the panel: PanelPageScrollView can hand its scroll proxy to page content and each inline section is wrapped in one anchored container. - The portrait panel's bottom fade sized its touch blocker from a stale "expanded comments pill" answer that ignored collapse on scroll, so a collapsed section header (the last item) sat under it and could not be tapped. The predicate now tracks the pill's actual collapse state.
454 lines
17 KiB
Swift
454 lines
17 KiB
Swift
//
|
|
// InfoPanelPage.swift
|
|
// Yattee
|
|
//
|
|
// Details panel page with title, stats, channel row, and description.
|
|
// Consolidates the previously duplicated video info bodies.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS)
|
|
|
|
struct InfoPanelPage: View {
|
|
let context: PanelPageContext
|
|
var settings: InfoPageSettings = InfoPageSettings()
|
|
var scrollConfig: PanelScrollConfig = PanelScrollConfig()
|
|
|
|
/// 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 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 {
|
|
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
|
|
}
|
|
|
|
private var relatedSectionSettings: RelatedPageSettings? {
|
|
inlineSections.first { $0.pageType == .related }?.relatedSettings
|
|
}
|
|
|
|
/// Seeds the description and section collapse state from the configured
|
|
/// defaults.
|
|
private func resetSectionCollapseState() {
|
|
isDescriptionExpanded = settings.effectiveDescriptionExpandedByDefault
|
|
isChaptersCollapsed = !(chaptersSectionSettings?.effectiveExpandedByDefault ?? true)
|
|
isRelatedCollapsed = !(relatedSectionSettings?.effectiveExpandedByDefault ?? true)
|
|
}
|
|
|
|
@State private var showFormattedDate = false
|
|
@State private var showOriginalTitle = false
|
|
@State private var isDescriptionExpanded = false
|
|
@State private var moreLabelSize: CGSize = .zero
|
|
@State private var isChaptersCollapsed = false
|
|
@State private var isRelatedCollapsed = false
|
|
|
|
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
|
private var video: Video { context.video }
|
|
private var playerService: PlayerService? { appEnvironment?.playerService }
|
|
private var playerState: PlayerState? { playerService?.state }
|
|
private var videoDetailsState: VideoDetailsLoadState { playerState?.videoDetailsState ?? .idle }
|
|
|
|
/// Returns the first enabled Yattee Server instance URL, if any.
|
|
private var yatteeServerURL: URL? {
|
|
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
|
|
}
|
|
|
|
/// Returns the DeArrow title if available and enabled.
|
|
private var deArrowTitle: String? {
|
|
appEnvironment?.deArrowBrandingProvider.title(for: video)
|
|
}
|
|
|
|
/// Returns the display title based on toggle state.
|
|
private var displayTitle: String {
|
|
if let deArrowTitle {
|
|
return showOriginalTitle ? video.title : deArrowTitle
|
|
}
|
|
return video.title
|
|
}
|
|
|
|
var body: some View {
|
|
PanelPageScrollView(config: scrollConfig) { proxy in
|
|
videoInfo
|
|
|
|
if settings.effectiveShowDescription {
|
|
Divider()
|
|
.panelHorizontalPadding()
|
|
|
|
descriptionContent
|
|
.padding(.vertical)
|
|
}
|
|
|
|
ForEach(inlineSections) { section in
|
|
// One container per section so the scroll anchor is a single
|
|
// view (the divider + header + rows), not each child.
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
inlineSection(section, scrollProxy: proxy)
|
|
}
|
|
.id(Self.sectionScrollID(section.pageType))
|
|
}
|
|
}
|
|
.onChange(of: video.id) { _, _ in
|
|
showOriginalTitle = false
|
|
resetSectionCollapseState()
|
|
}
|
|
.onAppear {
|
|
resetSectionCollapseState()
|
|
}
|
|
.task(id: context.video.id) {
|
|
if chaptersSectionSettings?.effectiveShowThumbnails == true {
|
|
await preloadChapterSheets()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Video Info
|
|
|
|
private var videoInfo: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Title - full width (shows DeArrow title if available, tap toggles)
|
|
Text(displayTitle)
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(3)
|
|
.onTapGesture {
|
|
if deArrowTitle != nil {
|
|
showOriginalTitle.toggle()
|
|
}
|
|
}
|
|
|
|
// Stats row - only show for non-media-source videos
|
|
if settings.effectiveShowStatsRow, !video.isFromMediaSource {
|
|
VideoStatsRow(
|
|
playerState: playerState,
|
|
showFormattedDate: $showFormattedDate,
|
|
returnYouTubeDislikeEnabled: appEnvironment?.settingsManager.returnYouTubeDislikeEnabled ?? false
|
|
)
|
|
}
|
|
|
|
// Channel row with context menu
|
|
if settings.effectiveShowChannelRow {
|
|
VideoChannelRow(
|
|
author: video.author,
|
|
source: video.id.source,
|
|
yatteeServerURL: yatteeServerURL,
|
|
onChannelTap: context.onChannelTap,
|
|
video: video,
|
|
accentColor: context.accentColor,
|
|
showSubscriberCount: !video.isFromMediaSource,
|
|
isLoadingDetails: videoDetailsState == .loading
|
|
)
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// MARK: - Description
|
|
|
|
private var descriptionContent: some View {
|
|
let description = video.description ?? ""
|
|
|
|
return VStack(alignment: .leading, spacing: 8) {
|
|
if !description.isEmpty {
|
|
if usesCollapsedDescription {
|
|
collapsibleDescription(description)
|
|
} else {
|
|
Text(DescriptionText.attributed(description, linkColor: context.accentColor))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.tint(context.accentColor)
|
|
.panelHorizontalPadding()
|
|
.handleTimestampLinks(using: playerService)
|
|
}
|
|
} else if videoDetailsState == .loading {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
} else {
|
|
Text(String(localized: "player.noDescription"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.panelHorizontalPadding()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Collapsible Description
|
|
|
|
/// Width of the fade between the last line's text and the "more" label.
|
|
private static let moreFadeWidth: CGFloat = 40
|
|
|
|
/// Alpha mask for the collapsed description: fully visible except the
|
|
/// end of the last line, which fades out under the "more" label. Masking
|
|
/// the text (instead of painting a background-colored gradient over it)
|
|
/// works on any host background, including the floating panel's
|
|
/// translucent material.
|
|
private var collapsedDescriptionMask: some View {
|
|
VStack(spacing: 0) {
|
|
Rectangle()
|
|
.fill(.black)
|
|
|
|
HStack(spacing: 0) {
|
|
Rectangle()
|
|
.fill(.black)
|
|
|
|
LinearGradient(
|
|
colors: [.black, .clear],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
.frame(width: Self.moreFadeWidth)
|
|
|
|
Color.clear
|
|
.frame(width: moreLabelSize.width)
|
|
}
|
|
.frame(height: moreLabelSize.height)
|
|
}
|
|
}
|
|
|
|
/// Collapsed: the configured number of 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 : settings.effectiveDescriptionCollapsedLines)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.handleTimestampLinks(using: playerService)
|
|
// Collapsed: let the container tap win over link taps
|
|
.allowsHitTesting(isDescriptionExpanded)
|
|
.mask {
|
|
if isDescriptionExpanded {
|
|
Rectangle()
|
|
} else {
|
|
collapsedDescriptionMask
|
|
}
|
|
}
|
|
.overlay(alignment: Alignment(horizontal: .trailing, vertical: .lastTextBaseline)) {
|
|
if !isDescriptionExpanded {
|
|
Text(String(localized: "common.more"))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(context.accentColor)
|
|
.onGeometryChange(for: CGSize.self) { proxy in
|
|
proxy.size
|
|
} action: { size in
|
|
moreLabelSize = size
|
|
}
|
|
}
|
|
}
|
|
|
|
if isDescriptionExpanded {
|
|
Button {
|
|
isDescriptionExpanded = false
|
|
} label: {
|
|
Text(String(localized: "common.less"))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(context.accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if !isDescriptionExpanded {
|
|
isDescriptionExpanded = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline Sections
|
|
|
|
private var chapters: [VideoChapter] { playerState?.chapters ?? [] }
|
|
private var storyboard: Storyboard? { playerState?.preferredStoryboard }
|
|
private var relatedVideos: [Video] { video.relatedVideos ?? [] }
|
|
|
|
/// Scroll anchor for a section: its Divider + header, so scrolling to it
|
|
/// puts the tapped header at the top of the panel.
|
|
private static func sectionScrollID(_ pageType: PanelPageType) -> String {
|
|
"inlineSection-\(pageType.rawValue)"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func inlineSection(_ section: PanelPageConfiguration, scrollProxy: ScrollViewProxy) -> some View {
|
|
switch section.pageType {
|
|
case .chapters:
|
|
chaptersSection(settings: section.chaptersSettings, scrollProxy: scrollProxy)
|
|
case .related:
|
|
relatedSection(settings: section.relatedSettings, scrollProxy: scrollProxy)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
/// Section header that toggles the section's collapsed state on tap.
|
|
/// Expanding also scrolls the section to the top of the panel so the
|
|
/// revealed content starts right under the tapped header, instead of
|
|
/// growing below the fold.
|
|
@ViewBuilder
|
|
private func sectionHeader(
|
|
_ title: String,
|
|
isCollapsed: Binding<Bool>,
|
|
scrollProxy: ScrollViewProxy,
|
|
scrollID: String
|
|
) -> some View {
|
|
Button {
|
|
let expanding = isCollapsed.wrappedValue
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
isCollapsed.wrappedValue.toggle()
|
|
}
|
|
if expanding {
|
|
// Next runloop: the expanded content must be laid out before
|
|
// the scroll view can reach the section's top.
|
|
DispatchQueue.main.async {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
scrollProxy.scrollTo(scrollID, anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isCollapsed.wrappedValue ? -90 : 0))
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.panelHorizontalPadding()
|
|
.padding(.top, 4)
|
|
.padding(.bottom, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func chaptersSection(settings: ChaptersPageSettings, scrollProxy: ScrollViewProxy) -> some View {
|
|
if !chapters.isEmpty {
|
|
Divider()
|
|
.panelHorizontalPadding()
|
|
.padding(.bottom, 8)
|
|
|
|
sectionHeader(
|
|
String(localized: "player.chapters"),
|
|
isCollapsed: $isChaptersCollapsed,
|
|
scrollProxy: scrollProxy,
|
|
scrollID: Self.sectionScrollID(.chapters)
|
|
)
|
|
|
|
if !isChaptersCollapsed {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(chapters) { chapter in
|
|
ChapterRow(
|
|
chapter: chapter,
|
|
isActive: isChapterActive(chapter),
|
|
storyboard: storyboard,
|
|
showThumbnail: settings.effectiveShowThumbnails,
|
|
onTap: {
|
|
Task {
|
|
await playerService?.seek(to: chapter.startTime)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func relatedSection(settings: RelatedPageSettings, scrollProxy: ScrollViewProxy) -> some View {
|
|
if !relatedVideos.isEmpty {
|
|
Divider()
|
|
.panelHorizontalPadding()
|
|
.padding(.bottom, 8)
|
|
|
|
sectionHeader(
|
|
String(localized: "videoInfo.section.relatedVideos"),
|
|
isCollapsed: $isRelatedCollapsed,
|
|
scrollProxy: scrollProxy,
|
|
scrollID: Self.sectionScrollID(.related)
|
|
)
|
|
|
|
if !isRelatedCollapsed {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in
|
|
VideoRowView(
|
|
video: relatedVideo,
|
|
style: .regular,
|
|
disableInternalTapHandling: true,
|
|
showThumbnail: settings.effectiveShowThumbnails
|
|
)
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isChapterActive(_ chapter: VideoChapter) -> Bool {
|
|
// Observes only chapter transitions, not per-tick currentTime updates.
|
|
playerState?.activeChapterID == chapter.id
|
|
}
|
|
|
|
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
|