feat(playlist): scoped header search on Playlists browse (#1308)

This commit is contained in:
cucadmuh
2026-07-16 04:30:41 +03:00
committed by GitHub
parent fa1dc1a328
commit 16aee64d66
42 changed files with 210 additions and 22 deletions
+6
View File
@@ -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
+1
View File
@@ -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)',
],
},
{
@@ -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}
+1
View File
@@ -29,6 +29,7 @@ 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/runPlaylistCsvImport';
export * from './utils/runPlaylistLoad';
+45 -9
View File
@@ -1,12 +1,14 @@
import { resolvePlaylistTracks } from '@/features/playlist/utils/resolvePlaylistTracks';
import { getGenres } from '@/lib/api/subsonicGenres';
import type { SubsonicPlaylist, SubsonicGenre } from '@/lib/api/subsonicTypes';
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,
@@ -38,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);
@@ -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<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();
@@ -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() {
<PlaylistsHeader
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedIds={visibleSelectedIds}
selectedPlaylists={selectedPlaylists}
isPlaylistDeletable={isPlaylistDeletable}
toggleSelectionMode={toggleSelectionMode}
@@ -274,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 && (
@@ -284,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}
/>
)}
@@ -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));
}
@@ -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;