diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index f8c06bba7..87ca11546 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -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 { 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 subsonic from '../subsonic' const useStyles = makeStyles( (theme) => ({ - container: { + root: { [theme.breakpoints.down('xs')]: { padding: '0.7em', - minWidth: '24em', + minWidth: '20em', }, [theme.breakpoints.up('sm')]: { padding: '1em', minWidth: '32em', }, }, + cardContents: { + display: 'flex', + }, details: { - display: 'inline-block', - verticalAlign: 'top', + display: 'flex', + flexDirection: 'column', + }, + content: { + flex: '2 0 auto', + }, + coverParent: { [theme.breakpoints.down('xs')]: { - width: '14em', + height: '8em', + width: '8em', + minWidth: '8em', }, [theme.breakpoints.up('sm')]: { - width: '26em', + height: '10em', + width: '10em', + minWidth: '10em', }, [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: { whiteSpace: 'nowrap', @@ -43,31 +83,95 @@ const PlaylistDetails = (props) => { const { record = {} } = props const translate = useTranslate() 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 ( - - - - {record.name || translate('ra.page.loading')} - - - {record.songCount ? ( - - {record.songCount}{' '} - {translate('resources.song.name', { - smart_count: record.songCount, - })} - {' · '} - - {' · '} - - - ) : ( -   - )} - - - + +
+
+ +
+
+ + + {record.name || translate('ra.page.loading')} + + + {record.songCount ? ( + + {record.songCount}{' '} + {translate('resources.song.name', { + smart_count: record.songCount, + })} + {' · '} + + {' · '} + + + ) : ( +   + )} + + + +
+
+ {isLightboxOpen && !imageError && ( + + )}
) } diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index ce5116bcb..12378dfc9 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -61,11 +61,20 @@ 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 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) { + // This is a playlist + return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) } else { return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js new file mode 100644 index 000000000..fac8d6dc2 --- /dev/null +++ b/ui/src/subsonic/index.test.js @@ -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('_=') + }) +})