add native nd radio endpoints, support radio station images

This commit is contained in:
jeffvli
2026-04-02 18:26:26 -07:00
parent fbf82c1ef0
commit db06e7f601
18 changed files with 789 additions and 90 deletions
+3
View File
@@ -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",
+28
View File
@@ -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);
@@ -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',
@@ -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<DeleteInternetRadioStationImageResponse> => {
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<UploadInternetRadioStationImageResponse> => {
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<ArrayBuffer>;
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<UploadPlaylistImageResponse> => {
@@ -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 ? (
<ItemImage
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
enableDebounce={false}
enableViewport={false}
fetchPriority="high"
id={currentStationArt?.imageId ?? undefined}
itemType={LibraryItem.RADIO_STATION}
serverId={currentStationArt?.serverId}
src={currentStationArt?.imageUrl ?? ''}
type="table"
/>
) : isRadioMode ? (
<Center
className={clsx(
styles.playerbarImage,
@@ -1,11 +1,19 @@
import { t } from 'i18next';
import { MouseEvent } from 'react';
import { MouseEvent, type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDeleteInternetRadioStationImage } from '/@/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation';
import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation';
import { useUploadInternetRadioStationImage } from '/@/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation';
import { useCurrentServer } from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { closeAllModals, openModal } from '/@/shared/components/modal/modal';
import { ModalButton } from '/@/shared/components/modal/model-shared';
@@ -15,19 +23,51 @@ import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import {
InternetRadioStation,
LibraryItem,
ServerListItem,
UpdateInternetRadioStationBody,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface EditRadioStationFormProps {
onCancel: () => 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<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(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<UpdateInternetRadioStationBody>({
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[] = [
<TextInput
data-autofocus
key="name"
label={t('form.createRadioStation.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>,
<TextInput
key="streamUrl"
label={t('form.createRadioStation.input', {
context: 'streamUrl',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('streamUrl')}
/>,
<TextInput
key="homepageUrl"
label={t('form.createRadioStation.input', {
context: 'homepageUrl',
postProcess: 'titleCase',
})}
{...form.getInputProps('homepageUrl')}
/>,
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={onCancel}>
{t('common.cancel')}
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={isSaving}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>,
];
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={t('form.createRadioStation.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>
<TextInput
label={t('form.createRadioStation.input', {
context: 'streamUrl',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('streamUrl')}
/>
<TextInput
label={t('form.createRadioStation.input', {
context: 'homepageUrl',
postProcess: 'titleCase',
})}
{...form.getInputProps('homepageUrl')}
/>
<Group justify="flex-end">
<ModalButton onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'sentenceCase' })}
</ModalButton>
<ModalButton loading={mutation.isPending} type="submit" variant="filled">
{t('common.save', { postProcess: 'sentenceCase' })}
</ModalButton>
</Group>
</Stack>
{isCoverImageDisplayed && server?.id ? (
<Flex align="flex-start" gap="lg" wrap="wrap">
<RadioStationCoverField
hadUploadedCover={hadUploadedCover}
onClearPending={() => setPendingFile(null)}
onFileSelect={(file) => {
if (!file) return;
setRemoveCustomCover(false);
setPendingFile(file);
}}
onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
pendingFile={pendingFile}
pendingPreviewUrl={pendingPreviewUrl}
removeCustomCover={removeCustomCover}
stationImage={stationImage}
/>
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
{fieldNodes}
</Stack>
</Flex>
) : (
<Stack gap="md">{fieldNodes}</Stack>
)}
</form>
);
};
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 = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
icon={secondaryIcon}
iconProps={{ size: 'lg' }}
onClick={secondaryAction}
radius="xl"
size="sm"
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
borderRadius: 'var(--mantine-radius-md)',
flexShrink: 0,
height: COVER_SIZE,
overflow: 'hidden',
position: 'relative',
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</Box>
);
}
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: <EditRadioStationForm onCancel={closeAllModals} station={station} />,
size: hasImageUpload ? 'lg' : 'md',
title: t('common.edit', { postProcess: 'titleCase' }) as string,
});
};
@@ -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;
}
@@ -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"
>
<Flex align="flex-start" gap="md" justify="space-between">
<button className={styles['radio-item-button']} onClick={handleClick} role="button">
<Stack gap="xs">
<Group gap="xs">
<Icon color="muted" icon="radio" size="md" />
<Flex align="center" gap="md" justify="space-between" wrap="nowrap">
<button className={styles['radio-item-button']} onClick={handleClick} type="button">
<Group align="center" gap="md" wrap="nowrap">
<Box className={styles.thumbnail}>
<ItemImage
enableViewport={false}
id={station.imageId ?? undefined}
imageContainerProps={{
className: styles['image-container'],
}}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={station.imageUrl ?? ''}
type="table"
/>
</Box>
<Stack className={styles.meta} gap={4}>
<Text fw={500} size="md">
{station.name}
</Text>
</Group>
<Text isMuted size="sm">
{station.streamUrl}
</Text>
{station.homepageUrl && (
<Text isMuted size="sm">
{station.homepageUrl}
<Text className={styles['meta-line']} isMuted size="sm">
{station.streamUrl}
</Text>
)}
</Stack>
{station.homepageUrl ? (
<Text className={styles['meta-line']} isMuted size="sm">
{station.homepageUrl}
</Text>
) : null}
</Stack>
</Group>
</button>
{(permissions.radio.edit || permissions.radio.delete) && (
<Group gap="xs">
<Group className={styles['radio-item-actions']} gap="xs">
{permissions.radio.edit && (
<ActionIcon
icon="edit"
@@ -7,6 +7,13 @@ import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/
import { usePlaybackType, usePlayerStoreBase, useSettingsStore } from '/@/renderer/store';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
export type RadioCurrentStationArt = {
id: string;
imageId?: null | string;
imageUrl?: null | string;
serverId: string;
};
export interface RadioMetadata {
artist: null | string;
title: null | string;
@@ -15,13 +22,18 @@ export interface RadioMetadata {
interface RadioStore {
actions: {
pause: () => 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<RadioStore>((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<RadioStore>((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<RadioStore>((set) => ({
const playbackType = useSettingsStore.getState().playback.type;
set({
currentStationArt: null,
currentStreamUrl: null,
isPlaying: false,
metadata: null,
@@ -79,6 +103,7 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((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);
@@ -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,
});
};
@@ -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,
});
};
@@ -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 ? (
<img className={styles.sidebarImage} loading="eager" src={radioImageUrl} />
) : isRadioActive ? (
<Center
className={styles.sidebarImage}
style={{
@@ -8,6 +8,7 @@ import {
AlbumArtist,
ExplicitStatus,
Genre,
InternetRadioStation,
LibraryItem,
Playlist,
RelatedArtist,
@@ -543,10 +544,28 @@ const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
};
};
const normalizeInternetRadioStation = (
item: z.infer<typeof ndType._response.radioStation>,
): 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,
@@ -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,
@@ -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,
};
@@ -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(),
+41 -1
View File
@@ -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<DeleteInternetRadioStationResponse>;
deleteInternetRadioStationImage?: (
args: DeleteInternetRadioStationImageArgs,
) => Promise<DeleteInternetRadioStationImageResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise<DeletePlaylistImageResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
@@ -1470,6 +1501,9 @@ export type ControllerEndpoint = {
args: UpdateInternetRadioStationArgs,
) => Promise<UpdateInternetRadioStationResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
uploadInternetRadioStationImage?: (
args: UploadInternetRadioStationImageArgs,
) => Promise<UploadInternetRadioStationImageResponse>;
uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>;
};
@@ -1540,6 +1574,9 @@ export type InternalControllerEndpoint = {
deleteInternetRadioStation: (
args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>,
) => Promise<DeleteInternetRadioStationResponse>;
deleteInternetRadioStationImage?: (
args: ReplaceApiClientProps<DeleteInternetRadioStationImageArgs>,
) => Promise<DeleteInternetRadioStationImageResponse>;
deletePlaylist: (
args: ReplaceApiClientProps<DeletePlaylistArgs>,
) => Promise<DeletePlaylistResponse>;
@@ -1630,6 +1667,9 @@ export type InternalControllerEndpoint = {
updatePlaylist: (
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
) => Promise<UpdatePlaylistResponse>;
uploadInternetRadioStationImage?: (
args: ReplaceApiClientProps<UploadInternetRadioStationImageArgs>,
) => Promise<UploadInternetRadioStationImageResponse>;
uploadPlaylistImage?: (
args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
) => Promise<UploadPlaylistImageResponse>;
+1
View File
@@ -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',