Merge pull request #1002 from jeffvli/react-image-lazy-loaded

Use lazy loading (react-intersection-observer) for image loading
This commit is contained in:
Jeff 2025-09-03 21:43:55 -07:00 committed by GitHub
commit 2260c0c02b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 60 additions and 31 deletions

View file

@ -112,6 +112,7 @@
"react-i18next": "^11.18.6", "react-i18next": "^11.18.6",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-image": "^4.1.0", "react-image": "^4.1.0",
"react-intersection-observer": "^9.16.0",
"react-loading-skeleton": "^3.5.0", "react-loading-skeleton": "^3.5.0",
"react-player": "^2.11.0", "react-player": "^2.11.0",
"react-router": "^6.16.0", "react-router": "^6.16.0",

18
pnpm-lock.yaml generated
View file

@ -182,6 +182,9 @@ importers:
react-image: react-image:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(@babel/runtime@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 4.1.0(@babel/runtime@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-intersection-observer:
specifier: ^9.16.0
version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-loading-skeleton: react-loading-skeleton:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0(react@19.1.0) version: 3.5.0(react@19.1.0)
@ -3654,6 +3657,15 @@ packages:
react: '>=16.8' react: '>=16.8'
react-dom: '>=16.8' react-dom: '>=16.8'
react-intersection-observer@9.16.0:
resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react-dom:
optional: true
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -8467,6 +8479,12 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
react-intersection-observer@9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react-is@16.13.1: {} react-is@16.13.1: {}
react-loading-skeleton@3.5.0(react@19.1.0): react-loading-skeleton@3.5.0(react@19.1.0):

View file

@ -78,7 +78,7 @@ export const useHandlePlayQueueAdd = () => {
// Allow this to be undefined for "play shuffled". If undefined, default to 0, // Allow this to be undefined for "play shuffled". If undefined, default to 0,
// otherwise, choose the selected item in the queue // otherwise, choose the selected item in the queue
let initialSongIndex: number | undefined; let initialSongIndex: number | undefined;
let toastId: string | null = null; let toastId: null | string = null;
if (byItemType) { if (byItemType) {
let songList: SongListResponse | undefined; let songList: SongListResponse | undefined;
@ -148,7 +148,7 @@ export const useHandlePlayQueueAdd = () => {
clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>); clearTimeout(timeoutIds.current[fetchId] as ReturnType<typeof setTimeout>);
delete timeoutIds.current[fetchId]; delete timeoutIds.current[fetchId];
if(toastId){ if (toastId) {
toast.hide(toastId); toast.hide(toastId);
} }
} catch (err: any) { } catch (err: any) {

View file

@ -230,19 +230,19 @@ export const AddToPlaylistContextModal = ({
clearable clearable
data={playlistSelect} data={playlistSelect}
disabled={playlistList.isLoading} disabled={playlistList.isLoading}
dropdownOpened={isDropdownOpened}
label={t('form.addToPlaylist.input', { label={t('form.addToPlaylist.input', {
context: 'playlists', context: 'playlists',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
searchable searchable
size="md" size="md"
dropdownOpened={isDropdownOpened}
{...form.getInputProps('playlistId')} {...form.getInputProps('playlistId')}
onClick={() => setIsDropdownOpened(true)}
onChange={(e) => { onChange={(e) => {
setIsDropdownOpened(false); setIsDropdownOpened(false);
form.getInputProps('playlistId').onChange(e); form.getInputProps('playlistId').onChange(e);
}} }}
onClick={() => setIsDropdownOpened(true)}
/> />
<Switch <Switch
label={t('form.addToPlaylist.input', { label={t('form.addToPlaylist.input', {

View file

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { motion, MotionConfigProps } from 'motion/react'; import { motion, MotionConfigProps } from 'motion/react';
import { type ImgHTMLAttributes } from 'react'; import { type ImgHTMLAttributes } from 'react';
import { Img } from 'react-image'; import { Img } from 'react-image';
import { InView } from 'react-intersection-observer';
import styles from './image.module.css'; import styles from './image.module.css';
@ -33,6 +34,9 @@ interface ImageUnloaderProps {
className?: string; className?: string;
} }
const FALLBACK_SVG =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=';
export function Image({ export function Image({
className, className,
containerClassName, containerClassName,
@ -44,33 +48,39 @@ export function Image({
}: ImageProps) { }: ImageProps) {
if (src) { if (src) {
return ( return (
<Img <InView>
className={clsx(styles.image, className)} {({ inView, ref }) => (
container={(children) => ( <div ref={ref}>
<ImageContainer <Img
className={containerClassName} className={clsx(styles.image, className)}
enableAnimation={enableAnimation} container={(children) => (
{...imageContainerProps} <ImageContainer
> className={containerClassName}
{children} enableAnimation={enableAnimation}
</ImageContainer> {...imageContainerProps}
>
{children}
</ImageContainer>
)}
loader={
includeLoader ? (
<ImageContainer className={containerClassName}>
<ImageLoader className={className} />
</ImageContainer>
) : null
}
src={inView ? src : FALLBACK_SVG}
unloader={
includeUnloader ? (
<ImageContainer className={containerClassName}>
<ImageUnloader className={className} />
</ImageContainer>
) : null
}
/>
</div>
)} )}
loader={ </InView>
includeLoader ? (
<ImageContainer className={containerClassName}>
<ImageLoader className={className} />
</ImageContainer>
) : null
}
src={src}
unloader={
includeUnloader ? (
<ImageContainer className={containerClassName}>
<ImageUnloader className={className} />
</ImageContainer>
) : null
}
/>
); );
} }