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.
This commit is contained in:
Arkadiusz Fal
2026-05-11 18:56:06 +02:00
parent a734bb47f7
commit f74659e903
3 changed files with 51 additions and 4 deletions

View File

@@ -91,6 +91,15 @@ struct Video: Identifiable, Codable, Sendable {
thumbnails.sorted { $0.quality > $1.quality }.first thumbnails.sorted { $0.quality > $1.quality }.first
} }
/// Thumbnail URLs ordered best-quality-first.
///
/// Used as a fallback chain: backends (and YouTube's CDN) often advertise
/// `maxres`/`sddefault` variants that don't actually exist for a given video
/// and 404. Consumers try these in order and drop to the next when one fails.
var thumbnailURLsByQuality: [URL] {
thumbnails.sorted { $0.quality > $1.quality }.map(\.url)
}
var formattedDuration: String { var formattedDuration: String {
guard !isLive else { return "LIVE" } guard !isLive else { return "LIVE" }
guard duration > 0 else { return "" } guard duration > 0 else { return "" }

View File

@@ -34,8 +34,15 @@ struct DeArrowVideoThumbnail: View {
appEnvironment?.deArrowBrandingProvider appEnvironment?.deArrowBrandingProvider
} }
private var displayThumbnailURL: URL? { /// Ordered thumbnail candidates: DeArrow branding (if any) first, then the
deArrowProvider?.thumbnailURL(for: video) ?? video.bestThumbnail?.url /// 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) #if !os(tvOS)
@@ -91,7 +98,8 @@ struct DeArrowVideoThumbnail: View {
var body: some View { var body: some View {
VideoThumbnailView( VideoThumbnailView(
url: displayThumbnailURL, url: thumbnailCandidates.primary,
fallbackURLs: thumbnailCandidates.fallbacks,
cornerRadius: cornerRadius, cornerRadius: cornerRadius,
watchProgress: watchProgress, watchProgress: watchProgress,
duration: duration, duration: duration,

View File

@@ -18,6 +18,14 @@ import NukeUI
struct VideoThumbnailView: View { struct VideoThumbnailView: View {
let url: URL? let url: URL?
/// Lower-quality URLs to try, in priority order, if `url` fails to load.
///
/// YouTube's CDN (and the backends that wrap it) frequently advertise
/// `maxresdefault`/`sddefault` thumbnails that don't exist for older or
/// low-resolution uploads and return 404. When the primary `url` fails we
/// step through these so a valid thumbnail is always shown.
var fallbackURLs: [URL] = []
var cornerRadius: CGFloat = 8 var cornerRadius: CGFloat = 8
var watchProgress: Double? = nil var watchProgress: Double? = nil
var duration: String? = nil var duration: String? = nil
@@ -30,8 +38,23 @@ struct VideoThumbnailView: View {
var placeholderTitle: String? = nil var placeholderTitle: String? = nil
var isWatched: Bool = false var isWatched: Bool = false
/// Index into `candidates` of the URL currently being attempted.
@State private var candidateIndex = 0
/// De-duplicated candidate URLs, best-quality first.
private var candidates: [URL] {
var seen = Set<URL>()
return ([url] + fallbackURLs).compactMap { $0 }.filter { seen.insert($0).inserted }
}
/// The URL currently being shown, advancing through `candidates` on failure.
private var currentURL: URL? {
guard candidates.indices.contains(candidateIndex) else { return candidates.last }
return candidates[candidateIndex]
}
var body: some View { var body: some View {
LazyImage(url: url) { state in LazyImage(url: currentURL) { state in
if let image = state.image { if let image = state.image {
image image
.resizable() .resizable()
@@ -52,6 +75,13 @@ struct VideoThumbnailView: View {
} }
} }
} }
.onCompletion { result in
// On a failed load, fall back to the next (lower-quality) candidate.
if case .failure = result, candidateIndex < candidates.count - 1 {
candidateIndex += 1
}
}
.onChange(of: candidates) { candidateIndex = 0 }
.aspectRatio(16/9, contentMode: .fit) .aspectRatio(16/9, contentMode: .fit)
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
watchProgressBar watchProgressBar