From 1272479303aa72d65a0c72ac699aa8792a6cf20d Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 8 May 2026 16:53:59 -0700 Subject: [PATCH] =?UTF-8?q?[Mobile]=20Eliminate=20low-res=E2=86=92high-res?= =?UTF-8?q?=20image=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When useImageSize had a smaller size cached (e.g. 150×150) but needed a larger one (e.g. 480×480), it set imageUrl to the small URL then switched to the large URL once fetched. The source change caused Image.tsx to reset opacity to 0 and re-fade — a visible flash on track, profile, and playlist pages. useImageSize now sets imageUrl to the target URL optimistically and returns priorityLowResUrl separately. TrackImage, CollectionImage, and UserImage pass it as priorityLowResSource to Artwork/Image, which shows it as a blurred backdrop while the high-res crossfades in. Co-Authored-By: Claude Sonnet 4.6 --- packages/common/src/hooks/useImageSize.ts | 26 +++++++++++++++---- .../src/components/image/CollectionImage.tsx | 9 ++++++- .../src/components/image/TrackImage.tsx | 9 ++++++- .../mobile/src/components/image/UserImage.tsx | 22 +++++++++++++--- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/common/src/hooks/useImageSize.ts b/packages/common/src/hooks/useImageSize.ts index 5d2f2a1dace..95044f52f4b 100644 --- a/packages/common/src/hooks/useImageSize.ts +++ b/packages/common/src/hooks/useImageSize.ts @@ -57,6 +57,11 @@ export const useImageSize = < preloadImageFn?: (url: string) => Promise }) => { const [imageUrl, setImageUrl] = useState>(undefined) + // When upgrading from a smaller cached image to the target size, holds the + // smaller URL so callers can show it as a blurred backdrop while the + // high-res crossfades in (avoids the opacity-reset flash on source change). + const [priorityLowResUrl, setPriorityLowResUrl] = + useState>(undefined) const [failedUrls, setFailedUrls] = useState>(new Set()) const fetchWithFallback = useCallback( @@ -150,10 +155,21 @@ export const useImageSize = < } if (smallerSize) { - setImageUrl(artwork[smallerSize]) - const finalUrl = await fetchWithFallback(targetUrl) - IMAGE_CACHE.add(finalUrl) - setImageUrl(finalUrl) + // Set the target URL optimistically so the main image slot starts + // loading the high-res immediately. The smaller cached URL is passed + // back as priorityLowResUrl so callers can show it as a blurred + // backdrop — this eliminates the opacity-reset flash that occurred + // when we previously did setImageUrl(small) then setImageUrl(large). + setPriorityLowResUrl(artwork[smallerSize]) + setImageUrl(targetUrl) + try { + const finalUrl = await fetchWithFallback(targetUrl) + IMAGE_CACHE.add(finalUrl) + if (finalUrl !== targetUrl) setImageUrl(finalUrl) + } catch (e) { + // Fall back to the smaller size if high-res is unreachable. + setImageUrl(artwork[smallerSize]) + } return } @@ -190,5 +206,5 @@ export const useImageSize = < resolveImageUrl() }, [resolveImageUrl]) - return { imageUrl, onError } + return { imageUrl, priorityLowResUrl, onError } } diff --git a/packages/mobile/src/components/image/CollectionImage.tsx b/packages/mobile/src/components/image/CollectionImage.tsx index 4369e516bb6..92659e57a53 100644 --- a/packages/mobile/src/components/image/CollectionImage.tsx +++ b/packages/mobile/src/components/image/CollectionImage.tsx @@ -71,7 +71,11 @@ export const useCollectionImage = ({ }) const artwork = artworkData?.artwork const hasNoArtwork = artworkData?.hasNoArtwork ?? false - const { imageUrl, onError: onImageError } = useImageSize({ + const { + imageUrl, + priorityLowResUrl, + onError: onImageError + } = useImageSize({ artwork, targetSize: size, defaultImage: '', @@ -98,6 +102,7 @@ export const useCollectionImage = ({ return { source: primitiveToImageSource(imageUrl), + priorityLowResSource: primitiveToImageSource(priorityLowResUrl), hasNoArtwork: false, onError: onImageError } @@ -121,6 +126,7 @@ export const CollectionImage = (props: CollectionImageProps) => { const collectionImageSource = useCollectionImage({ collectionId, size }) const { source: loadedSource, + priorityLowResSource, onError: onImageError, hasNoArtwork } = collectionImageSource @@ -160,6 +166,7 @@ export const CollectionImage = (props: CollectionImageProps) => { { const trackImageSource = useTrackImage({ trackId, size }) const { source: loadedSource, + priorityLowResSource, onError: onImageError, hasNoArtwork } = trackImageSource @@ -176,6 +182,7 @@ export const TrackImage = (props: TrackImageProps) => { return ( export const UserImage = (props: UserImageProps) => { const { userId, size, onError, ...imageProps } = props - const { source, onError: onImageError } = useProfilePicture({ userId, size }) + const { + source, + priorityLowResSource, + onError: onImageError + } = useProfilePicture({ userId, size }) const handleError = (error: { nativeEvent: { error: string } }) => { if (source && typeof source === 'object' && 'uri' in source) { @@ -72,5 +81,12 @@ export const UserImage = (props: UserImageProps) => { onError?.(error) } - return + return ( + + ) }