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", "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'),
});
};
+1 -4
View File
@@ -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;