From f74659e90300209285b01abf44aa97c1c9f12916 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 11 May 2026 18:56:06 +0200 Subject: [PATCH] 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. --- Yattee/Models/Video.swift | 9 ++++++ .../Components/DeArrowVideoThumbnail.swift | 14 ++++++-- .../Views/Components/VideoThumbnailView.swift | 32 ++++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Yattee/Models/Video.swift b/Yattee/Models/Video.swift index e92ce39e..e379e4bc 100644 --- a/Yattee/Models/Video.swift +++ b/Yattee/Models/Video.swift @@ -91,6 +91,15 @@ struct Video: Identifiable, Codable, Sendable { 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 { guard !isLive else { return "LIVE" } guard duration > 0 else { return "" } diff --git a/Yattee/Views/Components/DeArrowVideoThumbnail.swift b/Yattee/Views/Components/DeArrowVideoThumbnail.swift index 508cd54d..18a5dcd6 100644 --- a/Yattee/Views/Components/DeArrowVideoThumbnail.swift +++ b/Yattee/Views/Components/DeArrowVideoThumbnail.swift @@ -34,8 +34,15 @@ struct DeArrowVideoThumbnail: View { appEnvironment?.deArrowBrandingProvider } - private var displayThumbnailURL: URL? { - deArrowProvider?.thumbnailURL(for: video) ?? video.bestThumbnail?.url + /// 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) @@ -91,7 +98,8 @@ struct DeArrowVideoThumbnail: View { var body: some View { VideoThumbnailView( - url: displayThumbnailURL, + url: thumbnailCandidates.primary, + fallbackURLs: thumbnailCandidates.fallbacks, cornerRadius: cornerRadius, watchProgress: watchProgress, duration: duration, diff --git a/Yattee/Views/Components/VideoThumbnailView.swift b/Yattee/Views/Components/VideoThumbnailView.swift index 6ea92c84..198f56df 100644 --- a/Yattee/Views/Components/VideoThumbnailView.swift +++ b/Yattee/Views/Components/VideoThumbnailView.swift @@ -18,6 +18,14 @@ import NukeUI struct VideoThumbnailView: View { 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 watchProgress: Double? = nil var duration: String? = nil @@ -30,8 +38,23 @@ struct VideoThumbnailView: View { var placeholderTitle: String? = nil 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() + 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 { - LazyImage(url: url) { state in + LazyImage(url: currentURL) { state in if let image = state.image { image .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) .overlay(alignment: .bottom) { watchProgressBar