add new context menu implementation

This commit is contained in:
jeffvli
2025-11-15 04:22:06 -08:00
parent ec0590c79a
commit 8eb90ebf06
47 changed files with 2826 additions and 1593 deletions
@@ -0,0 +1,331 @@
import { openContextModal } from '@mantine/modals';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Fuse from 'fuse.js';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
getAlbumArtistSongsById,
getAlbumSongsById,
getGenreSongsById,
getPlaylistSongsById,
} from '/@/renderer/features/player/utils';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Icon } from '/@/shared/components/icon/icon';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
interface AddToPlaylistActionProps {
items: string[];
itemType: LibraryItem;
}
export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const serverId = useCurrentServerId();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const addToPlaylistMutation = useAddToPlaylist({});
const playlistsQuery = useQuery(
playlistsQueries.list({
query: {
_custom: {
navidrome: {
smart: false,
},
},
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
}),
);
const { recentPlaylistId } = useRecentPlaylists(serverId);
const fuse = useMemo(() => {
if (!playlistsQuery.data?.items) return null;
return new Fuse(playlistsQuery.data.items, {
fieldNormWeight: 1,
ignoreLocation: true,
keys: ['name'],
threshold: 0.3,
});
}, [playlistsQuery.data?.items]);
const recentPlaylist = useMemo(() => {
if (!playlistsQuery.data?.items || !recentPlaylistId) return null;
const playlist = playlistsQuery.data.items.find((p) => p.id === recentPlaylistId);
if (!playlist) return null;
if (searchTerm && fuse) {
const results = fuse.search(searchTerm);
const found = results.find((result) => result.item.id === recentPlaylistId);
if (!found) return null;
}
return playlist;
}, [playlistsQuery.data?.items, recentPlaylistId, searchTerm, fuse]);
const filteredPlaylists = useMemo(() => {
if (!playlistsQuery.data?.items) return [];
if (!searchTerm || !fuse) {
// Exclude recent playlist from the list if it exists
return recentPlaylistId
? playlistsQuery.data.items.filter((p) => p.id !== recentPlaylistId)
: playlistsQuery.data.items;
}
const results = fuse.search(searchTerm);
const filtered = results.map((result) => result.item);
// Exclude recent playlist from the filtered results if it exists
return recentPlaylistId ? filtered.filter((p) => p.id !== recentPlaylistId) : filtered;
}, [playlistsQuery.data?.items, searchTerm, fuse, recentPlaylistId]);
const getSongsByAlbum = useCallback(
async (albumId: string) => {
if (!server) return null;
return getAlbumSongsById({
id: [albumId],
queryClient,
server,
});
},
[queryClient, server],
);
const getSongsByArtist = useCallback(
async (artistId: string) => {
if (!server) return null;
return getAlbumArtistSongsById({
id: [artistId],
queryClient,
server,
});
},
[queryClient, server],
);
const getSongsByGenre = useCallback(
async (genreIds: string[]) => {
if (!server) return null;
return getGenreSongsById({
id: genreIds,
queryClient,
server,
});
},
[queryClient, server],
);
const getSongsByPlaylist = useCallback(
async (playlistId: string) => {
if (!server) return null;
return getPlaylistSongsById({
id: playlistId,
queryClient,
server,
});
},
[queryClient, server],
);
const handleAddToPlaylist = useCallback(
async (playlistId: string) => {
if (items.length === 0 || !serverId) return;
try {
let allSongIds: string[] = [];
if (itemType === LibraryItem.SONG) {
allSongIds = items;
} else if (itemType === LibraryItem.ALBUM) {
for (const id of items) {
const songs = await getSongsByAlbum(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
} else if (
itemType === LibraryItem.ALBUM_ARTIST ||
itemType === LibraryItem.ARTIST
) {
for (const id of items) {
const songs = await getSongsByArtist(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
} else if (itemType === LibraryItem.GENRE) {
const songs = await getSongsByGenre(items);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
} else if (itemType === LibraryItem.PLAYLIST) {
for (const id of items) {
const songs = await getSongsByPlaylist(id);
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
}
}
if (allSongIds.length === 0) {
toast.error({
message: t('error.noItemsSelected', { postProcess: 'sentenceCase' }),
});
return;
}
addToPlaylistMutation.mutate(
{
apiClientProps: { serverId },
body: {
songId: allSongIds,
},
query: {
id: playlistId,
},
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {},
},
);
} catch (error) {
toast.error({
message: (error as Error).message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
}
},
[
addToPlaylistMutation,
getSongsByAlbum,
getSongsByArtist,
getSongsByGenre,
getSongsByPlaylist,
itemType,
items,
serverId,
t,
],
);
const handleOpenModal = useCallback(() => {
const modalProps: {
albumId?: string[];
artistId?: string[];
genreId?: string[];
initialSelectedIds?: string[];
playlistId?: string[];
songId?: string[];
} = {};
switch (itemType) {
case LibraryItem.ALBUM:
modalProps.albumId = items;
break;
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
modalProps.artistId = items;
break;
case LibraryItem.GENRE:
modalProps.genreId = items;
break;
case LibraryItem.PLAYLIST:
modalProps.playlistId = items;
break;
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
modalProps.songId = items;
break;
default:
return;
}
openContextModal({
innerProps: {
itemIds: items,
resourceType: itemType,
},
modal: 'addToPlaylist',
title: t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' }),
});
}, [itemType, items, t]);
if (items.length === 0) return null;
const searchInput = (
<TextInput
autoFocus
leftSection={<Icon icon="search" />}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
pb="xs"
placeholder={t('common.search', { postProcess: 'sentenceCase' })}
size="sm"
value={searchTerm}
/>
);
return (
<ContextMenu.Submenu isCloseDisabled>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="playlist"
onSelect={handleOpenModal}
rightIcon="arrowRightS"
>
{t('page.contextMenu.addToPlaylist', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent stickyContent={searchInput}>
{playlistsQuery.isLoading && (
<ContextMenu.Item disabled>
<Spinner container />
</ContextMenu.Item>
)}
{playlistsQuery.isError && (
<ContextMenu.Item disabled>
{t('error.genericError', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
)}
{recentPlaylist && (
<>
<ContextMenu.Item
key={recentPlaylist.id}
onSelect={() => handleAddToPlaylist(recentPlaylist.id)}
>
{recentPlaylist.name}
</ContextMenu.Item>
{filteredPlaylists.length > 0 && <ContextMenu.Divider />}
</>
)}
{filteredPlaylists.length === 0 && !playlistsQuery.isLoading && (
<ContextMenu.Item disabled>
{t('common.noResultsFromQuery', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
)}
{filteredPlaylists.map((playlist) => (
<ContextMenu.Item
key={playlist.id}
onSelect={() => handleAddToPlaylist(playlist.id)}
>
{playlist.name}
</ContextMenu.Item>
))}
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,72 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServerId } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { toast } from '/@/shared/components/toast/toast';
import { Playlist } from '/@/shared/types/domain-types';
interface DeletePlaylistActionProps {
items: Playlist[];
}
export const DeletePlaylistAction = ({ items }: DeletePlaylistActionProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const serverId = useCurrentServerId();
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
if (items.length === 0 || !serverId) return;
const playlist = items[0];
deletePlaylistMutation.mutate(
{
apiClientProps: { serverId },
query: { id: playlist.id },
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
navigate(AppRoute.PLAYLISTS, { replace: true });
toast.success({
message: t('action.deletePlaylist', { postProcess: 'sentenceCase' }),
});
},
},
);
closeAllModals();
}, [deletePlaylistMutation, items, navigate, serverId, t]);
const openDeletePlaylistModal = useCallback(() => {
if (items.length === 0) return;
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
{t('common.areYouSure', { postProcess: 'sentenceCase' })}
</ConfirmModal>
),
title: t('form.deletePlaylist.title', { postProcess: 'sentenceCase' }),
});
}, [handleDeletePlaylist, items.length, t]);
if (items.length === 0) return null;
return (
<ContextMenu.Item leftIcon="remove" onSelect={openDeletePlaylistModal}>
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
);
};
@@ -0,0 +1,51 @@
import isElectron from 'is-electron';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '/@/renderer/api';
import { useCurrentServer } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { toast } from '/@/shared/components/toast/toast';
interface DownloadActionProps {
ids: string[];
}
const utils = isElectron() ? window.api.utils : null;
export const DownloadAction = ({ ids }: DownloadActionProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const onSelect = useCallback(async () => {
if (!utils) {
return;
}
try {
for (const id of ids) {
const downloadUrl = api.controller.getDownloadUrl({
apiClientProps: { serverId: server.id },
query: { id },
});
utils.download(downloadUrl);
}
toast.success({
message: t('action.downloadStarted', {
count: ids.length,
postProcess: 'sentenceCase',
}),
});
} catch (error) {
console.error('Failed to download items:', error);
}
}, [ids, server, t]);
return (
<ContextMenu.Item disabled={ids.length > 1} leftIcon="download" onSelect={onSelect}>
{t('page.contextMenu.download', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
);
};
@@ -0,0 +1,36 @@
import { openModal } from '@mantine/modals';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
ItemDetailsModal,
ItemDetailsModalProps,
} from '/@/renderer/features/item-details/components/item-details-modal';
import { useCurrentServer } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
interface GetInfoActionProps {
disabled?: boolean;
item: ItemDetailsModalProps['item'];
}
export const GetInfoAction = ({ disabled, item }: GetInfoActionProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const onSelect = useCallback(async () => {
if (!server) return;
openModal({
children: <ItemDetailsModal item={item} />,
size: 'lg',
title: item.name || t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),
});
}, [item, server, t]);
return (
<ContextMenu.Item disabled={disabled} leftIcon="info" onSelect={onSelect}>
{t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
);
};
@@ -0,0 +1,96 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
QueueSong,
Song,
} from '/@/shared/types/domain-types';
interface GoToActionProps {
items: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[];
}
export const GoToAction = ({ items }: GoToActionProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { albumArtists, albumId } = useMemo(() => {
const firstItem = items[0];
if (firstItem._itemType === LibraryItem.ALBUM) {
return {
albumArtists: firstItem.albumArtists || [],
albumId: firstItem.id,
};
} else if (firstItem._itemType === LibraryItem.SONG) {
return {
albumArtists: firstItem.albumArtists || [],
albumId: firstItem.albumId,
};
} else if (
firstItem._itemType === LibraryItem.ARTIST ||
firstItem._itemType === LibraryItem.ALBUM_ARTIST
) {
return {
albumArtists: [{ id: firstItem.id, name: firstItem.name }],
albumId: null,
};
}
return {
albumArtists: [],
albumId: null,
};
}, [items]);
const handleGoToAlbum = useCallback(() => {
if (!albumId) return;
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId }));
}, [albumId, navigate]);
const handleGoToAlbumArtist = useCallback(
(albumArtistId: string) => {
navigate(generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { albumArtistId }));
},
[navigate],
);
const hasAlbum = !!albumId;
return (
<ContextMenu.Submenu disabled={items.length !== 1}>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="externalLink"
onSelect={(e) => e.preventDefault()}
rightIcon="arrowRightS"
>
{t('page.contextMenu.goTo', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
{hasAlbum && (
<ContextMenu.Item leftIcon="album" onSelect={handleGoToAlbum}>
{t('page.contextMenu.goToAlbum', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
)}
{albumArtists.map((albumArtist) => (
<ContextMenu.Item
key={albumArtist.id}
leftIcon="artist"
onSelect={() => handleGoToAlbumArtist(albumArtist.id)}
>
{`${t('page.contextMenu.goTo', { postProcess: 'sentenceCase' })} ${albumArtist.name}`}
</ContextMenu.Item>
))}
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,52 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { QueueSong } from '/@/shared/types/domain-types';
interface MoveQueueItemsActionProps {
items: QueueSong[];
}
export const MoveQueueItemsAction = ({ items }: MoveQueueItemsActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const handleMoveToTop = useCallback(() => {
player.moveSelectedToTop(items);
}, [items, player]);
const handleMoveToNext = useCallback(() => {
player.moveSelectedToNext(items);
}, [items, player]);
const handleMoveToBottom = useCallback(() => {
player.moveSelectedToBottom(items);
}, [items, player]);
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="dragVertical"
onSelect={(e) => e.preventDefault()}
rightIcon="arrowRightS"
>
{t('page.contextMenu.moveItems', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item leftIcon="arrowUpToLine" onSelect={handleMoveToTop}>
{t('page.contextMenu.moveToTop', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayNext" onSelect={handleMoveToNext}>
{t('page.contextMenu.moveToNext', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="arrowDownToLine" onSelect={handleMoveToBottom}>
{t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,73 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useCurrentServerId } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface PlayActionProps {
ids: string[];
itemType: LibraryItem;
}
export const PlayAction = ({ ids, itemType }: PlayActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const serverId = useCurrentServerId();
const handlePlay = useCallback(
(playType: Play) => {
if (ids.length === 0 || !serverId) return;
player.addToQueueByFetch(serverId, ids, itemType, playType);
},
[ids, itemType, player, serverId],
);
const handlePlayNow = useCallback(() => {
handlePlay(Play.NOW);
}, [handlePlay]);
const handlePlayNext = useCallback(() => {
handlePlay(Play.NEXT);
}, [handlePlay]);
const handlePlayLast = useCallback(() => {
handlePlay(Play.LAST);
}, [handlePlay]);
const handlePlayShuffled = useCallback(() => {
handlePlay(Play.SHUFFLE);
}, [handlePlay]);
if (ids.length === 0) return null;
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="mediaPlay"
onSelect={(e) => e.preventDefault()}
rightIcon="arrowRightS"
>
{t('player.play', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item leftIcon="mediaPlay" onSelect={handlePlayNow}>
{t('player.play', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayNext" onSelect={handlePlayNext}>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaPlayLast" onSelect={handlePlayLast}>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="mediaShuffle" onSelect={handlePlayShuffled}>
{t('player.shuffle', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,60 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCurrentServerId } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { toast } from '/@/shared/components/toast/toast';
import { Song } from '/@/shared/types/domain-types';
interface RemoveFromPlaylistActionProps {
items: Song[];
}
export const RemoveFromPlaylistAction = ({ items }: RemoveFromPlaylistActionProps) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const { playlistId } = useParams() as { playlistId?: string };
const removeFromPlaylistMutation = useRemoveFromPlaylist();
const { ids } = useMemo(() => {
const ids = items.map((item) => item.id);
return { ids };
}, [items]);
const handleRemoveFromPlaylist = useCallback(() => {
if (ids.length === 0 || !serverId || !playlistId) return;
removeFromPlaylistMutation.mutate(
{
apiClientProps: { serverId },
query: {
id: playlistId,
songId: ids,
},
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: t('action.removeFromPlaylist', { postProcess: 'sentenceCase' }),
});
},
},
);
}, [ids, playlistId, removeFromPlaylistMutation, serverId, t]);
if (ids.length === 0 || !playlistId) return null;
return (
<ContextMenu.Item leftIcon="remove" onSelect={handleRemoveFromPlaylist}>
{t('action.removeFromPlaylist', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
);
};
@@ -0,0 +1,25 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { QueueSong } from '/@/shared/types/domain-types';
interface RemoveFromQueueActionProps {
items: QueueSong[];
}
export const RemoveFromQueueAction = ({ items }: RemoveFromQueueActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const onSelect = useCallback(() => {
player.clearSelected(items);
}, [items, player]);
return (
<ContextMenu.Item leftIcon="remove" onSelect={onSelect}>
{t('action.removeFromQueue', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
);
};
@@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useCurrentServerId } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { LibraryItem } from '/@/shared/types/domain-types';
interface SetFavoriteActionProps {
ids: string[];
itemType: LibraryItem;
}
export const SetFavoriteAction = ({ ids, itemType }: SetFavoriteActionProps) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleAddToFavorites = useCallback(() => {
if (ids.length === 0 || !serverId) return;
createFavoriteMutation.mutate({
apiClientProps: { serverId },
query: {
id: ids,
type: itemType,
},
});
}, [createFavoriteMutation, ids, itemType, serverId]);
const handleRemoveFromFavorites = useCallback(() => {
if (ids.length === 0 || !serverId) return;
deleteFavoriteMutation.mutate({
apiClientProps: { serverId },
query: {
id: ids,
type: itemType,
},
});
}, [deleteFavoriteMutation, ids, itemType, serverId]);
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="favorite"
onSelect={(e) => e.preventDefault()}
rightIcon="arrowRightS"
>
{t('common.favorite', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item leftIcon="favorite" onSelect={handleAddToFavorites}>
{t('action.addToFavorites', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item leftIcon="unfavorite" onSelect={handleRemoveFromFavorites}>
{t('action.removeFromFavorites', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { useCurrentServerId } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Rating } from '/@/shared/components/rating/rating';
import { LibraryItem } from '/@/shared/types/domain-types';
interface SetRatingActionProps {
ids: string[];
itemType: LibraryItem;
}
export const SetRatingAction = ({ ids, itemType }: SetRatingActionProps) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const setRatingMutation = useSetRating({});
const onRating = (rating: number) => {
setRatingMutation.mutate({
apiClientProps: { serverId },
query: {
id: ids,
rating,
type: itemType,
},
});
};
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="star"
onSelect={(e) => e.preventDefault()}
rightIcon="arrowRightS"
>
{t('action.setRating', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item onSelect={() => onRating(0)}>
<Rating readOnly value={0} />
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => onRating(1)}>
<Rating readOnly value={1} />
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => onRating(2)}>
<Rating readOnly value={2} />
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => onRating(3)}>
<Rating readOnly value={3} />
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => onRating(4)}>
<Rating readOnly value={4} />
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => onRating(5)}>
<Rating readOnly value={5} />
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};
@@ -0,0 +1,47 @@
import { openContextModal } from '@mantine/modals';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { LibraryItem } from '/@/shared/types/domain-types';
interface ShareActionProps {
ids: string[];
itemType: LibraryItem;
}
export const ShareAction = ({ ids, itemType }: ShareActionProps) => {
const { t } = useTranslation();
const resourceType = useMemo(() => {
switch (itemType) {
case LibraryItem.ALBUM:
return 'album';
case LibraryItem.ALBUM_ARTIST:
return 'albumArtist';
case LibraryItem.PLAYLIST:
return 'playlist';
case LibraryItem.SONG:
return 'song';
default:
return 'song';
}
}, [itemType]);
const onSelect = useCallback(() => {
openContextModal({
innerProps: {
itemIds: ids,
resourceType,
},
modal: 'shareItem',
title: t('page.contextMenu.shareItem', { postProcess: 'titleCase' }),
});
}, [ids, resourceType, t]);
return (
<ContextMenu.Item leftIcon="share" onSelect={onSelect}>
{t('page.contextMenu.shareItem', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
);
};
@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { QueueSong } from '/@/shared/types/domain-types';
interface ShuffleItemsActionProps {
items: QueueSong[];
}
export const ShuffleItemsAction = ({ items }: ShuffleItemsActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const handleShuffleSelected = useCallback(() => {
player.shuffleSelected(items);
}, [items, player]);
const handleShuffleAll = useCallback(() => {
player.shuffleAll();
}, [player]);
return (
<ContextMenu.Submenu>
<ContextMenu.SubmenuTarget>
<ContextMenu.Item
leftIcon="mediaShuffle"
onSelect={(e) => e.preventDefault()}
rightIcon="arrowRightS"
>
{t('action.shuffle', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuTarget>
<ContextMenu.SubmenuContent>
<ContextMenu.Item onSelect={handleShuffleSelected}>
{t('action.shuffleSelected', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
<ContextMenu.Item onSelect={handleShuffleAll}>
{t('action.shuffleAll', { postProcess: 'sentenceCase' })}
</ContextMenu.Item>
</ContextMenu.SubmenuContent>
</ContextMenu.Submenu>
);
};