Compare commits

...

7 Commits

Author SHA1 Message Date
jeffvli 3c442a2d40 update to v1.11.0 2026-04-06 17:10:18 -07:00
Andrzej Voss d67c185c93 feat: Make "Clear" button "Refresh" when there are no lyrics found. (#1920)
Ref: effvli/feishin#1919 - tl;dr: Button actually reloads/refreshes
lyrics info from the server too, it makes it, well, clearer what it does
in that case - allows to reread lyrics from server without clearing whole cache.
2026-04-06 16:59:01 -07:00
jeffvli ff96a5f121 lint 2026-04-06 12:06:55 -07:00
jeffvli 6fc7b6b271 support image drop for upload 2026-04-06 11:41:33 -07:00
jeffvli 918f453066 support navidrome artist image upload/delete 2026-04-06 11:41:26 -07:00
jeffvli 4a986069f8 set flac as default transcoding profile 2026-04-06 10:58:37 -07:00
jeffvli 11d26af893 remove arm/v7 from container build 2026-04-06 09:47:28 -07:00
21 changed files with 702 additions and 156 deletions
@@ -51,5 +51,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.10.0",
"version": "1.11.0",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
+28
View File
@@ -147,6 +147,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`,
);
}
return apiController(
'deleteArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteFavorite(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -988,6 +1002,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadArtistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`,
);
}
return apiController(
'uploadArtistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -46,6 +46,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
@@ -259,6 +268,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
uploadArtistImage: {
body: ndType._parameters.uploadArtistImage,
method: 'POST',
path: 'artist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadArtistImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
@@ -13,6 +13,8 @@ import {
albumArtistListSortMap,
albumListSortMap,
AuthenticationResponse,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
@@ -28,6 +30,8 @@ import {
SortOrder,
sortOrderMap,
tagListSortMap,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs,
@@ -42,6 +46,7 @@ const VERSION_INFO: VersionInfo = [
[
'0.61.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
},
@@ -186,6 +191,21 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete artist image');
}
return res.body.data.status === 'ok';
},
deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
@@ -1270,6 +1290,40 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
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/artist/${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 artist image');
}
return res.data?.status === 'ok';
},
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
@@ -1,5 +1,5 @@
import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback, useMemo } from 'react';
import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
@@ -8,6 +8,8 @@ import styles from './album-artist-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation';
import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
@@ -20,17 +22,80 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDurationString } from '/@/renderer/utils';
import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { hasFeature, SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types';
import {
AlbumArtistDetailResponse,
AlbumListResponse,
LibraryItem,
ServerType,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
}
function ArtistImageUploadOverlay({
data,
onUploadFile,
}: {
data?: AlbumArtistDetailResponse;
onUploadFile: (file: File) => Promise<void>;
}) {
const deleteArtistImageMutation = useDeleteArtistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deleteArtistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
({ albumsQuery }, ref) => {
const { albumArtistId, artistId } = useParams() as {
@@ -78,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const uploadArtistImageMutation = useUploadArtistImage({});
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
const sortBy = albumArtistDetailSort.sortBy;
@@ -167,40 +233,52 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
[detailQuery.data],
);
const imageUrl = useItemImageUrl({
const headerImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard',
});
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
type: 'header',
});
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => {
return detailQuery.data?.imageUrl || imageUrl;
}, [detailQuery.data?.imageUrl, imageUrl]);
const canUploadArtistImage =
hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
Boolean(detailQuery.data?._serverId);
const alternateImageUrl = artistInfoQuery.data?.imageUrl;
const hasImageId = Boolean(detailQuery.data?.imageId);
const fallbackHeaderImageUrl = alternateImageUrl || selectedImageUrl;
const handleArtistImageUpload = useCallback(
async (file: File) => {
const artist = detailQuery.data;
if (!artist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadArtistImageMutation.mutate({
apiClientProps: {
serverId: artist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: artist.id },
});
},
[detailQuery.data, uploadArtistImageMutation],
);
return (
<LibraryHeader
imageUrl={hasImageId ? undefined : fallbackHeaderImageUrl}
imageOverlay={
<ArtistImageUploadOverlay
data={detailQuery.data}
onUploadFile={handleArtistImageUpload}
/>
}
imageUrl={headerImageUrl}
item={{
imageId: detailQuery.data?.imageId,
imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl,
imageUrl: detailQuery.data?.imageUrl,
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST,
}}
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
ref={ref}
title={detailQuery.data?.name || ''}
>
@@ -0,0 +1,41 @@
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 { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types';
export const useDeleteArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<DeleteArtistImageResponse, AxiosError, DeleteArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.deleteArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -0,0 +1,41 @@
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 { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types';
export const useUploadArtistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<UploadArtistImageResponse, AxiosError, UploadArtistImageArgs, null>({
mutationFn: (args) => {
return api.controller.uploadArtistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
});
}
},
...options,
});
};
@@ -131,7 +131,9 @@ export const LyricsActions = ({
uppercase
variant="subtle"
>
{t('common.clear', { postProcess: 'sentenceCase' })}
{hasLyrics
? t('common.clear', { postProcess: 'sentenceCase' })
: t('common.refresh', { postProcess: 'sentenceCase' })}
</Button>
) : null}
</Group>
@@ -60,6 +60,7 @@ const CODEC_PROBES = [
];
const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'flac', container: 'flac', protocol: 'http' },
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
];
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -40,8 +41,13 @@ interface PlaylistDetailSongListHeaderProps {
onToggleQueryBuilder?: () => void;
}
function ImageUploadOverlay({ data }: { data?: Playlist }) {
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
function ImageUploadOverlay({
data,
onUploadFile,
}: {
data?: Playlist;
onUploadFile: (file: File) => Promise<void>;
}) {
const deletePlaylistImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
@@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) {
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file || !data?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: data.id },
});
if (!file) return;
await onUploadFile(file);
}}
>
{(props) => (
@@ -121,11 +119,33 @@ export const PlaylistDetailSongListHeader = ({
});
const player = usePlayer();
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
const handlePlay = (type?: Play) => {
player.addToQueueByData(listData as Song[], type || Play.NOW);
};
const canUploadPlaylistImage =
hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD) &&
Boolean(detailQuery?.data?._serverId);
const handlePlaylistImageUpload = useCallback(
async (file: File) => {
const playlist = detailQuery?.data;
if (!playlist?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: playlist._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: playlist.id },
});
},
[detailQuery?.data, uploadPlaylistImageMutation],
);
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
itemType: LibraryItem.PLAYLIST,
@@ -163,7 +183,12 @@ export const PlaylistDetailSongListHeader = ({
) : (
<LibraryHeader
compact
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
imageOverlay={
<ImageUploadOverlay
data={detailQuery?.data}
onUploadFile={handlePlaylistImageUpload}
/>
}
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -171,6 +196,7 @@ export const PlaylistDetailSongListHeader = ({
route: AppRoute.PLAYLISTS,
type: LibraryItem.PLAYLIST,
}}
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
@@ -13,6 +13,7 @@ import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/rendere
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
@@ -270,16 +271,20 @@ function PlaylistCoverField({
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
@@ -288,22 +293,12 @@ function PlaylistCoverField({
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
@@ -315,21 +310,41 @@ function PlaylistCoverField({
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
<DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
wrap="nowrap"
>
{iconControls}
</Group>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
@@ -12,6 +12,7 @@ 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 { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
@@ -241,16 +242,20 @@ function RadioStationCoverField({
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
{(props) => {
const { ...triggerRest } = props;
return (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...triggerRest}
style={{ pointerEvents: 'auto' }}
/>
);
}}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
@@ -259,22 +264,12 @@ function RadioStationCoverField({
onClick={secondaryAction}
radius="xl"
size="sm"
style={{ pointerEvents: 'auto' }}
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
@@ -286,21 +281,41 @@ function RadioStationCoverField({
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
<DragDropZone
accept="image/*"
mode="file"
onFileSelected={(file) => onFileSelect(file)}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
height: '100%',
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
wrap="nowrap"
>
{iconControls}
</Group>
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
bottom: 6,
padding: 4,
pointerEvents: 'none',
position: 'absolute',
right: 6,
zIndex: 2,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</DragDropZone>
</Box>
);
}
@@ -1,3 +1,5 @@
import type { KeyboardEvent } from 'react';
import { closeAllModals, openModal } from '@mantine/modals';
import clsx from 'clsx';
import { forwardRef, ReactNode, Ref, useCallback } from 'react';
@@ -22,6 +24,7 @@ import { useGeneralSettings } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { BaseImage } from '/@/shared/components/image/image';
@@ -47,6 +50,7 @@ interface LibraryHeaderProps {
type?: LibraryItem;
};
loading?: boolean;
onImageFileDrop?: (file: File) => Promise<void> | void;
title: string;
topRight?: ReactNode;
}
@@ -60,6 +64,7 @@ export const LibraryHeader = forwardRef(
imageOverlay,
imageUrl,
item,
onImageFileDrop,
title,
topRight,
}: LibraryHeaderProps,
@@ -136,6 +141,17 @@ export const LibraryHeader = forwardRef(
});
}, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);
const imageSectionSharedProps = {
onClick: () => {
openImage();
},
onKeyDown: (event: KeyboardEvent) =>
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage(),
role: 'button' as const,
style: { cursor: 'pointer' as const },
tabIndex: 0,
};
return (
<div
className={clsx(
@@ -146,41 +162,63 @@ export const LibraryHeader = forwardRef(
ref={ref}
>
{topRight && <div className={styles.topRight}>{topRight}</div>}
<div
className={styles.imageSection}
onClick={() => {
openImage();
}}
onKeyDown={(event) =>
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage()
}
role="button"
style={{ cursor: 'pointer' }}
tabIndex={0}
>
<ItemImage
className={styles.image}
containerClassName={styles.image}
enableDebounce={false}
enableViewport={false}
explicitStatus={item.explicitStatus ?? null}
fetchPriority="high"
id={item.imageId}
itemType={item.type as LibraryItem}
src={imageUrl || ''}
type="header"
/>
{imageOverlay && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div>
)}
</div>
{onImageFileDrop ? (
<DragDropZone
accept="image/*"
className={styles.imageSection}
mode="file"
onFileSelected={(file) => void onImageFileDrop(file)}
{...imageSectionSharedProps}
>
<ItemImage
className={styles.image}
containerClassName={styles.image}
enableDebounce={false}
enableViewport={false}
explicitStatus={item.explicitStatus ?? null}
fetchPriority="high"
id={item.imageId}
itemType={item.type as LibraryItem}
src={imageUrl || ''}
type="header"
/>
{imageOverlay && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div>
)}
</DragDropZone>
) : (
<div className={styles.imageSection} {...imageSectionSharedProps}>
<ItemImage
className={styles.image}
containerClassName={styles.image}
enableDebounce={false}
enableViewport={false}
explicitStatus={item.explicitStatus ?? null}
fetchPriority="high"
id={item.imageId}
itemType={item.type as LibraryItem}
src={imageUrl || ''}
type="header"
/>
{imageOverlay && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div>
)}
</div>
)}
{title && (
<div className={styles.metadataSection}>
{item.children ? (
+25 -12
View File
@@ -18,14 +18,20 @@ import {
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
const getImageUrl = (args: { url: null | string }) => {
const { url } = args;
if (url === '/app/artist-placeholder.webp') {
return null;
}
// const getImageUrl = (args: { url: null | string }) => {
// const { url } = args;
// if (url === '/app/artist-placeholder.webp') {
// return null;
// }
return url;
};
// return url;
// };
const navidromeImageIdWithCacheBust = (
id: string,
uploadedImage: string | undefined,
updatedAt: string | undefined,
): string => (!uploadedImage ? id : `${id}&_=${updatedAt ?? ''}`);
interface WithDate {
playDate?: string;
@@ -397,7 +403,7 @@ const normalizeAlbumArtist = (
},
server?: null | ServerListItem,
): AlbumArtist => {
const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
// const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
let albumCount: number;
let songCount: number;
@@ -416,6 +422,12 @@ const normalizeAlbumArtist = (
songCount = item.songCount;
}
const imageId = navidromeImageIdWithCacheBust(
item.id,
item.uploadedImage,
item.updatedAt ?? item.externalInfoUpdatedAt,
);
return {
_itemType: LibraryItem.ALBUM_ARTIST,
_serverId: server?.id || 'unknown',
@@ -435,8 +447,8 @@ const normalizeAlbumArtist = (
songCount: null,
})),
id: item.id,
imageId: item.id,
imageUrl: imageUrl || null,
imageId,
imageUrl: null,
lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null,
name: item.name,
@@ -451,6 +463,7 @@ const normalizeAlbumArtist = (
userRating: artist.userRating || null,
})) || [],
songCount,
uploadedImage: item.uploadedImage,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
@@ -460,7 +473,7 @@ const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server?: null | ServerListItem,
): Playlist => {
const imageId = !item.uploadedImage ? item.id : `${item.id}&_=${item.updatedAt}`;
const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
return {
_itemType: LibraryItem.PLAYLIST,
@@ -517,7 +530,7 @@ const normalizeInternetRadioStation = (
item: z.infer<typeof ndType._response.radioStation>,
): InternetRadioStation => {
const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null;
const imageId = item.uploadedImage ? `${item.id}&_=${item.updatedAt}` : item.id;
const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
return {
homepageUrl,
@@ -428,6 +428,7 @@ const albumArtist = z.object({
starredAt: z.string(),
stats: z.record(z.string(), stats).optional(),
updatedAt: z.string().optional(),
uploadedImage: z.string().optional(),
});
const albumArtistList = z.array(albumArtist);
@@ -683,6 +684,9 @@ const deletePlaylistImage = z.object({
const uploadInternetRadioStationImage = uploadPlaylistImage;
const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters;
const uploadArtistImage = uploadPlaylistImage;
const uploadArtistImageParameters = uploadPlaylistImageParameters;
const deleteArtistImage = deletePlaylistImage;
const deleteInternetRadioStationImage = deletePlaylistImage;
const deletePlaylist = z.null();
@@ -813,6 +817,7 @@ export const ndType = {
tagList: tagListParameters,
updateInternetRadioStation: updateInternetRadioStationParameters,
updatePlaylist: updatePlaylistParameters,
uploadArtistImage: uploadArtistImageParameters,
uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters,
uploadPlaylistImage: uploadPlaylistImageParameters,
userList: userListParameters,
@@ -825,6 +830,7 @@ export const ndType = {
albumList,
authenticate,
createPlaylist,
deleteArtistImage,
deleteInternetRadioStation,
deleteInternetRadioStationImage,
deletePlaylist,
@@ -848,6 +854,7 @@ export const ndType = {
tagList,
updateInternetRadioStation,
updatePlaylist,
uploadArtistImage,
uploadInternetRadioStationImage,
uploadPlaylistImage,
user,
@@ -0,0 +1,18 @@
/*
* Inset outline on the root is hidden behind a full-bleed ItemImage; a ::after layer paints
* above the image. Keep z-index below overlay controls (e.g. z-index: 2).
* Avoid positive outline-offset so ancestors with overflow:hidden do not clip the indicator.
*/
.file-target-drag-over {
position: relative;
}
.file-target-drag-over::after {
position: absolute;
inset: calc(var(--theme-spacing-sm) * -1);
z-index: 1;
pointer-events: none;
content: '';
border-radius: var(--theme-radius-md);
box-shadow: inset 0 0 0 3px var(--theme-colors-primary);
}
@@ -1,17 +1,38 @@
import type { ChangeEvent, DragEvent, HTMLAttributes, ReactNode } from 'react';
import clsx from 'clsx';
import { t } from 'i18next';
import { useCallback, useRef, useState } from 'react';
import styles from './drag-drop-zone.module.css';
import { Flex } from '/@/shared/components/flex/flex';
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { isNativeFileDrag, pickFirstImageFile } from '/@/shared/utils/image-drop';
interface DragDropZoneProps {
export interface DragDropZoneFileProps extends DivProps {
accept?: string;
children: ReactNode;
mode: 'file';
onFileSelected: (file: File) => Promise<void> | void;
}
export type DragDropZoneProps = DragDropZoneFileProps | DragDropZoneTextProps;
type DivProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'onDrop'
>;
interface DragDropZoneTextProps {
icon: keyof typeof AppIcon;
mode?: 'text';
onItemSelected: (contents: string) => void;
validateItem?: (contents: string) => { error?: string; isValid: boolean };
}
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
const DragDropZoneText = ({ icon, onItemSelected, validateItem }: DragDropZoneTextProps) => {
const zoneFileInput = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string>('');
@@ -32,7 +53,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
);
const onItemDropped = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const items = event.dataTransfer.items;
@@ -62,7 +83,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
[processItem],
);
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
}, []);
@@ -72,7 +93,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
}, []);
const onZoneInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
(event: ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (!files || files.length > 1) {
@@ -131,3 +152,83 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
</Flex>
);
};
const DragDropZoneFile = (props: DragDropZoneFileProps) => {
const { accept = 'image/*', children, className, mode, onFileSelected, ...divProps } = props;
void mode;
const fileDragDepth = useRef(0);
const [fileDragOver, setFileDragOver] = useState(false);
const resolveFile = useCallback(
(dataTransfer: DataTransfer): File | null => {
if (accept === 'image/*') {
return pickFirstImageFile(dataTransfer.files);
}
const first = dataTransfer.files?.item(0);
return first ?? null;
},
[accept],
);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
fileDragDepth.current += 1;
setFileDragOver(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
fileDragDepth.current -= 1;
if (fileDragDepth.current <= 0) {
fileDragDepth.current = 0;
setFileDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}, []);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
if (!isNativeFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
fileDragDepth.current = 0;
setFileDragOver(false);
const file = resolveFile(e.dataTransfer);
if (file) void onFileSelected(file);
},
[onFileSelected, resolveFile],
);
return (
<div
{...divProps}
className={clsx(className, {
[styles.fileTargetDragOver]: fileDragOver,
})}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
</div>
);
};
export const DragDropZone = (props: DragDropZoneProps) => {
if (props.mode === 'file') {
return <DragDropZoneFile {...props} />;
}
return <DragDropZoneText {...props} />;
};
+34
View File
@@ -225,6 +225,7 @@ export type AlbumArtist = {
playCount: null | number;
similarArtists: null | RelatedArtist[];
songCount: null | number;
uploadedImage?: string;
userFavorite: boolean;
userRating: null | number;
};
@@ -957,6 +958,16 @@ export type CreatePlaylistBody = {
// Create Playlist
export type CreatePlaylistResponse = undefined | { id: string };
export type DeleteArtistImageArgs = BaseEndpointArgs & {
query: DeleteArtistImageQuery;
};
export type DeleteArtistImageQuery = {
id: string;
};
export type DeleteArtistImageResponse = boolean;
export type DeleteInternetRadioStationArgs = BaseEndpointArgs & {
query: DeleteInternetRadioStationQuery;
};
@@ -1132,6 +1143,21 @@ export type UpdatePlaylistQuery = {
// Update Playlist
export type UpdatePlaylistResponse = null | undefined;
export type UploadArtistImageArgs = BaseEndpointArgs & {
body: UploadArtistImageBody;
query: UploadArtistImageQuery;
};
export type UploadArtistImageBody = {
image: Uint8Array;
};
export type UploadArtistImageQuery = {
id: string;
};
export type UploadArtistImageResponse = boolean;
export type UploadInternetRadioStationImageArgs = BaseEndpointArgs & {
body: UploadInternetRadioStationImageBody;
query: UploadInternetRadioStationImageQuery;
@@ -1441,6 +1467,7 @@ export type ControllerEndpoint = {
args: CreateInternetRadioStationArgs,
) => Promise<CreateInternetRadioStationResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteArtistImage?: (args: DeleteArtistImageArgs) => Promise<DeleteArtistImageResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deleteInternetRadioStation: (
args: DeleteInternetRadioStationArgs,
@@ -1503,6 +1530,7 @@ export type ControllerEndpoint = {
args: UpdateInternetRadioStationArgs,
) => Promise<UpdateInternetRadioStationResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
uploadArtistImage?: (args: UploadArtistImageArgs) => Promise<UploadArtistImageResponse>;
uploadInternetRadioStationImage?: (
args: UploadInternetRadioStationImageArgs,
) => Promise<UploadInternetRadioStationImageResponse>;
@@ -1572,6 +1600,9 @@ export type InternalControllerEndpoint = {
createPlaylist: (
args: ReplaceApiClientProps<CreatePlaylistArgs>,
) => Promise<CreatePlaylistResponse>;
deleteArtistImage?: (
args: ReplaceApiClientProps<DeleteArtistImageArgs>,
) => Promise<DeleteArtistImageResponse>;
deleteFavorite: (args: ReplaceApiClientProps<FavoriteArgs>) => Promise<FavoriteResponse>;
deleteInternetRadioStation: (
args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>,
@@ -1669,6 +1700,9 @@ export type InternalControllerEndpoint = {
updatePlaylist: (
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
) => Promise<UpdatePlaylistResponse>;
uploadArtistImage?: (
args: ReplaceApiClientProps<UploadArtistImageArgs>,
) => Promise<UploadArtistImageResponse>;
uploadInternetRadioStationImage?: (
args: ReplaceApiClientProps<UploadInternetRadioStationImageArgs>,
) => Promise<UploadInternetRadioStationImageResponse>;
+1
View File
@@ -2,6 +2,7 @@
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
export enum ServerFeature {
ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter',
ARTIST_IMAGE_UPLOAD = 'artistImageUpload',
BFR = 'bfr',
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
+16
View File
@@ -0,0 +1,16 @@
import type { DragEvent } from 'react';
// OS / native file drag (vs in-app library drag).
export function isNativeFileDrag(event: DragEvent): boolean {
return event.dataTransfer.types.includes('Files');
}
// First file in the list whose MIME type is an image.
export function pickFirstImageFile(files: FileList | null): File | null {
if (!files?.length) return null;
for (let i = 0; i < files.length; i++) {
const f = files.item(i);
if (f?.type.startsWith('image/')) return f;
}
return null;
}