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',
|
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user