mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 02:16:39 +02:00
feat: added playlist from queue creation (#2210)
* feat: added playlist from queue creation - added functionality to create a playlist from queue - prefilling is done as an extra function to be api agnostic since navidrome native api for example does not offer a parameter for filling a playlist with songs on creation #2204 * fix: fixed wrong declaration
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"collapseAllFolders": "Collapse all folders",
|
"collapseAllFolders": "Collapse all folders",
|
||||||
"expandAllFolders": "Expand all folders",
|
"expandAllFolders": "Expand all folders",
|
||||||
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
||||||
|
"createPlaylistFromQueue": "Create $t(entity.playlist, {\"count\": 1}) from queue",
|
||||||
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
||||||
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
||||||
"deleteRadioStation": "Delete $t(entity.radioStation, {\"count\": 1})",
|
"deleteRadioStation": "Delete $t(entity.radioStation, {\"count\": 1})",
|
||||||
@@ -365,6 +366,9 @@
|
|||||||
"success": "$t(entity.playlist, {\"count\": 1}) created successfully",
|
"success": "$t(entity.playlist, {\"count\": 1}) created successfully",
|
||||||
"title": "Create $t(entity.playlist, {\"count\": 1})"
|
"title": "Create $t(entity.playlist, {\"count\": 1})"
|
||||||
},
|
},
|
||||||
|
"createPrefilledPlaylist": {
|
||||||
|
"title": "Create $t(entity.playlist, {\"count\": 1}) from selected songs"
|
||||||
|
},
|
||||||
"createRadioStation": {
|
"createRadioStation": {
|
||||||
"success": "Radio station created successfully",
|
"success": "Radio station created successfully",
|
||||||
"title": "Create radio station",
|
"title": "Create radio station",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useIsFetching } from '@tanstack/react-query';
|
import { useIsFetching } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { RefObject, useCallback } from 'react';
|
import { MouseEvent, RefObject, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import styles from './play-queue-list-controls.module.css';
|
import styles from './play-queue-list-controls.module.css';
|
||||||
@@ -10,6 +10,7 @@ import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-
|
|||||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
|
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||||
|
import { openCreatePrefilledPlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
|
||||||
import {
|
import {
|
||||||
ListConfigMenu,
|
ListConfigMenu,
|
||||||
SONG_DISPLAY_TYPES,
|
SONG_DISPLAY_TYPES,
|
||||||
@@ -53,6 +54,7 @@ export const PlayQueueListControls = ({
|
|||||||
<Group gap="xs" style={{ flexShrink: 0 }} wrap="nowrap">
|
<Group gap="xs" style={{ flexShrink: 0 }} wrap="nowrap">
|
||||||
<QueueRestoreActions />
|
<QueueRestoreActions />
|
||||||
<QueuePlaybackIcons tableRef={tableRef} />
|
<QueuePlaybackIcons tableRef={tableRef} />
|
||||||
|
<QueuePlaylistIcons />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider h="60%" orientation="vertical" style={{ alignSelf: 'center' }} />
|
<Divider h="60%" orientation="vertical" style={{ alignSelf: 'center' }} />
|
||||||
<Box style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<Box style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
@@ -84,6 +86,29 @@ export const PlayQueueListControls = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QueuePlaylistIcons = () => {
|
||||||
|
const server = useCurrentServer();
|
||||||
|
const player = usePlayer();
|
||||||
|
|
||||||
|
const handleCreatePlaylistFromQueue = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const queueSongs = player.getQueue();
|
||||||
|
|
||||||
|
openCreatePrefilledPlaylistModal(server, queueSongs, e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActionIcon
|
||||||
|
icon="playlistAdd"
|
||||||
|
iconProps={{ size: 'lg' }}
|
||||||
|
onClick={handleCreatePlaylistFromQueue}
|
||||||
|
tooltip={{ label: t('action.createPlaylistFromQueue') }}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const QueuePlaybackIcons = ({ tableRef }: { tableRef: RefObject<ItemListHandle | null> }) => {
|
const QueuePlaybackIcons = ({ tableRef }: { tableRef: RefObject<ItemListHandle | null> }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface PlayerContext {
|
|||||||
clearQueue: () => void;
|
clearQueue: () => void;
|
||||||
clearSelected: (items: QueueSong[]) => void;
|
clearSelected: (items: QueueSong[]) => void;
|
||||||
decreaseVolume: (amount: number) => void;
|
decreaseVolume: (amount: number) => void;
|
||||||
|
getQueue: () => QueueSong[];
|
||||||
increaseVolume: (amount: number) => void;
|
increaseVolume: (amount: number) => void;
|
||||||
mediaNext: (toNextAlbum: boolean) => void;
|
mediaNext: (toNextAlbum: boolean) => void;
|
||||||
mediaPause: () => void;
|
mediaPause: () => void;
|
||||||
@@ -95,6 +96,7 @@ export const PlayerContext = createContext<PlayerContext>({
|
|||||||
clearQueue: () => {},
|
clearQueue: () => {},
|
||||||
clearSelected: () => {},
|
clearSelected: () => {},
|
||||||
decreaseVolume: () => {},
|
decreaseVolume: () => {},
|
||||||
|
getQueue: () => [],
|
||||||
increaseVolume: () => {},
|
increaseVolume: () => {},
|
||||||
mediaNext: () => {},
|
mediaNext: () => {},
|
||||||
mediaPause: () => {},
|
mediaPause: () => {},
|
||||||
@@ -567,6 +569,15 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
[storeActions],
|
[storeActions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getQueue = useCallback(() => {
|
||||||
|
logFn.debug(logMsg[LogCategory.PLAYER].clearQueue, {
|
||||||
|
category: LogCategory.PLAYER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queue = storeActions.getQueue();
|
||||||
|
return queue.items;
|
||||||
|
}, [storeActions]);
|
||||||
|
|
||||||
const increaseVolume = useCallback(
|
const increaseVolume = useCallback(
|
||||||
(amount: number) => {
|
(amount: number) => {
|
||||||
logFn.debug(logMsg[LogCategory.PLAYER].increaseVolume, {
|
logFn.debug(logMsg[LogCategory.PLAYER].increaseVolume, {
|
||||||
@@ -853,6 +864,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
clearQueue,
|
clearQueue,
|
||||||
clearSelected,
|
clearSelected,
|
||||||
decreaseVolume,
|
decreaseVolume,
|
||||||
|
getQueue,
|
||||||
increaseVolume,
|
increaseVolume,
|
||||||
mediaNext,
|
mediaNext,
|
||||||
mediaPause,
|
mediaPause,
|
||||||
@@ -887,6 +899,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
clearQueue,
|
clearQueue,
|
||||||
clearSelected,
|
clearSelected,
|
||||||
decreaseVolume,
|
decreaseVolume,
|
||||||
|
getQueue,
|
||||||
increaseVolume,
|
increaseVolume,
|
||||||
mediaNext,
|
mediaNext,
|
||||||
mediaPause,
|
mediaPause,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PlaylistQueryBuilder,
|
PlaylistQueryBuilder,
|
||||||
PlaylistQueryBuilderRef,
|
PlaylistQueryBuilderRef,
|
||||||
} from '/@/renderer/features/playlists/components/playlist-query-builder';
|
} from '/@/renderer/features/playlists/components/playlist-query-builder';
|
||||||
|
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
|
||||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||||
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
@@ -24,17 +25,22 @@ import {
|
|||||||
CreatePlaylistBody,
|
CreatePlaylistBody,
|
||||||
ServerListItem,
|
ServerListItem,
|
||||||
ServerType,
|
ServerType,
|
||||||
|
Song,
|
||||||
SongListSort,
|
SongListSort,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
interface CreatePlaylistFormProps {
|
interface CreatePlaylistFormProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
songs?: Song[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
export const CreatePlaylistForm = ({ onCancel, songs }: CreatePlaylistFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mutation = useCreatePlaylist({});
|
|
||||||
|
const createPlaylistMutation = useCreatePlaylist({});
|
||||||
|
const addToPlaylistMutation = useAddToPlaylist({});
|
||||||
|
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
||||||
|
|
||||||
@@ -48,6 +54,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
|
||||||
|
const isPrefilledPlaylist = !!songs && songs.length > 0;
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit((values) => {
|
const handleSubmit = form.onSubmit((values) => {
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
|
|
||||||
@@ -78,7 +86,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
mutation.mutate(
|
createPlaylistMutation.mutate(
|
||||||
{
|
{
|
||||||
apiClientProps: { serverId: server.id },
|
apiClientProps: { serverId: server.id },
|
||||||
body: {
|
body: {
|
||||||
@@ -93,18 +101,45 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
title: t('error.genericError'),
|
title: t('error.genericError'),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
toast.success({
|
toast.success({
|
||||||
message: t('form.createPlaylist.success'),
|
message: t('form.createPlaylist.success'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handlePlaylistPrefilling(data?.id);
|
||||||
|
|
||||||
onCancel();
|
onCancel();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handlePlaylistPrefilling = (playlistId?: string) => {
|
||||||
|
if (!songs || !playlistId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSongIds = songs.map((song) => song.id);
|
||||||
|
|
||||||
|
addToPlaylistMutation.mutate(
|
||||||
|
{
|
||||||
|
apiClientProps: { serverId: server.id },
|
||||||
|
body: { songId: allSongIds },
|
||||||
|
query: { id: playlistId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error({
|
||||||
|
message: `${err.message}`,
|
||||||
|
title: t('error.genericError'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
|
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
|
||||||
const isSubmitDisabled = !form.values.name || mutation.isPending;
|
const isSubmitDisabled = !form.values.name || createPlaylistMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -141,7 +176,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{server?.type === ServerType.NAVIDROME &&
|
{server?.type === ServerType.NAVIDROME &&
|
||||||
hasFeature(server, ServerFeature.PLAYLISTS_SMART) && (
|
hasFeature(server, ServerFeature.PLAYLISTS_SMART) &&
|
||||||
|
!isPrefilledPlaylist && (
|
||||||
<Switch
|
<Switch
|
||||||
checked={isSmartPlaylist}
|
checked={isSmartPlaylist}
|
||||||
label="Is smart playlist?"
|
label="Is smart playlist?"
|
||||||
@@ -182,7 +218,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
|||||||
</ModalButton>
|
</ModalButton>
|
||||||
<ModalButton
|
<ModalButton
|
||||||
disabled={isSubmitDisabled}
|
disabled={isSubmitDisabled}
|
||||||
loading={mutation.isPending}
|
loading={createPlaylistMutation.isPending}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
@@ -206,3 +242,17 @@ export const openCreatePlaylistModal = (
|
|||||||
title: t('form.createPlaylist.title'),
|
title: t('form.createPlaylist.title'),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const openCreatePrefilledPlaylistModal = (
|
||||||
|
server?: ServerListItem,
|
||||||
|
songs?: Song[],
|
||||||
|
e?: MouseEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
|
||||||
|
openModal({
|
||||||
|
children: <CreatePlaylistForm onCancel={() => closeAllModals()} songs={songs} />,
|
||||||
|
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
|
||||||
|
title: t('form.createPrefilledPlaylist.title'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ interface Actions {
|
|||||||
getCurrentSong: () => QueueSong | undefined;
|
getCurrentSong: () => QueueSong | undefined;
|
||||||
getPlayerData: () => PlayerData;
|
getPlayerData: () => PlayerData;
|
||||||
getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue;
|
getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue;
|
||||||
getQueueOrder: () => {
|
getQueueOrder: () => GroupedQueue;
|
||||||
groups: { count: number; name: string }[];
|
|
||||||
items: QueueSong[];
|
|
||||||
};
|
|
||||||
increaseVolume: (value: number) => void;
|
increaseVolume: (value: number) => void;
|
||||||
isFirstTrackInQueue: () => boolean;
|
isFirstTrackInQueue: () => boolean;
|
||||||
isLastTrackInQueue: () => boolean;
|
isLastTrackInQueue: () => boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user