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:
Kevin
2026-07-17 07:54:42 +02:00
committed by GitHub
parent 49b8c9d08f
commit d99fe8b8c6
5 changed files with 101 additions and 12 deletions
+4
View File
@@ -9,6 +9,7 @@
"collapseAllFolders": "Collapse all folders",
"expandAllFolders": "Expand all folders",
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
"createPlaylistFromQueue": "Create $t(entity.playlist, {\"count\": 1}) from queue",
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "Delete $t(entity.radioStation, {\"count\": 1})",
@@ -365,6 +366,9 @@
"success": "$t(entity.playlist, {\"count\": 1}) created successfully",
"title": "Create $t(entity.playlist, {\"count\": 1})"
},
"createPrefilledPlaylist": {
"title": "Create $t(entity.playlist, {\"count\": 1}) from selected songs"
},
"createRadioStation": {
"success": "Radio station created successfully",
"title": "Create radio station",
@@ -1,6 +1,6 @@
import { useIsFetching } from '@tanstack/react-query';
import { t } from 'i18next';
import { RefObject, useCallback } from 'react';
import { MouseEvent, RefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
import { openCreatePrefilledPlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form';
import {
ListConfigMenu,
SONG_DISPLAY_TYPES,
@@ -53,6 +54,7 @@ export const PlayQueueListControls = ({
<Group gap="xs" style={{ flexShrink: 0 }} wrap="nowrap">
<QueueRestoreActions />
<QueuePlaybackIcons tableRef={tableRef} />
<QueuePlaylistIcons />
</Group>
<Divider h="60%" orientation="vertical" style={{ alignSelf: 'center' }} />
<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 { t } = useTranslation();
const player = usePlayer();
@@ -60,6 +60,7 @@ export interface PlayerContext {
clearQueue: () => void;
clearSelected: (items: QueueSong[]) => void;
decreaseVolume: (amount: number) => void;
getQueue: () => QueueSong[];
increaseVolume: (amount: number) => void;
mediaNext: (toNextAlbum: boolean) => void;
mediaPause: () => void;
@@ -95,6 +96,7 @@ export const PlayerContext = createContext<PlayerContext>({
clearQueue: () => {},
clearSelected: () => {},
decreaseVolume: () => {},
getQueue: () => [],
increaseVolume: () => {},
mediaNext: () => {},
mediaPause: () => {},
@@ -567,6 +569,15 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
[storeActions],
);
const getQueue = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].clearQueue, {
category: LogCategory.PLAYER,
});
const queue = storeActions.getQueue();
return queue.items;
}, [storeActions]);
const increaseVolume = useCallback(
(amount: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].increaseVolume, {
@@ -853,6 +864,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
clearQueue,
clearSelected,
decreaseVolume,
getQueue,
increaseVolume,
mediaNext,
mediaPause,
@@ -887,6 +899,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
clearQueue,
clearSelected,
decreaseVolume,
getQueue,
increaseVolume,
mediaNext,
mediaPause,
@@ -6,6 +6,7 @@ import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
} 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 { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
@@ -24,17 +25,22 @@ import {
CreatePlaylistBody,
ServerListItem,
ServerType,
Song,
SongListSort,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface CreatePlaylistFormProps {
onCancel: () => void;
songs?: Song[];
}
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
export const CreatePlaylistForm = ({ onCancel, songs }: CreatePlaylistFormProps) => {
const { t } = useTranslation();
const mutation = useCreatePlaylist({});
const createPlaylistMutation = useCreatePlaylist({});
const addToPlaylistMutation = useAddToPlaylist({});
const server = useCurrentServer();
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
@@ -48,6 +54,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
const [step, setStep] = useState<1 | 2>(1);
const isPrefilledPlaylist = !!songs && songs.length > 0;
const handleSubmit = form.onSubmit((values) => {
if (!server) return;
@@ -78,7 +86,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
}
: undefined;
mutation.mutate(
createPlaylistMutation.mutate(
{
apiClientProps: { serverId: server.id },
body: {
@@ -93,18 +101,45 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
title: t('error.genericError'),
});
},
onSuccess: () => {
onSuccess: (data) => {
toast.success({
message: t('form.createPlaylist.success'),
});
handlePlaylistPrefilling(data?.id);
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 isSubmitDisabled = !form.values.name || mutation.isPending;
const isSubmitDisabled = !form.values.name || createPlaylistMutation.isPending;
return (
<form onSubmit={handleSubmit}>
@@ -141,7 +176,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
/>
)}
{server?.type === ServerType.NAVIDROME &&
hasFeature(server, ServerFeature.PLAYLISTS_SMART) && (
hasFeature(server, ServerFeature.PLAYLISTS_SMART) &&
!isPrefilledPlaylist && (
<Switch
checked={isSmartPlaylist}
label="Is smart playlist?"
@@ -182,7 +218,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={mutation.isPending}
loading={createPlaylistMutation.isPending}
type="submit"
variant="filled"
>
@@ -206,3 +242,17 @@ export const openCreatePlaylistModal = (
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'),
});
};
+1 -4
View File
@@ -45,10 +45,7 @@ interface Actions {
getCurrentSong: () => QueueSong | undefined;
getPlayerData: () => PlayerData;
getQueue: (groupBy?: QueueGroupingProperty) => GroupedQueue;
getQueueOrder: () => {
groups: { count: number; name: string }[];
items: QueueSong[];
};
getQueueOrder: () => GroupedQueue;
increaseVolume: (value: number) => void;
isFirstTrackInQueue: () => boolean;
isLastTrackInQueue: () => boolean;