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