mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add native nd radio endpoints, support radio station images
This commit is contained in:
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user