Compare commits

...

2 Commits

Author SHA1 Message Date
cucadmuh 16aee64d66 feat(playlist): scoped header search on Playlists browse (#1308) 2026-07-16 04:30:41 +03:00
Psychotoxical fa1dc1a328 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)
2026-07-16 01:41:22 +02:00
46 changed files with 356 additions and 61 deletions
+12
View File
@@ -127,6 +127,18 @@ 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.
### Playlists browse — scoped header search
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1308](https://github.com/Psychotoxical/psysonic/pull/1308)**
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
## Changed
+2
View File
@@ -206,6 +206,8 @@ 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)',
'Playlists browse — scoped header search by playlist name (PR #1308)',
],
},
{
@@ -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
@@ -10,6 +10,8 @@ interface Props {
playlists: SubsonicPlaylist[];
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
disableVirtualization: boolean;
/** When true, omit folder sections that currently have no matching playlists. */
hideEmptyFolders?: boolean;
}
/**
@@ -18,17 +20,26 @@ interface Props {
* virtualisation match the flat grid; only the grouping differs. Rendered only
* when at least one folder exists (the page falls back to the plain grid).
*/
export default function PlaylistsFolderView({ serverId, playlists, renderCard, disableVirtualization }: Props) {
export default function PlaylistsFolderView({
serverId,
playlists,
renderCard,
disableVirtualization,
hideEmptyFolders = false,
}: Props) {
const bucket = usePlaylistFolderStore(s => s.byServer[serverId]) ?? EMPTY_SERVER_FOLDERS;
const { isDragging } = useDragDrop();
const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments);
// Keep the ungrouped section as a drop target during a drag even when empty,
// so a playlist filed into a folder can always be dragged back out to root.
const showUngrouped = grouped.ungrouped.length > 0 || isDragging;
const folderSections = hideEmptyFolders && !isDragging
? grouped.folders.filter(({ playlists: items }) => items.length > 0)
: grouped.folders;
return (
<div className="playlist-folder-view">
{grouped.folders.map(({ folder, playlists: items }) => (
{folderSections.map(({ folder, playlists: items }) => (
<PlaylistFolderSection
key={folder.id}
serverId={serverId}
+2 -1
View File
@@ -29,8 +29,8 @@ export * from './utils/addTracksToPlaylistWithDedup';
export * from './utils/playlistBulkPlayActions';
export * from './utils/playlistDisplayedSongs';
export * from './utils/playlistFolders';
export * from './utils/playlistsBrowseSearch';
export * from './utils/playlistsSmart';
export * from './utils/playPlaylistById';
export * from './utils/runPlaylistCsvImport';
export * from './utils/runPlaylistLoad';
export * from './utils/runPlaylistReorderDrop';
@@ -38,6 +38,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';
+47 -20
View File
@@ -1,14 +1,14 @@
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 React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
import { filterPlaylistsByNameQuery } from '@/features/playlist/utils/playlistsBrowseSearch';
import {
defaultSmartFilters,
@@ -40,6 +40,12 @@ export default function Playlists() {
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
const playlists = usePlaylistStore((s) => s.playlists);
const playlistsSearchQuery = useScopedBrowseSearchQuery('playlists');
const visiblePlaylists = useMemo(
() => filterPlaylistsByNameQuery(playlists, playlistsSearchQuery),
[playlists, playlistsSearchQuery],
);
const textSearchActive = playlistsSearchQuery.trim().length > 0;
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const activeServerId = useAuthStore(s => s.activeServerId);
@@ -73,12 +79,37 @@ export default function Playlists() {
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists);
const {
selectedIds,
setSelectedIds,
toggleSelect,
clearSelection: resetSelection,
} = useRangeSelection(visiblePlaylists);
const isNavidromeServer = Boolean(
activeServerId &&
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
);
// Intersect with the visible list so header/bulk actions never count hidden ids
// (even for the render before the prune effect below runs).
const visibleSelectedIds = useMemo(() => {
if (selectedIds.size === 0) return selectedIds;
const visibleIds = new Set(visiblePlaylists.map(p => p.id));
let changed = false;
const next = new Set<string>();
for (const id of selectedIds) {
if (visibleIds.has(id)) next.add(id);
else changed = true;
}
return changed ? next : selectedIds;
}, [selectedIds, visiblePlaylists]);
// Drop ids that the scoped search hid so range-select state stays coherent.
useEffect(() => {
if (visibleSelectedIds === selectedIds) return;
setSelectedIds(visibleSelectedIds);
}, [visibleSelectedIds, selectedIds, setSelectedIds]);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
resetSelection();
@@ -89,7 +120,7 @@ export default function Playlists() {
resetSelection();
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
const selectedPlaylists = visiblePlaylists.filter(p => visibleSelectedIds.has(p.id));
const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => {
if (!pl.owner) return true;
if (!activeUsername) return false;
@@ -144,14 +175,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);
@@ -165,7 +189,7 @@ export default function Playlists() {
});
const handleDeleteSelected = () => runPlaylistDeleteSelected({
selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t,
selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t,
});
const renderCard = (pl: SubsonicPlaylist) => (
@@ -173,7 +197,7 @@ export default function Playlists() {
pl={pl}
selectionMode={selectionMode}
draggable={showFolderView}
selectedIds={selectedIds}
selectedIds={visibleSelectedIds}
selectedPlaylists={selectedPlaylists}
toggleSelect={toggleSelect}
isPlaylistDeletable={isPlaylistDeletable}
@@ -246,7 +270,7 @@ export default function Playlists() {
<PlaylistsHeader
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedIds={visibleSelectedIds}
selectedPlaylists={selectedPlaylists}
isPlaylistDeletable={isPlaylistDeletable}
toggleSelectionMode={toggleSelectionMode}
@@ -283,6 +307,8 @@ export default function Playlists() {
{/* ── Grid ── */}
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : visiblePlaylists.length === 0 && textSearchActive ? (
<div className="empty-state">{t('playlists.noMatchingSearch')}</div>
) : (
<>
{showFolderView && (
@@ -293,17 +319,18 @@ export default function Playlists() {
{showFolderView && activeServerId ? (
<PlaylistsFolderView
serverId={activeServerId}
playlists={playlists}
playlists={visiblePlaylists}
renderCard={renderCard}
disableVirtualization={perfFlags.disableMainstageVirtualLists}
hideEmptyFolders={textSearchActive}
/>
) : (
<VirtualCardGrid
items={playlists}
items={visiblePlaylists}
itemKey={(pl, _i) => pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={playlists.length}
layoutSignal={visiblePlaylists.length}
renderItem={renderCard}
/>
)}
@@ -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,31 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import {
filterPlaylistsByNameQuery,
isPlaylistsBrowsePath,
} from './playlistsBrowseSearch';
function pl(id: string, name: string): SubsonicPlaylist {
return { id, name, songCount: 0, duration: 0, created: '', changed: '' };
}
describe('isPlaylistsBrowsePath', () => {
it('matches only the playlists list route', () => {
expect(isPlaylistsBrowsePath('/playlists')).toBe(true);
expect(isPlaylistsBrowsePath('/playlists/abc')).toBe(false);
expect(isPlaylistsBrowsePath('/artists')).toBe(false);
});
});
describe('filterPlaylistsByNameQuery', () => {
const list = [pl('1', 'Road Trip'), pl('2', 'Focus Mix'), pl('3', 'road house')];
it('returns all playlists when query is empty', () => {
expect(filterPlaylistsByNameQuery(list, ' ')).toEqual(list);
});
it('filters by case-insensitive name substring', () => {
expect(filterPlaylistsByNameQuery(list, 'road').map(p => p.id)).toEqual(['1', '3']);
expect(filterPlaylistsByNameQuery(list, 'FOCUS').map(p => p.id)).toEqual(['2']);
});
});
@@ -0,0 +1,16 @@
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
/** True when pathname is the Playlists browse route (`/playlists`). */
export function isPlaylistsBrowsePath(pathname: string): boolean {
return pathname === '/playlists';
}
/** Scoped Playlists text search — playlist name only. */
export function filterPlaylistsByNameQuery(
playlists: SubsonicPlaylist[],
query: string,
): SubsonicPlaylist[] {
const needle = query.trim().toLowerCase();
if (!needle) return playlists;
return playlists.filter(p => (p.name ?? '').toLowerCase().includes(needle));
}
@@ -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 [];
}
}
@@ -42,7 +42,6 @@ export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise<vo
export interface RunPlaylistDeleteSelectedDeps {
selectedPlaylists: SubsonicPlaylist[];
selectedIds: Set<string>;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
removeId: (id: string) => void;
clearSelection: () => void;
@@ -50,26 +49,26 @@ export interface RunPlaylistDeleteSelectedDeps {
}
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise<void> {
const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps;
const { selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t } = deps;
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
if (deletable.length === 0) return;
let deleted = 0;
const removedIds = new Set<string>();
for (const pl of deletable) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
removedIds.add(pl.id);
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))),
}));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
if (removedIds.size > 0) {
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !removedIds.has(p.id)),
}));
showToast(t('playlists.deleteSuccess', { count: removedIds.size }), 3000, 'info');
}
clearSelection();
}
export interface RunPlaylistMergeSelectedDeps {
@@ -5,6 +5,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isArtistsBrowsePath } from '@/features/artist';
import { isComposersBrowsePath } from '@/features/composers';
import { isPlaylistsBrowsePath } from '@/features/playlist';
export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
artists: 'artists',
@@ -12,6 +13,7 @@ export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS>
newReleases: 'newReleases',
tracks: 'tracks',
composers: 'composers',
playlists: 'playlists',
};
/** Scope to restore when on a browse route but the badge was cleared (global search mode). */
@@ -25,6 +27,7 @@ export function resolveLiveSearchScopeGhost(
if (isNewReleasesBrowsePath(pathname)) return 'newReleases';
if (isTracksBrowsePath(pathname)) return 'tracks';
if (isComposersBrowsePath(pathname)) return 'composers';
if (isPlaylistsBrowsePath(pathname)) return 'playlists';
return null;
}
@@ -40,6 +43,8 @@ export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): st
return 'search.scopeTracksPlaceholder';
case 'composers':
return 'search.scopeComposersPlaceholder';
case 'playlists':
return 'search.scopePlaylistsPlaceholder';
default:
return 'search.placeholder';
}
@@ -62,6 +67,8 @@ export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string {
return 'search.scopeTracksBadgeTooltip';
case 'composers':
return 'search.scopeComposersBadgeTooltip';
case 'playlists':
return 'search.scopePlaylistsBadgeTooltip';
default:
return 'search.scopeArtistsBadgeTooltip';
}
@@ -79,6 +86,8 @@ export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string {
return 'search.scopeTracksGhostTooltip';
case 'composers':
return 'search.scopeComposersGhostTooltip';
case 'playlists':
return 'search.scopePlaylistsGhostTooltip';
default:
return 'search.scopeArtistsGhostTooltip';
}
@@ -41,6 +41,9 @@ describe('resolveLiveSearchScopeGhost', () => {
expect(resolveLiveSearchScopeGhost('/tracks', 'tracks')).toBeNull();
expect(resolveLiveSearchScopeGhost('/composers', null)).toBe('composers');
expect(resolveLiveSearchScopeGhost('/composers', 'composers')).toBeNull();
expect(resolveLiveSearchScopeGhost('/playlists', null)).toBe('playlists');
expect(resolveLiveSearchScopeGhost('/playlists', 'playlists')).toBeNull();
expect(resolveLiveSearchScopeGhost('/playlists/abc', null)).toBeNull();
});
});
@@ -128,6 +131,7 @@ describe('liveSearchScopePlaceholderKey', () => {
expect(liveSearchScopePlaceholderKey('newReleases')).toBe('search.scopeNewReleasesPlaceholder');
expect(liveSearchScopePlaceholderKey('tracks')).toBe('search.scopeTracksPlaceholder');
expect(liveSearchScopePlaceholderKey('composers')).toBe('search.scopeComposersPlaceholder');
expect(liveSearchScopePlaceholderKey('playlists')).toBe('search.scopePlaylistsPlaceholder');
expect(liveSearchScopePlaceholderKey(null)).toBe('search.placeholder');
});
});
@@ -16,6 +16,9 @@ describe('syncLiveSearchRouteScope', () => {
syncLiveSearchRouteScope('/composers');
expect(useLiveSearchScopeStore.getState().scope).toBe('composers');
syncLiveSearchRouteScope('/playlists');
expect(useLiveSearchScopeStore.getState().scope).toBe('playlists');
});
it('clears scope and query when leaving browse routes', () => {
@@ -27,6 +30,15 @@ describe('syncLiveSearchRouteScope', () => {
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('clears scope when entering playlist detail', () => {
useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' });
syncLiveSearchRouteScope('/playlists/abc123');
expect(useLiveSearchScopeStore.getState().scope).toBeNull();
expect(useLiveSearchScopeStore.getState().query).toBe('');
});
it('clears query when leaving browse with scope already cleared (ghost mode)', () => {
useLiveSearchScopeStore.setState({ query: 'beatles', scope: null });
@@ -4,6 +4,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
import { isArtistsBrowsePath } from '@/features/artist';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isComposersBrowsePath } from '@/features/composers';
import { isPlaylistsBrowsePath } from '@/features/playlist';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
@@ -20,6 +21,8 @@ export function syncLiveSearchRouteScope(pathname: string): void {
store.setScope('tracks');
} else if (isComposersBrowsePath(pathname)) {
store.setScope('composers');
} else if (isPlaylistsBrowsePath(pathname)) {
store.setScope('playlists');
} else {
if (store.scope != null) store.clearScope();
if (store.query !== '') store.setQuery('');
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Създай',
cancel: 'Отказ',
empty: 'Все още няма плейлисти.',
noMatchingSearch: 'Няма плейлисти, които съответстват на търсенето.',
emptyPlaylist: 'Този плейлист е празен.',
addFirstSong: 'Добавете първата си песен',
notFound: 'Плейлистът не е намерен.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Търси композитор…',
scopeComposersBadgeTooltip: 'Кликнете, за да премахнете',
scopeComposersGhostTooltip: 'Кликнете, за да търсите само на тази страница',
scopePlaylistsPlaceholder: 'Търси плейлист…',
scopePlaylistsBadgeTooltip: 'Кликнете, за да премахнете',
scopePlaylistsGhostTooltip: 'Кликнете, за да търсите само на тази страница',
genres: 'Жанрове',
shareLink: 'Връзка за споделяне',
shareTrackTitle: 'Споделена песен',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Erstellen',
cancel: 'Abbrechen',
empty: 'Noch keine Playlists.',
noMatchingSearch: 'Keine Playlists entsprechen deiner Suche.',
emptyPlaylist: 'Diese Playlist ist leer.',
addFirstSong: 'Ersten Song hinzufügen',
notFound: 'Playlist nicht gefunden.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Komponist suchen…',
scopeComposersBadgeTooltip: 'Klicken — entfernen',
scopeComposersGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
scopePlaylistsPlaceholder: 'Playlist suchen…',
scopePlaylistsBadgeTooltip: 'Klicken — entfernen',
scopePlaylistsGhostTooltip: 'Klicken — nur auf dieser Seite suchen',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Create',
cancel: 'Cancel',
empty: 'No playlists yet.',
noMatchingSearch: 'No playlists match your search.',
emptyPlaylist: 'This playlist is empty.',
addFirstSong: 'Add your first song',
notFound: 'Playlist not found.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Search for composer…',
scopeComposersBadgeTooltip: 'Click to remove',
scopeComposersGhostTooltip: 'Click to search on this page only',
scopePlaylistsPlaceholder: 'Search for playlist…',
scopePlaylistsBadgeTooltip: 'Click to remove',
scopePlaylistsGhostTooltip: 'Click to search on this page only',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Crear',
cancel: 'Cancelar',
empty: 'Aún no hay listas.',
noMatchingSearch: 'Ninguna playlist coincide con tu búsqueda.',
emptyPlaylist: 'Esta lista está vacía.',
addFirstSong: 'Agrega tu primera canción',
notFound: 'Lista no encontrada.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Buscar compositor…',
scopeComposersBadgeTooltip: 'Clic — quitar',
scopeComposersGhostTooltip: 'Clic — buscar solo en esta página',
scopePlaylistsPlaceholder: 'Buscar playlist…',
scopePlaylistsBadgeTooltip: 'Clic — quitar',
scopePlaylistsGhostTooltip: 'Clic — buscar solo en esta página',
genres: 'Géneros',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Créer',
cancel: 'Annuler',
empty: 'Aucune playlist pour l\'instant.',
noMatchingSearch: 'Aucune playlist ne correspond à votre recherche.',
emptyPlaylist: 'Cette playlist est vide.',
addFirstSong: 'Ajouter votre premier titre',
notFound: 'Playlist introuvable.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Rechercher un compositeur…',
scopeComposersBadgeTooltip: 'Clic — retirer',
scopeComposersGhostTooltip: 'Clic — rechercher sur cette page seulement',
scopePlaylistsPlaceholder: 'Rechercher une playlist…',
scopePlaylistsBadgeTooltip: 'Clic — retirer',
scopePlaylistsGhostTooltip: 'Clic — rechercher sur cette page seulement',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Létrehozás',
cancel: 'Mégse',
empty: 'Még nincsenek lejátszási listák.',
noMatchingSearch: 'Nincs a keresésnek megfelelő lejátszási lista.',
emptyPlaylist: 'Ez a lejátszási lista üres.',
addFirstSong: 'Add hozzá az első dalt',
notFound: 'A lejátszási lista nem található.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Zeneszerző keresése…',
scopeComposersBadgeTooltip: 'Kattints az eltávolításhoz',
scopeComposersGhostTooltip: 'Kattints, hogy csak ezen az oldalon keress',
scopePlaylistsPlaceholder: 'Lejátszási lista keresése…',
scopePlaylistsBadgeTooltip: 'Kattints az eltávolításhoz',
scopePlaylistsGhostTooltip: 'Kattints, hogy csak ezen az oldalon keress',
genres: 'Műfajok',
shareLink: 'Megosztási link',
shareTrackTitle: 'Megosztott szám',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Crea',
cancel: 'Annulla',
empty: 'Ancora nessuna playlist.',
noMatchingSearch: 'Nessuna playlist corrisponde alla ricerca.',
emptyPlaylist: 'Questa playlist è vuota.',
addFirstSong: 'Aggiungi il tuo primo brano',
notFound: 'Playlist non trovata.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Cerca un compositore…',
scopeComposersBadgeTooltip: 'Clicca per rimuovere',
scopeComposersGhostTooltip: 'Clicca per cercare solo in questa pagina',
scopePlaylistsPlaceholder: 'Cerca una playlist…',
scopePlaylistsBadgeTooltip: 'Clicca per rimuovere',
scopePlaylistsGhostTooltip: 'Clicca per cercare solo in questa pagina',
genres: 'Generi',
shareLink: 'Condividi link',
shareTrackTitle: 'Brano condiviso',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: '作成',
cancel: 'キャンセル',
empty: 'プレイリストはまだありません。',
noMatchingSearch: '検索に一致するプレイリストはありません。',
emptyPlaylist: 'このプレイリストは空です。',
addFirstSong: '最初の曲を追加',
notFound: 'プレイリストが見つかりません。',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: '作曲者を検索…',
scopeComposersBadgeTooltip: 'クリックして削除',
scopeComposersGhostTooltip: 'クリックしてこのページだけで検索',
scopePlaylistsPlaceholder: 'プレイリストを検索…',
scopePlaylistsBadgeTooltip: 'クリックして削除',
scopePlaylistsGhostTooltip: 'クリックしてこのページだけで検索',
genres: 'ジャンル',
shareLink: '共有リンク',
shareTrackTitle: '共有トラック',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Opprett',
cancel: 'Avbryt',
empty: 'Ingen spillelister ennå.',
noMatchingSearch: 'Ingen spillelister matcher søket.',
emptyPlaylist: 'Denne spillelisten er tom.',
addFirstSong: 'Legg til din første sang',
notFound: 'Spillelisten ble ikke funnet.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Søk komponist…',
scopeComposersBadgeTooltip: 'Klikk — fjern',
scopeComposersGhostTooltip: 'Klikk — søk bare på denne siden',
scopePlaylistsPlaceholder: 'Søk etter spilleliste…',
scopePlaylistsBadgeTooltip: 'Klikk — fjern',
scopePlaylistsGhostTooltip: 'Klikk — søk bare på denne siden',
genres: 'Sjangre',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Aanmaken',
cancel: 'Annuleren',
empty: 'Nog geen playlists.',
noMatchingSearch: 'Geen playlists komen overeen met je zoekopdracht.',
emptyPlaylist: 'Deze playlist is leeg.',
addFirstSong: 'Voeg je eerste nummer toe',
notFound: 'Playlist niet gevonden.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Componist zoeken…',
scopeComposersBadgeTooltip: 'Klik — verwijderen',
scopeComposersGhostTooltip: 'Klik — alleen op deze pagina zoeken',
scopePlaylistsPlaceholder: 'Zoek een playlist…',
scopePlaylistsBadgeTooltip: 'Klik — verwijderen',
scopePlaylistsGhostTooltip: 'Klik — alleen op deze pagina zoeken',
genres: 'Genres',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -6,6 +6,7 @@ export const playlists = {
create: 'Stwórz',
cancel: 'Anuluj',
empty: 'Nie masz jeszcze żadnych playlist.',
noMatchingSearch: 'Żadna playlista nie pasuje do wyszukiwania.',
emptyPlaylist: 'Ta playlista jest pusta.',
addFirstSong: 'Dodaj pierwszy utwór',
notFound: 'Playlista nie znaleziona.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Szukaj kompozytora…',
scopeComposersBadgeTooltip: 'Kliknij, aby usunąć',
scopeComposersGhostTooltip: 'Kliknij, aby wyszukać tylko na tej stronie',
scopePlaylistsPlaceholder: 'Szukaj playlisty…',
scopePlaylistsBadgeTooltip: 'Kliknij, aby usunąć',
scopePlaylistsGhostTooltip: 'Kliknij, aby wyszukać tylko na tej stronie',
genres: 'Gatunki',
shareLink: 'Udostępnij link',
shareTrackTitle: 'Udostępniony utwór',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Crează',
cancel: 'Anulează',
empty: 'Niciun playlist deocamdată.',
noMatchingSearch: 'Niciun playlist nu se potrivește căutării.',
emptyPlaylist: 'Acest playlist este gol.',
addFirstSong: 'Adaugă prima ta piesă',
notFound: 'Playlistul nu a fost găsit.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Caută compozitor…',
scopeComposersBadgeTooltip: 'Clic — elimină',
scopeComposersGhostTooltip: 'Clic — caută doar pe această pagină',
scopePlaylistsPlaceholder: 'Caută playlist…',
scopePlaylistsBadgeTooltip: 'Clic — elimină',
scopePlaylistsGhostTooltip: 'Clic — caută doar pe această pagină',
genres: 'Genuri',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Создать',
cancel: 'Отмена',
empty: 'Плейлистов пока нет.',
noMatchingSearch: 'Нет плейлистов, подходящих под поиск.',
emptyPlaylist: 'Плейлист пуст.',
addFirstSong: 'Добавьте первый трек',
notFound: 'Плейлист не найден.',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Поиск композитора…',
scopeComposersBadgeTooltip: 'Щелчок — удалить',
scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице',
scopePlaylistsPlaceholder: 'Поиск плейлиста…',
scopePlaylistsBadgeTooltip: 'Щелчок — удалить',
scopePlaylistsGhostTooltip: 'Щелчок — искать только на этой странице',
genres: 'Жанры',
shareLink: 'Ссылка для обмена',
shareTrackTitle: 'Общий трек',
+1
View File
@@ -9,6 +9,7 @@ export const playlists = {
create: '创建',
cancel: '取消',
empty: '暂无播放列表。',
noMatchingSearch: '没有匹配搜索的播放列表。',
emptyPlaylist: '此播放列表为空。',
addFirstSong: '添加第一首歌曲',
notFound: '未找到播放列表。',
+3
View File
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: '搜索作曲家…',
scopeComposersBadgeTooltip: '单击 — 移除',
scopeComposersGhostTooltip: '单击 — 仅在此页面搜索',
scopePlaylistsPlaceholder: '搜索播放列表…',
scopePlaylistsBadgeTooltip: '单击 — 移除',
scopePlaylistsGhostTooltip: '单击 — 仅在此页面搜索',
genres: '流派',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
+3
View File
@@ -24,6 +24,9 @@ describe('liveSearchScopeStore', () => {
useLiveSearchScopeStore.setState({ query: 'bach', scope: 'composers' });
expect(scopedBrowseSearchQuery('bach', 'composers', 'composers')).toBe('bach');
expect(scopedBrowseSearchQuery('bach', 'artists', 'composers')).toBe('');
useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' });
expect(scopedBrowseSearchQuery('road', 'playlists', 'playlists')).toBe('road');
expect(scopedBrowseSearchQuery('road', 'albums', 'playlists')).toBe('');
});
it('undoes query and scope badge changes', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
/** Page-scoped live search mode — badge in the header search field. */
export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers';
export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers' | 'playlists';
export type LiveSearchSnapshot = {
query: string;