mirror of
https://github.com/yattee/yattee.git
synced 2026-10-25 14:12:53 +00:00
Overdragging the side column's resize grabber past its minimum width now slides the column off the trailing edge (the video grows with it), and a swipe in from the trailing screen edge brings it back following the finger. The seek pan skips that edge strip while the column is collapsed. Showing the panel again restores every region hidden by its own drag handle. Swiping the below-video panel back up right after hiding it showed the video clipped to a short centered box that sprang back to full size. The hide flipped to the hidden layout in a `.logicallyComplete` completion while the slide spring was still registered on the SwiftUI host of the mpv render view; a reveal drag interrupting it made the release spring start from a bogus shrunken rect. All slide-off completions now wait for `.removed` and bail if a new drag took over. The visibility-keyed `.animation` modifiers key on a toggle generation bumped only by the animated show/hide paths, so drag flips never start the toggle easing. The partnerless `matchedGeometryEffect` on the widescreen video frame is dropped.
351 lines
13 KiB
Swift
351 lines
13 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()
|
|
|
|
/// Prototype: collapse the description to a few lines and show chapters
|
|
/// and related videos as inline sections below it.
|
|
var inlineSections: Bool = false
|
|
|
|
/// Collapse only while something actually renders below the description —
|
|
/// with no chapters and no related videos 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 {
|
|
inlineSections
|
|
&& (!chapters.isEmpty || !relatedVideos.isEmpty || videoDetailsState == .loading)
|
|
}
|
|
|
|
@State private var showFormattedDate = false
|
|
@State private var showOriginalTitle = false
|
|
@State private var isDescriptionExpanded = false
|
|
@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) {
|
|
videoInfo
|
|
|
|
if settings.effectiveShowDescription {
|
|
Divider()
|
|
.panelHorizontalPadding()
|
|
|
|
descriptionContent
|
|
.padding(.vertical)
|
|
}
|
|
|
|
if inlineSections {
|
|
chaptersSection
|
|
relatedSection
|
|
}
|
|
}
|
|
.onChange(of: video.id) { _, _ in
|
|
showOriginalTitle = false
|
|
isDescriptionExpanded = false
|
|
isChaptersCollapsed = false
|
|
isRelatedCollapsed = false
|
|
}
|
|
.task(id: context.video.id) {
|
|
if inlineSections {
|
|
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 (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)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.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)
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if !isDescriptionExpanded {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isDescriptionExpanded = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline Sections (prototype)
|
|
|
|
private var chapters: [VideoChapter] { playerState?.chapters ?? [] }
|
|
private var storyboard: Storyboard? { playerState?.preferredStoryboard }
|
|
private var relatedVideos: [Video] { video.relatedVideos ?? [] }
|
|
|
|
/// Section header that toggles the section's collapsed state on tap.
|
|
@ViewBuilder
|
|
private func sectionHeader(_ title: String, isCollapsed: Binding<Bool>) -> some View {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isCollapsed.wrappedValue.toggle()
|
|
}
|
|
} 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 var chaptersSection: some View {
|
|
if !chapters.isEmpty {
|
|
Divider()
|
|
.panelHorizontalPadding()
|
|
.padding(.bottom, 8)
|
|
|
|
sectionHeader(String(localized: "player.chapters"), isCollapsed: $isChaptersCollapsed)
|
|
|
|
if !isChaptersCollapsed {
|
|
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)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var relatedSection: some View {
|
|
if !relatedVideos.isEmpty {
|
|
Divider()
|
|
.panelHorizontalPadding()
|
|
.padding(.vertical, 8)
|
|
|
|
sectionHeader(String(localized: "videoInfo.section.relatedVideos"), isCollapsed: $isRelatedCollapsed)
|
|
|
|
if !isRelatedCollapsed {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in
|
|
VideoRowView(
|
|
video: relatedVideo,
|
|
style: .regular,
|
|
disableInternalTapHandling: true,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
.panelHorizontalPadding()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|