diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index cec66eb8b..c1dc7d7c5 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Card, CardContent, - CardMedia, Collapse, makeStyles, Typography, @@ -21,6 +20,7 @@ import Lightbox from 'react-image-lightbox' import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' +import { CoverImage } from '../common/CoverImage' import { ArtistLinkField, CollapsibleComment, @@ -30,7 +30,6 @@ import { RatingField, SizeField, useAlbumsPerPage, - useImageLoadingState, } from '../common' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' @@ -223,15 +222,7 @@ const AlbumDetails = (props) => { const classes = useStyles() const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) let notes = albumInfo?.notes || record.notes @@ -254,27 +245,17 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return (
- setLightboxOpen(true)} />
@@ -363,13 +344,13 @@ const AlbumDetails = (props) => {
)} - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( setLightboxOpen(false)} /> )} diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index dda761097..64f2c6b10 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -3,20 +3,15 @@ import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core' import Card from '@material-ui/core/Card' import CardContent from '@material-ui/core/CardContent' -import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { - LoveButton, - RatingField, - ImageUploadOverlay, - useImageLoadingState, -} from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import { CoverImage } from '../common/CoverImage' const useStyles = makeStyles( (theme) => ({ @@ -91,33 +86,18 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) return (
{artistInfo && ( - setLightboxOpen(true)} /> )} { )}
- {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( setLightboxOpen(false)} /> )} diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index e82d4c28a..1b3d68559 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -2,17 +2,12 @@ import React, { useState } from 'react' import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' -import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { - LoveButton, - RatingField, - ImageUploadOverlay, - useImageLoadingState, -} from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import { CoverImage } from '../common/CoverImage' const useStyles = makeStyles( (theme) => ({ @@ -93,15 +88,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) return ( <> @@ -109,18 +96,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
{artistInfo && ( - setLightboxOpen(true)} /> )} {
- {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( setLightboxOpen(false)} /> )} diff --git a/ui/src/common/BlurHashCanvas.jsx b/ui/src/common/BlurHashCanvas.jsx index 20c3aea48..554230385 100644 --- a/ui/src/common/BlurHashCanvas.jsx +++ b/ui/src/common/BlurHashCanvas.jsx @@ -5,7 +5,7 @@ import { decode } from 'blurhash' // A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas. const DECODE_SIZE = 32 -export const BlurHashCanvas = ({ hash, className }) => { +export const BlurHashCanvas = ({ hash, className, style }) => { const canvasRef = useRef(null) useEffect(() => { @@ -37,6 +37,7 @@ export const BlurHashCanvas = ({ hash, className }) => { width={DECODE_SIZE} height={DECODE_SIZE} className={className} + style={style} aria-hidden="true" /> ) @@ -45,4 +46,5 @@ export const BlurHashCanvas = ({ hash, className }) => { BlurHashCanvas.propTypes = { hash: PropTypes.string, className: PropTypes.string, + style: PropTypes.object, } diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index f70403774..81c0011ab 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -5,8 +5,15 @@ import clsx from 'clsx' import config from '../config' import subsonic from '../subsonic' import { useImageUrl } from './useImageUrl' +import { BlurHashCanvas } from './BlurHashCanvas' const useStyles = makeStyles({ + root: { + position: 'relative', + display: 'inline-flex', + width: '55px', + height: '55px', + }, avatar: { width: '55px', height: '55px', @@ -17,6 +24,16 @@ const useStyles = makeStyles({ square: { borderRadius: '4px', }, + circular: { + borderRadius: '50%', + }, + blur: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + }, }) export const CoverArtAvatar = ({ @@ -30,9 +47,10 @@ export const CoverArtAvatar = ({ const url = record ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square) : null - const { imgUrl } = useImageUrl(url) + const { imgUrl, loading } = useImageUrl(url) if (!record) return null - return ( + + const avatar = ( } ) + + // Show the blurhash behind the transparent avatar until the real image loads. + if (!(loading && record.blurHash)) return avatar + return ( +
+ + {avatar} +
+ ) } CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/CoverImage.jsx b/ui/src/common/CoverImage.jsx new file mode 100644 index 000000000..49f6ac58f --- /dev/null +++ b/ui/src/common/CoverImage.jsx @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types' +import clsx from 'clsx' +import { makeStyles } from '@material-ui/core/styles' +import config from '../config' +import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' +import { BlurHashCanvas } from './BlurHashCanvas' + +const useStyles = makeStyles({ + root: { position: 'relative', display: 'inline-flex' }, + blur: { position: 'absolute', top: 0, left: 0, zIndex: 0 }, + img: { position: 'relative', zIndex: 1 }, +}) + +// CoverImage renders an entity's cover through the shared useImageUrl blob cache, so it survives +// React remounts without re-fetching, with the blurhash as the loading placeholder. `className` +// supplies the size (and any transition); the fade opacity is applied here. +export const CoverImage = ({ + record, + size = config.uiCoverArtSize, + square = false, + className, + title, + onClick, +}) => { + const classes = useStyles() + const url = record ? subsonic.getCoverArtUrl(record, size, square) : '' + const { imgUrl, loading } = useImageUrl(url) + if (!record) return null + + const showBlurHash = loading && record.blurHash + const handleClick = imgUrl && onClick ? onClick : undefined + return ( +
+ {showBlurHash && ( + + )} + {title} +
+ ) +} + +CoverImage.propTypes = { + record: PropTypes.object, + size: PropTypes.number, + square: PropTypes.bool, + className: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, +} diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 894809ce0..692c0572d 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -1,7 +1,7 @@ +import { useState } from 'react' import { Card, CardContent, - CardMedia, Typography, useMediaQuery, } from '@material-ui/core' @@ -17,10 +17,9 @@ import { SizeField, isWritable, OverflowTooltip, - useImageLoadingState, } from '../common' -import config from '../config' import subsonic from '../subsonic' +import { CoverImage } from '../common/CoverImage' const useStyles = makeStyles( (theme) => ({ @@ -107,37 +106,20 @@ const PlaylistDetails = (props) => { const translate = useTranslate() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) - const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return (
- setLightboxOpen(true)} /> {isWritable(record.ownerId) && ( {
- {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( setLightboxOpen(false)} /> )}
diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index ccdb9f1ef..67d541c6b 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -22,6 +22,7 @@ import { ToggleFieldsMenu, useSelectedFields, } from '../common' +import { BlurHashCanvas } from '../common/BlurHashCanvas' import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' @@ -82,17 +83,34 @@ const RadioListActions = ({ } const avatarStyle = { width: 40, height: 40 } +const blurStyle = { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: '4px', +} const CoverArtField = ({ record }) => { const directUrl = record?.uploadedImage ? subsonic.getCoverArtUrl(record, 40, true) : null - const { imgUrl } = useImageUrl(directUrl) + const { imgUrl, loading } = useImageUrl(directUrl) if (!record) return null - const src = imgUrl || RADIO_PLACEHOLDER_IMAGE - return ( + const showBlurHash = loading && record.blurHash + // While the real image loads, prefer the blurhash over the generic radio icon. + const src = imgUrl || (showBlurHash ? undefined : RADIO_PLACEHOLDER_IMAGE) + const avatar = ( ) + if (!showBlurHash) return avatar + return ( +
+ + {avatar} +
+ ) } CoverArtField.defaultProps = { label: '' }