feat(ui): add smooth image transitions to album and artist artwork (#4120)
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user