feat(playlist): add play and queue actions to the playlist card context menu (#1307)

* refactor(playlist): extract resolvePlaylistTracks shared helper

Move the offline- and active-library-scope-aware playlist track resolution out of the Playlists overview Play handler into a reusable helper, so the overview and the playlist context menu share one resolution path instead of diverging.

* feat(playlist): add play and queue actions to the playlist card context menu

Right-clicking a playlist card now offers Play next and Add to queue alongside Play now, matching the album card. All three resolve tracks through resolvePlaylistTracks (offline- and active-library aware), so Play now becomes consistent with the queue actions and works offline. Drops the now-unused playPlaylistById.

* docs(changelog): add playlist card queue actions entry (#1307)
This commit is contained in:
Psychotoxical
2026-07-16 01:41:22 +02:00
committed by GitHub
parent 0df547e3be
commit fa1dc1a328
8 changed files with 146 additions and 39 deletions
+6
View File
@@ -127,6 +127,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Settings → Integrations → Discord → Cover art source** gets a **Server** option, alongside **None** and **Apple Music**. It resolves artwork through the standard Subsonic `getAlbumInfo2` endpoint's public image link — never an authenticated cover URL that could expose your login credentials (reported by lavioso on Discord). Needs a publicly reachable server; anyone viewing your Discord profile can see that server's public address, but nothing else.
### Playlist cards — play and queue from the right-click menu
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1307](https://github.com/Psychotoxical/psysonic/pull/1307)**
* Right-clicking a playlist card now offers **Play next** and **Add to queue** alongside **Play now**, matching the album card. All three honour offline mode and the active multi-library filter.
## Changed
+1
View File
@@ -206,6 +206,7 @@ const CONTRIBUTOR_ENTRIES = [
'Player bar — hideable stop button, optional album line, drag-reorderable action buttons (request: mikmik on Psysonic Discord, PR #1287)',
'Persistent shuffle mode — queue-reordering shuffle with restore, survives restart, in sync with other devices and Orbit (request: mikmik on Psysonic Discord, PR #1288)',
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)',
],
},
{
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
import { Play, ChevronsRight, ChevronRight, FolderTree, ListMusic, ListPlus, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { usePlaylistStore, resolvePlaylistTracks } from '@/features/playlist';
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from '@/features/contextMenu/components/PlaylistToPlaylistSubmenus';
import MoveToFolderSubmenu from '@/features/contextMenu/components/MoveToFolderSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
@@ -9,6 +9,7 @@ import type { ContextMenuItemsProps } from '@/features/contextMenu/components/co
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
const {
type, item, closeContextMenu,
playTrack, playNext, enqueue,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
handleAction,
@@ -23,15 +24,26 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
return (
<>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const { playPlaylistById } = await import('@/features/playlist');
try {
await playPlaylistById(playlist.id);
} catch {
// Network/load failure — leave playback untouched rather than crash.
}
const tracks = await resolvePlaylistTracks(playlist.id);
if (tracks.length === 0) return;
playTrack(tracks[0], tracks);
})}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
if (tracks.length === 0) return;
playNext(tracks);
})}>
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const tracks = await resolvePlaylistTracks(playlist.id);
if (tracks.length === 0) return;
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
<div className="context-menu-divider" />
{offlinePolicy.canAddToPlaylist && (
<div
+1 -1
View File
@@ -30,7 +30,6 @@ export * from './utils/playlistBulkPlayActions';
export * from './utils/playlistDisplayedSongs';
export * from './utils/playlistFolders';
export * from './utils/playlistsSmart';
export * from './utils/playPlaylistById';
export * from './utils/runPlaylistCsvImport';
export * from './utils/runPlaylistLoad';
export * from './utils/runPlaylistReorderDrop';
@@ -38,6 +37,7 @@ export * from './utils/runPlaylistsActions';
export * from './utils/runPlaylistSaveMeta';
export * from './utils/runPlaylistsOpenSmartEditor';
export * from './utils/runPlaylistsSaveSmart';
export * from './utils/resolvePlaylistTracks';
export * from './utils/runPlaylistZipDownload';
export * from './utils/spotifyCsvImport';
export * from './utils/spotifyCsvMatch';
+2 -11
View File
@@ -1,8 +1,6 @@
import { resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
import { getGenres } from '@/lib/api/subsonicGenres';
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
@@ -144,14 +142,7 @@ export default function Playlists() {
if (playingId === pl.id) return;
setPlayingId(pl.id);
try {
const serverId = resolveMediaServerId(activeServerId);
if (!serverId) return;
const data = await resolvePlaylist(serverId, pl.id);
if (!data) return;
const songs = offlineBrowseActive
? data.songs
: await filterSongsToActiveLibrary(data.songs);
const tracks = songs.map(songToTrack);
const tracks = await resolvePlaylistTracks(pl.id);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
@@ -1,19 +0,0 @@
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
import { songToTrack } from '@/lib/media/songToTrack';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { playPlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
/**
* Load a playlist's songs and start playback immediately ("Play Now").
*
* Used where only the playlist metadata is on hand — the playlist context menu
* on the Playlists overview — so the tracks have to be fetched first. Once
* loaded it defers to {@link playPlaylistAll}, the same action the playlist
* detail "Play All" button uses, so playback behaviour stays in one place.
*/
export async function playPlaylistById(id: string): Promise<void> {
const { songs } = await getPlaylist(id);
const tracks = songs.map(songToTrack);
const { playTrack, enqueue } = usePlayerStore.getState();
playPlaylistAll({ songsLength: tracks.length, id, tracks, playTrack, enqueue });
}
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
const offlineMock = vi.fn(() => false);
const resolveServerMock = vi.fn((id: string | null | undefined) => id ?? undefined);
const resolvePlaylistMock = vi.fn();
const filterMock = vi.fn();
let activeServerId: string | null = 'srv-1';
vi.mock('@/features/offline', () => ({
isOfflineBrowseActive: () => offlineMock(),
resolveMediaServerId: (id: string | null | undefined) => resolveServerMock(id),
resolvePlaylist: (serverId: string, playlistId: string) => resolvePlaylistMock(serverId, playlistId),
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({
filterSongsToActiveLibrary: (songs: unknown) => filterMock(songs),
}));
vi.mock('@/lib/media/songToTrack', () => ({
songToTrack: (song: { id: string }) => ({ id: song.id, track: true }),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => ({ activeServerId }) },
}));
describe('resolvePlaylistTracks', () => {
beforeEach(() => {
offlineMock.mockReset().mockReturnValue(false);
resolveServerMock.mockReset().mockImplementation((id: string | null | undefined) => id ?? undefined);
resolvePlaylistMock.mockReset();
filterMock.mockReset();
activeServerId = 'srv-1';
});
it('scopes to the active library when online', async () => {
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] });
// Active-library scope hides b and c.
filterMock.mockResolvedValue([{ id: 'a' }]);
const tracks = await resolvePlaylistTracks('pl-1');
expect(filterMock).toHaveBeenCalledOnce();
expect(tracks).toEqual([{ id: 'a', track: true }]);
});
it('uses the full offline list without library filtering', async () => {
offlineMock.mockReturnValue(true);
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }] });
const tracks = await resolvePlaylistTracks('pl-1');
expect(filterMock).not.toHaveBeenCalled();
expect(tracks).toEqual([{ id: 'a', track: true }, { id: 'b', track: true }]);
});
it('returns [] when the active server cannot be resolved', async () => {
activeServerId = null;
resolveServerMock.mockReturnValue(undefined);
const tracks = await resolvePlaylistTracks('pl-1');
expect(tracks).toEqual([]);
expect(resolvePlaylistMock).not.toHaveBeenCalled();
});
it('returns [] when the playlist cannot be resolved', async () => {
resolvePlaylistMock.mockResolvedValue(null);
const tracks = await resolvePlaylistTracks('pl-1');
expect(tracks).toEqual([]);
expect(filterMock).not.toHaveBeenCalled();
});
it('swallows a rejecting library-scope filter to [] (no unhandled rejection)', async () => {
resolvePlaylistMock.mockResolvedValue({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }] });
filterMock.mockRejectedValue(new Error('network'));
await expect(resolvePlaylistTracks('pl-1')).resolves.toEqual([]);
});
});
@@ -0,0 +1,33 @@
import { isOfflineBrowseActive, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
/**
* Resolve a playlist's playable tracks from its id alone — the same way the
* Playlists overview "Play" button does: offline-browse aware via
* {@link resolvePlaylist}, then scoped to the active library (#1241) when
* online. Shared by the overview play control and the playlist context-menu
* queue actions so those paths cannot drift.
*
* Best-effort: returns `[]` when the server is unknown or the playlist cannot
* be resolved, so callers can treat empty as "nothing to enqueue".
*/
export async function resolvePlaylistTracks(playlistId: string): Promise<Track[]> {
const serverId = resolveMediaServerId(useAuthStore.getState().activeServerId);
if (!serverId) return [];
try {
const data = await resolvePlaylist(serverId, playlistId);
if (!data) return [];
// The library-scope filter fetches the album list over the network, so it
// can reject; swallow to [] so context-menu callers (which run via a
// no-catch handler) never leak an unhandled rejection.
const songs = isOfflineBrowseActive()
? data.songs
: await filterSongsToActiveLibrary(data.songs);
return songs.map(songToTrack);
} catch {
return [];
}
}