mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
Compare commits
2 Commits
0df547e3be
...
16aee64d66
| Author | SHA1 | Date | |
|---|---|---|---|
| 16aee64d66 | |||
| fa1dc1a328 |
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: 'Създай',
|
||||
cancel: 'Отказ',
|
||||
empty: 'Все още няма плейлисти.',
|
||||
noMatchingSearch: 'Няма плейлисти, които съответстват на търсенето.',
|
||||
emptyPlaylist: 'Този плейлист е празен.',
|
||||
addFirstSong: 'Добавете първата си песен',
|
||||
notFound: 'Плейлистът не е намерен.',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: 'Търси композитор…',
|
||||
scopeComposersBadgeTooltip: 'Кликнете, за да премахнете',
|
||||
scopeComposersGhostTooltip: 'Кликнете, за да търсите само на тази страница',
|
||||
scopePlaylistsPlaceholder: 'Търси плейлист…',
|
||||
scopePlaylistsBadgeTooltip: 'Кликнете, за да премахнете',
|
||||
scopePlaylistsGhostTooltip: 'Кликнете, за да търсите само на тази страница',
|
||||
genres: 'Жанрове',
|
||||
shareLink: 'Връзка за споделяне',
|
||||
shareTrackTitle: 'Споделена песен',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ó.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: '作成',
|
||||
cancel: 'キャンセル',
|
||||
empty: 'プレイリストはまだありません。',
|
||||
noMatchingSearch: '検索に一致するプレイリストはありません。',
|
||||
emptyPlaylist: 'このプレイリストは空です。',
|
||||
addFirstSong: '最初の曲を追加',
|
||||
notFound: 'プレイリストが見つかりません。',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: '作曲者を検索…',
|
||||
scopeComposersBadgeTooltip: 'クリックして削除',
|
||||
scopeComposersGhostTooltip: 'クリックしてこのページだけで検索',
|
||||
scopePlaylistsPlaceholder: 'プレイリストを検索…',
|
||||
scopePlaylistsBadgeTooltip: 'クリックして削除',
|
||||
scopePlaylistsGhostTooltip: 'クリックしてこのページだけで検索',
|
||||
genres: 'ジャンル',
|
||||
shareLink: '共有リンク',
|
||||
shareTrackTitle: '共有トラック',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
empty: 'Плейлистов пока нет.',
|
||||
noMatchingSearch: 'Нет плейлистов, подходящих под поиск.',
|
||||
emptyPlaylist: 'Плейлист пуст.',
|
||||
addFirstSong: 'Добавьте первый трек',
|
||||
notFound: 'Плейлист не найден.',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: 'Поиск композитора…',
|
||||
scopeComposersBadgeTooltip: 'Щелчок — удалить',
|
||||
scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
scopePlaylistsPlaceholder: 'Поиск плейлиста…',
|
||||
scopePlaylistsBadgeTooltip: 'Щелчок — удалить',
|
||||
scopePlaylistsGhostTooltip: 'Щелчок — искать только на этой странице',
|
||||
genres: 'Жанры',
|
||||
shareLink: 'Ссылка для обмена',
|
||||
shareTrackTitle: 'Общий трек',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const playlists = {
|
||||
create: '创建',
|
||||
cancel: '取消',
|
||||
empty: '暂无播放列表。',
|
||||
noMatchingSearch: '没有匹配搜索的播放列表。',
|
||||
emptyPlaylist: '此播放列表为空。',
|
||||
addFirstSong: '添加第一首歌曲',
|
||||
notFound: '未找到播放列表。',
|
||||
|
||||
@@ -60,6 +60,9 @@ export const search = {
|
||||
scopeComposersPlaceholder: '搜索作曲家…',
|
||||
scopeComposersBadgeTooltip: '单击 — 移除',
|
||||
scopeComposersGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
scopePlaylistsPlaceholder: '搜索播放列表…',
|
||||
scopePlaylistsBadgeTooltip: '单击 — 移除',
|
||||
scopePlaylistsGhostTooltip: '单击 — 仅在此页面搜索',
|
||||
genres: '流派',
|
||||
shareLink: 'Share link',
|
||||
shareTrackTitle: 'Shared track',
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user