diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f2bb959..65b40b9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -133,6 +133,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* 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
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 6ddc8ee8..2aff7c1a 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -207,6 +207,7 @@ const CONTRIBUTOR_ENTRIES = [
'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)',
],
},
{
diff --git a/src/features/playlist/components/PlaylistsFolderView.tsx b/src/features/playlist/components/PlaylistsFolderView.tsx
index 4180c224..a96f5a59 100644
--- a/src/features/playlist/components/PlaylistsFolderView.tsx
+++ b/src/features/playlist/components/PlaylistsFolderView.tsx
@@ -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 (
- {grouped.folders.map(({ folder, playlists: items }) => (
+ {folderSections.map(({ folder, playlists: items }) => (
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);
@@ -71,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();
+ 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();
@@ -87,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;
@@ -156,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) => (
@@ -164,7 +197,7 @@ export default function Playlists() {
pl={pl}
selectionMode={selectionMode}
draggable={showFolderView}
- selectedIds={selectedIds}
+ selectedIds={visibleSelectedIds}
selectedPlaylists={selectedPlaylists}
toggleSelect={toggleSelect}
isPlaylistDeletable={isPlaylistDeletable}
@@ -237,7 +270,7 @@ export default function Playlists() {
{t('playlists.empty')}
+ ) : visiblePlaylists.length === 0 && textSearchActive ? (
+ {t('playlists.noMatchingSearch')}
) : (
<>
{showFolderView && (
@@ -284,17 +319,18 @@ export default function Playlists() {
{showFolderView && activeServerId ? (
) : (
pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
- layoutSignal={playlists.length}
+ layoutSignal={visiblePlaylists.length}
renderItem={renderCard}
/>
)}
diff --git a/src/features/playlist/utils/playlistsBrowseSearch.test.ts b/src/features/playlist/utils/playlistsBrowseSearch.test.ts
new file mode 100644
index 00000000..9761aafb
--- /dev/null
+++ b/src/features/playlist/utils/playlistsBrowseSearch.test.ts
@@ -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']);
+ });
+});
diff --git a/src/features/playlist/utils/playlistsBrowseSearch.ts b/src/features/playlist/utils/playlistsBrowseSearch.ts
new file mode 100644
index 00000000..4d9180ad
--- /dev/null
+++ b/src/features/playlist/utils/playlistsBrowseSearch.ts
@@ -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));
+}
diff --git a/src/features/playlist/utils/runPlaylistsActions.ts b/src/features/playlist/utils/runPlaylistsActions.ts
index 0a078c0a..0e9965d5 100644
--- a/src/features/playlist/utils/runPlaylistsActions.ts
+++ b/src/features/playlist/utils/runPlaylistsActions.ts
@@ -42,7 +42,6 @@ export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
removeId: (id: string) => void;
clearSelection: () => void;
@@ -50,26 +49,26 @@ export interface RunPlaylistDeleteSelectedDeps {
}
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise {
- 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();
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 {
diff --git a/src/features/search/components/liveSearchScope.ts b/src/features/search/components/liveSearchScope.ts
index 63841eb0..dd854dcb 100644
--- a/src/features/search/components/liveSearchScope.ts
+++ b/src/features/search/components/liveSearchScope.ts
@@ -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 = {
artists: 'artists',
@@ -12,6 +13,7 @@ export const SCOPE_NAV_ITEM: Record
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';
}
diff --git a/src/features/search/components/liveSearchScopeUi.test.ts b/src/features/search/components/liveSearchScopeUi.test.ts
index 33e6297b..867b767d 100644
--- a/src/features/search/components/liveSearchScopeUi.test.ts
+++ b/src/features/search/components/liveSearchScopeUi.test.ts
@@ -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');
});
});
diff --git a/src/features/search/hooks/useLiveSearchRouteScope.test.ts b/src/features/search/hooks/useLiveSearchRouteScope.test.ts
index 8f5ca195..ebc25081 100644
--- a/src/features/search/hooks/useLiveSearchRouteScope.test.ts
+++ b/src/features/search/hooks/useLiveSearchRouteScope.test.ts
@@ -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 });
diff --git a/src/features/search/hooks/useLiveSearchRouteScope.ts b/src/features/search/hooks/useLiveSearchRouteScope.ts
index 69f34645..bfc23116 100644
--- a/src/features/search/hooks/useLiveSearchRouteScope.ts
+++ b/src/features/search/hooks/useLiveSearchRouteScope.ts
@@ -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('');
diff --git a/src/locales/bg/playlists.ts b/src/locales/bg/playlists.ts
index 06e2c284..72f04c44 100644
--- a/src/locales/bg/playlists.ts
+++ b/src/locales/bg/playlists.ts
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Създай',
cancel: 'Отказ',
empty: 'Все още няма плейлисти.',
+ noMatchingSearch: 'Няма плейлисти, които съответстват на търсенето.',
emptyPlaylist: 'Този плейлист е празен.',
addFirstSong: 'Добавете първата си песен',
notFound: 'Плейлистът не е намерен.',
diff --git a/src/locales/bg/search.ts b/src/locales/bg/search.ts
index f04c3ca0..b5e6e3b7 100644
--- a/src/locales/bg/search.ts
+++ b/src/locales/bg/search.ts
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Търси композитор…',
scopeComposersBadgeTooltip: 'Кликнете, за да премахнете',
scopeComposersGhostTooltip: 'Кликнете, за да търсите само на тази страница',
+ scopePlaylistsPlaceholder: 'Търси плейлист…',
+ scopePlaylistsBadgeTooltip: 'Кликнете, за да премахнете',
+ scopePlaylistsGhostTooltip: 'Кликнете, за да търсите само на тази страница',
genres: 'Жанрове',
shareLink: 'Връзка за споделяне',
shareTrackTitle: 'Споделена песен',
diff --git a/src/locales/de/playlists.ts b/src/locales/de/playlists.ts
index 98b50b99..ef5736df 100644
--- a/src/locales/de/playlists.ts
+++ b/src/locales/de/playlists.ts
@@ -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.',
diff --git a/src/locales/de/search.ts b/src/locales/de/search.ts
index 95a76b2d..764d69cf 100644
--- a/src/locales/de/search.ts
+++ b/src/locales/de/search.ts
@@ -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',
diff --git a/src/locales/en/playlists.ts b/src/locales/en/playlists.ts
index 81712ed5..c9e8f74c 100644
--- a/src/locales/en/playlists.ts
+++ b/src/locales/en/playlists.ts
@@ -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.',
diff --git a/src/locales/en/search.ts b/src/locales/en/search.ts
index cbd0fa5d..bf4200fa 100644
--- a/src/locales/en/search.ts
+++ b/src/locales/en/search.ts
@@ -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',
diff --git a/src/locales/es/playlists.ts b/src/locales/es/playlists.ts
index f70d0a59..c016144f 100644
--- a/src/locales/es/playlists.ts
+++ b/src/locales/es/playlists.ts
@@ -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.',
diff --git a/src/locales/es/search.ts b/src/locales/es/search.ts
index 37bb355b..279feb45 100644
--- a/src/locales/es/search.ts
+++ b/src/locales/es/search.ts
@@ -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',
diff --git a/src/locales/fr/playlists.ts b/src/locales/fr/playlists.ts
index b23288e6..4e92f82a 100644
--- a/src/locales/fr/playlists.ts
+++ b/src/locales/fr/playlists.ts
@@ -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.',
diff --git a/src/locales/fr/search.ts b/src/locales/fr/search.ts
index ac1a506f..705b7357 100644
--- a/src/locales/fr/search.ts
+++ b/src/locales/fr/search.ts
@@ -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',
diff --git a/src/locales/hu/playlists.ts b/src/locales/hu/playlists.ts
index 03c8423b..b5c929d3 100644
--- a/src/locales/hu/playlists.ts
+++ b/src/locales/hu/playlists.ts
@@ -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ó.',
diff --git a/src/locales/hu/search.ts b/src/locales/hu/search.ts
index b3f68af4..6252cd81 100644
--- a/src/locales/hu/search.ts
+++ b/src/locales/hu/search.ts
@@ -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',
diff --git a/src/locales/it/playlists.ts b/src/locales/it/playlists.ts
index 818653ab..34a936c9 100644
--- a/src/locales/it/playlists.ts
+++ b/src/locales/it/playlists.ts
@@ -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.',
diff --git a/src/locales/it/search.ts b/src/locales/it/search.ts
index eb96e290..725764bd 100644
--- a/src/locales/it/search.ts
+++ b/src/locales/it/search.ts
@@ -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',
diff --git a/src/locales/ja/playlists.ts b/src/locales/ja/playlists.ts
index e002ca90..47096cb1 100644
--- a/src/locales/ja/playlists.ts
+++ b/src/locales/ja/playlists.ts
@@ -9,6 +9,7 @@ export const playlists = {
create: '作成',
cancel: 'キャンセル',
empty: 'プレイリストはまだありません。',
+ noMatchingSearch: '検索に一致するプレイリストはありません。',
emptyPlaylist: 'このプレイリストは空です。',
addFirstSong: '最初の曲を追加',
notFound: 'プレイリストが見つかりません。',
diff --git a/src/locales/ja/search.ts b/src/locales/ja/search.ts
index 34f4854e..97935c46 100644
--- a/src/locales/ja/search.ts
+++ b/src/locales/ja/search.ts
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: '作曲者を検索…',
scopeComposersBadgeTooltip: 'クリックして削除',
scopeComposersGhostTooltip: 'クリックしてこのページだけで検索',
+ scopePlaylistsPlaceholder: 'プレイリストを検索…',
+ scopePlaylistsBadgeTooltip: 'クリックして削除',
+ scopePlaylistsGhostTooltip: 'クリックしてこのページだけで検索',
genres: 'ジャンル',
shareLink: '共有リンク',
shareTrackTitle: '共有トラック',
diff --git a/src/locales/nb/playlists.ts b/src/locales/nb/playlists.ts
index fd911909..f251cc48 100644
--- a/src/locales/nb/playlists.ts
+++ b/src/locales/nb/playlists.ts
@@ -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.',
diff --git a/src/locales/nb/search.ts b/src/locales/nb/search.ts
index 88cea335..8c177c11 100644
--- a/src/locales/nb/search.ts
+++ b/src/locales/nb/search.ts
@@ -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',
diff --git a/src/locales/nl/playlists.ts b/src/locales/nl/playlists.ts
index 7ac9b5cb..b15d3e30 100644
--- a/src/locales/nl/playlists.ts
+++ b/src/locales/nl/playlists.ts
@@ -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.',
diff --git a/src/locales/nl/search.ts b/src/locales/nl/search.ts
index fb396c88..e8145d15 100644
--- a/src/locales/nl/search.ts
+++ b/src/locales/nl/search.ts
@@ -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',
diff --git a/src/locales/pl/playlists.ts b/src/locales/pl/playlists.ts
index e598006e..4b5f5f7a 100644
--- a/src/locales/pl/playlists.ts
+++ b/src/locales/pl/playlists.ts
@@ -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.',
diff --git a/src/locales/pl/search.ts b/src/locales/pl/search.ts
index 10adc9b6..9b5d7483 100644
--- a/src/locales/pl/search.ts
+++ b/src/locales/pl/search.ts
@@ -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',
diff --git a/src/locales/ro/playlists.ts b/src/locales/ro/playlists.ts
index ca5084e6..88e3b220 100644
--- a/src/locales/ro/playlists.ts
+++ b/src/locales/ro/playlists.ts
@@ -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.',
diff --git a/src/locales/ro/search.ts b/src/locales/ro/search.ts
index dcbb8a46..0724e901 100644
--- a/src/locales/ro/search.ts
+++ b/src/locales/ro/search.ts
@@ -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',
diff --git a/src/locales/ru/playlists.ts b/src/locales/ru/playlists.ts
index 19629a70..40d1a0c5 100644
--- a/src/locales/ru/playlists.ts
+++ b/src/locales/ru/playlists.ts
@@ -9,6 +9,7 @@ export const playlists = {
create: 'Создать',
cancel: 'Отмена',
empty: 'Плейлистов пока нет.',
+ noMatchingSearch: 'Нет плейлистов, подходящих под поиск.',
emptyPlaylist: 'Плейлист пуст.',
addFirstSong: 'Добавьте первый трек',
notFound: 'Плейлист не найден.',
diff --git a/src/locales/ru/search.ts b/src/locales/ru/search.ts
index 0716c976..cf22b98e 100644
--- a/src/locales/ru/search.ts
+++ b/src/locales/ru/search.ts
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: 'Поиск композитора…',
scopeComposersBadgeTooltip: 'Щелчок — удалить',
scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице',
+ scopePlaylistsPlaceholder: 'Поиск плейлиста…',
+ scopePlaylistsBadgeTooltip: 'Щелчок — удалить',
+ scopePlaylistsGhostTooltip: 'Щелчок — искать только на этой странице',
genres: 'Жанры',
shareLink: 'Ссылка для обмена',
shareTrackTitle: 'Общий трек',
diff --git a/src/locales/zh/playlists.ts b/src/locales/zh/playlists.ts
index 2814ea37..9fd3400b 100644
--- a/src/locales/zh/playlists.ts
+++ b/src/locales/zh/playlists.ts
@@ -9,6 +9,7 @@ export const playlists = {
create: '创建',
cancel: '取消',
empty: '暂无播放列表。',
+ noMatchingSearch: '没有匹配搜索的播放列表。',
emptyPlaylist: '此播放列表为空。',
addFirstSong: '添加第一首歌曲',
notFound: '未找到播放列表。',
diff --git a/src/locales/zh/search.ts b/src/locales/zh/search.ts
index 3598189d..db06f590 100644
--- a/src/locales/zh/search.ts
+++ b/src/locales/zh/search.ts
@@ -60,6 +60,9 @@ export const search = {
scopeComposersPlaceholder: '搜索作曲家…',
scopeComposersBadgeTooltip: '单击 — 移除',
scopeComposersGhostTooltip: '单击 — 仅在此页面搜索',
+ scopePlaylistsPlaceholder: '搜索播放列表…',
+ scopePlaylistsBadgeTooltip: '单击 — 移除',
+ scopePlaylistsGhostTooltip: '单击 — 仅在此页面搜索',
genres: '流派',
shareLink: 'Share link',
shareTrackTitle: 'Shared track',
diff --git a/src/store/liveSearchScopeStore.test.ts b/src/store/liveSearchScopeStore.test.ts
index 7117038e..be369230 100644
--- a/src/store/liveSearchScopeStore.test.ts
+++ b/src/store/liveSearchScopeStore.test.ts
@@ -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', () => {
diff --git a/src/store/liveSearchScopeStore.ts b/src/store/liveSearchScopeStore.ts
index 6d2f84af..b2a20219 100644
--- a/src/store/liveSearchScopeStore.ts
+++ b/src/store/liveSearchScopeStore.ts
@@ -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;