Files
yattee/Yattee/Services/TopShelfSnapshotWriter.swift
Arkadiusz Fal 2c0c2e853a 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.
2026-08-03 23:34:06 +02:00

207 lines
7.8 KiB
Swift

//
// TopShelfSnapshotWriter.swift
// Yattee
//
// Builds Top Shelf snapshots from app data and persists them to the App Group
// UserDefaults suite so the tvOS Top Shelf extension can read them without
// touching SwiftData. No-op on non-tvOS platforms.
//
import Foundation
import SwiftData
@MainActor
enum TopShelfSnapshotWriter {
#if os(tvOS)
private static var observers: [NSObjectProtocol] = []
#endif
/// Re-writes all three snapshot sections from current app data.
/// Call on app launch, after bookmark/watch mutations, and after feed refresh.
static func writeAll(dataManager: DataManager?, settingsManager: SettingsManager? = nil) {
#if os(tvOS)
writeContinueWatching(dataManager: dataManager)
writeBookmarks(dataManager: dataManager)
writeFeed()
if let settingsManager {
settingsManager.mirrorEnabledSectionsToAppGroup(settingsManager.topShelfSections)
}
#endif
}
/// Registers NotificationCenter observers so bookmark/history changes
/// elsewhere in the app keep the snapshot in sync. Idempotent.
static func startObserving(dataManager: DataManager?) {
#if os(tvOS)
guard observers.isEmpty, let dataManager else { return }
let center = NotificationCenter.default
let boxed = WeakDataManagerBox(dataManager)
observers.append(center.addObserver(
forName: .bookmarksDidChange, object: nil, queue: .main
) { _ in
Task { @MainActor in writeBookmarks(dataManager: boxed.value) }
})
observers.append(center.addObserver(
forName: .watchHistoryDidChange, object: nil, queue: .main
) { _ in
Task { @MainActor in writeContinueWatching(dataManager: boxed.value) }
})
#endif
}
static func writeContinueWatching(dataManager: DataManager?) {
#if os(tvOS)
guard let dataManager else { return }
let history = dataManager.watchHistory(limit: 50)
let items = history
.filter { !$0.isFinished && !$0.isLive && $0.watchedSeconds > 10 }
.prefix(TopShelfSnapshot.maxItems)
.compactMap(Self.makeItem(from:))
TopShelfSnapshot.write(Array(items), section: .continueWatching)
#endif
}
static func writeBookmarks(dataManager: DataManager?) {
#if os(tvOS)
guard let dataManager else { return }
let bookmarks = dataManager.bookmarks(limit: TopShelfSnapshot.maxItems)
let items = bookmarks.compactMap(Self.makeItem(from:))
TopShelfSnapshot.write(items, section: .recentBookmarks)
#endif
}
static func writeFeed() {
#if os(tvOS)
let videos = SubscriptionFeedCache.shared.videos.prefix(TopShelfSnapshot.maxItems)
let items = videos.compactMap(Self.makeItem(from:))
TopShelfSnapshot.write(items, section: .recentFeed)
#endif
}
}
#if os(tvOS)
/// Wraps a weak DataManager reference so it can be captured in Sendable closures.
/// Access is confined to the main actor via the enclosing NotificationCenter queue.
private final class WeakDataManagerBox: @unchecked Sendable {
weak var value: DataManager?
init(_ value: DataManager?) { self.value = value }
}
private extension TopShelfSnapshotWriter {
static func makeItem(from bookmark: Bookmark) -> TopShelfItem? {
guard let deepLink = deepLinkURL(
videoID: bookmark.videoID,
sourceRawValue: bookmark.sourceRawValue,
globalProvider: bookmark.globalProvider,
instanceURLString: bookmark.instanceURLString
) else { return nil }
return TopShelfItem(
videoID: bookmark.videoID,
title: bookmark.title,
authorName: bookmark.authorName,
duration: bookmark.duration,
thumbnailURL: reliableThumbnailURLString(bookmark.thumbnailURLString),
deepLinkURL: deepLink,
progressSeconds: nil
)
}
static func makeItem(from entry: WatchEntry) -> TopShelfItem? {
guard let deepLink = deepLinkURL(
videoID: entry.videoID,
sourceRawValue: entry.sourceRawValue,
globalProvider: entry.globalProvider,
instanceURLString: entry.instanceURLString
) else { return nil }
return TopShelfItem(
videoID: entry.videoID,
title: entry.title,
authorName: entry.authorName,
duration: entry.duration,
thumbnailURL: reliableThumbnailURLString(entry.thumbnailURLString),
deepLinkURL: deepLink,
progressSeconds: entry.watchedSeconds
)
}
static func makeItem(from video: Video) -> TopShelfItem? {
guard let deepLink = deepLinkURL(for: video.id) else { return nil }
let thumbnail = bestThumbnailURL(from: video.thumbnails)
return TopShelfItem(
videoID: video.id.videoID,
title: video.title,
authorName: video.author.name,
duration: video.duration,
thumbnailURL: thumbnail,
deepLinkURL: deepLink,
progressSeconds: nil
)
}
/// Builds a `yattee://video/...` deep link from a Video's ID.
/// Returns nil for source types not round-trippable via the URL scheme (e.g. extracted).
static func deepLinkURL(for videoID: VideoID) -> String? {
switch videoID.source {
case .global:
return "yattee://video/\(videoID.videoID)"
case .federated(_, let instance):
var components = URLComponents()
components.scheme = "yattee"
components.host = "video"
components.path = "/\(videoID.videoID)"
components.queryItems = [
URLQueryItem(name: "source", value: "peertube"),
URLQueryItem(name: "instance", value: instance.absoluteString)
]
return components.url?.absoluteString
case .extracted:
return nil
}
}
/// Builds a deep link from the stored Bookmark/WatchEntry source fields.
static func deepLinkURL(
videoID: String,
sourceRawValue: String,
globalProvider: String?,
instanceURLString: String?
) -> String? {
switch sourceRawValue {
case "global":
// Only YouTube survives the round-trip; other global providers fall through.
return "yattee://video/\(videoID)"
case "federated":
guard globalProvider == "peertube",
let urlStr = instanceURLString,
let instanceURL = URL(string: urlStr) else {
return nil
}
var components = URLComponents()
components.scheme = "yattee"
components.host = "video"
components.path = "/\(videoID)"
components.queryItems = [
URLQueryItem(name: "source", value: "peertube"),
URLQueryItem(name: "instance", value: instanceURL.absoluteString)
]
return components.url?.absoluteString
default:
return nil
}
}
/// 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? {
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