feat(ui): add playlist cover art display

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-05-25 23:22:55 -04:00
parent 0cb02bce06
commit 5c4fbdb7c1
3 changed files with 245 additions and 31 deletions
+135 -31
View File
@@ -1,32 +1,72 @@
import { Card, CardContent, Typography } from '@material-ui/core' import {
Card,
CardContent,
CardMedia,
Typography,
useMediaQuery,
} from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useTranslate } from 'react-admin' import { useTranslate } from 'react-admin'
import { useCallback, useState, useEffect } from 'react'
import Lightbox from 'react-image-lightbox'
import 'react-image-lightbox/style.css'
import { CollapsibleComment, DurationField, SizeField } from '../common' import { CollapsibleComment, DurationField, SizeField } from '../common'
import subsonic from '../subsonic'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
container: { root: {
[theme.breakpoints.down('xs')]: { [theme.breakpoints.down('xs')]: {
padding: '0.7em', padding: '0.7em',
minWidth: '24em', minWidth: '20em',
}, },
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up('sm')]: {
padding: '1em', padding: '1em',
minWidth: '32em', minWidth: '32em',
}, },
}, },
cardContents: {
display: 'flex',
},
details: { details: {
display: 'inline-block', display: 'flex',
verticalAlign: 'top', flexDirection: 'column',
},
content: {
flex: '2 0 auto',
},
coverParent: {
[theme.breakpoints.down('xs')]: { [theme.breakpoints.down('xs')]: {
width: '14em', height: '8em',
width: '8em',
minWidth: '8em',
}, },
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up('sm')]: {
width: '26em', height: '10em',
width: '10em',
minWidth: '10em',
}, },
[theme.breakpoints.up('lg')]: { [theme.breakpoints.up('lg')]: {
width: '38em', height: '15em',
width: '15em',
minWidth: '15em',
}, },
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
cover: {
objectFit: 'contain',
cursor: 'pointer',
display: 'block',
width: '100%',
height: '100%',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
}, },
title: { title: {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -43,31 +83,95 @@ const PlaylistDetails = (props) => {
const { record = {} } = props const { record = {} } = props
const translate = useTranslate() const translate = useTranslate()
const classes = useStyles() const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const [isLightboxOpen, setLightboxOpen] = useState(false)
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
const fullImageUrl = subsonic.getCoverArtUrl(record)
// Reset image state when playlist changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
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 ( return (
<Card className={classes.container}> <Card className={classes.root}>
<CardContent className={classes.details}> <div className={classes.cardContents}>
<Typography variant="h5" className={classes.title}> <div className={classes.coverParent}>
{record.name || translate('ra.page.loading')} <CardMedia
</Typography> key={record.id} // Force re-render when playlist changes
<Typography component="p"> component={'img'}
{record.songCount ? ( src={imageUrl}
<span> width="400"
{record.songCount}{' '} height="400"
{translate('resources.song.name', { className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
smart_count: record.songCount, onClick={handleOpenLightbox}
})} onLoad={handleImageLoad}
{' · '} onError={handleImageError}
<DurationField record={record} source={'duration'} /> title={record.name}
{' · '} style={{
<SizeField record={record} source={'size'} /> cursor: imageError ? 'default' : 'pointer',
</span> }}
) : ( />
<span>&nbsp;</span> </div>
)} <div className={classes.details}>
</Typography> <CardContent className={classes.content}>
<CollapsibleComment record={record} /> <Typography
</CardContent> variant={isDesktop ? 'h5' : 'h6'}
className={classes.title}
>
{record.name || translate('ra.page.loading')}
</Typography>
<Typography component="p">
{record.songCount ? (
<span>
{record.songCount}{' '}
{translate('resources.song.name', {
smart_count: record.songCount,
})}
{' · '}
<DurationField record={record} source={'duration'} />
{' · '}
<SizeField record={record} source={'size'} />
</span>
) : (
<span>&nbsp;</span>
)}
</Typography>
<CollapsibleComment record={record} />
</CardContent>
</div>
</div>
{isLightboxOpen && !imageError && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={fullImageUrl}
onCloseRequest={handleCloseLightbox}
/>
)}
</Card> </Card>
) )
} }
+9
View File
@@ -61,11 +61,20 @@ const getCoverArtUrl = (record, size, square) => {
...(square && { 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. `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) {
// This is a playlist
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
} else { } else {
return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
} }
+101
View File
@@ -0,0 +1,101 @@
import { vi } from 'vitest'
import subsonic from './index'
describe('getCoverArtUrl', () => {
beforeEach(() => {
// Mock window.location
delete window.location
window.location = { href: 'http://localhost:3000/app' }
// Mock localStorage values required by subsonic
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
setItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
})
it('should return playlist cover art URL for records with songCount', () => {
const playlistRecord = {
id: 'playlist-123',
songCount: 10,
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(playlistRecord, 300, true)
expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=300')
expect(url).toContain('square=true')
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
})
it('should add timestamp for playlists without updatedAt', () => {
const playlistRecord = {
id: 'playlist-123',
songCount: 5,
}
const url = subsonic.getCoverArtUrl(playlistRecord)
expect(url).toContain('pl-playlist-123')
expect(url).toMatch(/_=\d+/)
})
it('should return album cover art URL for records with albumArtist', () => {
const albumRecord = {
id: 'album-123',
albumArtist: 'Test Artist',
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(albumRecord, 300)
expect(url).toContain('al-album-123')
expect(url).toContain('size=300')
})
it('should return media file cover art URL for records with album', () => {
const mediaFileRecord = {
id: 'mf-123',
album: 'Test Album',
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(mediaFileRecord, 200)
expect(url).toContain('mf-mf-123')
expect(url).toContain('size=200')
})
it('should return artist cover art URL for other records', () => {
const artistRecord = {
id: 'artist-123',
updatedAt: '2023-01-01T00:00:00Z',
}
const url = subsonic.getCoverArtUrl(artistRecord, 150)
expect(url).toContain('ar-artist-123')
expect(url).toContain('size=150')
})
it('should handle records without updatedAt', () => {
const record = {
id: 'test-123',
}
const url = subsonic.getCoverArtUrl(record)
expect(url).toContain('ar-test-123')
expect(url).not.toContain('_=')
})
})