From 2c0c2e853a8c9c32dd9bb4c08bc880f3fe464530 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 31 Jul 2026 23:21:38 +0200 Subject: [PATCH] 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. --- Yattee/Data/DataManager+Recents.swift | 2 +- Yattee/Data/RecentPlaylist.swift | 19 +++---- Yattee/Models/Video.swift | 10 ++++ Yattee/Services/API/InvidiousAPI.swift | 6 +-- Yattee/Services/API/PipedAPI.swift | 8 +-- Yattee/Services/API/YatteeServerAPI.swift | 4 +- .../Downloads/DownloadManager+Assets.swift | 15 ++++-- Yattee/Services/Player/PlayerService.swift | 4 +- .../RemoteControlCoordinator.swift | 4 +- Yattee/Services/TopShelfSnapshotWriter.swift | 15 ++++-- .../Views/Components/VideoThumbnailView.swift | 50 ++++++++++++------- .../Player/ExpandedPlayerSheet+Autoplay.swift | 7 +-- .../Player/ExpandedPlayerSheet+Layouts.swift | 12 ++--- Yattee/Views/Player/ExpandedPlayerSheet.swift | 6 ++- Yattee/Views/Player/MiniPlayerView.swift | 13 +++-- .../Player/tvOS/TVAutoplayCountdownView.swift | 2 +- Yattee/Views/Player/tvOS/TVPlayerView.swift | 17 ++++--- .../Playlist/UnifiedPlaylistDetailView.swift | 9 ++-- Yattee/Views/Video/VideoInfoView.swift | 11 ++-- 19 files changed, 126 insertions(+), 88 deletions(-) diff --git a/Yattee/Data/DataManager+Recents.swift b/Yattee/Data/DataManager+Recents.swift index cdf4fd62..ad9909b3 100644 --- a/Yattee/Data/DataManager+Recents.swift +++ b/Yattee/Data/DataManager+Recents.swift @@ -358,7 +358,7 @@ extension DataManager { existing.title = playlist.title existing.authorName = playlist.authorName existing.videoCount = playlist.videoCount - existing.thumbnailURLString = RecentPlaylist.upgradedThumbnailURLString(playlist.thumbnailURL) + existing.thumbnailURLString = RecentPlaylist.reliableThumbnailURLString(playlist.thumbnailURL) savedEntry = existing } else { // Create new entry diff --git a/Yattee/Data/RecentPlaylist.swift b/Yattee/Data/RecentPlaylist.swift index 5e3635aa..89e6eaf7 100644 --- a/Yattee/Data/RecentPlaylist.swift +++ b/Yattee/Data/RecentPlaylist.swift @@ -57,23 +57,16 @@ final class RecentPlaylist { title: playlist.title, authorName: playlist.authorName, videoCount: playlist.videoCount, - thumbnailURLString: upgradedThumbnailURLString(playlist.thumbnailURL) + thumbnailURLString: reliableThumbnailURLString(playlist.thumbnailURL) ) } - /// Rewrites YouTube `/vi/ID/{default|mq|hq|sd}default.jpg` thumbnails to `maxresdefault.jpg` - /// so recent playlist cards show a higher-quality image. - static func upgradedThumbnailURLString(_ url: URL?) -> String? { + /// Rewrites YouTube `/vi/ID/...` thumbnails to the always-available `hqdefault.jpg` + /// variant: recent playlist cards render a single URL without fallback, and + /// higher-quality variants (`maxresdefault`/`sddefault`) 404 for many older videos. + static func reliableThumbnailURLString(_ url: URL?) -> String? { guard let url else { return nil } - let path = url.path - let upgradable = ["default.jpg", "mqdefault.jpg", "hqdefault.jpg", "sddefault.jpg"] - guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil, - let match = upgradable.first(where: { path.hasSuffix($0) }), - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } - components.path = String(path.dropLast(match.count)) + "maxresdefault.jpg" - return components.url?.absoluteString ?? url.absoluteString + return (Thumbnail.reliableURL(for: url) ?? url).absoluteString } private static func extractSourceInfo(from source: ContentSource) -> (String, String?) { diff --git a/Yattee/Models/Video.swift b/Yattee/Models/Video.swift index 28718d29..c6e9b295 100644 --- a/Yattee/Models/Video.swift +++ b/Yattee/Models/Video.swift @@ -100,6 +100,16 @@ struct Video: Identifiable, Codable, Sendable { thumbnails.sorted { $0.quality > $1.quality }.map(\.url) } + /// Best thumbnail URL rewritten to the always-available `hqdefault` variant. + /// + /// For consumers that render or fetch a single URL with no way to fall back + /// through the quality chain (Now Playing artwork, Top Shelf, remote control, + /// navigation covers): the best advertised variant is often `maxresdefault`, + /// which 404s for many older videos. + var reliableThumbnailURL: URL? { + Thumbnail.reliableURL(for: bestThumbnail?.url) + } + var formattedDuration: String { guard !isLive else { return "LIVE" } guard duration > 0 else { return "" } diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index 29d7121d..e4c3d233 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -763,7 +763,7 @@ private struct InvidiousAuthPlaylist: Decodable, Sendable { description: description, author: author.map { Author(id: "", name: $0) }, videoCount: videoCount, - thumbnailURL: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL), + thumbnailURL: Thumbnail.reliableURL(for: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL)), videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? [] ) } @@ -1551,7 +1551,7 @@ private struct InvidiousPlaylist: Decodable, Sendable { description: description, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, - thumbnailURL: validVideos.first?.thumbnails.first?.url, + thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url), videos: validVideos ) } @@ -1683,7 +1683,7 @@ private struct InvidiousSearchPlaylist: Decodable, Sendable { title: title, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, - thumbnailURL: thumbnailURL, + thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL), videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? [] ) } diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index ef77f4b3..6d8e1b6d 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -532,9 +532,7 @@ private struct PipedVideo: Decodable, Sendable { publishedText: uploadedDate, viewCount: views.map { Int($0) }, likeCount: nil, - thumbnails: thumbnail.flatMap { URL(string: $0) }.map { - [Thumbnail(url: $0, quality: .high)] - } ?? [], + thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }), isLive: duration == -1, isUpcoming: false, scheduledStartTime: nil @@ -771,9 +769,7 @@ private struct PipedSearchItem: Decodable, Sendable { publishedText: uploadedDate, viewCount: views.map { Int($0) }, likeCount: nil, - thumbnails: thumbnail.flatMap { URL(string: $0) }.map { - [Thumbnail(url: $0, quality: .high)] - } ?? [], + thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }), isLive: duration == -1, isUpcoming: false, scheduledStartTime: nil diff --git a/Yattee/Services/API/YatteeServerAPI.swift b/Yattee/Services/API/YatteeServerAPI.swift index 8648e2ce..f8d792e7 100644 --- a/Yattee/Services/API/YatteeServerAPI.swift +++ b/Yattee/Services/API/YatteeServerAPI.swift @@ -1363,7 +1363,7 @@ private struct YatteePlaylist: Decodable, Sendable { description: description, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, - thumbnailURL: validVideos.first?.thumbnails.first?.url, + thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url), videos: validVideos ) } @@ -1440,7 +1440,7 @@ private struct YatteeSearchPlaylist: Decodable, Sendable { title: title, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, - thumbnailURL: thumbnailURL, + thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL), videos: videos?.map { $0.toVideo() } ?? [] ) } diff --git a/Yattee/Services/Downloads/DownloadManager+Assets.swift b/Yattee/Services/Downloads/DownloadManager+Assets.swift index 3346bb5b..2c677a3c 100644 --- a/Yattee/Services/Downloads/DownloadManager+Assets.swift +++ b/Yattee/Services/Downloads/DownloadManager+Assets.swift @@ -346,12 +346,17 @@ extension DownloadManager { var thumbnailPath: String? var channelThumbnailPath: String? - // Download video thumbnail (best quality) - best-effort, ignore failures + // Download video thumbnail - best-effort, ignore failures. The stored URL + // is the best advertised variant (often maxresdefault, which 404s for + // older videos), so walk the quality chain until one succeeds. if let thumbnailURL = download.thumbnailURL { - thumbnailPath = await downloadThumbnail( - from: thumbnailURL, - filename: "\(videoID)_thumbnail.jpg" - ) + for candidate in Thumbnail.fallbackChain(for: thumbnailURL) { + thumbnailPath = await downloadThumbnail( + from: candidate.url, + filename: "\(videoID)_thumbnail.jpg" + ) + if thumbnailPath != nil { break } + } } // Download channel thumbnail - best-effort, ignore failures diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 1f3e239a..96fb07a7 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -522,8 +522,10 @@ final class PlayerService { let localThumbnailPath = download.localThumbnailPath { localThumbnailURL = downloadManager.downloadsDirectory().appendingPathComponent(localThumbnailPath) } + // Reliable (hqdefault) variant: artwork is a single fetch with no + // fallback, and the best advertised variant often 404s. await nowPlayingService.loadArtwork( - from: videoForNowPlaying.bestThumbnail?.url, + from: videoForNowPlaying.reliableThumbnailURL, localPath: localThumbnailURL ) } diff --git a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift index e64c1bdc..de4a9b36 100644 --- a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift +++ b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift @@ -466,7 +466,7 @@ final class RemoteControlCoordinator { networkService.updateAdvertisement( videoTitle: state.currentVideo?.title, channelName: state.currentVideo?.author.name, - thumbnailURL: state.currentVideo?.bestThumbnail?.url, + thumbnailURL: state.currentVideo?.reliableThumbnailURL, isPlaying: state.playbackState == .playing ) } @@ -494,7 +494,7 @@ final class RemoteControlCoordinator { videoID: state.currentVideo?.id.videoID, videoTitle: state.currentVideo?.title, channelName: state.currentVideo?.author.name, - thumbnailURL: state.currentVideo?.bestThumbnail?.url, + thumbnailURL: state.currentVideo?.reliableThumbnailURL, currentTime: state.currentTime, duration: state.duration, isPlaying: state.playbackState == .playing, diff --git a/Yattee/Services/TopShelfSnapshotWriter.swift b/Yattee/Services/TopShelfSnapshotWriter.swift index 14175c55..174b06c2 100644 --- a/Yattee/Services/TopShelfSnapshotWriter.swift +++ b/Yattee/Services/TopShelfSnapshotWriter.swift @@ -100,7 +100,7 @@ private extension TopShelfSnapshotWriter { title: bookmark.title, authorName: bookmark.authorName, duration: bookmark.duration, - thumbnailURL: bookmark.thumbnailURLString, + thumbnailURL: reliableThumbnailURLString(bookmark.thumbnailURLString), deepLinkURL: deepLink, progressSeconds: nil ) @@ -118,7 +118,7 @@ private extension TopShelfSnapshotWriter { title: entry.title, authorName: entry.authorName, duration: entry.duration, - thumbnailURL: entry.thumbnailURLString, + thumbnailURL: reliableThumbnailURLString(entry.thumbnailURLString), deepLinkURL: deepLink, progressSeconds: entry.watchedSeconds ) @@ -190,8 +190,17 @@ private extension TopShelfSnapshotWriter { } } + /// The Top Shelf extension sets a single image URL with no failure fallback, + /// so rewrite to the always-available `hqdefault` variant: the best advertised + /// variant is often `maxresdefault`, which 404s for many older videos and + /// would leave an empty tile. static func bestThumbnailURL(from thumbnails: [Thumbnail]) -> String? { - thumbnails.max(by: { $0.quality < $1.quality })?.url.absoluteString + Thumbnail.reliableURL(for: thumbnails.max(by: { $0.quality < $1.quality })?.url)?.absoluteString + } + + static func reliableThumbnailURLString(_ urlString: String?) -> String? { + guard let urlString else { return nil } + return Thumbnail.reliableURL(for: URL(string: urlString))?.absoluteString ?? urlString } } #endif diff --git a/Yattee/Views/Components/VideoThumbnailView.swift b/Yattee/Views/Components/VideoThumbnailView.swift index 3528868c..091bc4ea 100644 --- a/Yattee/Views/Components/VideoThumbnailView.swift +++ b/Yattee/Views/Components/VideoThumbnailView.swift @@ -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: 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() 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 diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Autoplay.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Autoplay.swift index 9abb48ba..484bab4d 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Autoplay.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Autoplay.swift @@ -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() diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 43edc76c..867d5f8f 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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 } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 4ec361ea..9f45899b 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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 } diff --git a/Yattee/Views/Player/MiniPlayerView.swift b/Yattee/Views/Player/MiniPlayerView.swift index 8cfed066..ceef2223 100644 --- a/Yattee/Views/Player/MiniPlayerView.swift +++ b/Yattee/Views/Player/MiniPlayerView.swift @@ -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() diff --git a/Yattee/Views/Player/tvOS/TVAutoplayCountdownView.swift b/Yattee/Views/Player/tvOS/TVAutoplayCountdownView.swift index 3a6b2e2e..1365f704 100644 --- a/Yattee/Views/Player/tvOS/TVAutoplayCountdownView.swift +++ b/Yattee/Views/Player/tvOS/TVAutoplayCountdownView.swift @@ -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() diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index e0b51694..120c6652 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -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) } diff --git a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift index 5c2dec55..201ca752 100644 --- a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift +++ b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift @@ -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() diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 3e021b22..fde1a99e 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -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()