Files
suwayomi-material-you-webui/src/base/components/SpinnerImage.tsx

247 lines
8.6 KiB
TypeScript
Raw Normal View History

2021-05-28 19:37:26 +04:30
/*
* 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/.
*/
2021-05-28 19:37:26 +04:30
2026-03-11 00:11:29 +01:00
import type { Ref } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
2026-03-20 03:02:52 +01:00
import { STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts';
2021-09-09 17:51:22 +04:30
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 ImageIcon from '@mui/icons-material/Image';
2026-03-11 00:11:29 +01:00
import type { SxProps, Theme } from '@mui/material/styles';
import { useLingui } from '@lingui/react/macro';
import { usePrevious } from '@mantine/hooks';
2026-03-11 00:11:29 +01:00
import type { ImageRequest } from '@/lib/requests/RequestManager.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import type { Priority } from '@/lib/Queue.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
import { noOp } from '@/lib/HelperFunctions.ts';
2021-05-28 19:37:26 +04:30
2025-04-25 00:50:51 +02:00
export interface SpinnerImageProps {
2024-11-06 13:00:36 +01:00
shouldLoad?: boolean;
src: string;
alt: string;
2021-05-28 19:37:26 +04:30
spinnerStyle?: SxProps<Theme> & { small?: boolean };
2024-11-06 13:00:36 +01:00
imgStyle?: SxProps<Theme>;
hideImgStyle?: Omit<SxProps<Theme>, 'accentColor'>;
2021-05-28 19:37:26 +04:30
2024-11-06 13:00:36 +01:00
onLoad?: () => void;
2024-12-07 04:47:28 +01:00
onError?: () => void;
shouldDecode?: boolean;
useFetchApi?: boolean;
disableCors?: boolean;
ignoreQueue?: boolean;
2024-11-13 14:32:41 +01:00
priority?: Priority;
2024-12-07 04:47:28 +01:00
retryKeyPrefix?: string;
2025-09-24 00:51:13 +02:00
ref?: Ref<HTMLImageElement | HTMLDivElement | null>;
2021-05-28 19:37:26 +04:30
}
2025-09-24 00:51:13 +02:00
export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
const {
shouldLoad = true,
shouldDecode,
useFetchApi,
disableCors,
ignoreQueue,
2025-09-24 00:51:13 +02:00
src,
alt,
onLoad,
onError,
2026-03-20 03:02:52 +01:00
spinnerStyle: { small, ...spinnerStyle } = STABLE_EMPTY_OBJECT,
2025-09-24 00:51:13 +02:00
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
const { t } = useLingui();
2025-09-24 00:51:13 +02:00
const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
const showMissingImageIcon = !src.length;
const [imageSourceUrl, setImageSourceUrl] = useState<string>();
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
const [isLoading, setIsLoading] = useState<boolean>();
const [hasError, setHasError] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const previousSrc = usePrevious(src);
const previousImgLoadRetryKey = usePrevious(imgLoadRetryKey);
const previousRetryKeyPrefix = usePrevious(retryKeyPrefix);
2025-09-24 00:51:13 +02:00
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(() => {
const didSrcChange = previousSrc !== src;
const isLoadedAndSrcUnchanged = !!imageSourceUrl && !didSrcChange;
const isLocalRetry =
hasError && previousImgLoadRetryKey !== undefined && previousImgLoadRetryKey !== imgLoadRetryKey;
const isGlobalRetry =
hasError && previousRetryKeyPrefix !== undefined && previousRetryKeyPrefix !== retryKeyPrefix;
const isRetry = isLocalRetry || isGlobalRetry;
const finalShouldLoad = shouldLoad || isRetry;
if (showMissingImageIcon || !finalShouldLoad || isLoadedAndSrcUnchanged) {
2025-09-24 00:51:13 +02:00
return () => {};
}
let isAborted = false;
let imageRequest: ImageRequest = {
response: Promise.resolve(''),
cleanup: noOp,
abortRequest: noOp,
fromCache: false,
};
const abortRequest = () => {
isAborted = true;
imageRequest.cleanup();
imageRequest.abortRequest(new Error('Component was unmounted'));
};
2025-09-24 00:51:13 +02:00
const fetchImage = async () => {
try {
imageRequest = await requestManager.requestImage(src, {
priority,
shouldDecode,
useFetchApi,
disableCors,
ignoreQueue,
});
// In case the request got aborted before it was queued, the abort was called against the "default noop" function and did nothing.
// Thus, abort again to ensure that the actual queued request gets aborted.
if (isAborted) {
abortRequest();
}
if (!imageRequest.fromCache) {
updateImageState(true);
}
2025-09-24 00:51:13 +02:00
const image = await imageRequest.response;
updateImageState(false);
setImageSourceUrl(image);
2025-09-24 00:51:13 +02:00
} catch (e) {
const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
updateImageState(false, !wasAborted, wasAborted);
2024-03-01 01:17:23 +01:00
}
};
2025-09-24 00:51:13 +02:00
fetchImage().catch(() => {});
2024-03-01 01:17:23 +01:00
2025-09-24 00:51:13 +02:00
return () => {
abortRequest();
2025-09-24 00:51:13 +02:00
};
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
return (
<>
{showMissingImageIcon ? (
<Stack
ref={ref}
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
background: (theme) => theme.palette.background.default,
...spinnerStyle,
}}
>
<ImageIcon fontSize="large" />
</Stack>
) : (
<Box
component="img"
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
sx={[
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
applyStyles(!imageSourceUrl || isLoading || hasError, {
...hideImgStyle,
...applyStyles(!hideImgStyle, {
display: 'none',
}),
2024-11-06 13:00:36 +01:00
}),
2025-09-24 00:51:13 +02:00
]}
ref={ref}
crossOrigin={disableCors ? undefined : 'anonymous'}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
{(!!isLoading || (src && !imageSourceUrl) || hasError) && (
2025-09-24 00:51:13 +02:00
<Stack
ref={loadingIndicatorRef}
sx={{
height: '100%',
justifyContent: 'center',
alignItems: 'center',
...spinnerStyle,
}}
>
2024-09-03 17:15:47 +02:00
<Stack
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isVisible && !!isLoading && <CircularProgress thickness={5} />}
2025-09-24 00:51:13 +02:00
{hasError && isLoading === false && (
<>
<BrokenImageIcon />
<Button
startIcon={!small && <RefreshIcon />}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
}}
size={small ? 'small' : 'large'}
>
{small ? <RefreshIcon /> : t`Retry`}
2025-09-24 00:51:13 +02:00
</Button>
</>
)}
</Stack>
2025-09-24 00:51:13 +02:00
</Stack>
)}
</>
);
};