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 ( + + ) }