From d99fe8b8c66fc3b9797dd9613049ee80edfab408 Mon Sep 17 00:00:00 2001 From: Kevin <17616113+Keviddle@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:54:42 +0200 Subject: [PATCH] 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 --- src/i18n/locales/en.json | 4 ++ .../components/play-queue-list-controls.tsx | 27 +++++++- .../player/context/player-context.tsx | 13 ++++ .../components/create-playlist-form.tsx | 64 +++++++++++++++++-- src/renderer/store/player.store.ts | 5 +- 5 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d48ff1482..9836b4ac5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx index d1e4a53aa..719b2ce31 100644 --- a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx +++ b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx @@ -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 = ({ + @@ -84,6 +86,29 @@ export const PlayQueueListControls = ({ ); }; +const QueuePlaylistIcons = () => { + const server = useCurrentServer(); + const player = usePlayer(); + + const handleCreatePlaylistFromQueue = (e: MouseEvent) => { + const queueSongs = player.getQueue(); + + openCreatePrefilledPlaylistModal(server, queueSongs, e); + }; + + return ( + <> + + + ); +}; + const QueuePlaybackIcons = ({ tableRef }: { tableRef: RefObject }) => { const { t } = useTranslation(); const player = usePlayer(); diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index e59c5fa12..88b3dec81 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -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({ 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, diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx index 462cb0226..d82936567 100644 --- a/src/renderer/features/playlists/components/create-playlist-form.tsx +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -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(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 (
@@ -141,7 +176,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { /> )} {server?.type === ServerType.NAVIDROME && - hasFeature(server, ServerFeature.PLAYLISTS_SMART) && ( + hasFeature(server, ServerFeature.PLAYLISTS_SMART) && + !isPrefilledPlaylist && ( { @@ -206,3 +242,17 @@ export const openCreatePlaylistModal = ( title: t('form.createPlaylist.title'), }); }; + +export const openCreatePrefilledPlaylistModal = ( + server?: ServerListItem, + songs?: Song[], + e?: MouseEvent, +) => { + e?.stopPropagation(); + + openModal({ + children: closeAllModals()} songs={songs} />, + size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm', + title: t('form.createPrefilledPlaylist.title'), + }); +}; diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index d9da80a11..85cca9e7b 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -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;