From fd0eab7784454bcaaaa00c6e3f8d6c6749e482c5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 23 Apr 2026 18:37:19 +0200 Subject: [PATCH] Prefetch fresh video thumbnail before swapping it into info view --- .../ImageLoading/ImageLoadingService.swift | 16 ++++++++++++++++ Yattee/Views/Video/VideoInfoView.swift | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Yattee/Services/ImageLoading/ImageLoadingService.swift b/Yattee/Services/ImageLoading/ImageLoadingService.swift index f008ce1d..8eabac7d 100644 --- a/Yattee/Services/ImageLoading/ImageLoadingService.swift +++ b/Yattee/Services/ImageLoading/ImageLoadingService.swift @@ -81,6 +81,22 @@ final class ImageLoadingService: Sendable { ) } + /// Warm the image cache for the given URL so a subsequent `LazyImage` + /// display hits the cache instantly. Returns after the image is cached + /// or the operation fails (silently). Bounded by `timeout` seconds. + nonisolated func prefetchImage(for url: URL, timeout: TimeInterval = 3) async { + await withTaskGroup(of: Void.self) { group in + group.addTask { + _ = try? await ImagePipeline.shared.image(for: url) + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + } + await group.next() + group.cancelAll() + } + } + /// Remove a specific URL from both the memory and disk image caches. /// Use when a previously cached URL is known to return stale or broken /// data (e.g. an expired proxied thumbnail URL). diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index a08072b6..922cf13b 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -2165,6 +2165,7 @@ struct VideoInfoView: View { do { // Use extractURL method - just use the video part let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance) + await prefetchNewThumbnailIfNeeded(old: base, new: fullVideo) invalidateStaleThumbnails(old: base, new: fullVideo) loadedVideoDetails[videoID] = fullVideo CachedChannelData.cacheAuthor(fullVideo.author) @@ -2190,6 +2191,7 @@ struct VideoInfoView: View { id: videoID, instance: instance ) + await prefetchNewThumbnailIfNeeded(old: base, new: fullVideo) invalidateStaleThumbnails(old: base, new: fullVideo) loadedVideoDetails[videoID] = fullVideo CachedChannelData.cacheAuthor(fullVideo.author) @@ -2200,6 +2202,20 @@ struct VideoInfoView: View { isLoadingVideoDetails = false } + /// Warms the Nuke cache for the freshly fetched thumbnail *before* we + /// swap it into the view. Without this, the view observes a URL change, + /// flips `LazyImage` to the new URL, and briefly shows a placeholder + /// while the download happens — the "thumbnail flash". By the time the + /// swap lands, the image is already cached. + @MainActor + private func prefetchNewThumbnailIfNeeded(old: Video, new: Video) async { + guard let newURL = new.bestThumbnail?.url else { return } + let oldKey = old.bestThumbnail.map { ImageLoadingService.cacheKey(for: $0.url) } + let newKey = ImageLoadingService.cacheKey(for: newURL) + guard oldKey != newKey else { return } + await ImageLoadingService.shared.prefetchImage(for: newURL) + } + /// Evicts image cache entries for thumbnails whose URLs changed between /// the queued/list copy of the video and the freshly fetched details, so /// other views holding the old URLs re-fetch instead of reusing broken