diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index cec66eb8b..de5869856 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -14,6 +14,7 @@ import { ChipField, Link, SingleFieldList, + useDataProvider, useRecordContext, useTranslate, } from 'react-admin' @@ -216,6 +217,9 @@ export const Details = (props) => { return <>{intersperse(details, ' · ')} } +// Bounded lightbox size: avoids transferring/caching multi-MB originals. +const GALLERY_IMAGE_SIZE = 1920 + const AlbumDetails = (props) => { const record = useRecordContext(props) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) @@ -233,6 +237,10 @@ const AlbumDetails = (props) => { handleCloseLightbox, } = useImageLoadingState(record.id) + const dataProvider = useDataProvider() + const [images, setImages] = useState([]) + const [photoIndex, setPhotoIndex] = useState(0) + let notes = albumInfo?.notes || record.notes if (notes) { @@ -255,7 +263,30 @@ const AlbumDetails = (props) => { }, [record]) const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) - const fullImageUrl = subsonic.getCoverArtUrl(record) + const fullImageUrl = subsonic.getCoverArtUrl(record, GALLERY_IMAGE_SIZE) + + const galleryCount = images.length || 1 + const imageSrcFor = (i) => + images.length + ? subsonic.getImageCoverArtUrl(images[i].coverArt, GALLERY_IMAGE_SIZE) + : fullImageUrl + + const openGallery = () => { + if (imageError) return + dataProvider + .getAlbumImages(record.id) + .then(({ data }) => setImages(Array.isArray(data) ? data : [])) + .catch(() => setImages([])) + .finally(() => { + setPhotoIndex(0) + handleOpenLightbox() + }) + } + + const closeGallery = () => { + handleCloseLightbox() + setPhotoIndex(0) + } return ( @@ -268,7 +299,7 @@ const AlbumDetails = (props) => { width="400" height="400" className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} + onClick={openGallery} onLoad={handleImageLoad} onError={handleImageError} title={record.name} @@ -367,9 +398,27 @@ const AlbumDetails = (props) => { 1 + ? imageSrcFor((photoIndex + 1) % galleryCount) + : undefined + } + prevSrc={ + galleryCount > 1 + ? imageSrcFor((photoIndex + galleryCount - 1) % galleryCount) + : undefined + } + onMoveNextRequest={() => setPhotoIndex((p) => (p + 1) % galleryCount)} + onMovePrevRequest={() => + setPhotoIndex((p) => (p + galleryCount - 1) % galleryCount) + } + onCloseRequest={closeGallery} /> )} diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 268d3668d..5dd065479 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -220,6 +220,11 @@ const wrapperDataProvider = { data: json, })) }, + getAlbumImages: (albumId) => { + return httpClient(`${REST_URL}/album/${albumId}/images`).then( + ({ json }) => ({ data: json }), + ) + }, } export default wrapperDataProvider diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..2c0976fa7 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -113,6 +113,12 @@ const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => { ) } +// Builds a getCoverArt URL from a ready-made coverArt id (from /album/{id}/images). +const getImageCoverArtUrl = (coverArtId, size) => { + const options = { ...(size && { size }) } + return baseUrl(url('getCoverArt', coverArtId, options)) +} + const getArtistInfo = (id) => { return httpClient(url('getArtistInfo', id)) } @@ -152,6 +158,7 @@ export default { getNowPlaying, getCoverArtUrl, getDiscCoverArtUrl, + getImageCoverArtUrl, getAvatarUrl, streamUrl, getAlbumInfo, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index ad4764c24..05e6e2dcd 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -172,6 +172,37 @@ describe('getDiscCoverArtUrl', () => { }) }) +describe('getImageCoverArtUrl', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('builds a getCoverArt URL from a fully-formed indexed coverArt id', () => { + const url = subsonic.getImageCoverArtUrl('al-album-123:1_0', 300) + + expect(url).toContain('getCoverArt') + expect(url).toContain('id=al-album-123%3A1_0') + expect(url).toContain('size=300') + }) + + it('omits size when not provided', () => { + const url = subsonic.getImageCoverArtUrl('al-album-123_0') + + expect(url).toContain('id=al-album-123_0') + expect(url).not.toContain('size=') + }) +}) + describe('getAvatarUrl', () => { beforeEach(() => { // Mock localStorage values required by subsonic