feat(ui): add smooth image transitions to album and artist artwork (#4120)

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-05-26 08:57:37 -04:00
committed by GitHub
parent 5c4fbdb7c1
commit d26e2e29a6
6 changed files with 174 additions and 33 deletions
+42 -3
View File
@@ -72,6 +72,10 @@ const useStyles = makeStyles(
width: '15em', width: '15em',
minWidth: '15em', minWidth: '15em',
}, },
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}, },
cover: { cover: {
objectFit: 'contain', objectFit: 'contain',
@@ -79,6 +83,11 @@ const useStyles = makeStyles(
display: 'block', display: 'block',
width: '100%', width: '100%',
height: '100%', height: '100%',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
}, },
loveButton: { loveButton: {
top: theme.spacing(-0.2), top: theme.spacing(-0.2),
@@ -213,6 +222,8 @@ const AlbumDetails = (props) => {
const [isLightboxOpen, setLightboxOpen] = useState(false) const [isLightboxOpen, setLightboxOpen] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState() const [albumInfo, setAlbumInfo] = useState()
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
let notes = let notes =
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
@@ -236,23 +247,51 @@ const AlbumDetails = (props) => {
}) })
}, [record]) }, [record])
// Reset image state when album changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const imageUrl = subsonic.getCoverArtUrl(record, 300) const imageUrl = subsonic.getCoverArtUrl(record, 300)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
const handleOpenLightbox = useCallback(() => setLightboxOpen(true), []) const handleImageLoad = useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
return ( return (
<Card className={classes.root}> <Card className={classes.root}>
<div className={classes.cardContents}> <div className={classes.cardContents}>
<div className={classes.coverParent}> <div className={classes.coverParent}>
<CardMedia <CardMedia
key={record.id}
component={'img'} component={'img'}
src={imageUrl} src={imageUrl}
width="400" width="400"
height="400" height="400"
className={classes.cover} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={record.name} title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/> />
</div> </div>
<div className={classes.details}> <div className={classes.details}>
@@ -337,7 +376,7 @@ const AlbumDetails = (props) => {
</Collapse> </Collapse>
</div> </div>
)} )}
{isLightboxOpen && ( {isLightboxOpen && !imageError && (
<Lightbox <Lightbox
imagePadding={50} imagePadding={50}
animationDuration={200} animationDuration={200}
+27 -1
View File
@@ -94,6 +94,10 @@ const useCoverStyles = makeStyles({
width: '100%', width: '100%',
objectFit: 'contain', objectFit: 'contain',
height: (props) => props.height, height: (props) => props.height,
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
}, },
}) })
@@ -113,6 +117,8 @@ const Cover = withContentRect('bounds')(({
// Force height to be the same as the width determined by the GridList // Force height to be the same as the width determined by the GridList
// noinspection JSSuspiciousNameCombination // noinspection JSSuspiciousNameCombination
const classes = useCoverStyles({ height: contentRect.bounds.width }) const classes = useCoverStyles({ height: contentRect.bounds.width })
const [imageLoading, setImageLoading] = React.useState(true)
const [imageError, setImageError] = React.useState(false)
const [, dragAlbumRef] = useDrag( const [, dragAlbumRef] = useDrag(
() => ({ () => ({
type: DraggableTypes.ALBUM, type: DraggableTypes.ALBUM,
@@ -121,13 +127,33 @@ const Cover = withContentRect('bounds')(({
}), }),
[record], [record],
) )
// Reset image state when record changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
return ( return (
<div ref={measureRef}> <div ref={measureRef}>
<div ref={dragAlbumRef}> <div ref={dragAlbumRef}>
<img <img
key={record.id} // Force re-render when record changes
src={subsonic.getCoverArtUrl(record, 300, true)} src={subsonic.getCoverArtUrl(record, 300, true)}
alt={record.name} alt={record.name}
className={classes.cover} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}
/> />
</div> </div>
</div> </div>
+45 -4
View File
@@ -38,11 +38,22 @@ const useStyles = makeStyles(
height: '12rem', height: '12rem',
borderRadius: '6em', borderRadius: '6em',
cursor: 'pointer', cursor: 'pointer',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
},
coverLoading: {
opacity: 0.5,
}, },
artistImage: { artistImage: {
maxHeight: '12rem', maxHeight: '12rem',
minHeight: '12rem',
width: '12rem',
minWidth: '12rem',
backgroundColor: 'inherit', backgroundColor: 'inherit',
display: 'flex', display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'none', boxShadow: 'none',
}, },
artistDetail: { artistDetail: {
@@ -73,8 +84,31 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
const classes = useStyles() const classes = useStyles()
const title = record.name const title = record.name
const [isLightboxOpen, setLightboxOpen] = React.useState(false) const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [imageLoading, setImageLoading] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
// Reset image state when artist changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = React.useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback( const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false), () => setLightboxOpen(false),
[], [],
@@ -86,10 +120,17 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<Card className={classes.artistImage}> <Card className={classes.artistImage}>
{artistInfo && ( {artistInfo && (
<CardMedia <CardMedia
className={classes.cover} key={record.id}
image={subsonic.getCoverArtUrl(record, 300)} component="img"
src={subsonic.getCoverArtUrl(record, 300)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={title} title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/> />
)} )}
</Card> </Card>
@@ -140,7 +181,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
)} )}
</Typography> </Typography>
</div> </div>
{isLightboxOpen && ( {isLightboxOpen && !imageError && (
<Lightbox <Lightbox
imagePadding={50} imagePadding={50}
animationDuration={200} animationDuration={200}
+40 -4
View File
@@ -50,6 +50,12 @@ const useStyles = makeStyles(
width: 151, width: 151,
boxShadow: '0px 0px 6px 0px #565656', boxShadow: '0px 0px 6px 0px #565656',
borderRadius: '5px', borderRadius: '5px',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
},
coverLoading: {
opacity: 0.5,
}, },
artistImage: { artistImage: {
marginLeft: '1em', marginLeft: '1em',
@@ -81,8 +87,31 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
const classes = useStyles({ img, expanded }) const classes = useStyles({ img, expanded })
const title = record.name const title = record.name
const [isLightboxOpen, setLightboxOpen] = React.useState(false) const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [imageLoading, setImageLoading] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
// Reset image state when artist changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = React.useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback( const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false), () => setLightboxOpen(false),
[], [],
@@ -95,10 +124,17 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<Card className={classes.artistImage}> <Card className={classes.artistImage}>
{artistInfo && ( {artistInfo && (
<CardMedia <CardMedia
className={classes.cover} key={record.id}
image={subsonic.getCoverArtUrl(record, 300)} component="img"
src={subsonic.getCoverArtUrl(record, 300)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={title} title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/> />
)} )}
</Card> </Card>
@@ -136,7 +172,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
</Typography> </Typography>
</Collapse> </Collapse>
</div> </div>
{isLightboxOpen && ( {isLightboxOpen && !imageError && (
<Lightbox <Lightbox
imagePadding={50} imagePadding={50}
animationDuration={200} animationDuration={200}
+2 -8
View File
@@ -61,18 +61,12 @@ const getCoverArtUrl = (record, size, square) => {
...(square && { square }), ...(square && { square }),
} }
// For playlists, add a timestamp to prevent caching issues when switching between playlists // TODO Move this logic to server
if (record.songCount !== undefined) {
// Add current timestamp to ensure fresh requests for playlists
options._ = record.updatedAt || new Date().getTime()
}
// TODO Move this logic to server. `song` and `album` should have a CoverArtID
if (record.album) { if (record.album) {
return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) return baseUrl(url('getCoverArt', 'mf-' + record.id, options))
} else if (record.albumArtist) { } else if (record.albumArtist) {
return baseUrl(url('getCoverArt', 'al-' + record.id, options)) return baseUrl(url('getCoverArt', 'al-' + record.id, options))
} else if (record.songCount !== undefined) { } else if (record.sync !== undefined) {
// This is a playlist // This is a playlist
return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
} else { } else {
+18 -13
View File
@@ -23,10 +23,10 @@ describe('getCoverArtUrl', () => {
Object.defineProperty(window, 'localStorage', { value: localStorageMock }) Object.defineProperty(window, 'localStorage', { value: localStorageMock })
}) })
it('should return playlist cover art URL for records with songCount', () => { it('should return playlist cover art URL for records with sync property', () => {
const playlistRecord = { const playlistRecord = {
id: 'playlist-123', id: 'playlist-123',
songCount: 10, sync: true,
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
@@ -41,13 +41,15 @@ describe('getCoverArtUrl', () => {
it('should add timestamp for playlists without updatedAt', () => { it('should add timestamp for playlists without updatedAt', () => {
const playlistRecord = { const playlistRecord = {
id: 'playlist-123', id: 'playlist-123',
songCount: 5, sync: true,
} }
const url = subsonic.getCoverArtUrl(playlistRecord) const url = subsonic.getCoverArtUrl(playlistRecord, 300, true)
expect(url).toContain('pl-playlist-123') expect(url).toContain('pl-playlist-123')
expect(url).toMatch(/_=\d+/) expect(url).toContain('size=300')
expect(url).toContain('square=true')
expect(url).not.toContain('_=')
}) })
it('should return album cover art URL for records with albumArtist', () => { it('should return album cover art URL for records with albumArtist', () => {
@@ -57,23 +59,25 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(albumRecord, 300) const url = subsonic.getCoverArtUrl(albumRecord, 300, true)
expect(url).toContain('al-album-123') expect(url).toContain('al-album-123')
expect(url).toContain('size=300') expect(url).toContain('size=300')
expect(url).toContain('square=true')
}) })
it('should return media file cover art URL for records with album', () => { it('should return media file cover art URL for records with album', () => {
const mediaFileRecord = { const songRecord = {
id: 'mf-123', id: 'song-123',
album: 'Test Album', album: 'Test Album',
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(mediaFileRecord, 200) const url = subsonic.getCoverArtUrl(songRecord, 300, true)
expect(url).toContain('mf-mf-123') expect(url).toContain('mf-song-123')
expect(url).toContain('size=200') expect(url).toContain('size=300')
expect(url).toContain('square=true')
}) })
it('should return artist cover art URL for other records', () => { it('should return artist cover art URL for other records', () => {
@@ -82,10 +86,11 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(artistRecord, 150) const url = subsonic.getCoverArtUrl(artistRecord, 300, true)
expect(url).toContain('ar-artist-123') expect(url).toContain('ar-artist-123')
expect(url).toContain('size=150') expect(url).toContain('size=300')
expect(url).toContain('square=true')
}) })
it('should handle records without updatedAt', () => { it('should handle records without updatedAt', () => {