feat(ui): add cover art support for internet radio stations (#5229)
* feat(artwork): add KindRadioArtwork and EntityRadio constant * feat(model): add UploadedImage field and artwork methods to Radio * feat(model): add Radio to GetEntityByID lookup chain * feat(db): add uploaded_image column to radio table * feat(artwork): add radio artwork reader with uploaded image fallback * feat(api): add radio image upload/delete endpoints * feat(ui): add radio artwork ID prefix to getCoverArtUrl * feat(ui): add cover art display and upload to RadioEdit * feat(ui): add cover art thumbnails to radio list * feat(ui): prefer artwork URL in radio player helper * refactor: remove redundant code in radio artwork - Remove duplicate Avatar rendering in RadioList by reusing CoverArtField - Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put) * refactor(ui): extract shared useImageLoadingState hook Move image loading/error/lightbox state management into a shared useImageLoadingState hook in common/. Consolidates duplicated logic from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views. * feat(ui): use radio placeholder icon when no uploaded image Remove album placeholder fallback from radio artwork reader so radios without an uploaded image return ErrUnavailable. On the frontend, show the internet-radio-icon.svg placeholder instead of requesting server artwork when no image is uploaded, allowing favicon fallback in the player. * refactor(ui): update defaultOff fields in useSelectedFields for RadioList Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback - Add missing alt attribute to CardMedia in RadioEdit for accessibility - Fix UpdateInternetRadio to preserve UploadedImage field by fetching existing radio before updating (prevents Subsonic API from clearing custom artwork) - Add Reader() level tests to verify ErrUnavailable is returned when radio has no uploaded image * refactor: add colsToUpdate to RadioRepository.Put Use the base sqlRepository.put with column filtering instead of hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API fields, preventing UploadedImage from being cleared. Image upload/delete handlers specify only UploadedImage. * fix: ensure UpdatedAt is included in colsToUpdate for radio Put --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
RatingField,
|
||||
SizeField,
|
||||
useAlbumsPerPage,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
@@ -220,11 +221,17 @@ const AlbumDetails = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const classes = useStyles()
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [albumInfo, setAlbumInfo] = useState()
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
let notes = albumInfo?.notes || record.notes
|
||||
|
||||
@@ -247,33 +254,9 @@ const AlbumDetails = (props) => {
|
||||
})
|
||||
}, [record])
|
||||
|
||||
// Reset image state when album changes
|
||||
useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
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 (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
|
||||
@@ -6,13 +6,17 @@ 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 } from '../common'
|
||||
import {
|
||||
LoveButton,
|
||||
RatingField,
|
||||
ImageUploadOverlay,
|
||||
useImageLoadingState,
|
||||
} 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 useArtistImageState from './useArtistImageState'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useArtistImageState(record.id)
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
|
||||
@@ -4,11 +4,15 @@ 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 } from '../common'
|
||||
import {
|
||||
LoveButton,
|
||||
RatingField,
|
||||
ImageUploadOverlay,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
import useArtistImageState from './useArtistImageState'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useArtistImageState(record.id)
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -45,3 +45,4 @@ export * from './OverflowTooltip'
|
||||
export * from './useSearchRefocus'
|
||||
export * from './ImageUploadOverlay'
|
||||
export * from './CoverArtAvatar'
|
||||
export * from './useImageLoadingState'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Manages image loading/error state and lightbox open/close for artist detail views.
|
||||
* Resets when record.id changes.
|
||||
* Manages image loading/error state and lightbox open/close.
|
||||
* Resets when recordId changes.
|
||||
*/
|
||||
const useArtistImageState = (recordId) => {
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
export const useImageLoadingState = (recordId) => {
|
||||
const [imageLoading, setImageLoading] = useState(true)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
@@ -42,5 +42,3 @@ const useArtistImageState = (recordId) => {
|
||||
handleCloseLightbox,
|
||||
}
|
||||
}
|
||||
|
||||
export default useArtistImageState
|
||||
@@ -24,6 +24,8 @@ DraggableTypes.ALL.push(
|
||||
DraggableTypes.ARTIST,
|
||||
)
|
||||
|
||||
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
|
||||
|
||||
export const DEFAULT_SHARE_BITRATE = 128
|
||||
|
||||
export const BITRATE_CHOICES = [
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} 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 {
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
SizeField,
|
||||
isWritable,
|
||||
OverflowTooltip,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
@@ -96,37 +96,19 @@ const PlaylistDetails = (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 {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
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 (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
|
||||
@@ -6,8 +6,37 @@ import {
|
||||
TextInput,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { CardMedia } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { urlValidate } from '../utils/validations'
|
||||
import { Title } from '../common'
|
||||
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
coverParent: {
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
width: '8rem',
|
||||
height: '8rem',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
cover: {
|
||||
width: '8rem',
|
||||
height: '8rem',
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
placeholder: {
|
||||
width: '8rem',
|
||||
height: '8rem',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
})
|
||||
|
||||
const RadioTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
@@ -21,6 +50,7 @@ const RadioEdit = (props) => {
|
||||
return (
|
||||
<Edit title={<RadioTitle />} {...props}>
|
||||
<SimpleForm variant="outlined" {...props}>
|
||||
<RadioCoverArt />
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<TextInput
|
||||
type="url"
|
||||
@@ -41,4 +71,39 @@ const RadioEdit = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const RadioCoverArt = ({ record }) => {
|
||||
const classes = useStyles()
|
||||
const { imageLoading, handleImageLoad, handleImageError } =
|
||||
useImageLoadingState(record?.id)
|
||||
|
||||
if (!record) return null
|
||||
|
||||
return (
|
||||
<div className={classes.coverParent}>
|
||||
{record.uploadedImage ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, 300, true)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={record.name}
|
||||
alt={record.name}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={RADIO_PLACEHOLDER_IMAGE}
|
||||
className={classes.placeholder}
|
||||
alt={record.name}
|
||||
/>
|
||||
)}
|
||||
<ImageUploadOverlay
|
||||
entityType="radio"
|
||||
entityId={record.id}
|
||||
hasUploadedImage={!!record.uploadedImage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RadioEdit
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { makeStyles, useMediaQuery } from '@material-ui/core'
|
||||
import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core'
|
||||
import React, { cloneElement } from 'react'
|
||||
import {
|
||||
CreateButton,
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
} from 'react-admin'
|
||||
import { List } from '../common'
|
||||
import { ToggleFieldsMenu, useSelectedFields } from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import { StreamField } from './StreamField'
|
||||
import { setTrack } from '../actions'
|
||||
import { songFromRadio } from './helper'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
@@ -73,6 +75,19 @@ const RadioListActions = ({
|
||||
)
|
||||
}
|
||||
|
||||
const avatarStyle = { width: 40, height: 40 }
|
||||
|
||||
const CoverArtField = ({ record }) => {
|
||||
if (!record) return null
|
||||
const src = record.uploadedImage
|
||||
? subsonic.getCoverArtUrl(record, 40, true)
|
||||
: RADIO_PLACEHOLDER_IMAGE
|
||||
return (
|
||||
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
|
||||
)
|
||||
}
|
||||
CoverArtField.defaultProps = { label: '' }
|
||||
|
||||
const RadioList = ({ permissions, ...props }) => {
|
||||
const classes = useStyles()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
@@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => {
|
||||
const isAdmin = permissions === 'admin'
|
||||
|
||||
const toggleableFields = {
|
||||
coverArt: <CoverArtField source="id" sortable={false} />,
|
||||
name: <TextField source="name" />,
|
||||
homePageUrl: (
|
||||
<UrlField
|
||||
@@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => {
|
||||
const columns = useSelectedFields({
|
||||
resource: 'radio',
|
||||
columns: toggleableFields,
|
||||
defaultOff: ['createdAt'],
|
||||
defaultOff: ['streamUrl', 'createdAt'],
|
||||
})
|
||||
|
||||
const handleRowClick = async (id, basePath, record) => {
|
||||
@@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => {
|
||||
>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
leftAvatar={(r) => <CoverArtField record={r} />}
|
||||
leftIcon={(r) => (
|
||||
<StreamField
|
||||
record={r}
|
||||
|
||||
+16
-8
@@ -1,16 +1,24 @@
|
||||
import subsonic from '../subsonic'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
|
||||
export async function songFromRadio(radio) {
|
||||
if (!radio) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cover = 'internet-radio-icon.svg'
|
||||
try {
|
||||
const url = new URL(radio.homePageUrl ?? radio.streamUrl)
|
||||
url.pathname = '/favicon.ico'
|
||||
await resourceExists(url)
|
||||
cover = url.toString()
|
||||
} catch {
|
||||
// ignore
|
||||
let cover = RADIO_PLACEHOLDER_IMAGE
|
||||
if (radio.uploadedImage) {
|
||||
cover = subsonic.getCoverArtUrl(radio, 300, true)
|
||||
} else {
|
||||
// Try favicon as fallback
|
||||
try {
|
||||
const url = new URL(radio.homePageUrl ?? radio.streamUrl)
|
||||
url.pathname = '/favicon.ico'
|
||||
await resourceExists(url)
|
||||
cover = url.toString()
|
||||
} catch {
|
||||
// No cover available
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -86,6 +86,9 @@ const getCoverArtUrl = (record, size, square) => {
|
||||
} else if (record.sync !== undefined) {
|
||||
// This is a playlist
|
||||
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
|
||||
} else if (record.streamUrl !== undefined) {
|
||||
// This is a radio station
|
||||
return baseUrl(url('getCoverArt', 'ra-' + record.id, options))
|
||||
} else {
|
||||
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user