/* * Copyright (C) Contributors to the Suwayomi project * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { useState, useEffect, forwardRef, ForwardedRef, useCallback, useRef } from 'react'; import CircularProgress from '@mui/material/CircularProgress'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import Button from '@mui/material/Button'; import BrokenImageIcon from '@mui/icons-material/BrokenImage'; import RefreshIcon from '@mui/icons-material/Refresh'; import { useTranslation } from 'react-i18next'; import ImageIcon from '@mui/icons-material/Image'; import { SxProps, Theme } from '@mui/material/styles'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { Priority } from '@/lib/Queue.ts'; import { applyStyles } from '@/base/utils/ApplyStyles.ts'; import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx'; export interface SpinnerImageProps { shouldLoad?: boolean; src: string; alt: string; spinnerStyle?: SxProps & { small?: boolean }; imgStyle?: SxProps; hideImgStyle?: Omit, 'accentColor'>; onLoad?: () => void; onError?: () => void; shouldDecode?: boolean; useFetchApi?: boolean; disableCors?: boolean; priority?: Priority; retryKeyPrefix?: string; } export const SpinnerImage = forwardRef( (props: SpinnerImageProps, imgRef: ForwardedRef) => { const { shouldLoad = true, shouldDecode, useFetchApi, disableCors, src, alt, onLoad, onError, spinnerStyle: { small, ...spinnerStyle } = {}, imgStyle, hideImgStyle, priority, retryKeyPrefix, } = props; const { t } = useTranslation(); const loadingIndicatorRef = useRef(null); const showMissingImageIcon = !src.length; const [imageSourceUrl, setImageSourceUrl] = useState(); const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0); const [isLoading, setIsLoading] = useState(); const [hasError, setHasError] = useState(false); const [isVisible, setIsVisible] = useState(false); const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => { setIsLoading(loading); setHasError(error); if (error && !loading && !aborted) { onError?.(); } if (!loading && !error && !aborted) { onLoad?.(); } }; useIntersectionObserver( loadingIndicatorRef, useCallback((entries) => setIsVisible(entries[0].isIntersecting), []), ); useEffect(() => { if (showMissingImageIcon || !shouldLoad) { return () => {}; } const imageRequest = requestManager.requestImage(src, { priority, shouldDecode, useFetchApi, disableCors, }); let cacheTimeout: NodeJS.Timeout; const fetchImage = async () => { try { const updateImage = async () => { const image = await imageRequest.response; updateImageState(false); setImageSourceUrl(image); }; const checkCache = await Promise.race([ imageRequest.response, new Promise((resolve) => { cacheTimeout = setTimeout(resolve, 50); }), ]); const isImageCached = !!checkCache; if (isImageCached) { await updateImage(); return; } updateImageState(true); await updateImage(); } catch (e) { const wasAborted = e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted'); updateImageState(false, !wasAborted, wasAborted); } }; fetchImage().catch(() => {}); return () => { imageRequest.cleanup(); clearTimeout(cacheTimeout); imageRequest.abortRequest(new Error('Component was unmounted')); }; }, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]); return ( <> {showMissingImageIcon ? ( theme.palette.background.default, ...spinnerStyle, }} > ) : ( )} {(isLoading || (src && !imageSourceUrl) || hasError) && ( {isVisible && (isLoading || (src && !imageSourceUrl && !hasError)) && ( )} {hasError && isLoading === false && ( <> )} )} ); }, );