mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
595 lines
23 KiB
TypeScript
595 lines
23 KiB
TypeScript
import { closeModal, ContextModalProps } from '@mantine/modals';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import styles from './add-to-playlist-context-modal.module.css';
|
|
|
|
import { api } from '/@/renderer/api';
|
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
|
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
|
import {
|
|
getAlbumSongsById,
|
|
getArtistSongsById,
|
|
getGenreSongsById,
|
|
getPlaylistSongsById,
|
|
getSongsByFolder,
|
|
} from '/@/renderer/features/player/utils';
|
|
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
|
|
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
|
import { queryClient } from '/@/renderer/lib/react-query';
|
|
import { useCurrentServerId } from '/@/renderer/store';
|
|
import { formatDurationString } from '/@/renderer/utils';
|
|
import { Box } from '/@/shared/components/box/box';
|
|
import { Button } from '/@/shared/components/button/button';
|
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
|
import { Flex } from '/@/shared/components/flex/flex';
|
|
import { Grid } from '/@/shared/components/grid/grid';
|
|
import { Group } from '/@/shared/components/group/group';
|
|
import { Icon } from '/@/shared/components/icon/icon';
|
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
|
import { Pill } from '/@/shared/components/pill/pill';
|
|
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
|
import { Stack } from '/@/shared/components/stack/stack';
|
|
import { Switch } from '/@/shared/components/switch/switch';
|
|
import { Table } from '/@/shared/components/table/table';
|
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
|
import { Text } from '/@/shared/components/text/text';
|
|
import { toast } from '/@/shared/components/toast/toast';
|
|
import { useForm } from '/@/shared/hooks/use-form';
|
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
|
import { LibraryItem, Playlist, PlaylistListSort, SortOrder } from '/@/shared/types/domain-types';
|
|
|
|
export const AddToPlaylistContextModal = ({
|
|
id,
|
|
innerProps,
|
|
}: ContextModalProps<{
|
|
albumId?: string[];
|
|
artistId?: string[];
|
|
folderId?: string[];
|
|
genreId?: string[];
|
|
initialSelectedIds?: string[];
|
|
playlistId?: string[];
|
|
songId?: string[];
|
|
}>) => {
|
|
const { t } = useTranslation();
|
|
const { albumId, artistId, folderId, genreId, initialSelectedIds, playlistId, songId } =
|
|
innerProps;
|
|
const serverId = useCurrentServerId();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [search, setSearch] = useState<string>('');
|
|
const [focusedRowIndex, setFocusedRowIndex] = useState<null | number>(null);
|
|
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
|
|
const [skipDuplicates, setSkipDuplicates] = useLocalStorage({
|
|
defaultValue: true,
|
|
key: 'playlist-skip-duplicate',
|
|
});
|
|
|
|
const form = useForm({
|
|
initialValues: {
|
|
newPlaylists: [] as string[],
|
|
selectedPlaylistIds: initialSelectedIds || [],
|
|
skipDuplicates: skipDuplicates,
|
|
},
|
|
});
|
|
|
|
form.watch('skipDuplicates', (event) => {
|
|
setSkipDuplicates(event.value);
|
|
});
|
|
|
|
const addToPlaylistMutation = useAddToPlaylist({});
|
|
|
|
const playlistList = useQuery(
|
|
playlistsQueries.list({
|
|
query: {
|
|
excludeSmartPlaylists: true,
|
|
sortBy: PlaylistListSort.NAME,
|
|
sortOrder: SortOrder.ASC,
|
|
startIndex: 0,
|
|
},
|
|
serverId,
|
|
}),
|
|
);
|
|
|
|
const [playlistSelect, playlistMap] = useMemo(() => {
|
|
const existingPlaylists = new Array<Playlist & { label: string; value: string }>();
|
|
const playlistMap = new Map<string, string>();
|
|
|
|
for (const playlist of playlistList.data?.items ?? []) {
|
|
existingPlaylists.push({ ...playlist, label: playlist.name, value: playlist.id });
|
|
playlistMap.set(playlist.id, playlist.name);
|
|
}
|
|
|
|
return [existingPlaylists, playlistMap];
|
|
}, [playlistList.data]);
|
|
|
|
const filteredItems = useMemo(() => {
|
|
if (search) {
|
|
return playlistSelect.filter((item) =>
|
|
item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
|
);
|
|
}
|
|
|
|
return playlistSelect;
|
|
}, [playlistSelect, search]);
|
|
|
|
const getSongsByAlbum = useCallback(
|
|
async (albumId: string) => {
|
|
return getAlbumSongsById({
|
|
id: [albumId],
|
|
queryClient,
|
|
serverId,
|
|
});
|
|
},
|
|
[serverId],
|
|
);
|
|
|
|
const getSongsByArtist = useCallback(
|
|
async (artistId: string) => {
|
|
return getArtistSongsById({
|
|
id: [artistId],
|
|
queryClient,
|
|
serverId,
|
|
});
|
|
},
|
|
[serverId],
|
|
);
|
|
|
|
const getSongsByPlaylist = useCallback(
|
|
async (playlistId: string) => {
|
|
return getPlaylistSongsById({
|
|
id: playlistId,
|
|
queryClient,
|
|
serverId,
|
|
});
|
|
},
|
|
[serverId],
|
|
);
|
|
|
|
const handleSubmit = form.onSubmit(async (values) => {
|
|
if (isLoading) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
const allSongIds: string[] = [];
|
|
let totalUniquesAdded = 0;
|
|
|
|
try {
|
|
if (albumId && albumId.length > 0) {
|
|
for (const id of albumId) {
|
|
const songs = await getSongsByAlbum(id);
|
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
|
}
|
|
}
|
|
|
|
if (artistId && artistId.length > 0) {
|
|
for (const id of artistId) {
|
|
const songs = await getSongsByArtist(id);
|
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
|
}
|
|
}
|
|
|
|
if (genreId && genreId.length > 0) {
|
|
const songs = await getGenreSongsById({
|
|
id: genreId,
|
|
queryClient,
|
|
serverId,
|
|
});
|
|
|
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
|
}
|
|
|
|
if (folderId && folderId.length > 0) {
|
|
const songs = await getSongsByFolder({
|
|
id: folderId,
|
|
queryClient,
|
|
serverId,
|
|
});
|
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
|
}
|
|
|
|
if (playlistId && playlistId.length > 0) {
|
|
for (const id of playlistId) {
|
|
const songs = await getSongsByPlaylist(id);
|
|
allSongIds.push(...(songs?.items?.map((song) => song.id) || []));
|
|
}
|
|
}
|
|
|
|
if (songId && songId.length > 0) {
|
|
allSongIds.push(...songId);
|
|
}
|
|
|
|
const playlistIds = [...values.selectedPlaylistIds];
|
|
|
|
if (values.newPlaylists) {
|
|
for (const playlist of values.newPlaylists) {
|
|
try {
|
|
const response = await api.controller.createPlaylist({
|
|
apiClientProps: { serverId },
|
|
body: {
|
|
name: playlist,
|
|
public: false,
|
|
},
|
|
});
|
|
|
|
if (response?.id) {
|
|
playlistIds.push(response?.id);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error({
|
|
message: `[${playlist}] ${error?.message}`,
|
|
title: t('error.genericError'),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const playlistId of playlistIds) {
|
|
const uniqueSongIds: string[] = [];
|
|
|
|
if (values.skipDuplicates) {
|
|
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
|
|
|
|
const playlistSongsRes = await queryClient.fetchQuery({
|
|
queryFn: ({ signal }) => {
|
|
return api.controller.getPlaylistSongList({
|
|
apiClientProps: {
|
|
serverId,
|
|
signal,
|
|
},
|
|
query: {
|
|
id: playlistId,
|
|
},
|
|
});
|
|
},
|
|
queryKey,
|
|
});
|
|
|
|
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
|
|
|
|
for (const songId of allSongIds) {
|
|
if (!playlistSongIds?.includes(songId)) {
|
|
uniqueSongIds.push(songId);
|
|
}
|
|
}
|
|
totalUniquesAdded += uniqueSongIds.length;
|
|
}
|
|
|
|
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
|
addToPlaylistMutation.mutate(
|
|
{
|
|
apiClientProps: { serverId },
|
|
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
|
query: { id: playlistId },
|
|
},
|
|
{
|
|
onError: (err) => {
|
|
toast.error({
|
|
message: `[${
|
|
playlistSelect.find(
|
|
(playlist) => playlist.value === playlistId,
|
|
)?.label
|
|
}] ${err.message}`,
|
|
title: t('error.genericError'),
|
|
});
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const addMessage =
|
|
values.skipDuplicates &&
|
|
allSongIds.length * playlistIds.length !== totalUniquesAdded
|
|
? Math.floor(totalUniquesAdded / playlistIds.length)
|
|
: allSongIds.length;
|
|
|
|
setIsLoading(false);
|
|
toast.success({
|
|
message: t('form.addToPlaylist.success', {
|
|
message: addMessage,
|
|
numOfPlaylists: playlistIds.length,
|
|
}),
|
|
});
|
|
closeModal(id);
|
|
} catch (error: any) {
|
|
setIsLoading(false);
|
|
toast.error({
|
|
message: error?.message || t('error.genericError'),
|
|
title: t('error.genericError'),
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleSelectItem = useCallback(
|
|
(item: { value: string }) => {
|
|
const currentIds = form.values.selectedPlaylistIds;
|
|
if (currentIds.includes(item.value)) {
|
|
form.setFieldValue(
|
|
'selectedPlaylistIds',
|
|
currentIds.filter((id) => id !== item.value),
|
|
);
|
|
} else {
|
|
form.setFieldValue('selectedPlaylistIds', [...currentIds, item.value]);
|
|
}
|
|
},
|
|
[form],
|
|
);
|
|
|
|
const handleCheckboxChange = useCallback(
|
|
(itemValue: string, checked: boolean) => {
|
|
const currentIds = form.values.selectedPlaylistIds;
|
|
if (checked) {
|
|
form.setFieldValue('selectedPlaylistIds', [...currentIds, itemValue]);
|
|
} else {
|
|
form.setFieldValue(
|
|
'selectedPlaylistIds',
|
|
currentIds.filter((id) => id !== itemValue),
|
|
);
|
|
}
|
|
},
|
|
[form],
|
|
);
|
|
|
|
const handleCreatePlaylist = useCallback(() => {
|
|
form.setFieldValue('newPlaylists', [...form.values.newPlaylists, search]);
|
|
setSearch('');
|
|
}, [form, search]);
|
|
|
|
const handleRemoveSelectedPlaylist = useCallback(
|
|
(playlistId: string) => {
|
|
form.setFieldValue(
|
|
'selectedPlaylistIds',
|
|
form.values.selectedPlaylistIds.filter((id) => id !== playlistId),
|
|
);
|
|
},
|
|
[form],
|
|
);
|
|
|
|
const handleRemoveNewPlaylist = useCallback(
|
|
(index: number) => {
|
|
form.setFieldValue(
|
|
'newPlaylists',
|
|
form.values.newPlaylists.filter((_, existingIdx) => index !== existingIdx),
|
|
);
|
|
},
|
|
[form],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(
|
|
event: React.KeyboardEvent<HTMLTableRowElement>,
|
|
index: number,
|
|
item: { value: string },
|
|
) => {
|
|
const totalRows = filteredItems.length;
|
|
|
|
switch (event.key) {
|
|
case ' ': {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleSelectItem(item);
|
|
break;
|
|
}
|
|
case 'ArrowDown': {
|
|
event.preventDefault();
|
|
const nextIndex = index < totalRows - 1 ? index + 1 : index;
|
|
setFocusedRowIndex(nextIndex);
|
|
rowRefs.current[nextIndex]?.focus();
|
|
break;
|
|
}
|
|
case 'ArrowUp': {
|
|
event.preventDefault();
|
|
const prevIndex = index > 0 ? index - 1 : 0;
|
|
setFocusedRowIndex(prevIndex);
|
|
rowRefs.current[prevIndex]?.focus();
|
|
break;
|
|
}
|
|
case 'Enter': {
|
|
event.preventDefault();
|
|
if (formRef.current) {
|
|
formRef.current.requestSubmit();
|
|
}
|
|
break;
|
|
}
|
|
case 'Tab': {
|
|
// Allow Tab to exit the table naturally - don't prevent default
|
|
setFocusedRowIndex(null);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
[filteredItems.length, handleSelectItem],
|
|
);
|
|
|
|
const setRowRef = useCallback(
|
|
(index: number) => (el: HTMLTableRowElement | null) => {
|
|
rowRefs.current[index] = el;
|
|
},
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<Box>
|
|
<form onSubmit={handleSubmit} ref={formRef}>
|
|
<Stack>
|
|
<TextInput
|
|
data-autofocus
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('form.addToPlaylist.searchOrCreate')}
|
|
value={search}
|
|
/>
|
|
<ScrollArea style={{ maxHeight: '18rem' }}>
|
|
<Table styles={{ td: { padding: 'var(--theme-spacing-sm)' } }}>
|
|
<Table.Tbody>
|
|
{filteredItems.map((item, index) => (
|
|
<Table.Tr
|
|
key={item.value}
|
|
onBlur={() => setFocusedRowIndex(null)}
|
|
onClick={() => handleSelectItem(item)}
|
|
onFocus={() => setFocusedRowIndex(index)}
|
|
onKeyDown={(e) => handleKeyDown(e, index, item)}
|
|
ref={setRowRef(index)}
|
|
role="button"
|
|
style={{
|
|
background:
|
|
focusedRowIndex === index
|
|
? 'var(--theme-colors-surface)'
|
|
: 'transparent',
|
|
cursor: 'pointer',
|
|
outline: 'none',
|
|
}}
|
|
tabIndex={index === 0 ? 0 : -1}
|
|
>
|
|
<Table.Td w={10}>
|
|
<Checkbox
|
|
checked={form.values.selectedPlaylistIds.includes(
|
|
item.value,
|
|
)}
|
|
onChange={(event) => {
|
|
handleCheckboxChange(
|
|
item.value,
|
|
event.target.checked,
|
|
);
|
|
event.preventDefault();
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
tabIndex={-1}
|
|
/>
|
|
</Table.Td>
|
|
<Table.Td style={{ maxWidth: 0, width: '100%' }}>
|
|
<PlaylistTableItem item={item} />
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</ScrollArea>
|
|
{search && (
|
|
<Button
|
|
leftSection={<Icon icon="add" size="lg" />}
|
|
onClick={handleCreatePlaylist}
|
|
variant="subtle"
|
|
w="100%"
|
|
>
|
|
{t('form.addToPlaylist.create', {
|
|
playlist: search,
|
|
})}
|
|
</Button>
|
|
)}
|
|
<Pill.Group>
|
|
{form.values.selectedPlaylistIds.map((item) => (
|
|
<Pill
|
|
key={item}
|
|
onRemove={() => handleRemoveSelectedPlaylist(item)}
|
|
withRemoveButton
|
|
>
|
|
{playlistMap.get(item)}
|
|
</Pill>
|
|
))}
|
|
{form.values.newPlaylists.map((item, idx) => (
|
|
<Pill
|
|
key={idx}
|
|
onRemove={() => handleRemoveNewPlaylist(idx)}
|
|
withRemoveButton
|
|
>
|
|
<Flex align="center" gap="lg" wrap="nowrap">
|
|
<Icon icon="plus" />
|
|
{item}
|
|
</Flex>
|
|
</Pill>
|
|
))}
|
|
</Pill.Group>
|
|
<Switch
|
|
label={t('form.addToPlaylist.input', {
|
|
context: 'skipDuplicates',
|
|
})}
|
|
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<ModalButton
|
|
disabled={isLoading || addToPlaylistMutation.isPending}
|
|
onClick={() => closeModal(id)}
|
|
uppercase
|
|
variant="subtle"
|
|
>
|
|
{t('common.cancel')}
|
|
</ModalButton>
|
|
<ModalButton
|
|
disabled={
|
|
isLoading ||
|
|
addToPlaylistMutation.isPending ||
|
|
(form.values.selectedPlaylistIds.length === 0 &&
|
|
form.values.newPlaylists.length === 0)
|
|
}
|
|
loading={isLoading}
|
|
type="submit"
|
|
uppercase
|
|
variant="filled"
|
|
>
|
|
{t('common.add')}
|
|
</ModalButton>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const PlaylistTableItem = memo(
|
|
({ item }: { item: Playlist & { label: string; value: string } }) => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<Box className={styles.container} w="100%">
|
|
<Grid align="center" gap="xs" w="100%">
|
|
<Grid.Col span="content">
|
|
<Flex align="center" justify="center" px="sm">
|
|
<ItemImage
|
|
id={item.imageId}
|
|
imageContainerProps={{
|
|
className: styles.imageContainer,
|
|
}}
|
|
itemType={LibraryItem.PLAYLIST}
|
|
type="table"
|
|
/>
|
|
</Flex>
|
|
</Grid.Col>
|
|
<Grid.Col className={styles.gridCol} span="auto">
|
|
<Stack gap="xs" w="100%">
|
|
<Text className={styles.labelText} isNoSelect overflow="hidden">
|
|
{item.label}
|
|
</Text>
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Group gap="md" wrap="nowrap">
|
|
<Group align="center" gap="xs" wrap="nowrap">
|
|
<Icon color="muted" icon="track" size="sm" />
|
|
<Text isMuted size="sm">
|
|
{item.songCount}
|
|
</Text>
|
|
</Group>
|
|
<Group align="center" gap="xs" wrap="nowrap">
|
|
<Icon color="muted" icon="duration" size="sm" />
|
|
<Text isMuted size="sm">
|
|
{formatDurationString(item.duration ?? 0)}
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Text className={styles.statusText} isMuted size="sm">
|
|
{item.public ? t('common.public') : t('common.private')}
|
|
</Text>
|
|
</Group>
|
|
</Stack>
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|