// // 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? @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 { HStack(spacing: 10) { ChannelAvatarView( author: video.author, size: 40, yatteeServerURL: yatteeServerURL, source: video.authorSource ) VStack(alignment: .leading, spacing: 2) { Text(video.author.name) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) if let subscribers = video.author.formattedSubscriberCount { Text(subscribers) .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 Button { let queueContext = VideoQueueContext( video: relatedVideo, queueSource: .manual, sourceLabel: String(localized: "videoInfo.section.relatedVideos"), videoList: videos, videoIndex: index, startTime: nil, loadMoreVideos: nil ) navigationCoordinator?.navigate(to: .video(.loaded(relatedVideo), queueContext: queueContext)) } label: { VideoRowView(video: relatedVideo, style: .regular, disableInternalTapHandling: true) } .buttonStyle(.plain) 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 } 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 } 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: 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)) } }