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