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
|
2023-05-18 13:17:41 +02:00
|
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
|
*/
|
2021-05-28 19:37:26 +04:30
|
|
|
|
2025-09-24 00:51:13 +02:00
|
|
|
import { useState, useEffect, useCallback, useRef, Ref } from 'react';
|
2021-09-09 17:51:22 +04:30
|
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
2023-06-04 21:08:39 +02:00
|
|
|
import Box from '@mui/material/Box';
|
2024-04-14 15:50:12 +02:00
|
|
|
import Stack from '@mui/material/Stack';
|
|
|
|
|
import Button from '@mui/material/Button';
|
2024-01-15 00:32:39 +01:00
|
|
|
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
|
|
|
|
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
2024-01-15 00:55:39 +01:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2024-03-10 14:33:55 +01:00
|
|
|
import ImageIcon from '@mui/icons-material/Image';
|
2024-04-14 15:50:12 +02:00
|
|
|
import { SxProps, Theme } from '@mui/material/styles';
|
2025-11-22 16:56:53 +01:00
|
|
|
import { ImageRequest, requestManager } from '@/lib/requests/RequestManager.ts';
|
2024-03-24 19:08:36 +01:00
|
|
|
import { Priority } from '@/lib/Queue.ts';
|
2025-08-16 02:48:46 +02:00
|
|
|
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
|
|
|
|
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
|
2025-11-22 16:56:53 +01:00
|
|
|
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;
|
|
|
|
|
|
2023-02-06 10:06:33 +01:00
|
|
|
src: string;
|
|
|
|
|
alt: string;
|
2021-05-28 19:37:26 +04:30
|
|
|
|
2024-03-08 00:24:01 +01:00
|
|
|
spinnerStyle?: SxProps<Theme> & { small?: boolean };
|
2024-11-06 13:00:36 +01:00
|
|
|
imgStyle?: SxProps<Theme>;
|
2024-12-19 16:38:52 +01:00
|
|
|
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;
|
2024-03-28 22:40:55 +01:00
|
|
|
|
2024-11-13 14:27:00 +01:00
|
|
|
shouldDecode?: boolean;
|
2024-03-28 22:40:55 +01:00
|
|
|
useFetchApi?: boolean;
|
2025-01-26 17:18:27 +01:00
|
|
|
disableCors?: boolean;
|
2025-11-22 17:28:56 +01:00
|
|
|
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,
|
2025-11-22 17:28:56 +01:00
|
|
|
ignoreQueue,
|
2025-09-24 00:51:13 +02:00
|
|
|
src,
|
|
|
|
|
alt,
|
|
|
|
|
onLoad,
|
|
|
|
|
onError,
|
|
|
|
|
spinnerStyle: { small, ...spinnerStyle } = {},
|
|
|
|
|
imgStyle,
|
|
|
|
|
hideImgStyle,
|
|
|
|
|
priority,
|
|
|
|
|
retryKeyPrefix,
|
|
|
|
|
} = props;
|
|
|
|
|
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
|
|
|
|
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 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 () => {};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-22 16:56:53 +01:00
|
|
|
let imageRequest: ImageRequest = {
|
|
|
|
|
response: Promise.resolve(''),
|
|
|
|
|
cleanup: noOp,
|
|
|
|
|
abortRequest: noOp,
|
|
|
|
|
fromCache: false,
|
|
|
|
|
};
|
2025-09-24 00:51:13 +02:00
|
|
|
const fetchImage = async () => {
|
|
|
|
|
try {
|
2025-11-22 16:56:53 +01:00
|
|
|
imageRequest = await requestManager.requestImage(src, {
|
|
|
|
|
priority,
|
|
|
|
|
shouldDecode,
|
|
|
|
|
useFetchApi,
|
|
|
|
|
disableCors,
|
2025-11-22 17:28:56 +01:00
|
|
|
ignoreQueue,
|
2025-11-22 16:56:53 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!imageRequest.fromCache) {
|
|
|
|
|
updateImageState(true);
|
|
|
|
|
}
|
2025-09-24 00:51:13 +02:00
|
|
|
|
2025-11-22 16:56:53 +01:00
|
|
|
const image = await imageRequest.response;
|
|
|
|
|
|
|
|
|
|
if (!imageRequest.fromCache) {
|
2025-09-24 00:51:13 +02:00
|
|
|
updateImageState(false);
|
|
|
|
|
}
|
2025-02-08 19:48:50 +01:00
|
|
|
|
2025-11-22 16:56:53 +01:00
|
|
|
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 () => {
|
|
|
|
|
imageRequest.cleanup();
|
|
|
|
|
imageRequest.abortRequest(new Error('Component was unmounted'));
|
|
|
|
|
};
|
|
|
|
|
}, [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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-22 16:56:53 +01:00
|
|
|
{(!!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',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-11-22 16:56:53 +01:00
|
|
|
{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('global.button.retry')}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2024-01-15 00:32:39 +01:00
|
|
|
</Stack>
|
2025-09-24 00:51:13 +02:00
|
|
|
</Stack>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|