Apply thumbnail fallback everywhere a single URL was rendered

The previous fix rebuilt the quality chain when converting persisted
models back to videos, but many consumers discarded it again by
rendering bestThumbnail (usually a maxresdefault.jpg that 404s for
older videos) as a single URL with no fallback.

Extract the retry logic from VideoThumbnailView into FallbackLazyImage
and use it at every in-app site that can iterate: player thumbnails
(mini bar, expanded sheet loaders, autoplay previews, tvOS audio-mode
artwork - previously a silent black screen), video info card and tvOS
header, and the tvOS playlist cover.

Sites that fetch or send exactly one URL are rewritten to the
always-available hqdefault variant via Thumbnail.reliableURL (exposed
as Video.reliableThumbnailURL): Now Playing artwork, Top Shelf
snapshots, remote control state, the frozen transition thumbnail,
blurred info background, navigation covers, and playlist covers
derived from a video's first thumbnail in Invidious/Yattee Server
responses.

Also invert RecentPlaylist's upgrade helper, which rewrote covers *to*
maxresdefault, walk the quality chain when caching download thumbnails
for offline artwork instead of giving up after one 404, and expand the
remaining single-thumbnail Piped conversions into full chains.
This commit is contained in:
Arkadiusz Fal
2026-07-31 23:21:38 +02:00
parent fd1029d3e0
commit 2c0c2e853a
19 changed files with 126 additions and 88 deletions

View File

@@ -8,6 +8,38 @@
import SwiftUI
import NukeUI
/// A `LazyImage` that steps through candidate URLs, advancing to the next
/// (lower-quality) one when a load fails.
///
/// 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. Pass best-quality-first candidates
/// (e.g. `video.thumbnailURLsByQuality`) so a valid thumbnail is always shown.
struct FallbackLazyImage<Content: View>: View {
let urls: [URL]
@ViewBuilder let content: (LazyImageState) -> Content
/// Index into `urls` of the URL currently being attempted.
@State private var candidateIndex = 0
private var currentURL: URL? {
guard urls.indices.contains(candidateIndex) else { return urls.last }
return urls[candidateIndex]
}
var body: some View {
LazyImage(url: currentURL) { state in
content(state)
}
.onCompletion { result in
if case .failure = result, candidateIndex < urls.count - 1 {
candidateIndex += 1
}
}
.onChange(of: urls) { candidateIndex = 0 }
}
}
/// A reusable video thumbnail view with 16:9 aspect ratio.
///
/// Supports optional overlays for:
@@ -38,23 +70,14 @@ 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<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 {
LazyImage(url: currentURL) { state in
FallbackLazyImage(urls: candidates) { state in
if let image = state.image {
image
.resizable()
@@ -75,13 +98,6 @@ 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