mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
2152 lines
82 KiB
Swift
2152 lines
82 KiB
Swift
//
|
|
// VideoInfoView.swift
|
|
// Yattee
|
|
//
|
|
// Full-screen video information page with technical metadata and comments.
|
|
//
|
|
|
|
import SwiftUI
|
|
import NukeUI
|
|
|
|
/// Initialization mode for VideoInfoView - either a loaded video or just an ID to fetch.
|
|
enum VideoInfoInitMode: Sendable {
|
|
case video(Video)
|
|
case videoID(VideoID)
|
|
}
|
|
|
|
struct VideoInfoView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
@Environment(\.videoQueueContext) private var videoQueueContext
|
|
|
|
private let initMode: VideoInfoInitMode
|
|
|
|
// Video loading state (for videoID mode)
|
|
@State private var loadedVideo: Video?
|
|
@State private var isLoadingInitialVideo = false
|
|
@State private var initialVideoLoadError: String?
|
|
|
|
// Navigation state - track current position in queue
|
|
@State private var currentVideoIndex: Int?
|
|
@State private var scrollViewID = UUID()
|
|
@State private var scrollOffset: CGFloat = 0
|
|
|
|
// Carousel scroll state (iOS only) - separate from currentVideoIndex to avoid mid-scroll updates
|
|
#if os(iOS)
|
|
@State private var carouselScrollPosition: Int?
|
|
#endif
|
|
|
|
// Continuation loading state
|
|
@State private var extendedVideoList: [Video] = []
|
|
@State private var isLoadingMoreVideos = false
|
|
@State private var loadMoreError: String?
|
|
|
|
@State private var isBookmarked = false
|
|
@State private var showingPlaylistSheet = false
|
|
@State private var showingCommentsSheet = false
|
|
@State private var showingRemoveBookmarkAlert = false
|
|
@State private var currentBookmark: Bookmark?
|
|
@State private var bookmarkTags: [String] = []
|
|
@State private var bookmarkNote: String = ""
|
|
@State private var bookmarkSaveTask: Task<Void, Never>?
|
|
@State private var isEditingBookmarkNote = false
|
|
@State private var isEditingBookmarkTags = false
|
|
@FocusState private var isBookmarkNoteFocused: Bool
|
|
|
|
// Comments state (independent from PlayerState)
|
|
@State private var comments: [Comment] = []
|
|
@State private var commentsState: CommentsLoadState = .idle
|
|
@State private var commentsContinuation: String?
|
|
|
|
// Collapsible section states
|
|
@State private var isStatsExpanded = true
|
|
@State private var isDescriptionExpanded = true
|
|
@State private var isRelatedExpanded = true
|
|
@State private var isCommentsExpanded = true
|
|
@State private var isWatchHistoryExpanded = false
|
|
@State private var isBookmarkExpanded = true
|
|
@State private var isDownloadExpanded = false
|
|
@State private var isOriginalTitleExpanded = true
|
|
|
|
// MARK: - Initializers
|
|
|
|
/// Initialize with a loaded video.
|
|
init(video: Video) {
|
|
self.initMode = .video(video)
|
|
}
|
|
|
|
/// Initialize with a video ID to fetch.
|
|
init(videoID: VideoID) {
|
|
self.initMode = .videoID(videoID)
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// The video from init or loaded from API (nil while loading in videoID mode).
|
|
private var video: Video? {
|
|
switch initMode {
|
|
case .video(let v): return v
|
|
case .videoID: return loadedVideo
|
|
}
|
|
}
|
|
|
|
// Watch history state
|
|
@State private var watchEntry: WatchEntry?
|
|
|
|
// Download state
|
|
@State private var download: Download?
|
|
@State private var showingDownloadSheet = false
|
|
@State private var isEnqueuingDownload = false
|
|
@State private var showingRemoveDownloadConfirmation = false
|
|
|
|
// Resume action sheet state
|
|
@State private var resumeSheetData: ResumeSheetData?
|
|
|
|
// Video details cache - stores full video details loaded from API
|
|
@State private var loadedVideoDetails: [String: Video] = [:]
|
|
@State private var isLoadingVideoDetails = false
|
|
/// Combined video list including originally loaded videos and extended videos from continuation
|
|
private var allVideos: [Video]? {
|
|
guard let originalList = videoQueueContext?.videoList else { return nil }
|
|
return originalList + extendedVideoList
|
|
}
|
|
|
|
/// The base video from queue or init parameter (before details are loaded).
|
|
/// Returns nil only when in videoID mode and video hasn't loaded yet.
|
|
private var baseVideo: Video? {
|
|
if videoQueueContext != nil,
|
|
let index = currentVideoIndex,
|
|
let list = allVideos,
|
|
index >= 0 && index < list.count {
|
|
return list[index]
|
|
}
|
|
return video
|
|
}
|
|
|
|
/// The video currently being displayed - prefers cached full details if available.
|
|
/// Returns nil only when in videoID mode and video hasn't loaded yet.
|
|
private var displayedVideo: Video? {
|
|
guard let base = baseVideo else { return nil }
|
|
return loadedVideoDetails[base.id.videoID] ?? base
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
appEnvironment?.settingsManager.accentColor.color ?? .accentColor
|
|
}
|
|
|
|
private var dataManager: DataManager? { appEnvironment?.dataManager }
|
|
private var contentService: ContentService? { appEnvironment?.contentService }
|
|
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
|
|
private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
|
private var playerService: PlayerService? { appEnvironment?.playerService }
|
|
private var queueManager: QueueManager? { appEnvironment?.queueManager }
|
|
#if !os(tvOS)
|
|
private var downloadManager: DownloadManager? { appEnvironment?.downloadManager }
|
|
#endif
|
|
|
|
/// Returns the first enabled Yattee Server instance URL, if any.
|
|
private var yatteeServerURL: URL? {
|
|
instancesManager?.yatteeServerInstances.first { $0.isEnabled }?.url
|
|
}
|
|
|
|
/// Whether this video is from YouTube (global YouTube provider).
|
|
private var isYouTube: Bool {
|
|
guard let video = displayedVideo else { return false }
|
|
if case .global(let provider) = video.id.source {
|
|
return provider == ContentSource.youtubeProvider
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Whether comments are supported for this video source.
|
|
private var supportsComments: Bool {
|
|
// Only YouTube videos support comments via Invidious API
|
|
isYouTube
|
|
}
|
|
|
|
/// Whether the description section should be visible.
|
|
private var shouldShowDescriptionSection: Bool {
|
|
guard !isLoadingVideoDetails else { return true }
|
|
guard let description = displayedVideo?.description else { return false }
|
|
return !description.isEmpty
|
|
}
|
|
|
|
/// Whether the stats section should be visible.
|
|
/// Duration alone does not warrant showing stats.
|
|
private var shouldShowStatsSection: Bool {
|
|
guard let video = displayedVideo else { return false }
|
|
return video.viewCount != nil || video.likeCount != nil || video.formattedPublishedDate != nil
|
|
}
|
|
|
|
/// Whether the original title section should be visible (when DeArrow replaces title).
|
|
private var shouldShowOriginalTitleSection: Bool {
|
|
guard let video = displayedVideo,
|
|
let settingsManager = appEnvironment?.settingsManager,
|
|
settingsManager.deArrowEnabled,
|
|
settingsManager.deArrowReplaceTitles,
|
|
let provider = appEnvironment?.deArrowBrandingProvider,
|
|
provider.title(for: video) != nil else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Display string for the video source (YouTube, PeerTube • instance, or MediaSourceType • share name).
|
|
private var videoSourceDisplay: String? {
|
|
guard let video = displayedVideo else { return nil }
|
|
switch video.id.source {
|
|
case .global(let provider):
|
|
return provider.prefix(1).uppercased() + provider.dropFirst()
|
|
case .federated(_, let instance):
|
|
return "PeerTube • \(instance.host ?? instance.absoluteString)"
|
|
case .extracted(let extractor, _):
|
|
// For media sources, show type + share name
|
|
if let mediaSourceID = video.mediaSourceID,
|
|
let mediaSource = appEnvironment?.mediaSourcesManager.source(byID: mediaSourceID) {
|
|
return "\(mediaSource.type.displayName) • \(mediaSource.name)"
|
|
}
|
|
// Fallback for other extracted sources (e.g., Bilibili, Vimeo)
|
|
let formatted = extractor.replacingOccurrences(of: "_", with: " ")
|
|
return formatted.prefix(1).uppercased() + formatted.dropFirst()
|
|
}
|
|
}
|
|
|
|
/// Navigation title - uses source label if available, otherwise video title
|
|
private var displayTitle: String {
|
|
videoQueueContext?.sourceLabel ?? displayedVideo?.title ?? ""
|
|
}
|
|
|
|
/// Thumbnail width - larger for carousel on iOS
|
|
private var thumbnailWidth: CGFloat {
|
|
#if os(iOS)
|
|
return videoQueueContext != nil ? 280 : 240
|
|
#else
|
|
return 240
|
|
#endif
|
|
}
|
|
|
|
/// Dynamic label for the play button - shows "Continue at XX:XX" if video has meaningful progress
|
|
/// and resume setting is continueWatching or ask.
|
|
private var playButtonLabel: String {
|
|
guard let video = displayedVideo,
|
|
let savedProgress = dataManager?.watchProgress(for: video.id.videoID),
|
|
savedProgress >= 5,
|
|
video.duration > 0,
|
|
savedProgress < video.duration * 0.9 else {
|
|
return String(localized: "video.context.play")
|
|
}
|
|
|
|
let resumeAction = appEnvironment?.settingsManager.resumeAction ?? .continueWatching
|
|
switch resumeAction {
|
|
case .continueWatching, .ask:
|
|
return String(localized: "resume.action.continueAt \(formatTime(savedProgress))")
|
|
case .startFromBeginning:
|
|
return String(localized: "video.context.play")
|
|
}
|
|
}
|
|
|
|
/// Formats a time interval as MM:SS or H:MM:SS.
|
|
private func formatTime(_ time: TimeInterval) -> String {
|
|
let hours = Int(time) / 3600
|
|
let minutes = (Int(time) % 3600) / 60
|
|
let seconds = Int(time) % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLoadingInitialVideo {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = initialVideoLoadError {
|
|
videoLoadErrorView(error)
|
|
} else if displayedVideo != nil {
|
|
videoContent
|
|
}
|
|
}
|
|
.task {
|
|
await loadInitialVideoIfNeeded()
|
|
}
|
|
.navigationTitle(displayTitle)
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.onAppear {
|
|
// Initialize current index from queue context
|
|
if currentVideoIndex == nil, let context = videoQueueContext {
|
|
currentVideoIndex = context.videoIndex
|
|
}
|
|
|
|
// Load initial video data (only if video is already loaded)
|
|
guard displayedVideo != nil else { return }
|
|
#if !os(tvOS)
|
|
loadVideoData()
|
|
#else
|
|
if let video = displayedVideo {
|
|
isBookmarked = dataManager?.isBookmarked(videoID: video.id.videoID) ?? false
|
|
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
|
}
|
|
loadComments()
|
|
#endif
|
|
|
|
// Load full video details from API
|
|
Task {
|
|
await loadVideoDetails()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
// Cancel any pending bookmark save
|
|
bookmarkSaveTask?.cancel()
|
|
}
|
|
.onChange(of: currentVideoIndex) { oldValue, newValue in
|
|
// Skip if this is the initial value setting (handled by onAppear)
|
|
guard oldValue != nil else { return }
|
|
|
|
// Reset state when navigating to different video
|
|
showingCommentsSheet = false
|
|
showingPlaylistSheet = false
|
|
showingDownloadSheet = false
|
|
showingRemoveBookmarkAlert = false
|
|
bookmarkSaveTask?.cancel()
|
|
comments = []
|
|
commentsState = .idle
|
|
commentsContinuation = nil
|
|
scrollViewID = UUID() // Reset scroll position
|
|
// Don't reset scrollOffset here - let the new scroll view report its geometry
|
|
// Resetting to 0 causes the blur to jump to an incorrect position briefly
|
|
|
|
// Load new video data
|
|
#if !os(tvOS)
|
|
loadVideoData()
|
|
|
|
// Pre-load more videos if we're at 95% of the list
|
|
if shouldPreloadMore {
|
|
Task {
|
|
await loadMoreVideos()
|
|
}
|
|
}
|
|
#else
|
|
if let video = displayedVideo {
|
|
isBookmarked = dataManager?.isBookmarked(videoID: video.id.videoID) ?? false
|
|
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
|
}
|
|
loadComments()
|
|
#endif
|
|
|
|
// Load full video details from API
|
|
Task {
|
|
await loadVideoDetails()
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingPlaylistSheet) {
|
|
if let video = displayedVideo {
|
|
PlaylistSelectorSheet(video: video)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingCommentsSheet) {
|
|
commentsSheetContent
|
|
}
|
|
.sheet(item: $resumeSheetData) { data in
|
|
ResumeActionSheet(
|
|
video: data.video,
|
|
resumeTime: data.resumeTime,
|
|
onContinue: { playVideoWithStartTime(data.resumeTime) },
|
|
onStartOver: { playVideoWithStartTime(0) }
|
|
)
|
|
}
|
|
#if !os(tvOS)
|
|
.sheet(isPresented: $showingDownloadSheet) {
|
|
if let video = displayedVideo {
|
|
DownloadQualitySheet(video: video)
|
|
}
|
|
}
|
|
.onChange(of: downloadManager?.completedDownloads) { _, newValue in
|
|
// Update download state when completedDownloads changes
|
|
if let video = displayedVideo {
|
|
download = downloadManager?.download(for: video.id)
|
|
}
|
|
}
|
|
.onChange(of: downloadManager?.activeDownloads) { _, newValue in
|
|
// Update download state when activeDownloads changes (download started)
|
|
if download == nil, let video = displayedVideo {
|
|
download = downloadManager?.download(for: video.id)
|
|
}
|
|
}
|
|
#endif
|
|
.alert(String(localized: "bookmark.remove.title"), isPresented: $showingRemoveBookmarkAlert) {
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
Button(String(localized: "bookmark.remove.confirm"), role: .destructive) {
|
|
removeBookmark()
|
|
}
|
|
} message: {
|
|
Text(String(localized: "bookmark.remove.message"))
|
|
}
|
|
#if !os(tvOS)
|
|
.alert(String(localized: "videoInfo.download.remove.title"), isPresented: $showingRemoveDownloadConfirmation) {
|
|
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
Button(String(localized: "videoInfo.download.remove.confirm"), role: .destructive) {
|
|
if let download = download {
|
|
Task {
|
|
await downloadManager?.delete(download)
|
|
}
|
|
}
|
|
}
|
|
} message: {
|
|
Text(String(localized: "videoInfo.download.remove.message"))
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Video Content
|
|
|
|
/// Main video content view (shown after video is loaded).
|
|
@ViewBuilder
|
|
private var videoContent: some View {
|
|
GeometryReader { geometry in
|
|
let safeAreaTop = geometry.safeAreaInsets.top
|
|
|
|
ZStack(alignment: .top) {
|
|
// Blurred background layer - moves up with scroll to keep edge hidden
|
|
// Add safeAreaTop to offset so background starts moving immediately when user scrolls from rest
|
|
// (at rest, scrollOffset ≈ -safeAreaTop, so offset = max(0, 0) = 0)
|
|
// Extra top padding keeps the clipped top edge hidden above the visible area during scroll
|
|
let extraTopPadding: CGFloat = 150
|
|
|
|
let blurOffset: CGFloat = -max(scrollOffset + safeAreaTop, 0) - extraTopPadding
|
|
|
|
Color.clear
|
|
.frame(height: headerBackgroundHeight + extraTopPadding)
|
|
.frame(maxWidth: .infinity)
|
|
.background(alignment: .bottom) {
|
|
blurredThumbnailBackground
|
|
}
|
|
.clipped()
|
|
.offset(y: blurOffset)
|
|
.animation(.easeOut(duration: 0.2), value: scrollOffset)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header with thumbnail, title, and channel
|
|
headerSection
|
|
|
|
// Action buttons
|
|
actionButtons
|
|
|
|
// Bookmark details section (only show if bookmarked)
|
|
if isBookmarked, let bookmark = currentBookmark {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
bookmarkDetailsSection(bookmark)
|
|
}
|
|
|
|
// Description section (collapsible) - only show if description available
|
|
if shouldShowDescriptionSection {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
descriptionSection
|
|
}
|
|
|
|
// Original title section - only show when DeArrow title is active
|
|
if shouldShowOriginalTitleSection {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
originalTitleSection
|
|
}
|
|
|
|
// Stats section (collapsible) - only show if meaningful stats exist
|
|
if shouldShowStatsSection {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
statsSection
|
|
}
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
// Comments section (collapsible)
|
|
commentsSection
|
|
|
|
// Related videos section (collapsible, only shown if videos exist)
|
|
if let relatedVideos = displayedVideo?.relatedVideos, !relatedVideos.isEmpty {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
relatedVideosSection(relatedVideos)
|
|
}
|
|
|
|
|
|
// Watch history section (only show if entry exists)
|
|
if let entry = watchEntry {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
watchHistorySection(entry)
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
// Download section (only show if downloaded)
|
|
if let download = download, download.status == .completed {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
downloadSection(download)
|
|
}
|
|
#endif
|
|
}
|
|
.id(currentVideoIndex) // Force re-render when video changes
|
|
}
|
|
#if !os(tvOS)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
#endif
|
|
.modifier(VideoInfoScrollOffsetModifier(scrollOffset: $scrollOffset))
|
|
// Navigation buttons overlay - floats above scrolling content in ZStack (macOS only)
|
|
#if os(macOS)
|
|
if videoQueueContext != nil {
|
|
navigationButtonsOverlay
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Error view shown when video fails to load (videoID init mode only).
|
|
@ViewBuilder
|
|
private func videoLoadErrorView(_ message: String) -> some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "video.error.title"), systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(message)
|
|
} actions: {
|
|
Button(String(localized: "common.retry")) {
|
|
Task {
|
|
await loadInitialVideoIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Header Section
|
|
|
|
private var headerSection: some View {
|
|
VStack(alignment: .center, spacing: 0) {
|
|
#if os(iOS)
|
|
if videoQueueContext != nil, let videos = allVideos, !videos.isEmpty {
|
|
// iOS with queue: full-width carousel with peek thumbnails
|
|
thumbnailCarousel
|
|
} else {
|
|
// iOS without queue or empty queue: just the thumbnail
|
|
singleVideoCard
|
|
}
|
|
#else
|
|
// Non-iOS platforms: just the thumbnail
|
|
singleVideoCard
|
|
#endif
|
|
}
|
|
.padding(.top)
|
|
}
|
|
|
|
/// Blurred thumbnail background for ambient effect behind the header
|
|
@ViewBuilder
|
|
private var blurredThumbnailBackground: some View {
|
|
BlurredImageBackground(
|
|
url: displayedVideo.flatMap { appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: $0) } ?? displayedVideo?.bestThumbnail?.url,
|
|
videoID: displayedVideo?.id.videoID,
|
|
blurRadius: BlurredImageBackground.platformBlurRadius,
|
|
scale: 1.8,
|
|
gradientColor: headerBackgroundColor,
|
|
contentOpacity: blurredBackgroundOpacity
|
|
)
|
|
.frame(height: headerBackgroundHeight)
|
|
}
|
|
|
|
/// Calculated height for the blurred background based on content
|
|
private var headerBackgroundHeight: CGFloat {
|
|
// Thumbnail height + title area + channel area + padding
|
|
let titleHeight: CGFloat = 50 // Title with line limit 2
|
|
let channelHeight: CGFloat = 60 // Avatar + name + subscribers
|
|
let padding: CGFloat = 60 // Top and bottom padding
|
|
return thumbnailHeight + titleHeight + channelHeight + padding
|
|
}
|
|
|
|
/// Opacity for the blurred background that fades as user scrolls down
|
|
private var blurredBackgroundOpacity: Double {
|
|
let fadeDistance = headerBackgroundHeight * 0.5 // Fade 2x faster to hide hard edge before it's visible
|
|
let progress = min(max(scrollOffset / fadeDistance, 0), 1)
|
|
return 1.0 - progress
|
|
}
|
|
|
|
/// Platform-specific background color for gradient fade
|
|
private var headerBackgroundColor: Color {
|
|
#if os(iOS)
|
|
Color(uiColor: .systemBackground)
|
|
#elseif os(macOS)
|
|
Color(nsColor: .windowBackgroundColor)
|
|
#else
|
|
Color.black
|
|
#endif
|
|
}
|
|
|
|
/// Single video card without carousel (used when no queue context or non-iOS)
|
|
@ViewBuilder
|
|
private var singleVideoCard: some View {
|
|
if let video = displayedVideo {
|
|
videoCard(for: video, isLoadingMore: false, showTitle: true, isCurrent: true)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
/// Spacing between carousel items
|
|
private var carouselSpacing: CGFloat { 16 }
|
|
|
|
/// Opacity for adjacent thumbnails (non-current videos)
|
|
private var peekOpacity: Double { 0.5 }
|
|
|
|
/// Full-width thumbnail carousel with peek effect (iOS only)
|
|
/// Uses horizontal ScrollView with scrollTargetBehavior for native gesture handling
|
|
@ViewBuilder
|
|
private var thumbnailCarousel: some View {
|
|
#if os(iOS)
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
LazyHStack(alignment: .top, spacing: carouselSpacing) {
|
|
if let videos = allVideos {
|
|
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
|
|
let isCurrent = index == currentVideoIndex
|
|
// Use original video for thumbnail (stable), get author from detailed video for avatar
|
|
let authorSource = loadedVideoDetails[video.id.videoID]
|
|
videoCard(for: video, authorFrom: authorSource, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent)
|
|
.opacity(isCurrent ? 1.0 : peekOpacity)
|
|
.containerRelativeFrame(.horizontal)
|
|
.id(index)
|
|
.onTapGesture {
|
|
if !isCurrent {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
carouselScrollPosition = index
|
|
currentVideoIndex = index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Placeholder for loading more
|
|
if videoQueueContext?.canLoadMore == true {
|
|
videoCardPlaceholder(isLoading: isLoadingMoreVideos)
|
|
.opacity(peekOpacity)
|
|
.containerRelativeFrame(.horizontal)
|
|
.id("load-more-placeholder")
|
|
}
|
|
}
|
|
}
|
|
.scrollTargetLayout()
|
|
}
|
|
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
|
|
.scrollPosition(id: $carouselScrollPosition)
|
|
.onScrollPhaseChange { oldPhase, newPhase in
|
|
// Only update currentVideoIndex when scrolling ends (user lifts finger)
|
|
if newPhase == .idle, let newIndex = carouselScrollPosition, newIndex != currentVideoIndex {
|
|
currentVideoIndex = newIndex
|
|
}
|
|
}
|
|
.onChange(of: currentVideoIndex) { oldValue, newValue in
|
|
// Sync scroll position when currentVideoIndex changes from outside (e.g., onAppear)
|
|
if let newValue, newValue != carouselScrollPosition {
|
|
carouselScrollPosition = newValue
|
|
proxy.scrollTo(newValue, anchor: .center)
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Initialize scroll position
|
|
if carouselScrollPosition == nil, let index = currentVideoIndex {
|
|
carouselScrollPosition = index
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Height of thumbnail based on width and 16:9 aspect ratio
|
|
private var thumbnailHeight: CGFloat {
|
|
thumbnailWidth * 9 / 16
|
|
}
|
|
|
|
/// A single video card with thumbnail, title, and channel info
|
|
/// - Parameters:
|
|
/// - video: The video to display (used for thumbnail - stable reference)
|
|
/// - authorFrom: Optional video to get author info from (for avatar URL from detailed video)
|
|
/// - isLoadingMore: Whether to show loading overlay for continuation loading
|
|
/// - showTitle: Whether to show the title and channel (animates in/out)
|
|
/// - isCurrent: Whether this is the currently selected video (thumbnail tap plays video)
|
|
private func videoCard(for video: Video, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View {
|
|
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
|
|
let bestThumb = video.bestThumbnail
|
|
let thumbnailURL = deArrowURL ?? bestThumb?.url
|
|
return VStack(spacing: 12) {
|
|
// Thumbnail with loading overlay
|
|
ZStack {
|
|
LazyImage(url: thumbnailURL) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(16/9, contentMode: .fill)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
} else {
|
|
Rectangle()
|
|
.fill(.quaternary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.overlay {
|
|
if video.bestThumbnail?.url == nil, video.isFromMediaSource {
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(3)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.aspectRatio(16/9, contentMode: .fit)
|
|
.frame(width: thumbnailWidth)
|
|
|
|
// Loading overlay when fetching more videos (continuation)
|
|
if isLoadingMore {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.black.opacity(0.4))
|
|
.frame(width: thumbnailWidth)
|
|
.aspectRatio(16/9, contentMode: .fit)
|
|
|
|
ProgressView()
|
|
.tint(.white)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
if isCurrent {
|
|
playVideo()
|
|
}
|
|
}
|
|
|
|
// Title
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
.frame(width: thumbnailWidth)
|
|
.opacity(showTitle ? 1.0 : 0.0)
|
|
|
|
// Channel row (horizontal) - only tappable if we have real channel info
|
|
// Use authorFrom for author info (includes avatar URL) if available
|
|
Group {
|
|
let authorVideo = authorFrom ?? video
|
|
if authorVideo.author.hasRealChannelInfo {
|
|
Button {
|
|
navigationCoordinator?.navigateToChannel(for: authorVideo)
|
|
} label: {
|
|
channelRowContent(for: authorVideo)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
channelRowContent(for: authorVideo)
|
|
}
|
|
}
|
|
.opacity(showTitle ? 1.0 : 0.0)
|
|
}
|
|
}
|
|
|
|
/// Channel row content used in both tappable and non-tappable variants
|
|
private func channelRowContent(for video: Video) -> some View {
|
|
let enrichedAuthor = appEnvironment.map { video.author.enriched(using: $0.dataManager) } ?? video.author
|
|
return HStack(spacing: 10) {
|
|
ChannelAvatarView(
|
|
author: enrichedAuthor,
|
|
size: 40,
|
|
yatteeServerURL: yatteeServerURL,
|
|
source: video.authorSource
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(enrichedAuthor.name)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.lineLimit(1)
|
|
|
|
Group {
|
|
if let subscribers = enrichedAuthor.formattedSubscriberCount {
|
|
Text(subscribers)
|
|
} else if isLoadingVideoDetails && video.supportsAPIStats {
|
|
Text("1.2M subscribers")
|
|
.redacted(reason: .placeholder)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Placeholder card shown when loading more videos
|
|
private func videoCardPlaceholder(isLoading: Bool) -> some View {
|
|
VStack(spacing: 12) {
|
|
// Thumbnail placeholder
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.quaternary)
|
|
.aspectRatio(16/9, contentMode: .fit)
|
|
.frame(width: thumbnailWidth)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(.secondary)
|
|
}
|
|
}
|
|
|
|
// Title placeholder
|
|
VStack(spacing: 8) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: thumbnailWidth * 0.8, height: 16)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: thumbnailWidth * 0.5, height: 12)
|
|
}
|
|
|
|
// Channel placeholder
|
|
VStack(spacing: 4) {
|
|
// Avatar placeholder (circle)
|
|
Circle()
|
|
.fill(.quaternary)
|
|
.frame(width: 56, height: 56)
|
|
|
|
// Name placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: thumbnailWidth * 0.4, height: 14)
|
|
|
|
// Subscriber count placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: thumbnailWidth * 0.25, height: 12)
|
|
}
|
|
}
|
|
.frame(width: thumbnailWidth)
|
|
}
|
|
|
|
// MARK: - Action Buttons
|
|
|
|
/// Vertical action button with large icon on top and text below
|
|
private func verticalActionButton(
|
|
icon: String,
|
|
label: String,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18))
|
|
Text(label)
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
|
|
private var actionButtons: some View {
|
|
VStack(spacing: 12) {
|
|
#if !os(tvOS)
|
|
// Play, Download and Share (vertical layout)
|
|
HStack(spacing: 12) {
|
|
// Play button
|
|
verticalActionButton(
|
|
icon: "play.fill",
|
|
label: String(localized: "video.context.play"),
|
|
action: playVideo
|
|
)
|
|
|
|
// Download button
|
|
verticalDownloadActionButton
|
|
|
|
// Share button
|
|
if let video = displayedVideo {
|
|
ShareLink(item: video.shareURL) {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 18))
|
|
Text(String(localized: "video.share"))
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
#endif
|
|
|
|
// Add to Playlist and Bookmark
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
showingPlaylistSheet = true
|
|
} label: {
|
|
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
|
|
.frame(maxWidth: .infinity)
|
|
.font(.subheadline)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button {
|
|
toggleBookmark()
|
|
} label: {
|
|
Label(
|
|
isBookmarked ? String(localized: "video.removeBookmark") : String(localized: "video.bookmark"),
|
|
systemImage: isBookmarked ? "bookmark.fill" : "bookmark"
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.font(.subheadline)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.fontWeight(.semibold)
|
|
.frame(maxWidth: 400)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
/// Vertical download action button with four states: default, enqueueing, downloading, downloaded
|
|
@ViewBuilder
|
|
private var verticalDownloadActionButton: some View {
|
|
let isDownloaded = download?.status == .completed
|
|
let isDownloading = displayedVideo.flatMap { downloadManager?.isDownloading($0.id) } ?? false
|
|
|
|
if isDownloaded {
|
|
// Downloaded state - tap to show delete confirmation
|
|
Button(role: .destructive) {
|
|
showingRemoveDownloadConfirmation = true
|
|
} label: {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 18))
|
|
Text(String(localized: "video.downloaded"))
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
} else if isDownloading || isEnqueuingDownload {
|
|
// Downloading/enqueueing state - shows progress
|
|
Button {
|
|
// No action while downloading
|
|
} label: {
|
|
VStack(spacing: 6) {
|
|
ProgressView()
|
|
.controlSize(.regular)
|
|
if isEnqueuingDownload {
|
|
Text(String(localized: "video.downloading"))
|
|
.allowsTightening(true)
|
|
.font(.caption)
|
|
} else if let video = displayedVideo,
|
|
let progress = downloadManager?.downloadProgressByVideo[video.id],
|
|
!progress.isIndeterminate {
|
|
Text("\(Int(progress.progress * 100))%")
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
} else {
|
|
Text(String(localized: "video.downloading"))
|
|
.allowsTightening(true)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(true)
|
|
} else if let video = displayedVideo {
|
|
// Default state - download button
|
|
Button {
|
|
startDownload(for: video)
|
|
} label: {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "arrow.down.circle")
|
|
.font(.system(size: 18))
|
|
Text(String(localized: "video.download"))
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
/// Starts a download either automatically or by showing the quality sheet.
|
|
private func startDownload(for video: Video) {
|
|
guard let appEnvironment else {
|
|
showingDownloadSheet = true
|
|
return
|
|
}
|
|
|
|
// Media source videos (SMB/WebDAV/local) use direct file URLs - no API call needed
|
|
if video.isFromMediaSource {
|
|
isEnqueuingDownload = true
|
|
Task {
|
|
do {
|
|
try await appEnvironment.downloadManager.autoEnqueueMediaSource(
|
|
video,
|
|
mediaSourcesManager: appEnvironment.mediaSourcesManager,
|
|
webDAVClient: appEnvironment.webDAVClient,
|
|
smbClient: appEnvironment.smbClient
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.show(
|
|
category: .error,
|
|
title: String(localized: "download.error.title"),
|
|
subtitle: error.localizedDescription,
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .red
|
|
)
|
|
}
|
|
await MainActor.run {
|
|
isEnqueuingDownload = false
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
let downloadSettings = appEnvironment.downloadSettings
|
|
|
|
// Check if auto-download mode
|
|
if downloadSettings.preferredDownloadQuality != .ask,
|
|
let instance = appEnvironment.instancesManager.instance(for: video) {
|
|
isEnqueuingDownload = true
|
|
Task {
|
|
do {
|
|
try await appEnvironment.downloadManager.autoEnqueue(
|
|
video,
|
|
preferredQuality: downloadSettings.preferredDownloadQuality,
|
|
preferredAudioLanguage: appEnvironment.settingsManager.preferredAudioLanguage,
|
|
preferredSubtitlesLanguage: appEnvironment.settingsManager.preferredSubtitlesLanguage,
|
|
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload,
|
|
contentService: appEnvironment.contentService,
|
|
instance: instance
|
|
)
|
|
} catch {
|
|
appEnvironment.toastManager.show(
|
|
category: .error,
|
|
title: String(localized: "download.error.title"),
|
|
subtitle: error.localizedDescription,
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .red
|
|
)
|
|
}
|
|
await MainActor.run {
|
|
isEnqueuingDownload = false
|
|
}
|
|
}
|
|
} else {
|
|
showingDownloadSheet = true
|
|
}
|
|
}
|
|
|
|
private func downloadSection(_ download: Download) -> some View {
|
|
CollapsibleSection(title: String(localized: "videoInfo.section.download"), isExpanded: $isDownloadExpanded) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 12) {
|
|
// Quality
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.quality"),
|
|
value: download.quality
|
|
)
|
|
|
|
// File size
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.fileSize"),
|
|
value: formatBytes(download.totalBytes)
|
|
)
|
|
|
|
// Video codec
|
|
if let codec = download.videoCodec {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.videoCodec"),
|
|
value: codec.uppercased()
|
|
)
|
|
}
|
|
|
|
// Audio codec
|
|
if let codec = download.audioCodec {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.audioCodec"),
|
|
value: codec.uppercased()
|
|
)
|
|
}
|
|
|
|
// Downloaded at
|
|
if let completedAt = download.completedAt {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.downloadedAt"),
|
|
value: completedAt.formatted(date: .abbreviated, time: .shortened)
|
|
)
|
|
}
|
|
|
|
// Bitrates
|
|
if let videoBitrate = download.videoBitrate {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.videoBitrate"),
|
|
value: formatBitrate(videoBitrate)
|
|
)
|
|
}
|
|
|
|
if let audioBitrate = download.audioBitrate {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.download.audioBitrate"),
|
|
value: formatBitrate(audioBitrate)
|
|
)
|
|
}
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
showingRemoveDownloadConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "videoInfo.download.remove"), systemImage: "trash")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.red)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
|
|
private func formatBitrate(_ bitrate: Int) -> String {
|
|
if bitrate >= 1_000_000 {
|
|
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
|
|
} else {
|
|
return String(format: "%d kbps", bitrate / 1000)
|
|
}
|
|
}
|
|
#endif
|
|
// MARK: - Stats Section
|
|
|
|
@ViewBuilder
|
|
private var statsSection: some View {
|
|
if let video = displayedVideo {
|
|
CollapsibleSection(title: String(localized: "videoInfo.section.stats"), isExpanded: $isStatsExpanded) {
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 12) {
|
|
if let viewCount = video.viewCount {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.views"),
|
|
value: CountFormatter.compact(viewCount)
|
|
)
|
|
}
|
|
|
|
if let likeCount = video.likeCount {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.likes"),
|
|
value: CountFormatter.compact(likeCount)
|
|
)
|
|
}
|
|
|
|
if let publishedText = video.formattedPublishedDate {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.published"),
|
|
value: publishedText
|
|
)
|
|
}
|
|
|
|
infoRow(
|
|
label: String(localized: "videoInfo.duration"),
|
|
value: video.formattedDuration
|
|
)
|
|
|
|
if let source = videoSourceDisplay {
|
|
infoRow(
|
|
label: String(localized: "videoInfo.source"),
|
|
value: source
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Watch History Section
|
|
|
|
private func watchHistorySection(_ entry: WatchEntry) -> some View {
|
|
CollapsibleSection(title: String(localized: "videoInfo.section.watchHistory"), isExpanded: $isWatchHistoryExpanded) {
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 12) {
|
|
// First watched
|
|
infoRow(
|
|
label: String(localized: "videoInfo.firstWatched"),
|
|
value: entry.createdAt.formatted(date: .abbreviated, time: .shortened)
|
|
)
|
|
|
|
// Last watched
|
|
infoRow(
|
|
label: String(localized: "videoInfo.lastWatched"),
|
|
value: entry.updatedAt.formatted(date: .abbreviated, time: .shortened)
|
|
)
|
|
|
|
// Progress
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(String(localized: "videoInfo.progress"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
// Progress bar
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(.quaternary)
|
|
.frame(height: 6)
|
|
|
|
Capsule()
|
|
.fill(entry.isFinished ? Color.green : accentColor)
|
|
.frame(width: geometry.size.width * entry.progress, height: 6)
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
|
|
// Percentage
|
|
Text("\(Int(entry.progress * 100))%")
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Completed
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(String(localized: "videoInfo.completed"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let finishedAt = entry.finishedAt {
|
|
Text(finishedAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
} else if entry.isFinished {
|
|
Text(String(localized: "common.yes"))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
} else {
|
|
Text(String(localized: "videoInfo.notCompleted"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Bookmark Details Section
|
|
|
|
private func bookmarkDetailsSection(_ bookmark: Bookmark) -> some View {
|
|
CollapsibleSection(title: String(localized: "videoInfo.section.bookmarkDetails"), isExpanded: $isBookmarkExpanded) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Bookmarked date
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(String(localized: "videoInfo.bookmarkedAt"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(bookmark.createdAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
}
|
|
|
|
// Tags section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: "videoInfo.bookmark.tags"))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
|
|
if bookmarkTags.isEmpty && !isEditingBookmarkTags {
|
|
Button {
|
|
isEditingBookmarkTags = true
|
|
} label: {
|
|
Label(String(localized: "videoInfo.bookmark.addTags"), systemImage: "plus.circle")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
} else {
|
|
TagInputView(tags: $bookmarkTags, isFocused: isEditingBookmarkTags)
|
|
.onChange(of: bookmarkTags) { _, newTags in
|
|
debouncedSaveBookmark()
|
|
if newTags.isEmpty {
|
|
isEditingBookmarkTags = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notes section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: "videoInfo.bookmark.notes"))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
|
|
if bookmarkNote.isEmpty && !isEditingBookmarkNote {
|
|
Button {
|
|
isEditingBookmarkNote = true
|
|
isBookmarkNoteFocused = true
|
|
} label: {
|
|
Label(String(localized: "videoInfo.bookmark.addNotes"), systemImage: "plus.circle")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
} else {
|
|
#if os(tvOS)
|
|
TextField(String(localized: "videoInfo.bookmark.notes"), text: $bookmarkNote, axis: .vertical)
|
|
.frame(minHeight: 100)
|
|
.font(.subheadline)
|
|
.onChange(of: bookmarkNote) { _, _ in
|
|
debouncedSaveBookmark()
|
|
}
|
|
#else
|
|
TextEditor(text: $bookmarkNote)
|
|
.focused($isBookmarkNoteFocused)
|
|
.frame(minHeight: 100)
|
|
.font(.subheadline)
|
|
#if os(iOS)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
#elseif os(macOS)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color(nsColor: .controlBackgroundColor))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
#endif
|
|
.onChange(of: bookmarkNote) { _, newValue in
|
|
if newValue.isEmpty {
|
|
// Save immediately when cleared to prevent data loss
|
|
bookmarkSaveTask?.cancel()
|
|
saveBookmark()
|
|
} else {
|
|
debouncedSaveBookmark()
|
|
}
|
|
}
|
|
.onChange(of: isBookmarkNoteFocused) { _, focused in
|
|
if focused {
|
|
isEditingBookmarkNote = true
|
|
} else if bookmarkNote.isEmpty {
|
|
isEditingBookmarkNote = false
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// Character count when approaching limit
|
|
if bookmarkNote.count > 900 {
|
|
HStack {
|
|
Spacer()
|
|
Text("\(bookmarkNote.count)/1000")
|
|
.font(.caption)
|
|
.foregroundStyle(bookmarkNote.count > 1000 ? .red : .secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func infoRow(label: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Description Section
|
|
|
|
private var descriptionSection: some View {
|
|
CollapsibleSection(title: String(localized: "video.description"), isExpanded: $isDescriptionExpanded) {
|
|
if isLoadingVideoDetails {
|
|
// Loading state
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 20)
|
|
} else if let description = displayedVideo?.description, !description.isEmpty {
|
|
// Description available
|
|
Text(DescriptionText.attributed(description, linkColor: accentColor))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.tint(accentColor)
|
|
.handleTimestampLinks(using: playerService)
|
|
#if !os(tvOS)
|
|
.textSelection(.enabled)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Original Title Section
|
|
|
|
private var originalTitleSection: some View {
|
|
CollapsibleSection(title: String(localized: "video.originalTitle"), isExpanded: $isOriginalTitleExpanded) {
|
|
if let title = displayedVideo?.title {
|
|
Text(title)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
#if !os(tvOS)
|
|
.textSelection(.enabled)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Related Videos Section
|
|
|
|
private func relatedVideosSection(_ videos: [Video]) -> some View {
|
|
CollapsibleSection(title: String(localized: "videoInfo.section.relatedVideos"), isExpanded: $isRelatedExpanded) {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(Array(videos.enumerated()), id: \.element.id) { index, relatedVideo in
|
|
VideoRowView(video: relatedVideo, style: .regular)
|
|
.tappableVideo(
|
|
relatedVideo,
|
|
queueSource: .manual,
|
|
sourceLabel: String(localized: "videoInfo.section.relatedVideos"),
|
|
videoList: videos,
|
|
videoIndex: index,
|
|
loadMoreVideos: nil
|
|
)
|
|
#if !os(tvOS)
|
|
.videoSwipeActions(video: relatedVideo)
|
|
#endif
|
|
|
|
if index < videos.count - 1 {
|
|
Divider()
|
|
.padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Comments Section
|
|
|
|
@ViewBuilder
|
|
private var commentsSection: some View {
|
|
if supportsComments {
|
|
CollapsibleSection(title: String(localized: "videoInfo.section.comments"), isExpanded: $isCommentsExpanded) {
|
|
switch commentsState {
|
|
case .idle, .loading:
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 20)
|
|
|
|
case .disabled:
|
|
HStack {
|
|
Spacer()
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.bubble")
|
|
.font(.title2)
|
|
.foregroundStyle(.secondary)
|
|
Text(String(localized: "videoInfo.comments.disabled"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 20)
|
|
|
|
case .error:
|
|
HStack {
|
|
Spacer()
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.title2)
|
|
.foregroundStyle(.secondary)
|
|
Text(String(localized: "videoInfo.comments.error"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 20)
|
|
|
|
case .loaded, .loadingMore:
|
|
if comments.isEmpty {
|
|
HStack {
|
|
Spacer()
|
|
Text(String(localized: "videoInfo.comments.empty"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 20)
|
|
} else if let video = displayedVideo {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Show first 3 comments as preview
|
|
ForEach(comments.prefix(3)) { comment in
|
|
CommentView(
|
|
comment: comment,
|
|
videoID: video.id.videoID,
|
|
source: video.id.source,
|
|
isReply: false
|
|
)
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
// View All Comments button
|
|
if comments.count > 3 || commentsContinuation != nil {
|
|
Button {
|
|
showingCommentsSheet = true
|
|
} label: {
|
|
HStack {
|
|
Text(String(localized: "videoInfo.viewAllComments"))
|
|
.fontWeight(.medium)
|
|
Image(systemName: "chevron.right")
|
|
}
|
|
.font(.subheadline)
|
|
.foregroundStyle(accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Comments Sheet
|
|
|
|
@ViewBuilder
|
|
private var commentsSheetContent: some View {
|
|
if let video = displayedVideo {
|
|
NavigationStack {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(comments) { comment in
|
|
CommentView(
|
|
comment: comment,
|
|
videoID: video.id.videoID,
|
|
source: video.id.source,
|
|
isReply: false
|
|
)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 4)
|
|
.onAppear {
|
|
// Infinite scroll: load more when last comment appears
|
|
if comment.id == comments.last?.id {
|
|
loadMoreComments()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Loading indicator at the bottom
|
|
if commentsState == .loadingMore {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "videoInfo.section.comments"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(role: .cancel) {
|
|
showingCommentsSheet = false
|
|
} label: {
|
|
Label(String(localized: "common.close"), systemImage: "xmark")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func toggleBookmark() {
|
|
guard let dataManager, let video = displayedVideo else { return }
|
|
|
|
if isBookmarked {
|
|
// Already bookmarked - show confirmation to remove
|
|
showingRemoveBookmarkAlert = true
|
|
} else {
|
|
// Not bookmarked - add bookmark
|
|
dataManager.addBookmark(for: video)
|
|
isBookmarked = true
|
|
|
|
// Fetch newly created bookmark and load its data
|
|
if let bookmark = dataManager.bookmark(for: video.id.videoID) {
|
|
currentBookmark = bookmark
|
|
bookmarkTags = bookmark.tags
|
|
bookmarkNote = bookmark.note ?? ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeBookmark() {
|
|
guard let dataManager, let video = displayedVideo else { return }
|
|
|
|
// Cancel any pending save
|
|
bookmarkSaveTask?.cancel()
|
|
|
|
// Remove bookmark
|
|
dataManager.removeBookmark(for: video.id.videoID)
|
|
isBookmarked = false
|
|
currentBookmark = nil
|
|
bookmarkTags = []
|
|
bookmarkNote = ""
|
|
}
|
|
|
|
private func debouncedSaveBookmark() {
|
|
// Cancel existing save task
|
|
bookmarkSaveTask?.cancel()
|
|
|
|
// Create new debounced save task
|
|
bookmarkSaveTask = Task {
|
|
// Wait 1 second before saving
|
|
try? await Task.sleep(for: .seconds(1))
|
|
|
|
// Check if task was cancelled
|
|
guard !Task.isCancelled else { return }
|
|
|
|
// Save bookmark
|
|
await MainActor.run {
|
|
saveBookmark()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveBookmark() {
|
|
guard let dataManager, let video = displayedVideo else { return }
|
|
|
|
// Truncate note if too long
|
|
let finalNote = bookmarkNote.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let truncatedNote = String(finalNote.prefix(1000))
|
|
|
|
// Update bookmark
|
|
dataManager.updateBookmark(
|
|
videoID: video.id.videoID,
|
|
tags: bookmarkTags,
|
|
note: truncatedNote.isEmpty ? nil : truncatedNote
|
|
)
|
|
|
|
// Refresh bookmark data
|
|
currentBookmark = dataManager.bookmark(for: video.id.videoID)
|
|
}
|
|
|
|
private func loadComments() {
|
|
// Don't load comments for non-YouTube sources
|
|
guard supportsComments else { return }
|
|
guard commentsState == .idle else { return }
|
|
guard let video = displayedVideo,
|
|
let contentService, let instancesManager,
|
|
let instance = instancesManager.instance(for: video) else {
|
|
commentsState = .error
|
|
return
|
|
}
|
|
|
|
commentsState = .loading
|
|
|
|
Task {
|
|
do {
|
|
let page = try await contentService.comments(
|
|
videoID: video.id.videoID,
|
|
instance: instance,
|
|
continuation: nil
|
|
)
|
|
await MainActor.run {
|
|
comments = page.comments
|
|
commentsContinuation = page.continuation
|
|
commentsState = .loaded
|
|
}
|
|
} catch let error as APIError where error == .commentsDisabled {
|
|
await MainActor.run {
|
|
commentsState = .disabled
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
commentsState = .error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadMoreComments() {
|
|
guard commentsState != .loadingMore else { return }
|
|
guard let continuation = commentsContinuation else { return }
|
|
guard let video = displayedVideo,
|
|
let contentService, let instancesManager,
|
|
let instance = instancesManager.instance(for: video) else { return }
|
|
|
|
commentsState = .loadingMore
|
|
|
|
Task {
|
|
do {
|
|
let page = try await contentService.comments(
|
|
videoID: video.id.videoID,
|
|
instance: instance,
|
|
continuation: continuation
|
|
)
|
|
await MainActor.run {
|
|
comments.append(contentsOf: page.comments)
|
|
commentsContinuation = page.continuation
|
|
commentsState = .loaded
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
commentsState = .loaded // Don't show error on load more failure
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Video Details Loading
|
|
|
|
/// Load full video details from the API (fails silently)
|
|
@MainActor
|
|
private func loadVideoDetails() async {
|
|
guard let base = baseVideo else {
|
|
isLoadingVideoDetails = false
|
|
return
|
|
}
|
|
let videoID = base.id.videoID
|
|
|
|
// Skip if already loaded
|
|
guard loadedVideoDetails[videoID] == nil else {
|
|
isLoadingVideoDetails = false
|
|
return
|
|
}
|
|
|
|
// Check the video source type for extracted content
|
|
if case .extracted(let extractor, let originalURL) = base.id.source {
|
|
// Skip API fetch for local media sources (SMB, WebDAV, local files)
|
|
if extractor == MediaFile.smbProvider
|
|
|| extractor == MediaFile.webdavProvider
|
|
|| extractor == MediaFile.localFolderProvider {
|
|
isLoadingVideoDetails = false
|
|
return
|
|
}
|
|
|
|
// For other extracted content (Bilibili, etc.), use the extract endpoint
|
|
guard let contentService,
|
|
let instancesManager,
|
|
let instance = instancesManager.yatteeServerInstances.first else {
|
|
isLoadingVideoDetails = false
|
|
return
|
|
}
|
|
|
|
isLoadingVideoDetails = true
|
|
do {
|
|
// Use extractURL method - just use the video part
|
|
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
|
|
loadedVideoDetails[videoID] = fullVideo
|
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
|
} catch {
|
|
// Fail silently - use partial video data we have
|
|
}
|
|
isLoadingVideoDetails = false
|
|
return
|
|
}
|
|
|
|
// For YouTube/global content, use existing video endpoint
|
|
guard let contentService,
|
|
let instancesManager,
|
|
let instance = instancesManager.instance(for: base) else {
|
|
isLoadingVideoDetails = false
|
|
return
|
|
}
|
|
|
|
isLoadingVideoDetails = true
|
|
|
|
do {
|
|
let fullVideo = try await contentService.video(
|
|
id: videoID,
|
|
instance: instance
|
|
)
|
|
loadedVideoDetails[videoID] = fullVideo
|
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
|
} catch {
|
|
// Fail silently - just use the partial video data we have
|
|
}
|
|
|
|
isLoadingVideoDetails = false
|
|
}
|
|
|
|
/// Load initial video from API (for videoID init mode).
|
|
private func loadInitialVideoIfNeeded() async {
|
|
guard case .videoID(let videoID) = initMode else { return }
|
|
|
|
guard let contentService,
|
|
let instancesManager,
|
|
let instance = instancesManager.instance(for: videoID.source) else {
|
|
initialVideoLoadError = String(localized: "error.noInstance")
|
|
return
|
|
}
|
|
|
|
isLoadingInitialVideo = true
|
|
initialVideoLoadError = nil
|
|
|
|
do {
|
|
loadedVideo = try await contentService.video(id: videoID.videoID, instance: instance)
|
|
isLoadingInitialVideo = false
|
|
|
|
// Now that video is loaded, trigger initial data loading
|
|
#if !os(tvOS)
|
|
loadVideoData()
|
|
#else
|
|
if let video = displayedVideo {
|
|
isBookmarked = dataManager?.isBookmarked(videoID: video.id.videoID) ?? false
|
|
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
|
}
|
|
loadComments()
|
|
#endif
|
|
} catch {
|
|
initialVideoLoadError = error.localizedDescription
|
|
isLoadingInitialVideo = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Queue Context Helpers
|
|
|
|
/// Play the video, respecting the user's resume action setting for partially watched videos.
|
|
private func playVideo() {
|
|
guard let video = displayedVideo, let env = appEnvironment else { return }
|
|
|
|
// Get saved watch progress from database
|
|
let savedProgress = env.dataManager.watchProgress(for: video.id.videoID)
|
|
let videoDuration = video.duration
|
|
// When duration is 0 (not yet loaded), use a large threshold to avoid false "completed" detection
|
|
let completionThreshold = videoDuration > 0 ? videoDuration * 0.9 : Double.greatestFiniteMagnitude
|
|
// Minimum threshold - treat < 5 seconds as "not watched" to avoid asking for very short progress
|
|
let minimumThreshold: TimeInterval = 5
|
|
|
|
// Only consider resume logic if there's meaningful saved progress (>5s) and video wasn't completed
|
|
if let savedProgress, savedProgress >= minimumThreshold, savedProgress < completionThreshold {
|
|
let resumeActionSetting = env.settingsManager.resumeAction
|
|
|
|
switch resumeActionSetting {
|
|
case .continueWatching:
|
|
// Use saved progress as start time
|
|
playVideoWithStartTime(savedProgress)
|
|
case .startFromBeginning:
|
|
// Always start from beginning
|
|
playVideoWithStartTime(0)
|
|
case .ask:
|
|
// Show the resume action sheet
|
|
resumeSheetData = ResumeSheetData(video: video, resumeTime: savedProgress)
|
|
}
|
|
} else {
|
|
// No saved progress or video was completed - play from beginning
|
|
playVideoWithStartTime(0)
|
|
}
|
|
}
|
|
|
|
/// Plays the video with the specified start time.
|
|
private func playVideoWithStartTime(_ time: TimeInterval) {
|
|
guard let video = displayedVideo else { return }
|
|
|
|
guard let context = videoQueueContext,
|
|
context.hasQueueInfo,
|
|
let queueManager = queueManager,
|
|
let list = context.videoList else {
|
|
// No queue context - play single video
|
|
if time > 0 {
|
|
playerService?.openVideo(video, startTime: time)
|
|
} else {
|
|
playerService?.openVideo(video)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Use current index if navigating, otherwise use original context index
|
|
let playIndex = currentVideoIndex ?? context.videoIndex ?? 0
|
|
|
|
// Play with queue context
|
|
queueManager.playFromList(
|
|
videos: list,
|
|
index: playIndex,
|
|
queueSource: context.queueSource,
|
|
sourceLabel: context.sourceLabel,
|
|
startTime: time
|
|
)
|
|
}
|
|
|
|
// MARK: - Video Navigation
|
|
|
|
#if !os(tvOS)
|
|
/// Whether we can navigate to the previous video
|
|
private var canNavigatePrevious: Bool {
|
|
guard let index = currentVideoIndex else { return false }
|
|
return index > 0
|
|
}
|
|
|
|
/// Whether we can navigate to the next video
|
|
private var canNavigateNext: Bool {
|
|
guard let index = currentVideoIndex else {
|
|
return false
|
|
}
|
|
|
|
// Check if we can navigate within loaded videos
|
|
if let videos = allVideos, index < videos.count - 1 {
|
|
return true
|
|
}
|
|
|
|
// Check if we can load more videos
|
|
return videoQueueContext?.canLoadMore == true
|
|
}
|
|
|
|
/// Whether we should pre-load more videos (at 95% of current list)
|
|
private var shouldPreloadMore: Bool {
|
|
guard let videos = allVideos,
|
|
let index = currentVideoIndex,
|
|
videoQueueContext?.canLoadMore == true,
|
|
!isLoadingMoreVideos,
|
|
loadMoreError == nil else {
|
|
return false
|
|
}
|
|
|
|
let threshold = Int(Double(videos.count) * 0.95)
|
|
return index >= threshold
|
|
}
|
|
|
|
/// Navigate to the previous video in the queue
|
|
private func navigateToPrevious() {
|
|
guard canNavigatePrevious, let index = currentVideoIndex else { return }
|
|
currentVideoIndex = index - 1
|
|
}
|
|
|
|
/// Navigate to the next video in the queue
|
|
private func navigateToNext() {
|
|
guard let index = currentVideoIndex else {
|
|
return
|
|
}
|
|
|
|
// Clear any previous errors when navigating
|
|
loadMoreError = nil
|
|
|
|
// If we're at the last loaded video and can load more, trigger loading
|
|
if let videos = allVideos,
|
|
index == videos.count - 1,
|
|
videoQueueContext?.canLoadMore == true {
|
|
Task {
|
|
await loadMoreVideos()
|
|
// After loading, navigate to next video if available
|
|
if let newVideos = allVideos, index < newVideos.count - 1 {
|
|
await MainActor.run {
|
|
currentVideoIndex = index + 1
|
|
}
|
|
}
|
|
}
|
|
} else if canNavigateNext {
|
|
// Normal navigation within loaded videos
|
|
currentVideoIndex = index + 1
|
|
}
|
|
}
|
|
|
|
/// Load more videos via continuation callback
|
|
@MainActor
|
|
private func loadMoreVideos() async {
|
|
guard !isLoadingMoreVideos,
|
|
let callback = videoQueueContext?.loadMoreVideos else {
|
|
return
|
|
}
|
|
|
|
isLoadingMoreVideos = true
|
|
loadMoreError = nil
|
|
|
|
do {
|
|
let (newVideos, _) = try await callback()
|
|
|
|
// Limit extended list to 500 videos to prevent memory issues
|
|
let remainingCapacity = max(0, 500 - extendedVideoList.count)
|
|
let videosToAdd = Array(newVideos.prefix(remainingCapacity))
|
|
|
|
extendedVideoList.append(contentsOf: videosToAdd)
|
|
} catch {
|
|
loadMoreError = error.localizedDescription
|
|
}
|
|
|
|
isLoadingMoreVideos = false
|
|
}
|
|
|
|
/// Load video-specific data (bookmark, watch history, comments, etc.)
|
|
private func loadVideoData() {
|
|
guard let video = displayedVideo else { return }
|
|
|
|
isBookmarked = dataManager?.isBookmarked(videoID: video.id.videoID) ?? false
|
|
|
|
// Load bookmark details if bookmarked
|
|
if isBookmarked, let bookmark = dataManager?.bookmark(for: video.id.videoID) {
|
|
currentBookmark = bookmark
|
|
bookmarkTags = bookmark.tags
|
|
bookmarkNote = bookmark.note ?? ""
|
|
} else {
|
|
currentBookmark = nil
|
|
bookmarkTags = []
|
|
bookmarkNote = ""
|
|
}
|
|
|
|
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
|
#if !os(tvOS)
|
|
download = downloadManager?.download(for: video.id)
|
|
#endif
|
|
loadComments()
|
|
}
|
|
|
|
#if os(macOS)
|
|
/// Navigation buttons overlay - floats at bottom of screen (macOS only)
|
|
@ViewBuilder
|
|
private var navigationButtonsOverlay: some View {
|
|
VStack {
|
|
Spacer()
|
|
|
|
HStack {
|
|
// Previous button
|
|
if canNavigatePrevious {
|
|
VideoNavigationButton(direction: .previous) {
|
|
navigateToPrevious()
|
|
}
|
|
.transition(.opacity.combined(with: .scale))
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
// Next button
|
|
if canNavigateNext {
|
|
VideoNavigationButton(
|
|
direction: .next,
|
|
action: navigateToNext,
|
|
isLoading: isLoadingMoreVideos,
|
|
hasError: loadMoreError != nil
|
|
)
|
|
.transition(.opacity.combined(with: .scale))
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 16)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: canNavigatePrevious)
|
|
.animation(.easeInOut(duration: 0.2), value: canNavigateNext)
|
|
}
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Collapsible Section
|
|
|
|
/// A collapsible section with animated expand/collapse behavior.
|
|
private struct CollapsibleSection<Content: View>: View {
|
|
let title: String
|
|
@Binding var isExpanded: Bool
|
|
@ViewBuilder let content: () -> Content
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header button
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
isExpanded.toggle()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding()
|
|
|
|
// Content
|
|
if isExpanded {
|
|
content()
|
|
.padding(.horizontal)
|
|
.padding(.bottom)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scroll Offset Modifier
|
|
|
|
private struct VideoInfoScrollOffsetModifier: ViewModifier {
|
|
@Binding var scrollOffset: CGFloat
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
|
geometry.contentOffset.y
|
|
} action: { _, newValue in
|
|
if scrollOffset != newValue {
|
|
scrollOffset = newValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
VideoInfoView(video: .preview)
|
|
.videoQueueContext(.init(video: .preview, queueSource: .manual, sourceLabel: "Manual", videoList: [.preview, .livePreview], videoIndex: 0, startTime: 0, loadMoreVideos: .none))
|
|
}
|
|
}
|