mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,9 @@ struct UnifiedPlaylistDetailView: View {
|
||||
}
|
||||
|
||||
private var navigationThumbnailURL: URL? {
|
||||
videos.first?.bestThumbnail?.url ?? thumbnailURL ?? cachedHeader?.thumbnailURL
|
||||
// Rendered as a single URL without fallback, so rewrite to the
|
||||
// always-available hqdefault variant.
|
||||
Thumbnail.reliableURL(for: videos.first?.bestThumbnail?.url ?? thumbnailURL ?? cachedHeader?.thumbnailURL)
|
||||
}
|
||||
|
||||
/// Summary text for the playlist (e.g., "5 videos · 1h 23m").
|
||||
@@ -532,8 +534,9 @@ struct UnifiedPlaylistDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var tvOSPlaylistThumbnail: some View {
|
||||
let url = videos.first?.bestThumbnail?.url ?? thumbnailURL
|
||||
LazyImage(url: url) { state in
|
||||
let urls = (videos.first?.thumbnailURLsByQuality ?? [])
|
||||
+ [Thumbnail.reliableURL(for: thumbnailURL)].compactMap { $0 }
|
||||
FallbackLazyImage(urls: urls) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
|
||||
@@ -652,9 +652,9 @@ struct VideoInfoView: View {
|
||||
@ViewBuilder
|
||||
private func tvOSThumbnail(for video: Video) -> some View {
|
||||
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
|
||||
let thumbnailURL = deArrowURL ?? video.bestThumbnail?.url
|
||||
let thumbnailURLs = [deArrowURL].compactMap { $0 } + video.thumbnailURLsByQuality
|
||||
|
||||
LazyImage(url: thumbnailURL) { state in
|
||||
FallbackLazyImage(urls: thumbnailURLs) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
@@ -816,7 +816,7 @@ struct VideoInfoView: View {
|
||||
@ViewBuilder
|
||||
private var blurredThumbnailBackground: some View {
|
||||
BlurredImageBackground(
|
||||
url: displayedVideo.flatMap { appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: $0) } ?? displayedVideo?.bestThumbnail?.url,
|
||||
url: displayedVideo.flatMap { appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: $0) } ?? displayedVideo?.reliableThumbnailURL,
|
||||
videoID: displayedVideo?.id.videoID,
|
||||
blurRadius: BlurredImageBackground.platformBlurRadius,
|
||||
scale: 1.8,
|
||||
@@ -949,12 +949,11 @@ struct VideoInfoView: View {
|
||||
private func videoCard(for video: Video, thumbnailFrom: Video? = nil, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View {
|
||||
let thumbnailSource = thumbnailFrom ?? video
|
||||
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource)
|
||||
let bestThumb = thumbnailSource.bestThumbnail
|
||||
let thumbnailURL = deArrowURL ?? bestThumb?.url
|
||||
let thumbnailURLs = [deArrowURL].compactMap { $0 } + thumbnailSource.thumbnailURLsByQuality
|
||||
return VStack(spacing: 12) {
|
||||
// Thumbnail with loading overlay
|
||||
ZStack {
|
||||
LazyImage(url: thumbnailURL) { state in
|
||||
FallbackLazyImage(urls: thumbnailURLs) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
|
||||
Reference in New Issue
Block a user