Files
yattee/Yattee/Views/Components/DeArrowVideoThumbnail.swift
Arkadiusz Fal f74659e903 Fall back to lower-quality thumbnails when higher-res variants 404
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.
2026-05-11 18:56:06 +02:00

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()
}