mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Unfolding an iPhone Duo kept the old scroll offset, leaving the selected card misaligned. Re-snap to the current card on width change, and clear the requested position once it settles so the lazy stack doesn't pull the next swipe back halfway between cards.
2850 lines
113 KiB
Swift
2850 lines
113 KiB
Swift
//
|
|
// VideoInfoView.swift
|
|
// Yattee
|
|
//
|
|
// Full-screen video information page with technical metadata and comments.
|
|
//
|
|
|
|
import SwiftUI
|
|
import NukeUI
|
|
#if !os(tvOS)
|
|
import TipKit
|
|
#endif
|
|
|
|
/// 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(\.presentAppSheet) private var presentAppSheet
|
|
@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?
|
|
@State private var clearsCarouselPositionWhenIdle = false
|
|
#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 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
|
|
#if os(tvOS)
|
|
@FocusState private var isPlayFocused: Bool
|
|
@State private var isDescriptionScrollLocked = false
|
|
#endif
|
|
|
|
// 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 isEnqueuingDownload = false
|
|
@State private var showingRemoveDownloadConfirmation = false
|
|
|
|
// Resume action sheet state
|
|
|
|
// Video details cache - stores full video details loaded from API
|
|
@State private var loadedVideoDetails: [String: Video] = [:]
|
|
@State private var isLoadingVideoDetails = false
|
|
@State private var videoDetailsLoadError: String?
|
|
/// 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
|
|
}
|
|
|
|
/// Whether the displayed video is only an ID standing in for metadata
|
|
/// (a server-history row whose hydration failed), so a failed details
|
|
/// fetch leaves nothing worth rendering.
|
|
private var displayedVideoIsPlaceholder: Bool {
|
|
guard let video = displayedVideo else { return false }
|
|
return video.author.name.isEmpty && video.thumbnails.isEmpty
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
appEnvironment?.settingsManager.resolvedAccentColor ?? .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 to surface a failed details fetch inline. The card still
|
|
/// renders from list metadata, but description/stats/related never
|
|
/// arrived — without this the sections just silently disappear.
|
|
private var shouldShowDetailsErrorSection: Bool {
|
|
guard !isLoadingVideoDetails, videoDetailsLoadError != nil,
|
|
let base = baseVideo else { return false }
|
|
return loadedVideoDetails[base.id.videoID] == nil
|
|
}
|
|
|
|
/// 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 ?? ""
|
|
}
|
|
|
|
/// Maximum thumbnail width - full card width on iPhone, capped on iPad/macOS
|
|
private var thumbnailMaxWidth: CGFloat { 500 }
|
|
|
|
/// Explicit thumbnail width for a given card width. Sized explicitly (not via
|
|
/// aspectRatio fit) because the carousel's LazyHStack proposes its ideal height
|
|
/// back to the cards, and that ideal is driven by the loaded image's native
|
|
/// size - small thumbnails would shrink the card instead of scaling up.
|
|
private func thumbnailWidth(forCardWidth cardWidth: CGFloat) -> CGFloat {
|
|
min(max(cardWidth - 32, 0), thumbnailMaxWidth)
|
|
}
|
|
|
|
/// 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,
|
|
!video.isLive,
|
|
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 \(savedProgress.formattedAsTimestamp)")
|
|
case .startFromBeginning:
|
|
return String(localized: "video.context.play")
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLoadingInitialVideo || (displayedVideoIsPlaceholder && isLoadingVideoDetails) {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = initialVideoLoadError {
|
|
videoLoadErrorView(error)
|
|
} else if let error = videoDetailsLoadError, displayedVideoIsPlaceholder {
|
|
videoLoadErrorView(error)
|
|
} else if displayedVideo != nil {
|
|
videoContent
|
|
}
|
|
}
|
|
// Opaque background so the header gradient (which fades to windowBackgroundColor)
|
|
// blends seamlessly even when presented outside a navigation stack.
|
|
.opaqueWindowBackground()
|
|
.task {
|
|
await loadInitialVideoIfNeeded()
|
|
}
|
|
#if os(tvOS)
|
|
.navigationTitle("")
|
|
#else
|
|
.navigationTitle(displayTitle)
|
|
#endif
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
#if !os(tvOS)
|
|
.toolbar {
|
|
if usesToolbarActions, displayedVideo != nil {
|
|
#if os(macOS)
|
|
// macOS has room for every action, so show them all as toolbar items.
|
|
// Explicit items rather than ForEach: a ForEach inside the toolbar builder
|
|
// is evaluated once and never picks up state changes (bookmark/download icons).
|
|
ToolbarItem(placement: .primaryAction) {
|
|
toolbarActionView(.play, inMenu: false)
|
|
}
|
|
// Separate Play from the secondary actions so it gets its own glass pill.
|
|
if #available(macOS 26, *) {
|
|
ToolbarSpacer(.fixed, placement: .primaryAction)
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
toolbarActionView(.download, inMenu: false)
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
toolbarActionView(.share, inMenu: false)
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
toolbarActionView(.addToPlaylist, inMenu: false)
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
toolbarActionView(.bookmark, inMenu: false)
|
|
}
|
|
#else
|
|
ToolbarItem(placement: .primaryAction) {
|
|
toolbarActionView(primaryAction, inMenu: false)
|
|
}
|
|
if #available(iOS 26, *) {
|
|
ToolbarSpacer(.fixed, placement: .primaryAction)
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Menu {
|
|
ForEach(VideoInfoPrimaryAction.allCases.filter { $0 != primaryAction }, id: \.self) { action in
|
|
toolbarActionView(action, inMenu: true)
|
|
}
|
|
} label: {
|
|
Label(String(localized: "videoInfo.toolbar.moreActions"), systemImage: "ellipsis")
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|
|
.onAppear {
|
|
#if !os(tvOS)
|
|
VideoInfoPlayThumbnailTip.isToolbarLayout = showsThumbnailPlayTip
|
|
#endif
|
|
// 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
|
|
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)
|
|
}
|
|
loadComments()
|
|
DispatchQueue.main.async {
|
|
isPlayFocused = true
|
|
}
|
|
#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
|
|
showingRemoveBookmarkAlert = false
|
|
bookmarkSaveTask?.cancel()
|
|
comments = []
|
|
commentsState = .idle
|
|
commentsContinuation = nil
|
|
videoDetailsLoadError = 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
|
|
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)
|
|
}
|
|
loadComments()
|
|
DispatchQueue.main.async {
|
|
isPlayFocused = true
|
|
}
|
|
#endif
|
|
|
|
// Load full video details from API
|
|
Task {
|
|
await loadVideoDetails()
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.fullScreenCover(isPresented: $showingCommentsSheet) {
|
|
commentsSheetContent
|
|
}
|
|
#else
|
|
.sheet(isPresented: $showingCommentsSheet) {
|
|
commentsSheetContent
|
|
}
|
|
#endif
|
|
#if !os(tvOS)
|
|
.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 {
|
|
#if os(tvOS)
|
|
tvOSVideoContent
|
|
#else
|
|
iOSVideoContent
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
@ViewBuilder
|
|
private var iOSVideoContent: 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
|
|
let bgHeight = headerBackgroundHeight(for: geometry.size.width)
|
|
|
|
Color.clear
|
|
.frame(height: bgHeight + extraTopPadding)
|
|
.frame(maxWidth: .infinity)
|
|
.background(alignment: .bottom) {
|
|
blurredThumbnailBackground(height: bgHeight)
|
|
}
|
|
.clipped()
|
|
.offset(y: blurOffset)
|
|
.animation(.easeOut(duration: 0.2), value: scrollOffset)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header with thumbnail, title, and channel. Kept outside the
|
|
// re-identified subtree below: recreating the carousel on every
|
|
// page change resets its scroll offset (visible on the iPhone
|
|
// Duo exterior display, where the restored position was lost).
|
|
headerSection(cardWidth: geometry.size.width)
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Action buttons (inline layout only; toolbar layout puts them in the nav bar)
|
|
if !usesToolbarActions {
|
|
actionButtons
|
|
} else {
|
|
Spacer().frame(height: 12)
|
|
}
|
|
|
|
// Bookmark details section (only show if bookmarked)
|
|
if isBookmarked, let bookmark = currentBookmark {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
bookmarkDetailsSection(bookmark)
|
|
}
|
|
|
|
// Details fetch failed - show error with retry instead of
|
|
// silently hiding the description/stats sections
|
|
if shouldShowDetailsErrorSection {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
|
|
detailsErrorSection
|
|
}
|
|
|
|
// 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
|
|
.softTopScrollEdgeEffect()
|
|
.modifier(VideoInfoScrollOffsetModifier(scrollOffset: $scrollOffset))
|
|
|
|
#if os(macOS)
|
|
videoNavigationKeyboardShortcuts
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
// Bare left/right arrow keys switch to previous/next video in the queue.
|
|
// Command-modified arrows are reserved for player seek shortcuts (see PlaybackCommands),
|
|
// so these don't collide. Disabled buttons don't fire their shortcuts, which also
|
|
// prevents activation when no queue context exists.
|
|
@ViewBuilder
|
|
private var videoNavigationKeyboardShortcuts: some View {
|
|
Group {
|
|
Button(action: navigateToPrevious) { EmptyView() }
|
|
.keyboardShortcut(.leftArrow, modifiers: [])
|
|
.disabled(!canNavigatePrevious)
|
|
|
|
Button(action: navigateToNext) { EmptyView() }
|
|
.keyboardShortcut(.rightArrow, modifiers: [])
|
|
.disabled(!canNavigateNext)
|
|
}
|
|
.frame(width: 0, height: 0)
|
|
.opacity(0)
|
|
.accessibilityHidden(true)
|
|
}
|
|
#endif
|
|
|
|
#if os(tvOS)
|
|
// MARK: - tvOS Two-Column Layout
|
|
|
|
@ViewBuilder
|
|
private var tvOSVideoContent: some View {
|
|
GeometryReader { geometry in
|
|
let leftWidth = geometry.size.width * 0.30
|
|
HStack(alignment: .top, spacing: 40) {
|
|
tvOSLeftColumn
|
|
.frame(width: leftWidth, alignment: .leading)
|
|
.focusSection()
|
|
|
|
tvOSRightColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.focusSection()
|
|
}
|
|
.padding(.horizontal, 60)
|
|
.padding(.vertical, 40)
|
|
}
|
|
}
|
|
|
|
private var tvOSLeftColumn: some View {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
if let video = displayedVideo {
|
|
tvOSThumbnail(for: video)
|
|
.frame(maxWidth: .infinity)
|
|
.aspectRatio(16.0 / 9.0, contentMode: .fit)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.primary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
tvOSChannelRow(for: video)
|
|
|
|
Button(action: playVideo) {
|
|
Label(playButtonLabel, systemImage: "play.fill")
|
|
.frame(maxWidth: .infinity, minHeight: 60)
|
|
.font(.headline)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.focused($isPlayFocused)
|
|
|
|
Button {
|
|
presentAddToPlaylist()
|
|
} label: {
|
|
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button {
|
|
toggleBookmark()
|
|
} label: {
|
|
Label(
|
|
isBookmarked ? String(localized: "video.removeBookmark") : String(localized: "video.bookmark"),
|
|
systemImage: isBookmarked ? "bookmark.fill" : "bookmark"
|
|
)
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tvOSThumbnail(for video: Video) -> some View {
|
|
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
|
|
let thumbnailURLs = [deArrowURL].compactMap { $0 } + video.thumbnailURLsByQuality
|
|
|
|
FallbackLazyImage(urls: thumbnailURLs) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(16.0 / 9.0, contentMode: .fill)
|
|
} else {
|
|
Rectangle()
|
|
.fill(.quaternary)
|
|
.overlay {
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(3)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tvOSChannelRow(for video: Video) -> some View {
|
|
if video.author.hasRealChannelInfo {
|
|
Button {
|
|
navigationCoordinator?.navigateToChannel(for: video)
|
|
} label: {
|
|
channelRowContent(for: video)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
channelRowContent(for: video)
|
|
}
|
|
}
|
|
|
|
private var tvOSRightColumn: some View {
|
|
GeometryReader { geometry in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if !isDescriptionScrollLocked, isBookmarked, let bookmark = currentBookmark {
|
|
bookmarkDetailsSection(bookmark)
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
if isLoadingVideoDetails {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.frame(height: 450)
|
|
.padding(.horizontal)
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
} else if let description = displayedVideo?.description, !description.isEmpty {
|
|
TVScrollableDescription(
|
|
description: description,
|
|
isScrollLocked: $isDescriptionScrollLocked,
|
|
showsHeader: false
|
|
)
|
|
.frame(height: isDescriptionScrollLocked ? geometry.size.height : 450)
|
|
.padding(.horizontal)
|
|
|
|
if !isDescriptionScrollLocked {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
}
|
|
} else if shouldShowDetailsErrorSection {
|
|
detailsErrorSection
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
if !isDescriptionScrollLocked {
|
|
if shouldShowOriginalTitleSection {
|
|
originalTitleSection
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
if shouldShowStatsSection {
|
|
statsSection
|
|
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
commentsSection
|
|
|
|
if let relatedVideos = displayedVideo?.relatedVideos, !relatedVideos.isEmpty {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
|
|
relatedVideosSection(relatedVideos)
|
|
}
|
|
|
|
if let entry = watchEntry {
|
|
Divider()
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
|
|
watchHistorySection(entry)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 20)
|
|
.id(currentVideoIndex)
|
|
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
|
|
}
|
|
.scrollClipDisabled()
|
|
.scrollDisabled(isDescriptionScrollLocked)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// Error view shown when the video fails to load — either the initial
|
|
/// fetch in videoID mode, or the details fetch for a placeholder video
|
|
/// that has no metadata of its own to fall back on.
|
|
@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()
|
|
await loadVideoDetails()
|
|
}
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
Button {
|
|
presentAppSheet(.errorDetails(videoDetailsLoadError ?? initialVideoLoadError ?? "Unknown error"))
|
|
} label: {
|
|
Label(String(localized: "player.error.button"), systemImage: "info.circle")
|
|
}
|
|
#endif
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Header Section
|
|
|
|
private func headerSection(cardWidth: CGFloat) -> 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(cardWidth: cardWidth)
|
|
} else {
|
|
// iOS without queue or empty queue: just the thumbnail
|
|
singleVideoCard(cardWidth: cardWidth)
|
|
}
|
|
#else
|
|
// Non-iOS platforms: just the thumbnail
|
|
singleVideoCard(cardWidth: cardWidth)
|
|
#endif
|
|
}
|
|
.padding(.top)
|
|
}
|
|
|
|
/// Blurred thumbnail background for ambient effect behind the header
|
|
@ViewBuilder
|
|
private func blurredThumbnailBackground(height: CGFloat) -> some View {
|
|
BlurredImageBackground(
|
|
url: displayedVideo.flatMap { appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: $0) } ?? displayedVideo?.reliableThumbnailURL,
|
|
videoID: displayedVideo?.id.videoID,
|
|
blurRadius: BlurredImageBackground.platformBlurRadius,
|
|
scale: 1.8,
|
|
gradientColor: headerBackgroundColor,
|
|
contentOpacity: blurredBackgroundOpacity(headerHeight: height)
|
|
)
|
|
.frame(height: height)
|
|
}
|
|
|
|
/// Calculated height for the blurred background based on content
|
|
private func headerBackgroundHeight(for containerWidth: CGFloat) -> 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(for: containerWidth) + titleHeight + channelHeight + padding
|
|
}
|
|
|
|
/// Opacity for the blurred background that fades as user scrolls down
|
|
private func blurredBackgroundOpacity(headerHeight: CGFloat) -> Double {
|
|
let fadeDistance = headerHeight * 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 func singleVideoCard(cardWidth: CGFloat) -> some View {
|
|
if let video = displayedVideo {
|
|
// No outer horizontal padding - the card pads its own content,
|
|
// matching the carousel cards which are laid out full-bleed.
|
|
videoCard(for: video, cardWidth: cardWidth, isLoadingMore: false, showTitle: true, isCurrent: true)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
/// 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 func thumbnailCarousel(cardWidth: CGFloat) -> 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
|
|
// Prefer freshly loaded details for thumbnail (so expired proxy URLs get replaced) and avatar.
|
|
// Keep `video` as the card's stable identity so title/channel don't flicker.
|
|
let detailedVideo = loadedVideoDetails[video.id.videoID]
|
|
// Unless the list never had a title to keep: a row
|
|
// from an account's server history arrives as its
|
|
// own video ID, and holding on to that would leave
|
|
// the card named after a string of characters under
|
|
// the real channel and description.
|
|
let titleVideo = video.hasPlaceholderTitle ? (detailedVideo ?? video) : video
|
|
videoCard(for: titleVideo, cardWidth: cardWidth, thumbnailFrom: detailedVideo, authorFrom: detailedVideo, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent)
|
|
.opacity(isCurrent ? 1.0 : peekOpacity)
|
|
.frame(width: cardWidth)
|
|
.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, cardWidth: cardWidth)
|
|
.opacity(peekOpacity)
|
|
.frame(width: cardWidth)
|
|
.id("load-more-placeholder")
|
|
}
|
|
}
|
|
}
|
|
.scrollTargetLayout()
|
|
}
|
|
// Pin the viewport to the page width so paging stays aligned even
|
|
// when the scroll view extends under a horizontal safe area (iPhone
|
|
// Duo exterior status bar column, landscape notch).
|
|
.frame(width: cardWidth)
|
|
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
|
|
.scrollPosition(id: $carouselScrollPosition)
|
|
.onScrollPhaseChange { oldPhase, newPhase in
|
|
if newPhase == .interacting {
|
|
clearsCarouselPositionWhenIdle = false
|
|
}
|
|
// Drop the re-snap request after a width change (see onChange(of: cardWidth))
|
|
if newPhase == .idle, clearsCarouselPositionWhenIdle {
|
|
clearsCarouselPositionWhenIdle = false
|
|
carouselScrollPosition = nil
|
|
return
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
.onChange(of: cardWidth) { _, _ in
|
|
// A width change (iPhone Duo unfold, rotation, split resize) keeps
|
|
// the old point offset, leaving the cards misaligned. Re-snap to the
|
|
// current card (setting the same id is a no-op, hence the nil hop).
|
|
// The LazyHStack keeps re-applying a programmatic position while it
|
|
// realizes cards, which stopped the next swipe halfway between two
|
|
// cards, so the position is cleared again once the re-snap settles.
|
|
guard let index = currentVideoIndex else { return }
|
|
carouselScrollPosition = nil
|
|
DispatchQueue.main.async {
|
|
clearsCarouselPositionWhenIdle = true
|
|
withAnimation(.linear(duration: 0.15)) {
|
|
carouselScrollPosition = index
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Initialize scroll position
|
|
if carouselScrollPosition == nil, let index = currentVideoIndex {
|
|
carouselScrollPosition = index
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Height of thumbnail based on available width (minus horizontal padding), capped, and 16:9 aspect ratio
|
|
private func thumbnailHeight(for containerWidth: CGFloat) -> CGFloat {
|
|
thumbnailWidth(forCardWidth: containerWidth) * 9 / 16
|
|
}
|
|
|
|
/// A single video card with thumbnail, title, and channel info
|
|
/// - Parameters:
|
|
/// - video: The video to display (used for title/channel - stable reference across carousel scrolls)
|
|
/// - thumbnailFrom: Optional video to source the thumbnail URL from (use the freshly loaded details so expired proxy URLs get replaced)
|
|
/// - 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, cardWidth: CGFloat, thumbnailFrom: Video? = nil, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View {
|
|
let thumbnailSource = thumbnailFrom ?? video
|
|
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource)
|
|
let thumbnailURLs = [deArrowURL].compactMap { $0 } + thumbnailSource.thumbnailURLsByQuality
|
|
let thumbWidth = thumbnailWidth(forCardWidth: cardWidth)
|
|
return VStack(spacing: 12) {
|
|
// Thumbnail with loading overlay
|
|
ZStack {
|
|
FallbackLazyImage(urls: thumbnailURLs) { 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 {
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(3)
|
|
.padding(8)
|
|
}
|
|
}
|
|
}
|
|
.aspectRatio(16/9, contentMode: .fit)
|
|
|
|
// Loading overlay when fetching more videos (continuation)
|
|
if isLoadingMore {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.black.opacity(0.4))
|
|
|
|
ProgressView()
|
|
.tint(.white)
|
|
}
|
|
}
|
|
// Explicit size - see thumbnailWidth(forCardWidth:) for why aspectRatio
|
|
// fit can't be trusted here
|
|
.frame(width: thumbWidth, height: thumbWidth * 9 / 16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
.onTapGesture {
|
|
if isCurrent {
|
|
#if !os(tvOS)
|
|
VideoInfoPlayThumbnailTip().invalidate(reason: .actionPerformed)
|
|
#endif
|
|
playVideo()
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.modifier(VideoInfoPlayThumbnailTipModifier(enabled: isCurrent && showsThumbnailPlayTip, canSwipe: canSwipeThumbnails))
|
|
#endif
|
|
|
|
// Title - full card width so long titles fit more characters per line.
|
|
// Always reserves two lines of height so carousel cards don't shift
|
|
// vertically when swiping between short and long titles.
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.headline)
|
|
.lineLimit(2, reservesSpace: true)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
.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)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
.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, cardWidth: CGFloat) -> some View {
|
|
let thumbWidth = thumbnailWidth(forCardWidth: cardWidth)
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
// Thumbnail placeholder
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.quaternary)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(.secondary)
|
|
}
|
|
}
|
|
.frame(width: thumbWidth, height: thumbWidth * 9 / 16)
|
|
|
|
// Title placeholder
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: 220, height: 16)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: 140, height: 12)
|
|
}
|
|
|
|
// Channel placeholder
|
|
HStack(spacing: 10) {
|
|
// Avatar placeholder (circle)
|
|
Circle()
|
|
.fill(.quaternary)
|
|
.frame(width: 40, height: 40)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Name placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: 110, height: 14)
|
|
|
|
// Subscriber count placeholder
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(.quaternary)
|
|
.frame(width: 70, height: 12)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
// 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) {
|
|
#if os(macOS)
|
|
Label(label, systemImage: icon)
|
|
.font(.body)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
#else
|
|
VStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18))
|
|
Text(label)
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.bordered)
|
|
#else
|
|
.buttonStyle(.borderedProminent)
|
|
#endif
|
|
}
|
|
|
|
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
|
|
)
|
|
#if os(macOS)
|
|
.tint(accentColor)
|
|
#endif
|
|
|
|
// Download button
|
|
verticalDownloadActionButton
|
|
|
|
// Share button
|
|
if let video = displayedVideo {
|
|
ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) {
|
|
#if os(macOS)
|
|
Label(String(localized: "video.share"), systemImage: "square.and.arrow.up")
|
|
.font(.body)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
#else
|
|
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)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.bordered)
|
|
#else
|
|
.buttonStyle(.borderedProminent)
|
|
#endif
|
|
}
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
#endif
|
|
|
|
// Add to Playlist and Bookmark
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
presentAddToPlaylist()
|
|
} 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)
|
|
}
|
|
}
|
|
#if !os(macOS)
|
|
.fontWeight(.semibold)
|
|
#endif
|
|
// Leading-aligned to line up with the thumbnail/title column on wide screens
|
|
.frame(maxWidth: 400)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
// MARK: - Toolbar Actions (iOS/macOS)
|
|
|
|
#if !os(tvOS)
|
|
/// True when the user chose the toolbar layout in Layout & Navigation settings.
|
|
private var usesToolbarActions: Bool {
|
|
appEnvironment?.settingsManager.videoInfoActionsLayout == .toolbar
|
|
}
|
|
|
|
/// The action pinned to the toolbar; every other action lives in the ellipsis menu.
|
|
/// macOS shows every action in the toolbar, so the pinned action only matters on iOS.
|
|
private var primaryAction: VideoInfoPrimaryAction {
|
|
#if os(macOS)
|
|
.play
|
|
#else
|
|
appEnvironment?.settingsManager.videoInfoPrimaryAction ?? .play
|
|
#endif
|
|
}
|
|
|
|
/// The hero thumbnail is the only play affordance when the toolbar layout is active
|
|
/// and Play isn't the pinned action, so show the one-time "tap to play" tip.
|
|
private var showsThumbnailPlayTip: Bool {
|
|
usesToolbarActions && primaryAction != .play
|
|
}
|
|
|
|
/// The hero thumbnail is a swipeable carousel over the queue context (iOS only, see headerSection).
|
|
private var canSwipeThumbnails: Bool {
|
|
#if os(iOS)
|
|
videoQueueContext != nil && (allVideos?.count ?? 0) > 1
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
/// One toolbar action rendered either as a standalone toolbar item or as a menu row.
|
|
/// The same Label works in both places: the toolbar shows the icon, the menu shows icon + text.
|
|
@ViewBuilder
|
|
private func toolbarActionView(_ action: VideoInfoPrimaryAction, inMenu: Bool) -> some View {
|
|
switch action {
|
|
case .play:
|
|
Button(action: playVideo) {
|
|
Label(inMenu ? playButtonLabel : String(localized: "video.context.play"), systemImage: "play.fill")
|
|
}
|
|
case .download:
|
|
toolbarDownloadAction
|
|
case .share:
|
|
if let video = displayedVideo {
|
|
ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) {
|
|
Label(String(localized: "video.share"), systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
case .addToPlaylist:
|
|
Button {
|
|
presentAddToPlaylist()
|
|
} label: {
|
|
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
|
|
}
|
|
case .bookmark:
|
|
Button(action: toggleBookmark) {
|
|
Label(
|
|
isBookmarked ? String(localized: "video.removeBookmark") : String(localized: "video.bookmark"),
|
|
systemImage: isBookmarked ? "bookmark.fill" : "bookmark"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Download action for the toolbar/menu, mirroring `verticalDownloadActionButton` states.
|
|
@ViewBuilder
|
|
private var toolbarDownloadAction: some View {
|
|
let isDownloaded = download?.status == .completed
|
|
let isDownloading = displayedVideo.flatMap { downloadManager?.isDownloading($0.id) } ?? false
|
|
|
|
if isDownloaded {
|
|
Button(role: .destructive) {
|
|
showingRemoveDownloadConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "video.downloaded"), systemImage: "checkmark.circle.fill")
|
|
}
|
|
} else if isDownloading || isEnqueuingDownload {
|
|
Button {} label: {
|
|
Label(String(localized: "video.downloading"), systemImage: "arrow.down.circle.dotted")
|
|
}
|
|
.disabled(true)
|
|
} else if let video = displayedVideo {
|
|
Button {
|
|
startDownload(for: video)
|
|
} label: {
|
|
Label(String(localized: "video.download"), systemImage: "arrow.down.circle")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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: {
|
|
#if os(macOS)
|
|
Label(String(localized: "video.downloaded"), systemImage: "checkmark.circle.fill")
|
|
.font(.body)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
#else
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 18))
|
|
Text(String(localized: "video.downloaded"))
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.bordered)
|
|
#else
|
|
.buttonStyle(.borderedProminent)
|
|
#endif
|
|
} else if isDownloading || isEnqueuingDownload {
|
|
// Downloading/enqueueing state - shows progress
|
|
Button {
|
|
// No action while downloading
|
|
} label: {
|
|
#if os(macOS)
|
|
HStack(spacing: 6) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
if isEnqueuingDownload {
|
|
Text(String(localized: "video.downloading"))
|
|
.allowsTightening(true)
|
|
} else if let video = displayedVideo,
|
|
let progress = downloadManager?.downloadProgressByVideo[video.id],
|
|
!progress.isIndeterminate {
|
|
Text("\(Int(progress.progress * 100))%")
|
|
.monospacedDigit()
|
|
} else {
|
|
Text(String(localized: "video.downloading"))
|
|
.allowsTightening(true)
|
|
}
|
|
}
|
|
.font(.body)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
#else
|
|
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)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.bordered)
|
|
#else
|
|
.buttonStyle(.borderedProminent)
|
|
#endif
|
|
.disabled(true)
|
|
} else if let video = displayedVideo {
|
|
// Default state - download button
|
|
Button {
|
|
startDownload(for: video)
|
|
} label: {
|
|
#if os(macOS)
|
|
Label(String(localized: "video.download"), systemImage: "arrow.down.circle")
|
|
.font(.body)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
#else
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "arrow.down.circle")
|
|
.font(.system(size: 18))
|
|
Text(String(localized: "video.download"))
|
|
.font(.caption)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.buttonStyle(.bordered)
|
|
#else
|
|
.buttonStyle(.borderedProminent)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Starts a download either automatically or by showing the quality sheet.
|
|
private func startDownload(for video: Video) {
|
|
guard let appEnvironment else {
|
|
presentAppSheet(.downloadOptions(video))
|
|
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 {
|
|
presentAppSheet(.downloadOptions(video))
|
|
}
|
|
}
|
|
|
|
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(verbatim: "\(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)
|
|
#if os(macOS)
|
|
.font(.subheadline)
|
|
#else
|
|
.font(.caption)
|
|
#endif
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Details Error Section
|
|
|
|
/// Inline error shown when the details fetch failed but the video card
|
|
/// still renders from list metadata (e.g. deleted/unavailable video).
|
|
private var detailsErrorSection: some View {
|
|
VStack(spacing: 12) {
|
|
Label(String(localized: "video.error.title"), systemImage: "exclamationmark.triangle")
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let error = videoDetailsLoadError {
|
|
Text(error)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button(String(localized: "common.retry")) {
|
|
Task {
|
|
await loadVideoDetails()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
#if !os(tvOS)
|
|
Button {
|
|
presentAppSheet(.errorDetails(videoDetailsLoadError ?? initialVideoLoadError ?? "Unknown error"))
|
|
} label: {
|
|
Label(String(localized: "player.error.button"), systemImage: "info.circle")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
#endif
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
// 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))
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
.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)
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
.foregroundStyle(.secondary)
|
|
#if !os(tvOS)
|
|
.textSelection(.enabled)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Related Videos Section
|
|
|
|
private func relatedVideosSection(_ videos: [Video]) -> some View {
|
|
#if os(tvOS)
|
|
let rowSpacing: CGFloat = 32
|
|
#else
|
|
let rowSpacing: CGFloat = 12
|
|
#endif
|
|
return CollapsibleSection(title: String(localized: "videoInfo.section.relatedVideos"), isExpanded: $isRelatedExpanded) {
|
|
LazyVStack(spacing: rowSpacing) {
|
|
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 !os(tvOS)
|
|
if index < videos.count - 1 {
|
|
Divider()
|
|
.padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12)
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
.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"))
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 20)
|
|
|
|
case .loaded, .loadingMore:
|
|
if comments.isEmpty {
|
|
HStack {
|
|
Spacer()
|
|
Text(String(localized: "videoInfo.comments.empty"))
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
.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")
|
|
}
|
|
#if os(macOS)
|
|
.font(.body)
|
|
#else
|
|
.font(.subheadline)
|
|
#endif
|
|
#if !os(tvOS)
|
|
.foregroundStyle(accentColor)
|
|
#endif
|
|
}
|
|
.buttonStyle(.plain)
|
|
#if os(tvOS)
|
|
.padding(.vertical, 16)
|
|
#else
|
|
.padding(.top, 12)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
.tvFullScreenCoverBackground()
|
|
.navigationTitle(String(localized: "videoInfo.section.comments"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
#if !os(tvOS)
|
|
.toolbar {
|
|
sheetCloseToolbarItem { showingCommentsSheet = false }
|
|
}
|
|
#endif
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 500, minHeight: 550)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func presentAddToPlaylist() {
|
|
guard let video = displayedVideo else { return }
|
|
presentAppSheet(.addToPlaylist(video))
|
|
}
|
|
|
|
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
|
|
|| extractor == DirectMediaHelper.provider {
|
|
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
|
|
videoDetailsLoadError = nil
|
|
do {
|
|
// Use extractURL method - just use the video part
|
|
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
|
|
await prefetchNewThumbnailIfNeeded(old: base, new: fullVideo)
|
|
invalidateStaleThumbnails(old: base, new: fullVideo)
|
|
loadedVideoDetails[videoID] = fullVideo
|
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
|
} catch {
|
|
// Use the partial video data we have; the body surfaces the
|
|
// error only when the video is a bare placeholder.
|
|
videoDetailsLoadError = error.localizedDescription
|
|
}
|
|
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
|
|
videoDetailsLoadError = nil
|
|
|
|
do {
|
|
let fullVideo = try await contentService.video(
|
|
id: videoID,
|
|
instance: instance
|
|
)
|
|
await prefetchNewThumbnailIfNeeded(old: base, new: fullVideo)
|
|
invalidateStaleThumbnails(old: base, new: fullVideo)
|
|
loadedVideoDetails[videoID] = fullVideo
|
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
|
} catch {
|
|
// Use the partial video data we have; the body surfaces the
|
|
// error only when the video is a bare placeholder.
|
|
videoDetailsLoadError = error.localizedDescription
|
|
}
|
|
|
|
isLoadingVideoDetails = false
|
|
}
|
|
|
|
/// Warms the Nuke cache for the freshly fetched thumbnail *before* we
|
|
/// swap it into the view. Without this, the view observes a URL change,
|
|
/// flips `LazyImage` to the new URL, and briefly shows a placeholder
|
|
/// while the download happens — the "thumbnail flash". By the time the
|
|
/// swap lands, the image is already cached.
|
|
@MainActor
|
|
private func prefetchNewThumbnailIfNeeded(old: Video, new: Video) async {
|
|
guard let newURL = new.bestThumbnail?.url else { return }
|
|
let oldKey = old.bestThumbnail.map { ImageLoadingService.cacheKey(for: $0.url) }
|
|
let newKey = ImageLoadingService.cacheKey(for: newURL)
|
|
guard oldKey != newKey else { return }
|
|
await ImageLoadingService.shared.prefetchImage(for: newURL)
|
|
}
|
|
|
|
/// Evicts image cache entries for thumbnails whose URLs changed between
|
|
/// the queued/list copy of the video and the freshly fetched details, so
|
|
/// other views holding the old URLs re-fetch instead of reusing broken
|
|
/// payloads from Nuke's cache.
|
|
@MainActor
|
|
private func invalidateStaleThumbnails(old: Video, new: Video) {
|
|
// Compare by normalized cache key so URLs that only differ in a
|
|
// rotating signing `token` aren't considered stale and evicted.
|
|
let newKeys = Set(new.thumbnails.map { ImageLoadingService.cacheKey(for: $0.url) })
|
|
for thumb in old.thumbnails where !newKeys.contains(ImageLoadingService.cacheKey(for: thumb.url)) {
|
|
ImageLoadingService.shared.removeCachedImage(for: thumb.url)
|
|
}
|
|
}
|
|
|
|
/// 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 }
|
|
|
|
// Live streams have no fixed timeline, so a resume position is meaningless -
|
|
// never ask, always join at the live edge.
|
|
guard !video.isLive else {
|
|
playVideoWithStartTime(0)
|
|
return
|
|
}
|
|
|
|
// An opened timed link asks for an exact position - honor it over any
|
|
// saved progress, without the resume prompt.
|
|
if let linkStartTime = videoQueueContext?.linkStartTime,
|
|
videoQueueContext?.video.id == video.id {
|
|
playVideoWithStartTime(linkStartTime, forceStartTime: true)
|
|
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
|
|
presentAppSheet(.resumeAction(
|
|
video,
|
|
resumeTime: savedProgress,
|
|
onContinue: { playVideoWithStartTime(savedProgress) },
|
|
onStartOver: { playVideoWithStartTime(0) }
|
|
))
|
|
}
|
|
} 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, forceStartTime: Bool = false) {
|
|
guard let video = displayedVideo else { return }
|
|
|
|
// Media-browser playback must go through `playFromMediaBrowser` so the
|
|
// queue manager sets up on-demand stream/caption resolution — otherwise
|
|
// Samba/WebDAV files cannot play.
|
|
if let ctx = videoQueueContext,
|
|
let mb = ctx.mediaBrowserPlayback,
|
|
let queueManager = queueManager {
|
|
let playableFiles = mb.allFilesInFolder.filter { $0.isPlayable }
|
|
let index = playableFiles.firstIndex(where: { $0.toVideo().id.videoID == video.id.videoID })
|
|
?? currentVideoIndex
|
|
?? ctx.videoIndex
|
|
?? 0
|
|
queueManager.playFromMediaBrowser(
|
|
files: playableFiles,
|
|
index: index,
|
|
source: mb.source,
|
|
allFilesInFolder: mb.allFilesInFolder
|
|
)
|
|
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, forceStartTime: forceStartTime)
|
|
} 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()
|
|
}
|
|
#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))
|
|
}
|
|
}
|