diff --git a/consts/consts.go b/consts/consts.go index 38b277ea..6fb6c5da 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -108,6 +108,7 @@ const ( const ( EntityArtist = "artist" EntityPlaylist = "playlist" + EntityRadio = "radio" ) const ( diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 4a0a32af..b8c395c1 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newPlaylistArtworkReader(ctx, a, artID) case model.KindDiscArtwork: artReader, err = newDiscArtworkReader(ctx, a, artID) + case model.KindRadioArtwork: + artReader, err = newRadioArtworkReader(ctx, a, artID) default: return nil, ErrUnavailable } diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go new file mode 100644 index 00000000..22db6e30 --- /dev/null +++ b/core/artwork/reader_radio.go @@ -0,0 +1,40 @@ +package artwork + +import ( + "context" + "io" + "time" + + "github.com/navidrome/navidrome/model" +) + +type radioArtworkReader struct { + cacheKey + a *artwork + radio model.Radio +} + +func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { + r, err := artwork.ds.Radio(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &radioArtworkReader{a: artwork, radio: *r} + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = r.UpdatedAt + return a, nil +} + +func (a *radioArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + return selectImageReader(ctx, a.artID, + a.fromRadioUploadedImage(), + ) +} + +func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { + return fromLocalFile(a.radio.UploadedImagePath()) +} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go new file mode 100644 index 00000000..1f5bc908 --- /dev/null +++ b/core/artwork/reader_radio_test.go @@ -0,0 +1,84 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("radioArtworkReader", func() { + var ( + tempDir string + reader *radioArtworkReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) + + reader = &radioArtworkReader{} + }) + + Describe("fromRadioUploadedImage", func() { + When("radio has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.radio = model.Radio{ID: "rd-1"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("Reader", func() { + When("radio has an uploaded image", func() { + It("returns the image reader", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns ErrUnavailable", func() { + reader.radio = model.Radio{ID: "rd-1"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(r).To(BeNil()) + }) + }) + }) +}) diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go new file mode 100644 index 00000000..e92a6d2e --- /dev/null +++ b/db/migrations/20260318182414_add_radio_uploaded_image.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage) +} + +func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`) + return err +} + +func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/artwork_id.go b/model/artwork_id.go index 8d935f42..1bd146c1 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -23,6 +23,7 @@ var ( KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} KindDiscArtwork = Kind{"dc", "disc"} + KindRadioArtwork = Kind{"ra", "radio"} ) var artworkKindMap = map[string]Kind{ @@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{ KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindDiscArtwork.prefix: KindDiscArtwork, + KindRadioArtwork.prefix: KindRadioArtwork, } type ArtworkID struct { @@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID { ID: ar.ID, } } + +func artworkIDFromRadio(r Radio) ArtworkID { + return ArtworkID{ + Kind: KindRadioArtwork, + ID: r.ID, + LastUpdate: r.UpdatedAt, + } +} diff --git a/model/get_entity.go b/model/get_entity.go index 26f71839..60972b2e 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { if err == nil { return mf, nil } + r, err := ds.Radio(ctx).Get(id) + if err == nil { + return r, nil + } return nil, err } diff --git a/model/radio.go b/model/radio.go index 567d32e4..86f27c24 100644 --- a/model/radio.go +++ b/model/radio.go @@ -1,14 +1,27 @@ package model -import "time" +import ( + "time" + + "github.com/navidrome/navidrome/consts" +) type Radio struct { - ID string `structs:"id" json:"id"` - StreamUrl string `structs:"stream_url" json:"streamUrl"` - Name string `structs:"name" json:"name"` - HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ID string `structs:"id" json:"id"` + StreamUrl string `structs:"stream_url" json:"streamUrl"` + Name string `structs:"name" json:"name"` + HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +func (r Radio) CoverArtID() ArtworkID { + return artworkIDFromRadio(r) +} + +func (r Radio) UploadedImagePath() string { + return UploadedImagePath(consts.EntityRadio, r.UploadedImage) } type Radios []Radio @@ -19,5 +32,5 @@ type RadioRepository interface { Delete(id string) error Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) - Put(u *Radio) error + Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go new file mode 100644 index 00000000..dc421454 --- /dev/null +++ b/model/radio_test.go @@ -0,0 +1,42 @@ +package model_test + +import ( + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio", func() { + Describe("CoverArtID", func() { + It("returns a radio artwork ID", func() { + now := time.Now() + r := model.Radio{ID: "rd-1", UpdatedAt: now} + artID := r.CoverArtID() + Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) + Expect(artID.ID).To(Equal("rd-1")) + Expect(artID.LastUpdate).To(Equal(now)) + }) + }) + + Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = "/data" + }) + + It("returns empty string when no image uploaded", func() { + r := model.Radio{ID: "rd-1"} + Expect(r.UploadedImagePath()).To(BeEmpty()) + }) + + It("returns full path when image is set", func() { + r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg"))) + }) + }) +}) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 543b76c5..a073643d 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e return res, err } -func (r *radioRepository) Put(radio *model.Radio) error { +func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - var values map[string]any - radio.UpdatedAt = time.Now() - if radio.ID == "" { radio.CreatedAt = time.Now() radio.ID = id.NewRandom() - values, _ = toSQLArgs(*radio) - } else { - values, _ = toSQLArgs(*radio) - update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values) - count, err := r.executeSQL(update) - - if err != nil { - return err - } else if count > 0 { - return nil - } } - - values["created_at"] = time.Now() - insert := Insert(r.tableName).SetMap(values) - _, err := r.executeSQL(insert) + if len(colsToUpdate) > 0 { + colsToUpdate = append(colsToUpdate, "UpdatedAt") + } + _, err := r.put(radio.ID, radio, colsToUpdate...) return err } diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3ef00ebb..669c4d7b 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler { api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) - api.R(r, "/radio", model.Radio{}, true) + api.addRadioRoute(r) api.R(r, "/tag", model.Tag{}, true) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go new file mode 100644 index 00000000..701c6c92 --- /dev/null +++ b/server/nativeapi/radios.go @@ -0,0 +1,70 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addRadioRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Radio{}) + } + r.Route("/radio", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + r.Post("/image", api.uploadRadioImage()) + r.Delete("/image", api.deleteRadioImage()) + }) + }) +} + +func (api *Router) uploadRadioImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + radioID := chi.URLParamFromCtx(ctx, "id") + radio, err := api.ds.Radio(ctx).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath := radio.UploadedImagePath() + filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext) + if err != nil { + return err + } + radio.UploadedImage = filename + return api.ds.Radio(ctx).Put(radio, "UploadedImage") + }) +} + +func (api *Router) deleteRadioImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + radioID := chi.URLParamFromCtx(ctx, "id") + radio, err := api.ds.Radio(ctx).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil { + return err + } + radio.UploadedImage = "" + return api.ds.Radio(ctx).Put(radio, "UploadedImage") + }) +} diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 9f2cd48f..c6626834 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -103,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio(ctx).Put(radio) + err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name") if err != nil { return nil, err } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 279b735d..c50a529e 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -73,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) Put(radio *model.Radio) error { +func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index bd6a4152..c5d9a7ac 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -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 (
diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index da8d0601..7052b163 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -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 (
diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 3add1e99..03cc4de8 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -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 ( <> diff --git a/ui/src/common/index.js b/ui/src/common/index.js index b93e4021..a7d6a43c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -45,3 +45,4 @@ export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' +export * from './useImageLoadingState' diff --git a/ui/src/artist/useArtistImageState.js b/ui/src/common/useImageLoadingState.js similarity index 76% rename from ui/src/artist/useArtistImageState.js rename to ui/src/common/useImageLoadingState.js index bd7e4ad9..3528b0f3 100644 --- a/ui/src/artist/useArtistImageState.js +++ b/ui/src/common/useImageLoadingState.js @@ -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 diff --git a/ui/src/consts.js b/ui/src/consts.js index 30731a08..472cd494 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -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 = [ diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index a2d5e753..911e6c71 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -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 (
diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index f00f889f..6b1d2df7 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -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 ( } {...props}> + { ) } +const RadioCoverArt = ({ record }) => { + const classes = useStyles() + const { imageLoading, handleImageLoad, handleImageError } = + useImageLoadingState(record?.id) + + if (!record) return null + + return ( +
+ {record.uploadedImage ? ( + + ) : ( + {record.name} + )} + +
+ ) +} + export default RadioEdit diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 3d1adacc..582fcaff 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -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 ( + + ) +} +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: , name: , homePageUrl: ( { 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 ? ( } leftIcon={(r) => ( { } 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)) }