From db06e7f601bfb5ec6ce749e4ae96d7860130fa82 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 2 Apr 2026 18:26:26 -0700 Subject: [PATCH] add native nd radio endpoints, support radio station images --- src/i18n/locales/en.json | 3 + src/renderer/api/controller.ts | 28 ++ src/renderer/api/navidrome/navidrome-api.ts | 45 +++ .../api/navidrome/navidrome-controller.ts | 123 ++++++- .../player/components/left-controls.tsx | 24 +- .../components/edit-radio-station-form.tsx | 317 ++++++++++++++---- .../components/radio-list-items.module.css | 44 +++ .../radio/components/radio-list-items.tsx | 56 ++-- .../features/radio/hooks/use-radio-player.ts | 36 +- ...e-internet-radio-station-image-mutation.ts | 40 +++ ...d-internet-radio-station-image-mutation.ts | 40 +++ .../features/sidebar/components/sidebar.tsx | 14 +- .../api/navidrome/navidrome-normalize.ts | 19 ++ src/shared/api/navidrome/navidrome-types.ts | 44 +++ src/shared/api/subsonic/subsonic-normalize.ts | 2 + src/shared/api/subsonic/subsonic-types.ts | 1 + src/shared/types/domain-types.ts | 42 ++- src/shared/types/features-types.ts | 1 + 18 files changed, 789 insertions(+), 90 deletions(-) create mode 100644 src/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation.ts create mode 100644 src/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation.ts diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b82c4d431..f9fd80daa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -364,6 +364,9 @@ "input_name": "name", "input_streamUrl": "stream url" }, + "editRadioStation": { + "success": "radio station updated successfully" + }, "deletePlaylist": { "input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm", "success": "$t(entity.playlist, {\"count\": 1}) deleted successfully", diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 6262ef233..4be8280ce 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -175,6 +175,20 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + deleteInternetRadioStationImage(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`, + ); + } + + return apiController( + 'deleteInternetRadioStationImage', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, deletePlaylist(args) { const server = getServerById(args.apiClientProps.serverId); @@ -974,6 +988,20 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + uploadInternetRadioStationImage(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`, + ); + } + + return apiController( + 'uploadInternetRadioStationImage', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, uploadPlaylistImage(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 73904c524..6497fbc9d 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -46,6 +46,24 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + deleteInternetRadioStation: { + body: null, + method: 'DELETE', + path: 'radio/:id', + responses: { + 200: resultWithHeaders(ndType._response.deleteInternetRadioStation), + 500: resultWithHeaders(ndType._response.error), + }, + }, + deleteInternetRadioStationImage: { + body: null, + method: 'DELETE', + path: 'radio/:id/image', + responses: { + 200: resultWithHeaders(ndType._response.deleteInternetRadioStationImage), + 500: resultWithHeaders(ndType._response.error), + }, + }, deletePlaylist: { body: null, method: 'DELETE', @@ -141,6 +159,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + getRadioList: { + method: 'GET', + path: 'radio', + query: ndType._parameters.radioList, + responses: { + 200: resultWithHeaders(ndType._response.radioList), + 500: resultWithHeaders(ndType._response.error), + }, + }, getSongDetail: { method: 'GET', path: 'song/:id', @@ -214,6 +241,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + updateInternetRadioStation: { + body: ndType._parameters.updateInternetRadioStation, + method: 'PUT', + path: 'radio/:id', + responses: { + 200: resultWithHeaders(ndType._response.updateInternetRadioStation), + 500: resultWithHeaders(ndType._response.error), + }, + }, updatePlaylist: { body: ndType._parameters.updatePlaylist, method: 'PUT', @@ -223,6 +259,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + uploadInternetRadioStationImage: { + body: ndType._parameters.uploadInternetRadioStationImage, + method: 'POST', + path: 'radio/:id/image', + responses: { + 200: resultWithHeaders(ndType._response.uploadInternetRadioStationImage), + 500: resultWithHeaders(ndType._response.error), + }, + }, uploadPlaylistImage: { body: ndType._parameters.uploadPlaylistImage, method: 'POST', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 6ff7df6bd..e3e09a0f8 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -6,13 +6,15 @@ import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize'; -import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types'; +import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils'; import { albumArtistListSortMap, albumListSortMap, AuthenticationResponse, + DeleteInternetRadioStationImageArgs, + DeleteInternetRadioStationImageResponse, DeletePlaylistImageArgs, DeletePlaylistImageResponse, genreListSortMap, @@ -26,6 +28,8 @@ import { SortOrder, sortOrderMap, tagListSortMap, + UploadInternetRadioStationImageArgs, + UploadInternetRadioStationImageResponse, UploadPlaylistImageArgs, UploadPlaylistImageResponse, userListSortMap, @@ -35,7 +39,13 @@ import { ServerFeature } from '/@/shared/types/features-types'; const VERSION_INFO: VersionInfo = [ // Why 2? Subsonic controller will return 1 for its own implementation // Use 2 to denote that Navidrome's own API has a different endpoint - ['0.61.0', { [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1] }], + [ + '0.61.0', + { + [ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1], + [ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1], + }, + ], ['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }], ['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }], ['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }], @@ -177,7 +187,38 @@ export const NavidromeController: InternalControllerEndpoint = { }; }, deleteFavorite: SubsonicController.deleteFavorite, - deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation, + deleteInternetRadioStation: async (args) => { + const { apiClientProps, query } = args; + + const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete internet radio station'); + } + + return null; + }, + deleteInternetRadioStationImage: async ( + args: DeleteInternetRadioStationImageArgs, + ): Promise => { + const { apiClientProps, query } = args; + + const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete internet radio station image'); + } + + return res.body.data.status === 'ok'; + }, deletePlaylist: async (args) => { const { apiClientProps, query } = args; @@ -570,7 +611,24 @@ export const NavidromeController: InternalControllerEndpoint = { }, getImageRequest: SubsonicController.getImageRequest, getImageUrl: SubsonicController.getImageUrl, - getInternetRadioStations: SubsonicController.getInternetRadioStations, + getInternetRadioStations: async (args) => { + const { apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getRadioList({ + query: { + _end: -1, + _order: 'ASC', + _sort: NDRadioListSort.NAME, + _start: 0, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get internet radio stations'); + } + + return res.body.data.map((station) => ndNormalize.internetRadioStation(station)); + }, getLyrics: SubsonicController.getLyrics, getMusicFolderList: SubsonicController.getMusicFolderList, getPlaylistDetail: async (args) => { @@ -1168,7 +1226,26 @@ export const NavidromeController: InternalControllerEndpoint = { id: res.body.data.id, }; }, - updateInternetRadioStation: SubsonicController.updateInternetRadioStation, + updateInternetRadioStation: async (args) => { + const { apiClientProps, body, query } = args; + + const res = await ndApiClient(apiClientProps).updateInternetRadioStation({ + body: { + homePageUrl: body.homepageUrl ?? '', + name: body.name, + streamUrl: body.streamUrl, + }, + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to update internet radio station'); + } + + return null; + }, updatePlaylist: async (args) => { const { apiClientProps, body, query } = args; @@ -1193,6 +1270,42 @@ export const NavidromeController: InternalControllerEndpoint = { return null; }, + uploadInternetRadioStationImage: async ( + args: UploadInternetRadioStationImageArgs, + ): Promise => { + const { apiClientProps, body, query } = args; + + const server = apiClientProps.server; + const serverUrl = server?.url?.replace(/\/$/, ''); + + if (!serverUrl) { + throw new Error('Server is required'); + } + + const form = new FormData(); + const bytes = body.image as Uint8Array; + const fileLike = + typeof File !== 'undefined' + ? new File([bytes], 'image', { type: 'application/octet-stream' }) + : new Blob([bytes], { type: 'application/octet-stream' }); + form.append('image', fileLike as any); + + const res = await axios.post(`${serverUrl}/api/radio/${query.id}/image`, form, { + headers: { + 'Content-Type': 'multipart/form-data', + ...(server?.ndCredential && { + 'x-nd-authorization': `Bearer ${server.ndCredential}`, + }), + }, + signal: apiClientProps.signal, + }); + + if (res.status !== 200) { + throw new Error('Failed to upload internet radio station image'); + } + + return res.data?.status === 'ok'; + }, uploadPlaylistImage: async ( args: UploadPlaylistImageArgs, ): Promise => { diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index 9dac999c3..0e5d9c2e8 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -11,7 +11,10 @@ import { ItemImage } from '/@/renderer/components/item-image/item-image'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display'; -import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player'; +import { + useIsRadioActive, + useRadioPlayer, +} from '/@/renderer/features/radio/hooks/use-radio-player'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStore, @@ -50,9 +53,11 @@ export const LeftControls = () => { const currentSong = usePlayerSong(); const isRadioActive = useIsRadioActive(); + const { currentStationArt } = useRadioPlayer(); const { bindings } = useHotkeySettings(); const isRadioMode = isRadioActive; + const hasRadioStationImage = Boolean(currentStationArt?.imageId || currentStationArt?.imageUrl); const hideImage = image && !collapsed; const isSongDefined = Boolean(currentSong?.id) && !isRadioMode; const title = currentSong?.name; @@ -128,7 +133,22 @@ export const LeftControls = () => { })} openDelay={0} > - {isRadioMode ? ( + {isRadioMode && hasRadioStationImage ? ( + + ) : isRadioMode ? (
void; station: InternetRadioStation; } +type RadioStationImageProps = { + imageId: null | string; + imageUrl: null | string; + uploadedImage?: string; +}; + export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationFormProps) => { const { t } = useTranslation(); - const mutation = useUpdateRadioStation({}); + const updateMutation = useUpdateRadioStation({}); + const uploadImageMutation = useUploadInternetRadioStationImage({}); + const deleteImageMutation = useDeleteInternetRadioStationImage({}); const server = useCurrentServer(); + const isCoverImageDisplayed = hasFeature(server, ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD); + + const stationImage: RadioStationImageProps = { + imageId: station.imageId ?? null, + imageUrl: station.imageUrl ?? null, + uploadedImage: station.uploadedImage ?? undefined, + }; + + const [pendingFile, setPendingFile] = useState(null); + const [pendingPreviewUrl, setPendingPreviewUrl] = useState(null); + const [removeCustomCover, setRemoveCustomCover] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!pendingFile) { + setPendingPreviewUrl(null); + return; + } + const url = URL.createObjectURL(pendingFile); + setPendingPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [pendingFile]); const form = useForm({ initialValues: { @@ -37,74 +77,234 @@ export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationForm }, }); - const handleSubmit = form.onSubmit((values) => { - if (!server) return; + const handleSubmit = form.onSubmit(async (values) => { + if (!server?.id) return; - mutation.mutate( - { + setIsSaving(true); + try { + await updateMutation.mutateAsync({ apiClientProps: { serverId: server.id }, body: values, query: { id: station.id }, - }, - { - onError: (error) => { - logFn.error(logMsg.other.error, { - meta: { error: error as Error }, - }); + }); - toast.error({ - message: (error as Error).message, - title: t('error.genericError', { - postProcess: 'sentenceCase', - }) as string, - }); - }, - onSuccess: () => { - closeAllModals(); - }, - }, - ); + if (pendingFile) { + const buffer = await pendingFile.arrayBuffer(); + await uploadImageMutation.mutateAsync({ + apiClientProps: { serverId: server.id }, + body: { image: new Uint8Array(buffer) }, + query: { id: station.id }, + }); + } else if (removeCustomCover && stationImage.uploadedImage) { + await deleteImageMutation.mutateAsync({ + apiClientProps: { serverId: server.id }, + query: { id: station.id }, + }); + } + + toast.success({ + message: t('form.editRadioStation.success', { + postProcess: 'sentenceCase', + }) as string, + }); + closeAllModals(); + } catch (err: unknown) { + logFn.error(logMsg.other.error, { + meta: { error: err as Error }, + }); + + toast.error({ + message: (err as Error)?.message, + title: t('error.genericError', { postProcess: 'sentenceCase' }) as string, + }); + } finally { + setIsSaving(false); + } }); + const isSubmitDisabled = !form.values.name || !form.values.streamUrl || isSaving; + const hadUploadedCover = !!stationImage.uploadedImage; + + const fieldNodes: ReactNode[] = [ + , + , + , + + + {t('common.cancel')} + + + {t('common.save')} + + , + ]; + return (
- - - - - - - {t('common.cancel', { postProcess: 'sentenceCase' })} - - - {t('common.save', { postProcess: 'sentenceCase' })} - - - + {isCoverImageDisplayed && server?.id ? ( + + setPendingFile(null)} + onFileSelect={(file) => { + if (!file) return; + setRemoveCustomCover(false); + setPendingFile(file); + }} + onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)} + pendingFile={pendingFile} + pendingPreviewUrl={pendingPreviewUrl} + removeCustomCover={removeCustomCover} + stationImage={stationImage} + /> + + {fieldNodes} + + + ) : ( + {fieldNodes} + )}
); }; +const COVER_SIZE = 240; + +function RadioStationCoverField({ + hadUploadedCover, + onClearPending, + onFileSelect, + onToggleRemoveCover, + pendingFile, + pendingPreviewUrl, + removeCustomCover, + stationImage, +}: { + hadUploadedCover: boolean; + onClearPending: () => void; + onFileSelect: (file: File | null) => void; + onToggleRemoveCover: () => void; + pendingFile: File | null; + pendingPreviewUrl: null | string; + removeCustomCover: boolean; + stationImage: RadioStationImageProps; +}) { + const server = useCurrentServer(); + + const showServerCover = !pendingPreviewUrl && !removeCustomCover; + const previewId = showServerCover ? stationImage.imageId || undefined : undefined; + const previewSrc = pendingPreviewUrl || (showServerCover ? stationImage.imageUrl || '' : ''); + + const secondaryAction = () => { + if (pendingFile) { + onClearPending(); + return; + } + if (hadUploadedCover) { + onToggleRemoveCover(); + } + }; + + const secondaryDisabled = !pendingFile && !hadUploadedCover; + + const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete'; + + const iconControls = ( + <> + + {(props) => ( + + )} + + + + ); + + const coverArt = ( + + ); + + return ( + + {coverArt} + + {iconControls} + + + ); +} + export const openEditRadioStationModal = ( station: InternetRadioStation, server: null | ServerListItem, @@ -119,8 +319,11 @@ export const openEditRadioStationModal = ( return; } + const hasImageUpload = hasFeature(server, ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD); + openModal({ children: , + size: hasImageUpload ? 'lg' : 'md', title: t('common.edit', { postProcess: 'titleCase' }) as string, }); }; diff --git a/src/renderer/features/radio/components/radio-list-items.module.css b/src/renderer/features/radio/components/radio-list-items.module.css index cd7bb1ea2..b273170ef 100644 --- a/src/renderer/features/radio/components/radio-list-items.module.css +++ b/src/renderer/features/radio/components/radio-list-items.module.css @@ -20,11 +20,55 @@ .radio-item-button { all: unset; + box-sizing: border-box; + display: block; flex: 1; width: 100%; + min-width: 0; + text-align: left; + cursor: pointer; +} + +.thumbnail { + flex-shrink: 0; + width: 3rem; + height: 3rem; + overflow: hidden; + border-radius: var(--mantine-radius-md); +} + +.image-container { + width: 3rem; + height: 3rem; +} + +.meta { + flex: 1; + min-width: 0; +} + +.meta-line { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .radio-item-link { color: inherit; text-decoration: underline; } + +.radio-item-actions { + flex-shrink: 0; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.radio-item:hover .radio-item-actions, +.radio-item:focus-within .radio-item-actions { + pointer-events: auto; + opacity: 1; +} diff --git a/src/renderer/features/radio/components/radio-list-items.tsx b/src/renderer/features/radio/components/radio-list-items.tsx index 5999925cc..c984fed5a 100644 --- a/src/renderer/features/radio/components/radio-list-items.tsx +++ b/src/renderer/features/radio/components/radio-list-items.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import styles from './radio-list-items.module.css'; +import { ItemImage } from '/@/renderer/components/item-image/item-image'; import { openEditRadioStationModal } from '/@/renderer/features/radio/components/edit-radio-station-form'; import { useRadioControls, @@ -12,15 +13,15 @@ import { import { useDeleteRadioStation } from '/@/renderer/features/radio/mutations/delete-radio-station-mutation'; import { useCurrentServer, usePermissions } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Box } from '/@/shared/components/box/box'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; -import { Icon } from '/@/shared/components/icon/icon'; import { closeAllModals, ConfirmModal, openModal } from '/@/shared/components/modal/modal'; import { Paper } from '/@/shared/components/paper/paper'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; -import { InternetRadioStation } from '/@/shared/types/domain-types'; +import { InternetRadioStation, LibraryItem } from '/@/shared/types/domain-types'; interface RadioListItemProps { station: InternetRadioStation; @@ -44,8 +45,13 @@ const RadioListItem = ({ station }: RadioListItemProps) => { const handleClick = () => { if (stationIsPlaying) { stop(); - } else { - play(station.streamUrl, station.name); + } else if (server?.id) { + play(station.streamUrl, station.name, { + id: station.id, + imageId: station.imageId, + imageUrl: station.imageUrl, + serverId: server.id, + }); } }; @@ -107,27 +113,39 @@ const RadioListItem = ({ station }: RadioListItemProps) => { })} p="md" > - - {(permissions.radio.edit || permissions.radio.delete) && ( - + {permissions.radio.edit && ( void; - play: (streamUrl?: string, stationName?: string) => void; + play: ( + streamUrl?: string, + stationName?: string, + stationArt?: null | RadioCurrentStationArt, + ) => void; setCurrentStreamUrl: (currentStreamUrl: null | string) => void; setIsPlaying: (isPlaying: boolean) => void; setMetadata: (metadata: null | RadioMetadata) => void; setStationName: (stationName: null | string) => void; stop: () => void; }; + currentStationArt: null | RadioCurrentStationArt; currentStreamUrl: null | string; isPlaying: boolean; metadata: null | RadioMetadata; @@ -34,7 +46,11 @@ export const useRadioStore = createWithEqualityFn((set) => ({ set({ isPlaying: false }); usePlayerStoreBase.getState().mediaPause(); }, - play: (streamUrl?: string, stationName?: string) => { + play: ( + streamUrl?: string, + stationName?: string, + stationArt?: null | RadioCurrentStationArt, + ) => { set((state) => { const newStreamUrl = streamUrl ?? state.currentStreamUrl; const newStationName = stationName ?? state.stationName; @@ -43,12 +59,19 @@ export const useRadioStore = createWithEqualityFn((set) => ({ return state; } - // Reset metadata when switching stations (streamUrl changes) - const isSwitchingStation = newStreamUrl !== state.currentStreamUrl; + const streamUrlExplicit = streamUrl !== undefined; + const isSwitchingStation = + streamUrlExplicit && streamUrl !== state.currentStreamUrl; + + let nextStationArt = state.currentStationArt; + if (isSwitchingStation) { + nextStationArt = stationArt ?? null; + } usePlayerStoreBase.getState().mediaPlay(); return { + currentStationArt: nextStationArt, currentStreamUrl: newStreamUrl, isPlaying: true, metadata: isSwitchingStation ? null : state.metadata, @@ -64,6 +87,7 @@ export const useRadioStore = createWithEqualityFn((set) => ({ const playbackType = useSettingsStore.getState().playback.type; set({ + currentStationArt: null, currentStreamUrl: null, isPlaying: false, metadata: null, @@ -79,6 +103,7 @@ export const useRadioStore = createWithEqualityFn((set) => ({ } }, }, + currentStationArt: null, currentStreamUrl: null, isPlaying: false, metadata: null, @@ -90,12 +115,14 @@ export const useIsPlayingRadio = () => useRadioStore((state) => state.isPlaying) export const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl)); export const useRadioPlayer = () => { + const currentStationArt = useRadioStore((state) => state.currentStationArt); const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl); const isPlaying = useRadioStore((state) => state.isPlaying); const metadata = useRadioStore((state) => state.metadata); const stationName = useRadioStore((state) => state.stationName); return { + currentStationArt, currentStreamUrl, isPlaying, metadata, @@ -163,6 +190,7 @@ export const useRadioAudioInstance = () => { setIsPlaying(false); setCurrentStreamUrl(null); setStationName(null); + useRadioStore.setState({ currentStationArt: null, metadata: null }); }; mpvPlayerListener.rendererPlay(handleMpvPlay); diff --git a/src/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation.ts b/src/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation.ts new file mode 100644 index 000000000..452c2480d --- /dev/null +++ b/src/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { MutationHookArgs } from '/@/renderer/lib/react-query'; +import { + DeleteInternetRadioStationImageArgs, + DeleteInternetRadioStationImageResponse, +} from '/@/shared/types/domain-types'; + +export const useDeleteInternetRadioStationImage = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation< + DeleteInternetRadioStationImageResponse, + AxiosError, + DeleteInternetRadioStationImageArgs, + null + >({ + mutationFn: (args) => { + return api.controller.deleteInternetRadioStationImage({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_data, variables) => { + const { apiClientProps } = variables; + const serverId = apiClientProps.serverId; + + if (!serverId) return; + + queryClient.invalidateQueries({ + queryKey: queryKeys.radio.list(serverId), + }); + }, + ...options, + }); +}; diff --git a/src/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation.ts b/src/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation.ts new file mode 100644 index 000000000..a8b24277e --- /dev/null +++ b/src/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { MutationHookArgs } from '/@/renderer/lib/react-query'; +import { + UploadInternetRadioStationImageArgs, + UploadInternetRadioStationImageResponse, +} from '/@/shared/types/domain-types'; + +export const useUploadInternetRadioStationImage = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation< + UploadInternetRadioStationImageResponse, + AxiosError, + UploadInternetRadioStationImageArgs, + null + >({ + mutationFn: (args) => { + return api.controller.uploadInternetRadioStationImage({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_data, variables) => { + const { apiClientProps } = variables; + const serverId = apiClientProps.serverId; + + if (!serverId) return; + + queryClient.invalidateQueries({ + queryKey: queryKeys.radio.list(serverId), + }); + }, + ...options, + }); +}; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 872ab24a4..733c7e74a 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -167,7 +167,7 @@ const SidebarImage = () => { const { setSideBar } = useAppStoreActions(); const currentSong = usePlayerSong(); const isRadioActive = useIsRadioActive(); - const { isPlaying: isRadioPlaying } = useRadioPlayer(); + const { currentStationArt, isPlaying: isRadioPlaying } = useRadioPlayer(); const { blurExplicitImages } = useGeneralSettings(); const imageUrl = useItemImageUrl({ @@ -177,6 +177,14 @@ const SidebarImage = () => { type: 'sidebar', }); + const radioImageUrl = useItemImageUrl({ + id: isRadioActive ? currentStationArt?.imageId || undefined : undefined, + imageUrl: isRadioActive ? currentStationArt?.imageUrl || undefined : undefined, + itemType: LibraryItem.RADIO_STATION, + serverId: isRadioActive ? currentStationArt?.serverId : undefined, + type: 'sidebar', + }); + const isPlayingRadio = isRadioActive && isRadioPlaying; const isSongDefined = Boolean(currentSong?.id); @@ -224,7 +232,9 @@ const SidebarImage = () => { postProcess: 'sentenceCase', })} > - {isPlayingRadio ? ( + {isRadioActive && radioImageUrl ? ( + + ) : isRadioActive ? (
): User => { }; }; +const normalizeInternetRadioStation = ( + item: z.infer, +): InternetRadioStation => { + const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null; + const imageId = item.uploadedImage ? `ra-${item.id}&square=true&_=${item.updatedAt}` : item.id; + + return { + homepageUrl, + id: item.id, + imageId, + imageUrl: null, + name: item.name, + streamUrl: item.streamUrl, + uploadedImage: item.uploadedImage || null, + }; +}; + export const ndNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, genre: normalizeGenre, + internetRadioStation: normalizeInternetRadioStation, playlist: normalizePlaylist, song: normalizeSong, user: normalizeUser, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index 6b4c38641..2d13bd5eb 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -660,6 +660,12 @@ const updatePlaylist = playlist; const updatePlaylistParameters = createPlaylistParameters.partial(); +const updateInternetRadioStationParameters = z.object({ + homePageUrl: z.string().optional(), + name: z.string(), + streamUrl: z.string(), +}); + const uploadPlaylistImage = z.object({ status: z.string(), }); @@ -672,8 +678,14 @@ const deletePlaylistImage = z.object({ status: z.string(), }); +const uploadInternetRadioStationImage = uploadPlaylistImage; +const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters; +const deleteInternetRadioStationImage = deletePlaylistImage; + const deletePlaylist = z.null(); +const deleteInternetRadioStation = deletePlaylist; + const addToPlaylist = z.object({ added: z.number(), }); @@ -748,12 +760,35 @@ const queue = z.object({ userId: z.string(), }); +export enum NDRadioListSort { + NAME = 'name', +} + +const radioStation = z.object({ + createdAt: z.string(), + homePageUrl: z.string().optional(), + id: z.string(), + name: z.string(), + streamUrl: z.string(), + updatedAt: z.string(), + uploadedImage: z.string().optional(), +}); + +const radioList = z.array(radioStation); + +const updateInternetRadioStation = radioStation; + +const radioListParameters = optionalPaginationParameters.extend({ + _sort: z.nativeEnum(NDRadioListSort).optional(), +}); + export const ndType = { _enum: { albumArtistList: NDAlbumArtistListSort, albumList: NDAlbumListSort, genreList: genreListSort, playlistList: NDPlaylistListSort, + radioList: NDRadioListSort, songList: NDSongListSort, tagList: NDTagListSort, userList: ndUserListSort, @@ -767,12 +802,15 @@ export const ndType = { genreList: genreListParameters, moveItem: moveItemParameters, playlistList: playlistListParameters, + radioList: radioListParameters, removeFromPlaylist: removeFromPlaylistParameters, saveQueue: saveQueueParameters, shareItem: shareItemParameters, songList: songListParameters, tagList: tagListParameters, + updateInternetRadioStation: updateInternetRadioStationParameters, updatePlaylist: updatePlaylistParameters, + uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters, uploadPlaylistImage: uploadPlaylistImageParameters, userList: userListParameters, }, @@ -784,6 +822,8 @@ export const ndType = { albumList, authenticate, createPlaylist, + deleteInternetRadioStation, + deleteInternetRadioStationImage, deletePlaylist, deletePlaylistImage, error, @@ -795,13 +835,17 @@ export const ndType = { playlistSong, playlistSongList, queue, + radioList, + radioStation, removeFromPlaylist, saveQueue, shareItem, song, songList, tagList, + updateInternetRadioStation, updatePlaylist, + uploadInternetRadioStationImage, uploadPlaylistImage, user, userList, diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index 4835c89fd..f00982854 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -432,6 +432,8 @@ const normalizeInternetRadioStation = ( return { homepageUrl: item.homepageUrl || null, id: item.id, + imageId: item.coverArt?.toString() || null, + imageUrl: null, name: item.name, streamUrl: item.streamUrl, }; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index f591d49b8..7e9cf4267 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -755,6 +755,7 @@ const playQueueByIndex = z.object({ }); const internetRadioStation = z.object({ + coverArt: z.string().optional(), homepageUrl: z.string().optional(), id: z.string(), name: z.string(), diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 155897a4e..37a47baed 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -959,6 +959,16 @@ export type DeleteInternetRadioStationArgs = BaseEndpointArgs & { query: DeleteInternetRadioStationQuery; }; +export type DeleteInternetRadioStationImageArgs = BaseEndpointArgs & { + query: DeleteInternetRadioStationImageQuery; +}; + +export type DeleteInternetRadioStationImageQuery = { + id: string; +}; + +export type DeleteInternetRadioStationImageResponse = boolean; + export type DeleteInternetRadioStationQuery = { id: string; }; @@ -999,10 +1009,13 @@ export type GetInternetRadioStationsArgs = BaseEndpointArgs; export type GetInternetRadioStationsResponse = InternetRadioStation[]; export type InternetRadioStation = { - homepageUrl?: null | string; + homepageUrl: null | string; id: string; + imageId?: null | string; + imageUrl?: null | string; name: string; streamUrl: string; + uploadedImage?: null | string; }; export type PlaylistListArgs = BaseEndpointArgs & { query: PlaylistListQuery }; @@ -1117,6 +1130,21 @@ export type UpdatePlaylistQuery = { // Update Playlist export type UpdatePlaylistResponse = null | undefined; +export type UploadInternetRadioStationImageArgs = BaseEndpointArgs & { + body: UploadInternetRadioStationImageBody; + query: UploadInternetRadioStationImageQuery; +}; + +export type UploadInternetRadioStationImageBody = { + image: Uint8Array; +}; + +export type UploadInternetRadioStationImageQuery = { + id: string; +}; + +export type UploadInternetRadioStationImageResponse = boolean; + export type UploadPlaylistImageArgs = BaseEndpointArgs & { body: UploadPlaylistImageBody; query: UploadPlaylistImageQuery; @@ -1415,6 +1443,9 @@ export type ControllerEndpoint = { deleteInternetRadioStation: ( args: DeleteInternetRadioStationArgs, ) => Promise; + deleteInternetRadioStationImage?: ( + args: DeleteInternetRadioStationImageArgs, + ) => Promise; deletePlaylist: (args: DeletePlaylistArgs) => Promise; deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; @@ -1470,6 +1501,9 @@ export type ControllerEndpoint = { args: UpdateInternetRadioStationArgs, ) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; + uploadInternetRadioStationImage?: ( + args: UploadInternetRadioStationImageArgs, + ) => Promise; uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise; }; @@ -1540,6 +1574,9 @@ export type InternalControllerEndpoint = { deleteInternetRadioStation: ( args: ReplaceApiClientProps, ) => Promise; + deleteInternetRadioStationImage?: ( + args: ReplaceApiClientProps, + ) => Promise; deletePlaylist: ( args: ReplaceApiClientProps, ) => Promise; @@ -1630,6 +1667,9 @@ export type InternalControllerEndpoint = { updatePlaylist: ( args: ReplaceApiClientProps, ) => Promise; + uploadInternetRadioStationImage?: ( + args: ReplaceApiClientProps, + ) => Promise; uploadPlaylistImage?: ( args: ReplaceApiClientProps, ) => Promise; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 5720bb166..0f1d1fedc 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -3,6 +3,7 @@ export enum ServerFeature { ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter', BFR = 'bfr', + INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',