mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user