mirror of
https://github.com/yattee/yattee.git
synced 2026-08-07 07:41: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:
@@ -358,7 +358,7 @@ extension DataManager {
|
|||||||
existing.title = playlist.title
|
existing.title = playlist.title
|
||||||
existing.authorName = playlist.authorName
|
existing.authorName = playlist.authorName
|
||||||
existing.videoCount = playlist.videoCount
|
existing.videoCount = playlist.videoCount
|
||||||
existing.thumbnailURLString = RecentPlaylist.upgradedThumbnailURLString(playlist.thumbnailURL)
|
existing.thumbnailURLString = RecentPlaylist.reliableThumbnailURLString(playlist.thumbnailURL)
|
||||||
savedEntry = existing
|
savedEntry = existing
|
||||||
} else {
|
} else {
|
||||||
// Create new entry
|
// Create new entry
|
||||||
|
|||||||
@@ -57,23 +57,16 @@ final class RecentPlaylist {
|
|||||||
title: playlist.title,
|
title: playlist.title,
|
||||||
authorName: playlist.authorName,
|
authorName: playlist.authorName,
|
||||||
videoCount: playlist.videoCount,
|
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`
|
/// Rewrites YouTube `/vi/ID/...` thumbnails to the always-available `hqdefault.jpg`
|
||||||
/// so recent playlist cards show a higher-quality image.
|
/// variant: recent playlist cards render a single URL without fallback, and
|
||||||
static func upgradedThumbnailURLString(_ url: URL?) -> String? {
|
/// higher-quality variants (`maxresdefault`/`sddefault`) 404 for many older videos.
|
||||||
|
static func reliableThumbnailURLString(_ url: URL?) -> String? {
|
||||||
guard let url else { return nil }
|
guard let url else { return nil }
|
||||||
let path = url.path
|
return (Thumbnail.reliableURL(for: url) ?? url).absoluteString
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
|
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ struct Video: Identifiable, Codable, Sendable {
|
|||||||
thumbnails.sorted { $0.quality > $1.quality }.map(\.url)
|
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 {
|
var formattedDuration: String {
|
||||||
guard !isLive else { return "LIVE" }
|
guard !isLive else { return "LIVE" }
|
||||||
guard duration > 0 else { return "" }
|
guard duration > 0 else { return "" }
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ private struct InvidiousAuthPlaylist: Decodable, Sendable {
|
|||||||
description: description,
|
description: description,
|
||||||
author: author.map { Author(id: "", name: $0) },
|
author: author.map { Author(id: "", name: $0) },
|
||||||
videoCount: videoCount,
|
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) } ?? []
|
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1551,7 +1551,7 @@ private struct InvidiousPlaylist: Decodable, Sendable {
|
|||||||
description: description,
|
description: description,
|
||||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||||
videoCount: videoCount,
|
videoCount: videoCount,
|
||||||
thumbnailURL: validVideos.first?.thumbnails.first?.url,
|
thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url),
|
||||||
videos: validVideos
|
videos: validVideos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1683,7 +1683,7 @@ private struct InvidiousSearchPlaylist: Decodable, Sendable {
|
|||||||
title: title,
|
title: title,
|
||||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||||
videoCount: videoCount,
|
videoCount: videoCount,
|
||||||
thumbnailURL: thumbnailURL,
|
thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL),
|
||||||
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,9 +532,7 @@ private struct PipedVideo: Decodable, Sendable {
|
|||||||
publishedText: uploadedDate,
|
publishedText: uploadedDate,
|
||||||
viewCount: views.map { Int($0) },
|
viewCount: views.map { Int($0) },
|
||||||
likeCount: nil,
|
likeCount: nil,
|
||||||
thumbnails: thumbnail.flatMap { URL(string: $0) }.map {
|
thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
|
||||||
[Thumbnail(url: $0, quality: .high)]
|
|
||||||
} ?? [],
|
|
||||||
isLive: duration == -1,
|
isLive: duration == -1,
|
||||||
isUpcoming: false,
|
isUpcoming: false,
|
||||||
scheduledStartTime: nil
|
scheduledStartTime: nil
|
||||||
@@ -771,9 +769,7 @@ private struct PipedSearchItem: Decodable, Sendable {
|
|||||||
publishedText: uploadedDate,
|
publishedText: uploadedDate,
|
||||||
viewCount: views.map { Int($0) },
|
viewCount: views.map { Int($0) },
|
||||||
likeCount: nil,
|
likeCount: nil,
|
||||||
thumbnails: thumbnail.flatMap { URL(string: $0) }.map {
|
thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
|
||||||
[Thumbnail(url: $0, quality: .high)]
|
|
||||||
} ?? [],
|
|
||||||
isLive: duration == -1,
|
isLive: duration == -1,
|
||||||
isUpcoming: false,
|
isUpcoming: false,
|
||||||
scheduledStartTime: nil
|
scheduledStartTime: nil
|
||||||
|
|||||||
@@ -1363,7 +1363,7 @@ private struct YatteePlaylist: Decodable, Sendable {
|
|||||||
description: description,
|
description: description,
|
||||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||||
videoCount: videoCount,
|
videoCount: videoCount,
|
||||||
thumbnailURL: validVideos.first?.thumbnails.first?.url,
|
thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url),
|
||||||
videos: validVideos
|
videos: validVideos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1440,7 +1440,7 @@ private struct YatteeSearchPlaylist: Decodable, Sendable {
|
|||||||
title: title,
|
title: title,
|
||||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||||
videoCount: videoCount,
|
videoCount: videoCount,
|
||||||
thumbnailURL: thumbnailURL,
|
thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL),
|
||||||
videos: videos?.map { $0.toVideo() } ?? []
|
videos: videos?.map { $0.toVideo() } ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,12 +346,17 @@ extension DownloadManager {
|
|||||||
var thumbnailPath: String?
|
var thumbnailPath: String?
|
||||||
var channelThumbnailPath: 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 {
|
if let thumbnailURL = download.thumbnailURL {
|
||||||
thumbnailPath = await downloadThumbnail(
|
for candidate in Thumbnail.fallbackChain(for: thumbnailURL) {
|
||||||
from: thumbnailURL,
|
thumbnailPath = await downloadThumbnail(
|
||||||
filename: "\(videoID)_thumbnail.jpg"
|
from: candidate.url,
|
||||||
)
|
filename: "\(videoID)_thumbnail.jpg"
|
||||||
|
)
|
||||||
|
if thumbnailPath != nil { break }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download channel thumbnail - best-effort, ignore failures
|
// Download channel thumbnail - best-effort, ignore failures
|
||||||
|
|||||||
@@ -522,8 +522,10 @@ final class PlayerService {
|
|||||||
let localThumbnailPath = download.localThumbnailPath {
|
let localThumbnailPath = download.localThumbnailPath {
|
||||||
localThumbnailURL = downloadManager.downloadsDirectory().appendingPathComponent(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(
|
await nowPlayingService.loadArtwork(
|
||||||
from: videoForNowPlaying.bestThumbnail?.url,
|
from: videoForNowPlaying.reliableThumbnailURL,
|
||||||
localPath: localThumbnailURL
|
localPath: localThumbnailURL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ final class RemoteControlCoordinator {
|
|||||||
networkService.updateAdvertisement(
|
networkService.updateAdvertisement(
|
||||||
videoTitle: state.currentVideo?.title,
|
videoTitle: state.currentVideo?.title,
|
||||||
channelName: state.currentVideo?.author.name,
|
channelName: state.currentVideo?.author.name,
|
||||||
thumbnailURL: state.currentVideo?.bestThumbnail?.url,
|
thumbnailURL: state.currentVideo?.reliableThumbnailURL,
|
||||||
isPlaying: state.playbackState == .playing
|
isPlaying: state.playbackState == .playing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -494,7 +494,7 @@ final class RemoteControlCoordinator {
|
|||||||
videoID: state.currentVideo?.id.videoID,
|
videoID: state.currentVideo?.id.videoID,
|
||||||
videoTitle: state.currentVideo?.title,
|
videoTitle: state.currentVideo?.title,
|
||||||
channelName: state.currentVideo?.author.name,
|
channelName: state.currentVideo?.author.name,
|
||||||
thumbnailURL: state.currentVideo?.bestThumbnail?.url,
|
thumbnailURL: state.currentVideo?.reliableThumbnailURL,
|
||||||
currentTime: state.currentTime,
|
currentTime: state.currentTime,
|
||||||
duration: state.duration,
|
duration: state.duration,
|
||||||
isPlaying: state.playbackState == .playing,
|
isPlaying: state.playbackState == .playing,
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ private extension TopShelfSnapshotWriter {
|
|||||||
title: bookmark.title,
|
title: bookmark.title,
|
||||||
authorName: bookmark.authorName,
|
authorName: bookmark.authorName,
|
||||||
duration: bookmark.duration,
|
duration: bookmark.duration,
|
||||||
thumbnailURL: bookmark.thumbnailURLString,
|
thumbnailURL: reliableThumbnailURLString(bookmark.thumbnailURLString),
|
||||||
deepLinkURL: deepLink,
|
deepLinkURL: deepLink,
|
||||||
progressSeconds: nil
|
progressSeconds: nil
|
||||||
)
|
)
|
||||||
@@ -118,7 +118,7 @@ private extension TopShelfSnapshotWriter {
|
|||||||
title: entry.title,
|
title: entry.title,
|
||||||
authorName: entry.authorName,
|
authorName: entry.authorName,
|
||||||
duration: entry.duration,
|
duration: entry.duration,
|
||||||
thumbnailURL: entry.thumbnailURLString,
|
thumbnailURL: reliableThumbnailURLString(entry.thumbnailURLString),
|
||||||
deepLinkURL: deepLink,
|
deepLinkURL: deepLink,
|
||||||
progressSeconds: entry.watchedSeconds
|
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? {
|
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
|
#endif
|
||||||
|
|||||||
@@ -8,6 +8,38 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NukeUI
|
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.
|
/// A reusable video thumbnail view with 16:9 aspect ratio.
|
||||||
///
|
///
|
||||||
/// Supports optional overlays for:
|
/// Supports optional overlays for:
|
||||||
@@ -38,23 +70,14 @@ 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.
|
/// De-duplicated candidate URLs, best-quality first.
|
||||||
private var candidates: [URL] {
|
private var candidates: [URL] {
|
||||||
var seen = Set<URL>()
|
var seen = Set<URL>()
|
||||||
return ([url] + fallbackURLs).compactMap { $0 }.filter { seen.insert($0).inserted }
|
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: currentURL) { state in
|
FallbackLazyImage(urls: candidates) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.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)
|
.aspectRatio(16/9, contentMode: .fit)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
watchProgressBar
|
watchProgressBar
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ extension ExpandedPlayerSheet {
|
|||||||
|
|
||||||
// Clear loaded image so next video gets fresh thumbnail
|
// Clear loaded image so next video gets fresh thumbnail
|
||||||
displayedThumbnailImage = nil
|
displayedThumbnailImage = nil
|
||||||
// Immediately switch to next video's thumbnail to prevent old thumbnail flash
|
// Immediately switch to next video's thumbnail to prevent old thumbnail flash.
|
||||||
displayedThumbnailURL = nextQueuedVideo?.video.bestThumbnail?.url
|
// Reliable variant: the frozen URL is loaded without fallback.
|
||||||
|
displayedThumbnailURL = nextQueuedVideo?.video.reliableThumbnailURL
|
||||||
isThumbnailFrozen = true
|
isThumbnailFrozen = true
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
@@ -200,7 +201,7 @@ extension ExpandedPlayerSheet {
|
|||||||
func videoPreviewCard(video: Video) -> some View {
|
func videoPreviewCard(video: Video) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
LazyImage(url: video.bestThumbnail?.url) { state in
|
FallbackLazyImage(urls: video.thumbnailURLsByQuality) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
@@ -691,11 +691,11 @@ extension ExpandedPlayerSheet {
|
|||||||
let isBufferReady = playerState?.isBufferReady ?? false
|
let isBufferReady = playerState?.isBufferReady ?? false
|
||||||
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
||||||
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
|
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
|
||||||
// Use frozen URL during transition, otherwise current video's thumbnail
|
// Use frozen URL during transition, otherwise current video's thumbnail chain
|
||||||
let thumbnailURL = isThumbnailFrozen ? displayedThumbnailURL : video.bestThumbnail?.url
|
let thumbnailURLs = isThumbnailFrozen ? [displayedThumbnailURL].compactMap { $0 } : video.thumbnailURLsByQuality
|
||||||
|
|
||||||
// Hidden loader - loads image into @State (invisible)
|
// Hidden loader - loads image into @State (invisible)
|
||||||
LazyImage(url: thumbnailURL) { state in
|
FallbackLazyImage(urls: thumbnailURLs) { state in
|
||||||
Color.clear
|
Color.clear
|
||||||
.onChange(of: state.image) { _, newImage in
|
.onChange(of: state.image) { _, newImage in
|
||||||
if let newImage { displayedThumbnailImage = newImage }
|
if let newImage { displayedThumbnailImage = newImage }
|
||||||
@@ -1353,11 +1353,11 @@ extension ExpandedPlayerSheet {
|
|||||||
let isBufferReady = playerState?.isBufferReady ?? false
|
let isBufferReady = playerState?.isBufferReady ?? false
|
||||||
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
||||||
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
|
let showThumbnail = !info.hasBackend || !isFirstFrameReady || !isBufferReady || isAudioOnly
|
||||||
// Use frozen URL during transition, otherwise current video's thumbnail
|
// Use frozen URL during transition, otherwise current video's thumbnail chain
|
||||||
let thumbnailURL = isThumbnailFrozen ? displayedThumbnailURL : video.bestThumbnail?.url
|
let thumbnailURLs = isThumbnailFrozen ? [displayedThumbnailURL].compactMap { $0 } : video.thumbnailURLsByQuality
|
||||||
|
|
||||||
// Hidden loader - loads image into @State (invisible)
|
// Hidden loader - loads image into @State (invisible)
|
||||||
LazyImage(url: thumbnailURL) { state in
|
FallbackLazyImage(urls: thumbnailURLs) { state in
|
||||||
Color.clear
|
Color.clear
|
||||||
.onChange(of: state.image) { _, newImage in
|
.onChange(of: state.image) { _, newImage in
|
||||||
if let newImage { displayedThumbnailImage = newImage }
|
if let newImage { displayedThumbnailImage = newImage }
|
||||||
|
|||||||
@@ -840,8 +840,10 @@ private struct PlayerEventHandlersModifier: ViewModifier {
|
|||||||
|
|
||||||
// Clear loaded image so new video gets fresh thumbnail
|
// Clear loaded image so new video gets fresh thumbnail
|
||||||
displayedThumbnailImage = nil
|
displayedThumbnailImage = nil
|
||||||
// Capture thumbnail URL immediately and freeze to prevent flash during details load
|
// Capture thumbnail URL immediately and freeze to prevent flash during details load.
|
||||||
displayedThumbnailURL = playerState?.currentVideo?.bestThumbnail?.url
|
// 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
|
isThumbnailFrozen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,11 @@ struct MiniPlayerView: View {
|
|||||||
return currentVideo?.title ?? String(localized: "player.notPlaying")
|
return currentVideo?.title ?? String(localized: "player.notPlaying")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The thumbnail URL to display, preferring DeArrow thumbnail if available.
|
/// Thumbnail URLs to try in order, preferring DeArrow, then the quality chain.
|
||||||
private var displayThumbnailURL: URL? {
|
private var displayThumbnailURLs: [URL] {
|
||||||
if let video = currentVideo, let deArrowThumbnail = deArrowProvider?.thumbnailURL(for: video) {
|
guard let video = currentVideo else { return [] }
|
||||||
return deArrowThumbnail
|
let deArrowThumbnail = deArrowProvider?.thumbnailURL(for: video)
|
||||||
}
|
return [deArrowThumbnail].compactMap { $0 } + video.thumbnailURLsByQuality
|
||||||
return currentVideo?.bestThumbnail?.url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
@@ -406,7 +405,7 @@ struct MiniPlayerView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var thumbnailView: some View {
|
private var thumbnailView: some View {
|
||||||
LazyImage(url: displayThumbnailURL) { state in
|
FallbackLazyImage(urls: displayThumbnailURLs) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct TVAutoplayCountdownView: View {
|
|||||||
private var nextVideoCard: some View {
|
private var nextVideoCard: some View {
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
LazyImage(url: nextVideo.video.bestThumbnail?.url) { state in
|
FallbackLazyImage(urls: nextVideo.video.thumbnailURLsByQuality) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
|
import NukeUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Focus targets for tvOS player controls navigation.
|
/// 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
|
// Thumbnail for audio-only playback and the pre-backend loading state
|
||||||
if isAudioOnly || !hasBackend,
|
if isAudioOnly || !hasBackend,
|
||||||
let video = playerState?.currentVideo,
|
let video = playerState?.currentVideo,
|
||||||
let thumbnailURL = video.bestThumbnail?.url {
|
!video.thumbnailURLsByQuality.isEmpty {
|
||||||
AsyncImage(url: thumbnailURL) { image in
|
FallbackLazyImage(urls: video.thumbnailURLsByQuality) { state in
|
||||||
image
|
if let image = state.image {
|
||||||
.resizable()
|
image
|
||||||
.aspectRatio(contentMode: .fit)
|
.resizable()
|
||||||
} placeholder: {
|
.aspectRatio(contentMode: .fit)
|
||||||
Color.black
|
} else {
|
||||||
|
Color.black
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ struct UnifiedPlaylistDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var navigationThumbnailURL: URL? {
|
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").
|
/// Summary text for the playlist (e.g., "5 videos · 1h 23m").
|
||||||
@@ -532,8 +534,9 @@ struct UnifiedPlaylistDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var tvOSPlaylistThumbnail: some View {
|
private var tvOSPlaylistThumbnail: some View {
|
||||||
let url = videos.first?.bestThumbnail?.url ?? thumbnailURL
|
let urls = (videos.first?.thumbnailURLsByQuality ?? [])
|
||||||
LazyImage(url: url) { state in
|
+ [Thumbnail.reliableURL(for: thumbnailURL)].compactMap { $0 }
|
||||||
|
FallbackLazyImage(urls: urls) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
@@ -652,9 +652,9 @@ struct VideoInfoView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func tvOSThumbnail(for video: Video) -> some View {
|
private func tvOSThumbnail(for video: Video) -> some View {
|
||||||
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
|
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 {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -816,7 +816,7 @@ struct VideoInfoView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var blurredThumbnailBackground: some View {
|
private var blurredThumbnailBackground: some View {
|
||||||
BlurredImageBackground(
|
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,
|
videoID: displayedVideo?.id.videoID,
|
||||||
blurRadius: BlurredImageBackground.platformBlurRadius,
|
blurRadius: BlurredImageBackground.platformBlurRadius,
|
||||||
scale: 1.8,
|
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 {
|
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 thumbnailSource = thumbnailFrom ?? video
|
||||||
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource)
|
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource)
|
||||||
let bestThumb = thumbnailSource.bestThumbnail
|
let thumbnailURLs = [deArrowURL].compactMap { $0 } + thumbnailSource.thumbnailURLsByQuality
|
||||||
let thumbnailURL = deArrowURL ?? bestThumb?.url
|
|
||||||
return VStack(spacing: 12) {
|
return VStack(spacing: 12) {
|
||||||
// Thumbnail with loading overlay
|
// Thumbnail with loading overlay
|
||||||
ZStack {
|
ZStack {
|
||||||
LazyImage(url: thumbnailURL) { state in
|
FallbackLazyImage(urls: thumbnailURLs) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|||||||
Reference in New Issue
Block a user