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

@@ -44,8 +44,9 @@ extension ExpandedPlayerSheet {
// Clear loaded image so next video gets fresh thumbnail
displayedThumbnailImage = nil
// Immediately switch to next video's thumbnail to prevent old thumbnail flash
displayedThumbnailURL = nextQueuedVideo?.video.bestThumbnail?.url
// Immediately switch to next video's thumbnail to prevent old thumbnail flash.
// Reliable variant: the frozen URL is loaded without fallback.
displayedThumbnailURL = nextQueuedVideo?.video.reliableThumbnailURL
isThumbnailFrozen = true
Task {
@@ -200,7 +201,7 @@ extension ExpandedPlayerSheet {
func videoPreviewCard(video: Video) -> some View {
HStack(spacing: 12) {
// Thumbnail
LazyImage(url: video.bestThumbnail?.url) { state in
FallbackLazyImage(urls: video.thumbnailURLsByQuality) { state in
if let image = state.image {
image
.resizable()

View File

@@ -691,11 +691,11 @@ extension ExpandedPlayerSheet {
let isBufferReady = playerState?.isBufferReady ?? false
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
// Use frozen URL during transition, otherwise current video's thumbnail
let thumbnailURL = isThumbnailFrozen ? displayedThumbnailURL : video.bestThumbnail?.url
// Use frozen URL during transition, otherwise current video's thumbnail chain
let thumbnailURLs = isThumbnailFrozen ? [displayedThumbnailURL].compactMap { $0 } : video.thumbnailURLsByQuality
// Hidden loader - loads image into @State (invisible)
LazyImage(url: thumbnailURL) { state in
FallbackLazyImage(urls: thumbnailURLs) { state in
Color.clear
.onChange(of: state.image) { _, newImage in
if let newImage { displayedThumbnailImage = newImage }
@@ -1353,11 +1353,11 @@ extension ExpandedPlayerSheet {
let isBufferReady = playerState?.isBufferReady ?? false
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
// Use frozen URL during transition, otherwise current video's thumbnail
let thumbnailURL = isThumbnailFrozen ? displayedThumbnailURL : video.bestThumbnail?.url
// Use frozen URL during transition, otherwise current video's thumbnail chain
let thumbnailURLs = isThumbnailFrozen ? [displayedThumbnailURL].compactMap { $0 } : video.thumbnailURLsByQuality
// Hidden loader - loads image into @State (invisible)
LazyImage(url: thumbnailURL) { state in
FallbackLazyImage(urls: thumbnailURLs) { state in
Color.clear
.onChange(of: state.image) { _, newImage in
if let newImage { displayedThumbnailImage = newImage }

View File

@@ -840,8 +840,10 @@ private struct PlayerEventHandlersModifier: ViewModifier {
// Clear loaded image so new video gets fresh thumbnail
displayedThumbnailImage = nil
// Capture thumbnail URL immediately and freeze to prevent flash during details load
displayedThumbnailURL = playerState?.currentVideo?.bestThumbnail?.url
// Capture thumbnail URL immediately and freeze to prevent flash during details load.
// Use the reliable (hqdefault) variant: the frozen URL is loaded without fallback,
// and the best advertised variant often 404s for older videos.
displayedThumbnailURL = playerState?.currentVideo?.reliableThumbnailURL
isThumbnailFrozen = true
}

View File

@@ -67,12 +67,11 @@ struct MiniPlayerView: View {
return currentVideo?.title ?? String(localized: "player.notPlaying")
}
/// The thumbnail URL to display, preferring DeArrow thumbnail if available.
private var displayThumbnailURL: URL? {
if let video = currentVideo, let deArrowThumbnail = deArrowProvider?.thumbnailURL(for: video) {
return deArrowThumbnail
}
return currentVideo?.bestThumbnail?.url
/// Thumbnail URLs to try in order, preferring DeArrow, then the quality chain.
private var displayThumbnailURLs: [URL] {
guard let video = currentVideo else { return [] }
let deArrowThumbnail = deArrowProvider?.thumbnailURL(for: video)
return [deArrowThumbnail].compactMap { $0 } + video.thumbnailURLsByQuality
}
// MARK: - Actions
@@ -406,7 +405,7 @@ struct MiniPlayerView: View {
@ViewBuilder
private var thumbnailView: some View {
LazyImage(url: displayThumbnailURL) { state in
FallbackLazyImage(urls: displayThumbnailURLs) { state in
if let image = state.image {
image
.resizable()

View File

@@ -62,7 +62,7 @@ struct TVAutoplayCountdownView: View {
private var nextVideoCard: some View {
HStack(spacing: 20) {
// Thumbnail
LazyImage(url: nextVideo.video.bestThumbnail?.url) { state in
FallbackLazyImage(urls: nextVideo.video.thumbnailURLsByQuality) { state in
if let image = state.image {
image
.resizable()

View File

@@ -6,6 +6,7 @@
//
#if os(tvOS)
import NukeUI
import SwiftUI
/// Focus targets for tvOS player controls navigation.
@@ -644,13 +645,15 @@ struct TVPlayerView: View {
// Thumbnail for audio-only playback and the pre-backend loading state
if isAudioOnly || !hasBackend,
let video = playerState?.currentVideo,
let thumbnailURL = video.bestThumbnail?.url {
AsyncImage(url: thumbnailURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Color.black
!video.thumbnailURLsByQuality.isEmpty {
FallbackLazyImage(urls: video.thumbnailURLsByQuality) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Color.black
}
}
.allowsHitTesting(false)
}