mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
YouTube's CDN advertises maxres/sddefault thumbnails for every video but only generates them for sufficiently high-res uploads, so older/low-res videos return 404 for those qualities. The view picked the best quality and showed a blank placeholder on failure with no fallback. VideoThumbnailView now accepts an ordered fallback chain and advances to the next candidate via NukeUI's onCompletion when a load fails, so a valid thumbnail is always shown. DeArrowVideoThumbnail feeds it DeArrow branding first, then the video's own thumbnails best-quality-first.
136 lines
4.6 KiB
Swift
136 lines
4.6 KiB
Swift
//
|
|
// DeArrowVideoThumbnail.swift
|
|
// Yattee
|
|
//
|
|
// Video thumbnail that automatically fetches and displays DeArrow branding.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// A video thumbnail that automatically handles DeArrow integration.
|
|
///
|
|
/// This view wraps `VideoThumbnailView` and automatically:
|
|
/// - Fetches DeArrow branding when the view appears
|
|
/// - Displays the DeArrow thumbnail if available and enabled
|
|
/// - Falls back to the original thumbnail otherwise
|
|
/// - Shows live download progress from the download manager
|
|
///
|
|
/// Note: Download progress uses per-video dictionary observation. SwiftUI's @Observable
|
|
/// tracks dictionary access per-key, so this view only re-renders when THIS video's
|
|
/// progress changes - not when any other download progresses.
|
|
struct DeArrowVideoThumbnail: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let video: Video
|
|
|
|
var cornerRadius: CGFloat = 8
|
|
var watchProgress: Double? = nil
|
|
var duration: String? = nil
|
|
var durationAlignment: Alignment = .bottomLeading
|
|
|
|
@State private var isWatched = false
|
|
|
|
private var deArrowProvider: DeArrowBrandingProvider? {
|
|
appEnvironment?.deArrowBrandingProvider
|
|
}
|
|
|
|
/// Ordered thumbnail candidates: DeArrow branding (if any) first, then the
|
|
/// video's own thumbnails best-quality-first. The view falls back through
|
|
/// these so a thumbnail always shows even when higher-res variants 404.
|
|
private var thumbnailCandidates: (primary: URL?, fallbacks: [URL]) {
|
|
let videoURLs = video.thumbnailURLsByQuality
|
|
if let deArrowURL = deArrowProvider?.thumbnailURL(for: video) {
|
|
return (deArrowURL, videoURLs)
|
|
}
|
|
return (videoURLs.first, Array(videoURLs.dropFirst()))
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
private var downloadManager: DownloadManager? {
|
|
appEnvironment?.downloadManager
|
|
}
|
|
|
|
/// Per-video download progress. Only triggers re-render when THIS video's progress changes.
|
|
/// SwiftUI's @Observable tracks dictionary subscript access per-key.
|
|
private var downloadProgressInfo: DownloadProgressInfo? {
|
|
downloadManager?.downloadProgressByVideo[video.id]
|
|
}
|
|
|
|
/// Whether this video is fully downloaded (uses cached Set for O(1) lookup).
|
|
private var isDownloaded: Bool {
|
|
downloadManager?.isDownloaded(video.id) ?? false
|
|
}
|
|
|
|
private var downloadProgress: Double? {
|
|
downloadProgressInfo?.progress
|
|
}
|
|
|
|
private var downloadProgressIndeterminate: Bool {
|
|
downloadProgressInfo?.isIndeterminate ?? false
|
|
}
|
|
#else
|
|
private var isDownloaded: Bool { false }
|
|
private var downloadProgress: Double? { nil }
|
|
private var downloadProgressIndeterminate: Bool { false }
|
|
#endif
|
|
|
|
/// Title to show on placeholder while the thumbnail is loading or absent.
|
|
private var placeholderTitle: String? {
|
|
let title = video.displayTitle(using: deArrowProvider)
|
|
return title.isEmpty ? nil : title
|
|
}
|
|
|
|
/// Whether to show watched checkmark from settings.
|
|
private var showWatchedCheckmark: Bool {
|
|
appEnvironment?.settingsManager.showWatchedCheckmark ?? true
|
|
}
|
|
|
|
/// Whether this video has been fully watched.
|
|
private var isFinishedWatching: Bool {
|
|
guard showWatchedCheckmark else { return false }
|
|
return isWatched
|
|
}
|
|
|
|
/// Updates the watched state from the data manager.
|
|
private func updateWatchedState() {
|
|
isWatched = appEnvironment?.dataManager.watchEntry(for: video.id.videoID)?.isFinished ?? false
|
|
}
|
|
|
|
var body: some View {
|
|
VideoThumbnailView(
|
|
url: thumbnailCandidates.primary,
|
|
fallbackURLs: thumbnailCandidates.fallbacks,
|
|
cornerRadius: cornerRadius,
|
|
watchProgress: watchProgress,
|
|
duration: duration,
|
|
durationAlignment: durationAlignment,
|
|
isDownloaded: isDownloaded,
|
|
downloadProgress: downloadProgress,
|
|
downloadProgressIndeterminate: downloadProgressIndeterminate,
|
|
isLive: video.isLive,
|
|
placeholderTitle: placeholderTitle,
|
|
isWatched: isFinishedWatching
|
|
)
|
|
.task(id: video.id) {
|
|
deArrowProvider?.fetchIfNeeded(for: video)
|
|
}
|
|
.onAppear { updateWatchedState() }
|
|
.onChange(of: video.id) { updateWatchedState() }
|
|
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
|
|
updateWatchedState()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
DeArrowVideoThumbnail(
|
|
video: .preview,
|
|
watchProgress: 0.5,
|
|
duration: "12:34"
|
|
)
|
|
.frame(width: 280)
|
|
.padding()
|
|
}
|