diff --git a/packages/common/src/hooks/useImageSize.ts b/packages/common/src/hooks/useImageSize.ts index 1dc3e5009dd..4502d30cdd4 100644 --- a/packages/common/src/hooks/useImageSize.ts +++ b/packages/common/src/hooks/useImageSize.ts @@ -57,13 +57,14 @@ export const useImageSize = < preloadImageFn?: (url: string) => Promise }) => { const [imageUrl, setImageUrl] = useState>(undefined) + const [failedUrls, setFailedUrls] = useState>(new Set()) const fetchWithFallback = useCallback( async (url: string) => { const mirrors = [...(artwork?.mirrors ?? [])] let currentUrl = url - while (mirrors.length > 0) { + while (mirrors.length >= 0) { try { await preloadImageFn?.(currentUrl) return currentUrl @@ -81,6 +82,25 @@ export const useImageSize = < [artwork?.mirrors, preloadImageFn] ) + const getNextMirrorUrl = useCallback( + (originalUrl: string, failedUrls: Set) => { + const mirrors = artwork?.mirrors ?? [] + if (mirrors.length === 0) return null + + for (const mirror of mirrors) { + const nextUrl = new URL(originalUrl) + nextUrl.hostname = new URL(mirror).hostname + const mirrorUrl = nextUrl.toString() + + if (!failedUrls.has(mirrorUrl)) { + return mirrorUrl + } + } + return null + }, + [artwork?.mirrors] + ) + const resolveImageUrl = useCallback(async () => { if (!artwork) { return @@ -91,6 +111,15 @@ export const useImageSize = < return } + // If the original URL failed, try mirrors + if (failedUrls.has(targetUrl)) { + const mirrorUrl = getNextMirrorUrl(targetUrl, failedUrls) + if (mirrorUrl) { + setImageUrl(mirrorUrl) + return + } + } + if (IMAGE_CACHE.has(targetUrl)) { setImageUrl(targetUrl) return @@ -136,11 +165,25 @@ export const useImageSize = < } catch (e) { console.error(`Unable to load image ${targetUrl} after retries: ${e}`) } - }, [artwork, targetSize, fetchWithFallback, defaultImage]) + }, [ + artwork, + targetSize, + fetchWithFallback, + defaultImage, + failedUrls, + getNextMirrorUrl + ]) + + const onError = useCallback( + (url: string) => { + setFailedUrls((prev) => new Set(prev).add(url)) + }, + [setFailedUrls] + ) useEffect(() => { resolveImageUrl() }, [resolveImageUrl]) - return imageUrl + return { imageUrl, onError } } diff --git a/packages/harmony/src/hooks/useHoverDelay.ts b/packages/harmony/src/hooks/useHoverDelay.ts index d85f14272cd..0a7ce1d6892 100644 --- a/packages/harmony/src/hooks/useHoverDelay.ts +++ b/packages/harmony/src/hooks/useHoverDelay.ts @@ -67,8 +67,8 @@ export const useHoverDelay = ( triggeredBy === 'click' ? isClicked : triggeredBy === 'both' - ? isHovered || isClicked - : isHovered + ? isHovered || isClicked + : isHovered return { isHovered, diff --git a/packages/mobile/src/components/audio/GoogleCast.tsx b/packages/mobile/src/components/audio/GoogleCast.tsx index 7976c1b8baa..ee7039c0848 100644 --- a/packages/mobile/src/components/audio/GoogleCast.tsx +++ b/packages/mobile/src/components/audio/GoogleCast.tsx @@ -43,7 +43,7 @@ export const useChromecast = () => { const previousCastState = usePrevious(castState) const [internalCounter, setInternalCounter] = useState(0) - const imageUrl = useImageSize({ + const { imageUrl } = useImageSize({ artwork: track?.artwork, targetSize: SquareSizes.SIZE_1000_BY_1000 }) diff --git a/packages/mobile/src/components/image/CollectionImage.tsx b/packages/mobile/src/components/image/CollectionImage.tsx index 07dcaa9a64d..356fe373134 100644 --- a/packages/mobile/src/components/image/CollectionImage.tsx +++ b/packages/mobile/src/components/image/CollectionImage.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react' + import { useCollection } from '@audius/common/api' import { useImageSize } from '@audius/common/hooks' import type { SquareSizes, ID } from '@audius/common/models' @@ -49,7 +51,7 @@ export const useCollectionImage = ({ const { data: artwork } = useCollection(collectionId, { select: (collection) => collection.artwork }) - const image = useImageSize({ + const { imageUrl, onError } = useImageSize({ artwork, targetSize: size, defaultImage: '', @@ -58,10 +60,11 @@ export const useCollectionImage = ({ } }) - if (image === '') { + if (imageUrl === '') { return { source: imageEmpty, - isFallbackImage: true + isFallbackImage: true, + onError } } @@ -73,13 +76,15 @@ export const useCollectionImage = ({ return { // @ts-ignore source: primitiveToImageSource(artwork.url), - isFallbackImage: false + isFallbackImage: false, + onError } } return { - source: primitiveToImageSource(image), - isFallbackImage: false + source: primitiveToImageSource(imageUrl), + isFallbackImage: false, + onError } } @@ -98,10 +103,20 @@ export const CollectionImage = (props: CollectionImageProps) => { const collectionImageSource = useCollectionImage({ collectionId, size }) const { cornerRadius } = useTheme() const { skeleton } = useThemeColors() - const { source: loadedSource, isFallbackImage } = collectionImageSource + const { + source: loadedSource, + isFallbackImage, + onError + } = collectionImageSource const source = loadedSource ?? localCollectionImageUri + const handleError = useCallback(() => { + if (source && typeof source === 'object' && 'uri' in source && source.uri) { + onError(source.uri) + } + }, [source, onError]) + return ( { }, style ]} - source={source ?? { uri: '' }} + source={source} onLoad={onLoad} + onError={handleError} /> ) } diff --git a/packages/mobile/src/components/image/CoverPhoto.tsx b/packages/mobile/src/components/image/CoverPhoto.tsx index d11101843ca..7a4e7d650e9 100644 --- a/packages/mobile/src/components/image/CoverPhoto.tsx +++ b/packages/mobile/src/components/image/CoverPhoto.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react' + import { useUser } from '@audius/common/api' import { useImageSize } from '@audius/common/hooks' import type { ID } from '@audius/common/models' @@ -40,7 +42,7 @@ export const useCoverPhoto = ({ }) const { cover_photo, updatedCoverPhoto } = partialUser ?? {} const coverPhoto = cover_photo - const image = useImageSize({ + const { imageUrl, onError } = useImageSize({ artwork: coverPhoto, targetSize: size, defaultImage: '', @@ -49,20 +51,21 @@ export const useCoverPhoto = ({ } }) - const isDefaultCover = image === '' + const isDefaultCover = imageUrl === '' const shouldBlur = isDefaultCover && !isDefaultProfile if (updatedCoverPhoto && !shouldBlur) { return { source: primitiveToImageSource(updatedCoverPhoto.url), - shouldBlur + shouldBlur, + onError } } if (shouldBlur) { - return { source: profilePicture, shouldBlur } + return { source: profilePicture, shouldBlur, onError } } - return { source: primitiveToImageSource(image), shouldBlur } + return { source: primitiveToImageSource(imageUrl), shouldBlur, onError } } type CoverPhotoProps = { @@ -73,7 +76,7 @@ export const CoverPhoto = (props: CoverPhotoProps) => { const { userId, ...imageProps } = props const scrollY = useCurrentTabScrollY() - const { source, shouldBlur } = useCoverPhoto({ + const { source, shouldBlur, onError } = useCoverPhoto({ userId, size: WidthSizes.SIZE_640 }) @@ -104,11 +107,17 @@ export const CoverPhoto = (props: CoverPhotoProps) => { }) })) + const handleError = useCallback(() => { + if (source && typeof source === 'object' && 'uri' in source && source.uri) { + onError(source.uri) + } + }, [source, onError]) + if (!source) return null return ( - + {shouldBlur || scrollY ? ( { const trackImageSource = useTrackImage({ trackId, size }) const { cornerRadius } = useTheme() const { skeleton } = useThemeColors() - const { source: loadedSource, isFallbackImage } = trackImageSource + const { source: loadedSource, isFallbackImage, onError } = trackImageSource const source = loadedSource ?? localTrackImageUri + const handleError = useCallback(() => { + if ( + source && + typeof source === 'object' && + 'uri' in source && + typeof source.uri === 'string' + ) { + onError(source.uri) + } + }, [source, onError]) + return ( { }, style ]} - source={source ?? { uri: '' }} + source={source} + onError={handleError} onLoad={onLoad} /> ) diff --git a/packages/mobile/src/components/image/UserImage.tsx b/packages/mobile/src/components/image/UserImage.tsx index 461fc64a742..6bef372ffdc 100644 --- a/packages/mobile/src/components/image/UserImage.tsx +++ b/packages/mobile/src/components/image/UserImage.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react' + import { useUser } from '@audius/common/api' import { useImageSize } from '@audius/common/hooks' import type { SquareSizes, ID } from '@audius/common/models' @@ -28,7 +30,7 @@ export const useProfilePicture = ({ }) const { profile_picture, updatedProfilePicture } = partialUser ?? {} - const image = useImageSize({ + const { imageUrl, onError } = useImageSize({ artwork: profile_picture, targetSize: size, defaultImage: '', @@ -37,23 +39,26 @@ export const useProfilePicture = ({ } }) - if (image === '') { + if (imageUrl === '') { return { source: profilePicEmpty, - isFallbackImage: true + isFallbackImage: true, + onError } } if (updatedProfilePicture) { return { source: primitiveToImageSource(updatedProfilePicture.url), - isFallbackImage: false + isFallbackImage: false, + onError } } return { - source: primitiveToImageSource(image), - isFallbackImage: false + source: primitiveToImageSource(imageUrl), + isFallbackImage: false, + onError } } @@ -61,7 +66,13 @@ export type UserImageProps = UseUserImageOptions & Partial export const UserImage = (props: UserImageProps) => { const { userId, size, ...imageProps } = props - const { source } = useProfilePicture({ userId, size }) + const { source, onError } = useProfilePicture({ userId, size }) + + const handleError = useCallback(() => { + if (source && typeof source === 'object' && 'uri' in source && source.uri) { + onError(source.uri) + } + }, [source, onError]) - return + return } diff --git a/packages/mobile/src/harmony-native/components/FastImage/FastImage.tsx b/packages/mobile/src/harmony-native/components/FastImage/FastImage.tsx index bdf1fe3accd..92f9aca1205 100644 --- a/packages/mobile/src/harmony-native/components/FastImage/FastImage.tsx +++ b/packages/mobile/src/harmony-native/components/FastImage/FastImage.tsx @@ -6,7 +6,7 @@ import type { import RNFastImage from 'react-native-fast-image' export type FastImageProps = Omit & { - source: ImageSourcePropType + source?: ImageSourcePropType priority?: Priority } diff --git a/packages/web/src/components/ai-attribution-modal/SearchBarResult.tsx b/packages/web/src/components/ai-attribution-modal/SearchBarResult.tsx index 41928f25041..0788d9acf6e 100644 --- a/packages/web/src/components/ai-attribution-modal/SearchBarResult.tsx +++ b/packages/web/src/components/ai-attribution-modal/SearchBarResult.tsx @@ -39,7 +39,7 @@ type ImageProps = { const Image = memo((props: ImageProps) => { const { defaultImage, artwork, size, isUser } = props - const image = useImageSize({ + const { imageUrl } = useImageSize({ artwork, targetSize: size, defaultImage, @@ -50,11 +50,11 @@ const Image = memo((props: ImageProps) => { skeletonClassName={cn({ [styles.userImageContainerSkeleton]: isUser })} wrapperClassName={cn(styles.imageContainer)} className={cn({ - [styles.image]: image, + [styles.image]: imageUrl, [styles.userImage]: isUser, - [styles.emptyUserImage]: isUser && image === defaultImage + [styles.emptyUserImage]: isUser && imageUrl === defaultImage })} - image={image} + image={imageUrl} /> ) }) diff --git a/packages/web/src/hooks/useCollectionCoverArt.ts b/packages/web/src/hooks/useCollectionCoverArt.ts index c79cde79ceb..2f987392f7b 100644 --- a/packages/web/src/hooks/useCollectionCoverArt.ts +++ b/packages/web/src/hooks/useCollectionCoverArt.ts @@ -18,7 +18,7 @@ export const useCollectionCoverArt = ({ const { data: artwork } = useCollection(collectionId, { select: (collection) => collection.artwork }) - const image = useImageSize({ + const { imageUrl } = useImageSize({ artwork, targetSize: size, defaultImage: defaultImage ?? imageEmpty, @@ -31,5 +31,5 @@ export const useCollectionCoverArt = ({ // @ts-ignore if (artwork?.url) return artwork.url - return image + return imageUrl } diff --git a/packages/web/src/hooks/useCoverPhoto.ts b/packages/web/src/hooks/useCoverPhoto.ts index a04f83918e9..2c851cdfd72 100644 --- a/packages/web/src/hooks/useCoverPhoto.ts +++ b/packages/web/src/hooks/useCoverPhoto.ts @@ -32,14 +32,14 @@ export const useCoverPhoto = ({ select: (user) => pick(user, 'cover_photo', 'updatedCoverPhoto') }) const { cover_photo, updatedCoverPhoto } = partialUser ?? {} - const image = useImageSize({ + const { imageUrl } = useImageSize({ artwork: cover_photo, targetSize: size, defaultImage: defaultImage ?? imageCoverPhotoBlank, preloadImageFn: preload }) - const isDefaultCover = image === imageCoverPhotoBlank + const isDefaultCover = imageUrl === imageCoverPhotoBlank const isDefaultProfile = profilePicture === imageProfilePicEmpty const shouldBlur = isDefaultCover && !isDefaultProfile @@ -47,5 +47,5 @@ export const useCoverPhoto = ({ return { image: updatedCoverPhoto.url, shouldBlur } } - return { image: shouldBlur ? profilePicture : image, shouldBlur } + return { image: shouldBlur ? profilePicture : imageUrl, shouldBlur } } diff --git a/packages/web/src/hooks/useProfilePicture.ts b/packages/web/src/hooks/useProfilePicture.ts index c9ecb549a98..8760d8af31b 100644 --- a/packages/web/src/hooks/useProfilePicture.ts +++ b/packages/web/src/hooks/useProfilePicture.ts @@ -20,7 +20,7 @@ export const useProfilePicture = ({ }) const { profile_picture, updatedProfilePicture } = partialUser ?? {} - const image = useImageSize({ + const { imageUrl } = useImageSize({ artwork: profile_picture, targetSize: size, defaultImage: defaultImage ?? profilePicEmpty, @@ -30,5 +30,5 @@ export const useProfilePicture = ({ if (updatedProfilePicture) { return updatedProfilePicture.url } - return image + return imageUrl } diff --git a/packages/web/src/hooks/useTrackCoverArt.ts b/packages/web/src/hooks/useTrackCoverArt.ts index 3c9874c0e67..e58e9b437ad 100644 --- a/packages/web/src/hooks/useTrackCoverArt.ts +++ b/packages/web/src/hooks/useTrackCoverArt.ts @@ -26,7 +26,7 @@ export const useTrackCoverArt = ({ const { data: artwork } = useTrack(trackId, { select: (track) => track?.artwork }) - const image = useImageSize({ + const { imageUrl } = useImageSize({ artwork, targetSize: size, defaultImage: defaultImage ?? imageEmpty, @@ -39,7 +39,7 @@ export const useTrackCoverArt = ({ // @ts-ignore if (artwork?.url) return artwork.url - return image + return imageUrl } export const useTrackCoverArtDominantColors = ({