mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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:
@@ -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 "" }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user