refactor(playlist): co-locate playlist feature into features/playlist

This commit is contained in:
Psychotoxical
2026-06-30 01:37:05 +02:00
parent 2a425862ae
commit 862941c145
91 changed files with 336 additions and 292 deletions
@@ -1,6 +1,6 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { uploadArtistImage } from '@/api/subsonicPlaylists';
import { uploadArtistImage } from '@/features/playlist';
import { setRating, star, unstar } from '@/api/subsonicStarRating';
import type { SubsonicArtist } from '@/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { getPlaylists } from '@/api/subsonicPlaylists';
import { getPlaylists } from '@/features/playlist';
import { getArtists, getArtist } from '@/features/artist';
import { getAlbumList } from '@/api/subsonicLibrary';
import { search as searchSubsonic } from '@/api/subsonicSearch';
@@ -57,7 +57,7 @@ vi.mock('@/features/artist', () => ({
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
}));
vi.mock('@/api/subsonicPlaylists', () => ({
vi.mock('@/features/playlist', () => ({
getPlaylistForServer: (serverId: string, playlistId: string) =>
getPlaylistForServerMock(serverId, playlistId),
}));
@@ -1,6 +1,6 @@
import { getAlbumForServer } from '@/api/subsonicLibrary';
import { getArtistForServer } from '@/features/artist';
import { getPlaylistForServer } from '@/api/subsonicPlaylists';
import { getPlaylistForServer } from '@/features/playlist';
import type {
SubsonicAlbum,
SubsonicArtist,
@@ -29,7 +29,7 @@ vi.mock('@/utils/network/activeServerReachability', () => ({
onActiveServerBecameReachable: () => () => {},
}));
vi.mock('@/api/subsonicPlaylists', () => ({
vi.mock('@/features/playlist', () => ({
getPlaylistForServer: (serverId: string, id: string) => getPlaylistMock(serverId, id),
}));
@@ -1,6 +1,6 @@
import { libraryGetTracksByAlbum, subscribeLibrarySyncIdle } from '@/api/library';
import { getAlbumForServer, filterSongsToServerLibrary } from '@/api/subsonicLibrary';
import { getPlaylistForServer } from '@/api/subsonicPlaylists';
import { getPlaylistForServer } from '@/features/playlist';
import { getArtistForServer } from '@/features/artist';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/authStore';
import type { PinSource } from '@/store/localPlaybackStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineStore } from '@/features/offline/store/offlineStore';
import { usePlaylistStore } from '@/store/playlistStore';
import { usePlaylistStore } from '@/features/playlist';
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
import { getMediaDir } from '@/utils/media/mediaDir';
import {
@@ -1,5 +1,5 @@
import { libraryGetTracksBatchChunked } from '@/api/library';
import { getPlaylist } from '@/api/subsonicPlaylists';
import { getPlaylist } from '@/features/playlist';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import type { OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
+1 -1
View File
@@ -19,7 +19,7 @@ const { authState, orbitState } = vi.hoisted(() => ({
orbitState: { sessionId: null as string | null },
}));
vi.mock('@/api/subsonicPlaylists', () => ({ getPlaylists, deletePlaylist }));
vi.mock('@/features/playlist', () => ({ getPlaylists, deletePlaylist }));
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => ({
+1 -1
View File
@@ -1,4 +1,4 @@
import { deletePlaylist, getPlaylists } from '@/api/subsonicPlaylists';
import { deletePlaylist, getPlaylists } from '@/features/playlist';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { ORBIT_PLAYLIST_PREFIX, parseOrbitState } from '@/features/orbit/api/orbit';
+1 -1
View File
@@ -1,4 +1,4 @@
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/api/subsonicPlaylists';
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/features/playlist';
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useAuthStore } from '@/store/authStore';
+1 -1
View File
@@ -24,7 +24,7 @@ vi.mock('@/features/orbit/utils/remote', () => ({
vi.mock('@/features/orbit/store/orbitStore', () => ({ useOrbitStore: { getState: () => orbitStore } }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({}) } }));
vi.mock('@/store/playerStore', () => ({ usePlayerStore: { getState: () => ({ enqueue: vi.fn() }) } }));
vi.mock('@/api/subsonicPlaylists', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
vi.mock('@/features/playlist', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
vi.mock('@/api/subsonicLibrary', () => ({ getSong: vi.fn() }));
vi.mock('@/utils/playback/songToTrack', () => ({ songToTrack: vi.fn() }));
+1 -1
View File
@@ -1,4 +1,4 @@
import { createPlaylist, deletePlaylist } from '@/api/subsonicPlaylists';
import { createPlaylist, deletePlaylist } from '@/features/playlist';
import { getSong } from '@/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useAuthStore } from '@/store/authStore';
+1 -1
View File
@@ -1,4 +1,4 @@
import { deletePlaylist, getPlaylists } from '@/api/subsonicPlaylists';
import { deletePlaylist, getPlaylists } from '@/features/playlist';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { orbitOutboxPlaylistName, type OrbitState } from '@/features/orbit/api/orbit';
import { writeOrbitState } from '@/features/orbit/utils/remote';
+1 -1
View File
@@ -1,4 +1,4 @@
import { getPlaylist, getPlaylists, updatePlaylistMeta } from '@/api/subsonicPlaylists';
import { getPlaylist, getPlaylists, updatePlaylistMeta } from '@/features/playlist';
import {
orbitSessionPlaylistName,
parseOrbitState,
+1 -1
View File
@@ -1,4 +1,4 @@
import { getPlaylist, getPlaylists, updatePlaylist } from '@/api/subsonicPlaylists';
import { getPlaylist, getPlaylists, updatePlaylist } from '@/features/playlist';
import { type OrbitOutboxMeta } from '@/features/orbit/api/orbit';
import { parseOutboxPlaylistName } from '@/features/orbit/utils/helpers';
import { type OutboxSnapshot } from '@/features/orbit/utils/stateMath';
@@ -0,0 +1,110 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { api, apiForServer } from '@/api/subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from '@/api/subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
const all = data.playlists?.playlist ?? [];
// Orbit session + outbox playlists are technical internals. They're `public`
// so guests can reach them, which means they leak into every UI picker and
// even into the Navidrome web client. Filter them out of every UI call;
// orbit's own sweep passes `includeOrbit=true`.
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
}
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id });
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('@/features/offline')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
@@ -0,0 +1,185 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Download, X } from 'lucide-react';
import { showToast } from '@/utils/ui/toast';
import type { SpotifyCsvTrack } from '@/features/playlist/utils/spotifyCsvImport';
interface CsvReportModalProps {
report: {
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
};
playlistName: string;
onClose: () => void;
}
export default function CsvImportReportModal({ report, playlistName, onClose }: CsvReportModalProps) {
const { t } = useTranslation();
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const downloadReport = () => {
try {
const content = [
'CSV Import Report',
`Playlist: ${playlistName}`,
`Date: ${new Date().toLocaleString()}`,
`Total: ${report.total}, Added: ${report.added}, Duplicates: ${report.duplicates}, Not Found: ${report.notFound.length}${report.searchErrors ? `, Network Errors: ${report.searchErrors.length}` : ''}`,
'',
...(report.duplicateTracks.length > 0 ? ['Duplicate Tracks (skipped):', ...report.duplicateTracks.map(t => `- ${t.trackName} by ${t.artistName}${t.albumName ? ` (${t.albumName})` : ''}`), ''] : []),
...(report.notFound.length > 0 ? ['Not Found Tracks:', ...report.notFound.map(t => ` - ${t.trackName} | ${t.artistName} | ${t.albumName || 'N/A'} | Score: ${(t.score ?? 0).toFixed(2)} (threshold: ${(t.thresholdNeeded ?? 0).toFixed(2)})`), ''] : []),
...(report.searchErrors && report.searchErrors.length > 0 ? ['Network Error Tracks (may retry):', ...report.searchErrors.map(t => `- ${t.trackName} by ${t.artistName}`), ''] : []),
].join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
// Detailed name: playlist + date-time-seconds
const timestamp = new Date().toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
const safePlaylistName = playlistName.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 50);
a.download = `import-report-${safePlaylistName}-${timestamp}.txt`;
a.href = url;
a.click();
URL.revokeObjectURL(url);
showToast(t('playlists.csvImportDownloadSuccess'), 3000, 'info');
} catch (err) {
console.error('Failed to download report:', err);
showToast(t('playlists.csvImportDownloadError'), 3000, 'error');
}
};
return createPortal(
<div
className="modal-overlay modal-overlay--csv"
onClick={handleOverlayClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div
className="modal-content"
style={{
maxWidth: 500,
maxHeight: '80vh',
width: '90%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ position: 'absolute', top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 20, marginBottom: 16 }}>{t('playlists.csvImportReport')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: report.searchErrors && report.searchErrors.length > 0 ? 'repeat(5, 1fr)' : 'repeat(4, 1fr)', gap: 12, marginBottom: 20, textAlign: 'center' }}>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>{report.total}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportTotal')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{report.added}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportAdded')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--text-muted)' }}>{report.duplicates}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicates')}</div>
</div>
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: report.notFound.length > 0 ? '#ff6b6b' : 'var(--text-muted)' }}>{report.notFound.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFound')}</div>
</div>
{report.searchErrors && report.searchErrors.length > 0 && (
<div style={{ padding: 12, background: 'var(--surface)', borderRadius: 8 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#ffa500' }}>{report.searchErrors.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('playlists.csvImportNetworkErrors')}</div>
</div>
)}
</div>
{report.duplicateTracks.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportDuplicatesTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.duplicateTracks.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
{report.notFound.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: 'var(--text-muted)' }}>{t('playlists.csvImportNotFoundTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 200, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.notFound.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
{track.albumName && <div style={{ color: 'var(--text-muted)', fontSize: 11 }}>{track.albumName}</div>}
{track.score !== undefined && (
<div style={{ fontSize: 11, marginTop: 2 }}>
<span style={{ color: 'var(--text-muted)' }}>Score: </span>
<span style={{ color: track.score >= (track.thresholdNeeded ?? 0.6) ? '#4ade80' : '#f87171' }}>
{track.score.toFixed(2)}
</span>
<span style={{ color: 'var(--text-muted)' }}> (threshold: {(track.thresholdNeeded ?? 0.6).toFixed(2)})</span>
</div>
)}
</div>
))}
</div>
</>
)}
{report.searchErrors && report.searchErrors.length > 0 && (
<>
<h3 style={{ fontSize: 14, marginBottom: 8, color: '#ffa500' }}>{t('playlists.csvImportNetworkErrorsTitle')}</h3>
<div style={{ overflowY: 'auto', maxHeight: 150, marginBottom: 16, border: '1px solid var(--surface)', borderRadius: 8 }}>
{report.searchErrors.map((track, i) => (
<div key={i} style={{ padding: '8px 12px', borderBottom: '1px solid var(--surface)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{track.trackName}</div>
<div style={{ color: 'var(--text-muted)' }}>{track.artistName}</div>
</div>
))}
</div>
</>
)}
<div className="modal-actions" style={{ marginTop: 'auto', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn btn-surface" onClick={downloadReport}>
<Download size={14} /> {t('playlists.csvImportDownloadReport')}
</button>
<button className="btn btn-primary" onClick={onClose}>
{t('playlists.csvImportClose')}
</button>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { PlaylistArtistCell } from '@/features/playlist/components/PlaylistArtistCell';
import type { SubsonicSong } from '@/api/subsonicTypes';
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
return {
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
...overrides,
} as SubsonicSong;
}
describe('PlaylistArtistCell', () => {
it('splits the OpenSubsonic artists array into individual links', () => {
renderWithProviders(
<PlaylistArtistCell song={song({
artist: 'Apocalyptica', artistId: 'a1',
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
})} />,
);
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
});
it('falls back to the legacy artist string when no structured array exists', () => {
renderWithProviders(
<PlaylistArtistCell song={song({ artist: 'Gathering Of Kings', artistId: 'a1' })} />,
);
expect(screen.getByText('Gathering Of Kings')).toHaveClass('track-artist-link');
});
it('renders a non-navigable name when the ref has no id', () => {
renderWithProviders(
<PlaylistArtistCell song={song({ artist: 'Various Artists', artistId: '' })} />,
);
const el = screen.getByText('Various Artists');
expect(el).not.toHaveClass('track-artist-link');
});
});
@@ -0,0 +1,32 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
/**
* Multi-artist credit for playlist track rows (main list + suggestions).
* Renders the OpenSubsonic `artists` array as ·-separated, individually
* navigable links, falling back to the legacy `artist`/`artistId` pair.
* Mirrors the album track list (TrackRow) so a track reads the same before
* and after it is added to the playlist.
*/
export function PlaylistArtistCell({ song }: { song: SubsonicSong }) {
const navigate = useNavigate();
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
return (
<div className="track-artist-cell">
{artistRefs.map((a, i) => (
<React.Fragment key={a.id ?? a.name ?? i}>
{i > 0 && <span className="track-artist-sep">&nbsp;·&nbsp;</span>}
<span
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
style={{ cursor: a.id ? 'pointer' : 'default' }}
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
>
{a.name ?? song.artist}
</span>
</React.Fragment>
))}
</div>
);
}
@@ -0,0 +1,181 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, Clock3, ListMusic, Pencil, Play, Sparkles, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { usePlayerStore } from '@/store/playerStore';
import {
displayPlaylistName, isSmartPlaylistName, type PendingSmartPlaylist,
} from '@/features/playlist/utils/playlistsSmart';
import { formatHumanHoursMinutes } from '@/utils/format/formatHumanDuration';
import { useDragSource } from '@/contexts/DragDropContext';
import { PlaylistCardMainCover, PlaylistSmartCoverCell } from '@/features/playlist/components/PlaylistCoverImages';
interface Props {
pl: SubsonicPlaylist;
selectionMode: boolean;
/** Enables dragging the card onto a folder drop target (folder view only). */
draggable?: boolean;
selectedIds: Set<string>;
selectedPlaylists: SubsonicPlaylist[];
toggleSelect: (id: string, opts?: { shiftKey?: boolean }) => void;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
deleteConfirmId: string | null;
setDeleteConfirmId: React.Dispatch<React.SetStateAction<string | null>>;
handleOpenSmartEditor: (pl: SubsonicPlaylist) => Promise<void>;
handleDelete: (e: React.MouseEvent, pl: SubsonicPlaylist) => void;
handlePlay: (e: React.MouseEvent, pl: SubsonicPlaylist) => void;
playingId: string | null;
smartCoverIdsByPlaylist: Record<string, string[]>;
pendingSmart: PendingSmartPlaylist[];
filteredSongCountByPlaylist: Record<string, number>;
filteredDurationByPlaylist: Record<string, number>;
}
export default function PlaylistCard({
pl, selectionMode, draggable, selectedIds, selectedPlaylists,
toggleSelect, isPlaylistDeletable,
deleteConfirmId, setDeleteConfirmId,
handleOpenSmartEditor, handleDelete, handlePlay, playingId,
smartCoverIdsByPlaylist, pendingSmart,
filteredSongCountByPlaylist, filteredDurationByPlaylist,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const dragHandlers = useDragSource(() => ({
data: JSON.stringify({ type: 'playlist', id: pl.id }),
label: displayPlaylistName(pl.name),
}));
const dragEnabled = Boolean(draggable) && !selectionMode;
return (
<div
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' album-card--selected' : ''}${dragEnabled ? ' album-card--draggable' : ''}`}
{...(dragEnabled ? dragHandlers : {})}
onClick={(e) => {
if (selectionMode) {
toggleSelect(pl.id, { shiftKey: e.shiftKey });
} else {
navigate(`/playlists/${pl.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist');
} else {
openContextMenu(e.clientX, e.clientY, pl, 'playlist');
}
}}
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
>
{!selectionMode && (
<div className="playlist-card-actions">
{isPlaylistDeletable(pl) && (
<button
className="playlist-card-action playlist-card-action--edit"
onClick={(e) => {
e.stopPropagation();
if (isSmartPlaylistName(pl.name)) {
void handleOpenSmartEditor(pl);
return;
}
navigate(`/playlists/${pl.id}`, { state: { openEditMeta: true } });
}}
data-tooltip={t('playlists.editMeta')}
>
<Pencil size={13} />
</button>
)}
{isPlaylistDeletable(pl) && (
<button
className={`playlist-card-action playlist-card-action--delete${deleteConfirmId === pl.id ? ' playlist-card-action--delete-confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('common.delete')}
>
<Trash2 size={13} />
</button>
)}
</div>
)}
{selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? (
<div className="playlist-cover-grid">
{Array.from({ length: 4 }, (_, i) => {
const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length];
return id ? (
<PlaylistSmartCoverCell key={i} coverId={id} />
) : (
<div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
);
})}
</div>
) : pl.coverArt ? (
<PlaylistCardMainCover coverArt={pl.coverArt} alt={pl.name} />
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<ListMusic size={48} strokeWidth={1.2} />
</div>
)}
{pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && (
<div
style={{
position: 'absolute',
top: 8,
left: 8,
width: 24,
height: 24,
borderRadius: 'var(--radius-sm)',
background: 'rgba(0,0,0,0.45)',
border: '1px solid rgba(255,255,255,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
zIndex: 8,
pointerEvents: 'none',
}}
data-tooltip={t('common.loading')}
>
<Clock3 size={13} />
</div>
)}
{/* Play overlay — same pattern as AlbumCard */}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={(e) => handlePlay(e, pl)}
disabled={playingId === pl.id}
>
{playingId === pl.id
? <span className="spinner" style={{ width: 14, height: 14 }} />
: <Play size={15} fill="currentColor" />
}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isSmartPlaylistName(pl.name) && <Sparkles size={14} style={{ color: 'var(--text-muted)', flex: '0 0 auto' }} />}
<span>{displayPlaylistName(pl.name)}</span>
</div>
<div className="album-card-artist">
{t('playlists.songs', { count: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import React from 'react';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
/** 2×2 collage cell — half of clamp(120px, 15vw, 200px) playlist hero grid. */
const PLAYLIST_QUAD_CELL_CSS_PX = 100;
/** Full playlist hero / card cover square. */
const PLAYLIST_MAIN_COVER_CSS_PX = 200;
export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
return (
<AlbumCoverArtImage
albumId={coverId}
coverArt={coverId}
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
surface="dense"
className="playlist-cover-cell"
alt=""
/>
);
}
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
return (
<AlbumCoverArtImage
albumId={coverArt}
coverArt={coverArt}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt={alt}
className="album-card-cover-img"
/>
);
}
@@ -0,0 +1,160 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Camera, Loader2, X } from 'lucide-react';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import type { CoverArtId } from '@/cover/types';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '@/features/playlist/hooks/usePlaylistCovers';
import { PlaylistSmartCoverCell } from '@/features/playlist/components/PlaylistCoverImages';
interface EditModalProps {
playlist: SubsonicPlaylist;
customCoverId: string | null;
coverQuadIds: (CoverArtId | null)[];
onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
}
export default function PlaylistEditModal({
playlist, customCoverId, coverQuadIds, onClose, onSave,
}: EditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(playlist.name);
const [comment, setComment] = useState(playlist.comment ?? '');
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
<div className="playlist-edit-body">
{/* Left: cover */}
<div
className="playlist-edit-cover-wrap"
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverId ? (
<AlbumCoverArtImage
albumId={customCoverId}
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadIds.map((coverId, i) =>
coverId
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={14} />
{t('playlists.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('playlists.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Right: fields */}
<div className="playlist-edit-fields">
<input
className="input playlist-edit-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('playlists.editNamePlaceholder')}
autoFocus
/>
<textarea
className="input playlist-edit-desc-input"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('playlists.editCommentPlaceholder')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<label className="toggle-switch" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
<span className="toggle-track" />
</label>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-surface" onClick={onClose}>
{t('playlists.editCancel')}
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,129 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Search, X } from 'lucide-react';
import SortDropdown, { type SortOption } from '@/components/SortDropdown';
import type { PlaylistSortKey, PlaylistSortDir } from '@/features/playlist/utils/playlistDisplayedSongs';
type PlaylistSortValue =
| 'added-newest'
| 'added-oldest'
| 'title'
| 'artist'
| 'album'
| 'duration'
| 'favorite'
| 'rating'
| 'plays';
const SORT_MAP: Record<PlaylistSortValue, { key: PlaylistSortKey; dir: PlaylistSortDir }> = {
'added-newest': { key: 'position', dir: 'desc' },
'added-oldest': { key: 'position', dir: 'asc' },
title: { key: 'title', dir: 'asc' },
artist: { key: 'artist', dir: 'asc' },
album: { key: 'album', dir: 'asc' },
duration: { key: 'duration', dir: 'asc' },
favorite: { key: 'favorite', dir: 'desc' },
rating: { key: 'rating', dir: 'desc' },
plays: { key: 'playCount', dir: 'desc' },
};
// Column keys whose dropdown value is the same string as the sort key.
const DIRECT_COLUMN_SORTS = new Set<PlaylistSortKey>([
'title',
'artist',
'album',
'duration',
'favorite',
'rating',
]);
interface Props {
filterText: string;
setFilterText: (v: string) => void;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
setSortKey: (k: PlaylistSortKey) => void;
setSortDir: (d: PlaylistSortDir) => void;
setSortClickCount: (n: number) => void;
}
export default function PlaylistFilterToolbar({
filterText,
setFilterText,
sortKey,
sortDir,
setSortKey,
setSortDir,
setSortClickCount,
}: Props) {
const { t } = useTranslation();
// The dropdown and the column-header clicks drive the same (sortKey, sortDir)
// state. Map the current state back to a dropdown value so the two stay in
// sync; playlist load order (natural) shows as "date added (oldest)" since
// they are the same ordering.
const currentSortValue: PlaylistSortValue =
sortKey === 'position'
? sortDir === 'desc'
? 'added-newest'
: 'added-oldest'
: sortKey === 'playCount'
? 'plays'
: DIRECT_COLUMN_SORTS.has(sortKey)
? (sortKey as PlaylistSortValue)
: 'added-oldest';
const sortOptions: SortOption<PlaylistSortValue>[] = [
{ value: 'added-newest', label: t('playlists.sortDateAddedNewest') },
{ value: 'added-oldest', label: t('playlists.sortDateAddedOldest') },
{ value: 'title', label: t('albumDetail.trackTitle') },
{ value: 'artist', label: t('albumDetail.trackArtist') },
{ value: 'album', label: t('albumDetail.trackAlbum') },
{ value: 'duration', label: t('albumDetail.trackDuration') },
{ value: 'favorite', label: t('albumDetail.trackFavorite') },
{ value: 'rating', label: t('albumDetail.trackRating') },
{ value: 'plays', label: t('albumDetail.trackPlayCount') },
];
const onSortChange = (v: PlaylistSortValue) => {
const { key, dir } = SORT_MAP[v];
setSortKey(key);
setSortDir(dir);
// Reset the column click cycle so a follow-up header click starts cleanly.
setSortClickCount(1);
};
return (
<div className="playlist-filter-toolbar" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setFilterText('')}
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
<div style={{ marginLeft: 'auto' }}>
<SortDropdown
value={currentSortValue}
options={sortOptions}
onChange={onSortChange}
tooltip={t('playlists.sortTooltip')}
ariaLabel={t('playlists.sortTooltip')}
align="right"
/>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import PlaylistFolderSection from '@/features/playlist/components/PlaylistFolderSection';
import { usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
import type { PlaylistFolder } from '@/features/playlist/utils/playlistFolders';
const store = () => usePlaylistFolderStore.getState();
const assignments = (serverId: string) => store().byServer[serverId]?.assignments ?? {};
beforeEach(() => {
usePlaylistFolderStore.setState({ byServer: {}, groupView: true });
});
/** Simulate the shared mouse-DnD system releasing a playlist over an element. */
function dropPlaylist(el: Element, playlistId: string) {
el.dispatchEvent(
new CustomEvent('psy-drop', {
bubbles: true,
detail: { data: JSON.stringify({ type: 'playlist', id: playlistId }) },
}),
);
}
describe('PlaylistFolderSection — drop target', () => {
it('files a dropped playlist into the folder', () => {
const id = store().createFolder('s1', 'Rock');
const folder: PlaylistFolder = { id, name: 'Rock', order: 0, collapsed: false };
const { container } = renderWithProviders(
<PlaylistFolderSection
serverId="s1"
folder={folder}
items={[]}
renderCard={() => null}
disableVirtualization
/>,
);
dropPlaylist(container.querySelector('.playlist-folder')!, 'p1');
expect(assignments('s1').p1).toBe(id);
});
it('unfiles a dropped playlist in the ungrouped section', () => {
const id = store().createFolder('s1', 'Rock');
store().setPlaylistFolder('s1', 'p1', id);
const { container } = renderWithProviders(
<PlaylistFolderSection
serverId="s1"
folder={null}
items={[]}
renderCard={() => null}
disableVirtualization
/>,
);
dropPlaylist(container.querySelector('.playlist-folder--ungrouped')!, 'p1');
expect(assignments('s1').p1).toBeUndefined();
});
});
@@ -0,0 +1,170 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, Folder, Pencil, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
import type { PlaylistFolder } from '@/features/playlist/utils/playlistFolders';
import { useDragDrop } from '@/contexts/DragDropContext';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
interface Props {
serverId: string;
/** The folder this section renders, or null for the ungrouped remainder. */
folder: PlaylistFolder | null;
items: SubsonicPlaylist[];
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
disableVirtualization: boolean;
}
/**
* One folder section (header + card grid), or the ungrouped remainder when
* `folder` is null. The header is a `psy-drop` target: dropping a dragged
* playlist card here assigns it to this folder (or unfiles it for ungrouped).
* Uses the shared mouse-based DnD system (HTML5 DnD is unusable in WebKitGTK).
*/
export default function PlaylistFolderSection({
serverId, folder, items, renderCard, disableVirtualization,
}: Props) {
const { t } = useTranslation();
const { isDragging } = useDragDrop();
const renameFolder = usePlaylistFolderStore(s => s.renameFolder);
const deleteFolder = usePlaylistFolderStore(s => s.deleteFolder);
const toggleFolderCollapsed = usePlaylistFolderStore(s => s.toggleFolderCollapsed);
const setPlaylistFolder = usePlaylistFolderStore(s => s.setPlaylistFolder);
const sectionRef = useRef<HTMLElement>(null);
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const [isOver, setIsOver] = useState(false);
const targetId = folder ? folder.id : null;
// The whole section is the drop zone: a `psy-drop` released anywhere inside it
// (header or a card in the grid — events bubble up) files the playlist here.
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const handler = (e: Event) => {
setIsOver(false);
try {
const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (data.type === 'playlist' && data.id) setPlaylistFolder(serverId, data.id, targetId);
} catch { /* ignore non-playlist drops */ }
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [serverId, targetId, setPlaylistFolder]);
// Highlight while a drag hovers the section (mouse events still fire during the
// custom drag, unlike native HTML5 DnD).
const hoverProps = {
onMouseMove: () => { if (isDragging && !isOver) setIsOver(true); },
onMouseLeave: () => setIsOver(false),
};
const grid = items.length > 0 && (
<VirtualCardGrid
items={items}
itemKey={pl => pl.id}
rowVariant="playlist"
disableVirtualization={disableVirtualization}
layoutSignal={items.length}
renderItem={renderCard}
/>
);
if (!folder) {
return (
<section
ref={sectionRef}
className={`playlist-folder playlist-folder--ungrouped${isOver ? ' drag-over' : ''}`}
{...hoverProps}
>
<div className="playlist-folder-header playlist-folder-header--static">
<Folder size={16} className="playlist-folder-icon" />
<span className="playlist-folder-name playlist-folder-name--static">
{t('playlists.folders.ungrouped')}
</span>
<span className="playlist-folder-count">{t('playlists.folders.count', { count: items.length })}</span>
</div>
{items.length > 0 ? grid : (
<div className="playlist-folder-dropzone">{t('playlists.folders.removeFromFolder')}</div>
)}
</section>
);
}
const commitRename = () => {
if (draft.trim()) renameFolder(serverId, folder.id, draft.trim());
setRenaming(false);
setDraft('');
};
return (
<section
ref={sectionRef}
className={`playlist-folder${isOver ? ' drag-over' : ''}`}
{...hoverProps}
>
<div className="playlist-folder-header">
<button
className={`playlist-folder-toggle${folder.collapsed ? '' : ' expanded'}`}
onClick={() => toggleFolderCollapsed(serverId, folder.id)}
aria-expanded={!folder.collapsed}
aria-label={folder.collapsed ? t('playlists.folders.expandFolder') : t('playlists.folders.collapseFolder')}
>
<ChevronRight size={16} />
</button>
<Folder size={16} className="playlist-folder-icon" />
{renaming ? (
<input
autoFocus
className="playlist-folder-rename-input"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={e => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') { setRenaming(false); setDraft(''); }
}}
/>
) : (
<button className="playlist-folder-name" onClick={() => toggleFolderCollapsed(serverId, folder.id)}>
{folder.name}
</button>
)}
<span className="playlist-folder-count">{t('playlists.folders.count', { count: items.length })}</span>
<div className="playlist-folder-actions">
<button
className="playlist-folder-action"
data-tooltip={t('playlists.folders.rename')}
aria-label={t('playlists.folders.rename')}
onClick={() => { setRenaming(true); setDraft(folder.name); }}
>
<Pencil size={14} />
</button>
<button
className={`playlist-folder-action playlist-folder-action--delete${confirmDelete ? ' is-confirm' : ''}`}
data-tooltip={confirmDelete ? t('playlists.confirmDelete') : t('playlists.folders.delete')}
aria-label={t('playlists.folders.delete')}
onClick={() => {
if (confirmDelete) { deleteFolder(serverId, folder.id); setConfirmDelete(false); }
else setConfirmDelete(true);
}}
onMouseLeave={() => setConfirmDelete(false)}
>
<Trash2 size={14} />
</button>
</div>
</div>
{!folder.collapsed && (
items.length === 0 ? (
<div className="playlist-folder-empty">{t('playlists.empty')}</div>
) : (
grid
)
)}
</section>
);
}
@@ -0,0 +1,267 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
Camera, ChevronLeft, Download, FileUp, Globe, HardDriveDownload, ListPlus,
Loader2, Lock, Pencil, Play, Search, Shuffle, Sparkles, Trash2,
} from 'lucide-react';
import type { SubsonicPlaylist, SubsonicSong } from '@/api/subsonicTypes';
import type { ZipDownload } from '@/features/offline';
import type { AlbumOfflineStatus } from '@/features/album';
import { dequeueOfflinePin } from '@/features/offline';
import { useThemeStore } from '@/store/themeStore';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/features/playlist/store/playlistLayoutStore';
import {
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
} from '@/utils/componentHelpers/playlistDetailHelpers';
import type { CoverArtId } from '@/cover/types';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '@/features/playlist/hooks/usePlaylistCovers';
import { PlaylistSmartCoverCell } from '@/features/playlist/components/PlaylistCoverImages';
import type { OfflineActionPolicy } from '@/features/offline';
interface Props {
playlist: SubsonicPlaylist;
songs: SubsonicSong[];
id: string | undefined;
customCoverId: string | null;
coverQuadIds: (CoverArtId | null)[];
resolvedBgUrl: string | null;
saving: boolean;
searchOpen: boolean;
csvImporting: boolean;
activeZip: ZipDownload | undefined;
offlineStatus: AlbumOfflineStatus;
offlineProgress: { done: number; total: number } | null;
activeServerId: string;
actionPolicy: OfflineActionPolicy;
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
setSelectedSearchIds: React.Dispatch<React.SetStateAction<Set<string>>>;
setSearchPlPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
handlePlayAll: () => void;
handleShuffleAll: () => void;
handleEnqueueAll: () => void;
handleImportCsv: () => void;
handleDownload: () => void;
deleteAlbum: (id: string, serverId: string) => void;
downloadPlaylist: (id: string, name: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => void;
}
export default function PlaylistHero({
playlist, songs, id,
customCoverId, coverQuadIds,
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
offlineStatus, offlineProgress, activeServerId, actionPolicy,
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
setSelectedSearchIds, setSearchPlPickerOpen,
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
deleteAlbum, downloadPlaylist,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
const layoutItems = usePlaylistLayoutStore(s => s.items);
const isLayoutVisible = (id: PlaylistLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
return (
<div className="album-detail-header">
{resolvedBgUrl && enableCoverArtBackground && (
<>
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
<div className="album-detail-overlay" aria-hidden="true" />
</>
)}
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
<ChevronLeft size={16} /> {t('playlists.title')}
</button>
<div className="album-detail-hero">
{/* Cover — click to open edit modal */}
{enablePlaylistCoverPhoto && (
<div
className="playlist-hero-cover"
onClick={() => { if (actionPolicy.canEditPlaylist) setEditingMeta(true); }}
>
{customCoverId ? (
<AlbumCoverArtImage
albumId={customCoverId}
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid">
{coverQuadIds.map((coverId, i) =>
coverId
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-hero-cover-overlay">
<Camera size={28} />
</div>
</div>
)}
<div className="album-detail-meta">
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
<span>{displayPlaylistName(playlist.name)}</span>
</h1>
{actionPolicy.canEditPlaylist && (
<button
className="btn btn-ghost"
onClick={() => setEditingMeta(true)}
data-tooltip={t('playlists.editMeta')}
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
>
<Pencil size={14} />
</button>
)}
</div>
{playlist.comment && (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
)}
</>
<div className="album-detail-info">
<span>{t('playlists.songs', { count: songs.length })}</span>
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{playlist.public !== undefined && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
· {playlist.public
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
</span>
)}
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
</div>
<div className="album-detail-actions compact-action-bar">
<div className="album-detail-actions-primary">
<button
className="btn btn-primary"
disabled={songs.length === 0}
onClick={handlePlayAll}
aria-label={t('playlists.playTooltip')}
data-tooltip={t('playlists.playTooltip')}
>
<Play size={15} /> <span className="compact-btn-label">{t('common.play', 'Reproducir')}</span>
</button>
<button
className="btn btn-ghost"
disabled={songs.length === 0}
onClick={handleShuffleAll}
data-tooltip={t('playlists.shuffle', 'Shuffle')}
>
<Shuffle size={16} />
</button>
<button
className="btn btn-ghost"
disabled={songs.length === 0}
onClick={handleEnqueueAll}
data-tooltip={t('playlists.addToQueue')}
>
<ListPlus size={16} />
</button>
</div>
{actionPolicy.canEditPlaylist && isLayoutVisible('addSongs') && (
<button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
aria-label={t('playlists.addSongsTooltip')}
data-tooltip={t('playlists.addSongsTooltip')}
>
<Search size={16} /> <span className="compact-btn-label">{t('playlists.addSongs')}</span>
</button>
)}
{actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && (
<button
className="btn btn-ghost"
onClick={handleImportCsv}
disabled={csvImporting}
aria-label={t('playlists.importCSVTooltip')}
data-tooltip={t('playlists.importCSVTooltip')}
>
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
<span className="compact-btn-label">{t('playlists.importCSV')}</span>
</button>
)}
{actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
</div>
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} aria-label={t('playlists.downloadZip')} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> <span className="compact-btn-label">{t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''}</span>
</button>
)
)}
{actionPolicy.canPinOffline && isLayoutVisible('offlineCache') && songs.length > 0 && id
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
<button
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
disabled={offlineStatus === 'downloading'}
onClick={() => {
if (offlineStatus === 'cached') {
deleteAlbum(id, activeServerId);
} else if (offlineStatus === 'queued') {
dequeueOfflinePin(id);
} else if (playlist) {
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
}
}}
data-tooltip={offlineStatus === 'downloading'
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
: offlineStatus === 'queued'
? t('albumDetail.removeFromOfflineQueue')
: offlineStatus === 'cached'
? t('playlists.removeOffline')
: t('playlists.cacheOffline')}
>
{offlineStatus === 'downloading' ? (
<>
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
<span className="compact-btn-label">{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}</span>
</>
) : offlineStatus === 'queued' ? (
<>
<HardDriveDownload size={16} />
<span className="compact-btn-label">{t('albumDetail.offlineQueued')}</span>
</>
) : offlineStatus === 'cached' ? (
<>
<Trash2 size={16} />
<span className="compact-btn-label">{t('playlists.removeOffline')}</span>
</>
) : (
<>
<HardDriveDownload size={16} />
<span className="compact-btn-label">{t('playlists.cacheOffline')}</span>
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,154 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Heart, Play, Square, Trash2 } from 'lucide-react';
import type { ColDef } from '@/utils/useTracklistColumns';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/i18n';
import { formatTrackTime } from '@/utils/format/formatDuration';
import StarRating from '@/components/StarRating';
import { PlaylistArtistCell } from '@/features/playlist/components/PlaylistArtistCell';
export interface PlaylistRowCallbacks {
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
dblOrbit: (songId: string, e: React.MouseEvent) => void;
context: (song: SubsonicSong, realIdx: number, e: React.MouseEvent) => void;
mouseDownRow: (realIdx: number, e: React.MouseEvent) => void;
mouseEnterRow: (index: number, e: React.MouseEvent) => void;
toggleSelect: (songId: string, index: number, shift: boolean) => void;
play: (index: number) => void;
startPreview: (song: SubsonicSong) => void;
toggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
rate: (songId: string, rating: number) => void;
remove: (realIdx: number) => void;
navArtist: (artistId: string) => void;
navAlbum: (albumId: string) => void;
}
interface Props {
song: SubsonicSong;
index: number;
realIdx: number;
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
showBitrate: boolean;
isActive: boolean;
showEq: boolean;
isContextActive: boolean;
isSelected: boolean;
inSelectMode: boolean;
isStarred: boolean;
ratingValue: number;
isPreviewing: boolean;
previewStarted: boolean;
orbitActive: boolean;
cb: PlaylistRowCallbacks;
}
function PlaylistRow({
song, index: i, realIdx, visibleCols, gridStyle, showBitrate,
isActive, showEq, isContextActive, isSelected, inSelectMode,
isStarred, ratingValue, isPreviewing, previewStarted, orbitActive, cb,
}: Props) {
const { t } = useTranslation();
return (
<div
data-track-idx={realIdx}
className={`track-row track-row-va track-row-with-actions tracklist-playlist${isActive ? ' active' : ''}${isContextActive ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
onMouseEnter={e => cb.mouseEnterRow(i, e)}
onMouseDown={e => cb.mouseDownRow(realIdx, e)}
onClick={e => cb.activate(song, i, e)}
onDoubleClick={orbitActive ? e => cb.dblOrbit(song.id, e) : undefined}
onContextMenu={e => cb.context(song, realIdx, e)}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${isActive ? ' track-num-active' : ''}`}>
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); cb.toggleSelect(song.id, i, e.shiftKey); }} />
{showEq ? (
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{i + 1}</span>
)}
</div>
);
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); cb.play(i); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewing && previewStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); cb.startPreview(song); }}
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{isPreviewing
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return <PlaylistArtistCell key="artist" song={song} />;
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => cb.toggleStar(song, e)} style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratingValue} onChange={r => cb.rate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatTrackTime(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">{song.genre ?? '—'}</div>
);
case 'playCount': return (
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
);
case 'lastPlayed': return (
<div key="lastPlayed" className="track-genre">{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}</div>
);
case 'bpm': return (
<div key="bpm" className="track-duration">{song.bpm && song.bpm > 0 ? song.bpm : '—'}</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); cb.remove(realIdx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
<Trash2 size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
}
export default React.memo(PlaylistRow);
@@ -0,0 +1,149 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Check, ListPlus, X } from 'lucide-react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { usePlayerStore } from '@/store/playerStore';
import { songToTrack } from '@/utils/playback/songToTrack';
import { formatTrackTime } from '@/utils/format/formatDuration';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
function PlaylistSearchResultThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
alt=""
className="playlist-search-thumb"
/>
);
}
interface Props {
query: string;
setQuery: (v: string) => void;
searching: boolean;
searchResults: SubsonicSong[];
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
selectedSearchIds: Set<string>;
setSelectedSearchIds: React.Dispatch<React.SetStateAction<Set<string>>>;
searchPlPickerOpen: boolean;
setSearchPlPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
contextMenuSongId: string | null;
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
addSong: (song: SubsonicSong) => void;
}
export default function PlaylistSongSearchPanel({
query, setQuery, searching, searchResults, setSearchResults,
selectedSearchIds, setSelectedSearchIds,
searchPlPickerOpen, setSearchPlPickerOpen,
contextMenuSongId, setContextMenuSongId,
addSong,
}: Props) {
const { t } = useTranslation();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
return (
<div className="playlist-search-panel">
<div className="playlist-search-input-wrap">
<input
className="input"
placeholder={t('playlists.searchPlaceholder')}
value={query}
onChange={e => setQuery(e.target.value)}
autoFocus
/>
{query && (
<button className="live-search-clear" onClick={() => { setQuery(''); setSearchResults([]); }}>
<X size={14} />
</button>
)}
</div>
{searching && <div style={{ textAlign: 'center', padding: '0.75rem' }}><div className="spinner" /></div>}
{!searching && query && searchResults.length === 0 && (
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
)}
{selectedSearchIds.size > 0 && (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0.75rem', background: 'color-mix(in srgb, var(--accent) 10%, transparent)', borderRadius: 'var(--radius-sm)', margin: '0.25rem 0' }}>
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, flex: 1 }}>
{t('common.bulkSelected', { count: selectedSearchIds.size })}
</span>
<button
className="btn btn-sm btn-ghost"
style={{ fontSize: 12 }}
onClick={() => setSelectedSearchIds(new Set())}
>
{t('common.clearSelection')}
</button>
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm btn-primary"
style={{ fontSize: 12 }}
onClick={() => setSearchPlPickerOpen(v => !v)}
>
<ListPlus size={13} /> {t('contextMenu.addToPlaylist')}
</button>
{searchPlPickerOpen && (
<AddToPlaylistSubmenu
songIds={[...selectedSearchIds]}
dropDown
onDone={() => { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }}
/>
)}
</div>
<button
className="btn btn-sm btn-primary"
style={{ fontSize: 12 }}
onClick={() => {
searchResults
.filter(s => selectedSearchIds.has(s.id))
.forEach(s => addSong(s));
setSelectedSearchIds(new Set());
}}
>
<Check size={13} /> {t('playlists.addSelected')}
</button>
</div>
)}
{searchResults.map(song => {
const isSelected = selectedSearchIds.has(song.id);
return (
<div
key={song.id}
className={`playlist-search-row${isSelected ? ' playlist-search-row--selected' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => addSong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<input
type="checkbox"
className="playlist-search-checkbox"
checked={isSelected}
onClick={e => e.stopPropagation()}
onChange={() => setSelectedSearchIds(prev => {
const next = new Set(prev);
if (next.has(song.id)) next.delete(song.id);
else next.add(song.id);
return next;
})}
/>
<PlaylistSearchResultThumb albumId={song.albumId} coverArt={song.coverArt ?? ''} />
<div className="playlist-search-info">
<span className="playlist-search-title">{song.title}</span>
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
</div>
<span className="playlist-search-duration">{formatTrackTime(song.duration ?? 0)}</span>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,225 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Heart, Play, Plus, RefreshCw, Square } from 'lucide-react';
import type { ColDef } from '@/utils/useTracklistColumns';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { usePlayerStore } from '@/store/playerStore';
import { usePreviewStore } from '@/store/previewStore';
import StarRating from '@/components/StarRating';
import { PlaylistArtistCell } from '@/features/playlist/components/PlaylistArtistCell';
import { useThemeStore } from '@/store/themeStore';
import { usePlaylistLayoutStore } from '@/features/playlist/store/playlistLayoutStore';
import { songToTrack } from '@/utils/playback/songToTrack';
import { getQueueTracksView } from '@/utils/library/queueTrackView';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import { formatTrackTime } from '@/utils/format/formatDuration';
import i18n from '@/i18n';
const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
interface Props {
songs: SubsonicSong[];
suggestions: SubsonicSong[];
existingIds: Set<string>;
loadingSuggestions: boolean;
loadSuggestions: (songs: SubsonicSong[]) => void;
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
contextMenuSongId: string | null;
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
hoveredSuggestionId: string | null;
setHoveredSuggestionId: React.Dispatch<React.SetStateAction<string | null>>;
addSong: (song: SubsonicSong) => void;
startPreview: (song: SubsonicSong) => void;
ratings: Record<string, number>;
starredSongs: Set<string>;
handleRate: (songId: string, rating: number) => void;
handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
}
export default function PlaylistSuggestions({
songs, suggestions, existingIds,
loadingSuggestions, loadSuggestions,
visibleCols, gridStyle,
contextMenuSongId, setContextMenuSongId,
hoveredSuggestionId, setHoveredSuggestionId,
addSong, startPreview,
ratings, starredSongs, handleRate, handleToggleStar,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate);
const suggestionsVisible = usePlaylistLayoutStore(s =>
s.items.find(i => i.id === 'suggestions')?.visible !== false);
if (!suggestionsVisible) return null;
const filteredSuggestions = suggestions.filter(s => !existingIds.has(s.id));
return (
<div className="playlist-suggestions tracklist" data-preview-loc="suggestions">
<div className="playlist-suggestions-header compact-action-bar">
<div className="playlist-suggestions-title">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
<span className="playlist-suggestions-hint">{t('playlists.suggestionsHint')}</span>
</div>
<button
className="btn btn-surface"
onClick={() => loadSuggestions(songs)}
disabled={loadingSuggestions || songs.length === 0}
aria-label={t('playlists.refreshSuggestions')}
data-tooltip={t('playlists.refreshSuggestions')}
>
<RefreshCw size={14} className={loadingSuggestions ? 'spin-slow' : ''} />
<span className="compact-btn-label">{t('playlists.refreshSuggestions')}</span>
</button>
</div>
{!loadingSuggestions && filteredSuggestions.length === 0 && (
<div className="empty-state" style={{ padding: '1.5rem 0', fontSize: '0.85rem' }}>{t('playlists.noSuggestions')}</div>
)}
{filteredSuggestions.length > 0 && (
<>
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
{visibleCols.map((colDef) => {
const key = colDef.key;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="col-center">#</div>;
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
})}
</div>
{filteredSuggestions.map((song, idx) => {
const isStarred = song.id in starredOverrides
? !!starredOverrides[song.id]
: (starredSongs.has(song.id) || !!song.starred);
const ratingValue = ratings[song.id]
?? userRatingOverrides[song.id]
?? song.userRating
?? 0;
return (
<div
key={song.id}
className={`track-row track-row-va track-row-with-actions tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onDoubleClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
addSong(song);
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
className="playlist-suggestion-play-btn"
onClick={e => {
e.stopPropagation();
const { queueItems, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
const track = songToTrack(song);
if (!currentTrack || queueItems.length === 0) {
playTrack(track, [track]);
return;
}
// Thin-state: resolve the current queue, insert after
// the playing track, and play the inserted track.
const resolved = getQueueTracksView(queueItems);
const insertAt = Math.min(queueIndex + 1, resolved.length);
const newQueue = [
...resolved.slice(0, insertAt),
track,
...resolved.slice(insertAt),
];
playTrack(track, newQueue, undefined, undefined, insertAt);
}}
data-tooltip={t('playlists.playNextSuggestion')}
aria-label={t('playlists.playNextSuggestion')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); startPreview(song); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return <PlaylistArtistCell key="artist" song={song} />;
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }} data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratingValue} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatTrackTime(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">{song.genre ?? '—'}</div>
);
case 'playCount': return (
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
);
case 'lastPlayed': return (
<div key="lastPlayed" className="track-genre">{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}</div>
);
case 'bpm': return (
<div key="bpm" className="track-duration">{song.bpm && song.bpm > 0 ? song.bpm : '—'}</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
<Plus size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
})}
</>
)}
</div>
);
}
@@ -0,0 +1,466 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import PlaylistRow, { type PlaylistRowCallbacks } from '@/features/playlist/components/PlaylistRow';
import { TracklistColumnPicker } from '@/components/albumTrackList/TracklistColumnPicker';
import { useTranslation } from 'react-i18next';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useElementClientHeightById } from '@/hooks/useResizeClientHeight';
import { useNavigate } from 'react-router-dom';
import {
ListPlus, Search, Trash2, X,
} from 'lucide-react';
import type { ColDef } from '@/utils/useTracklistColumns';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import { usePlayerStore } from '@/store/playerStore';
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
import { useThemeStore } from '@/store/themeStore';
import { useDragDrop } from '@/contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '@/features/orbit';
import { songToTrack } from '@/utils/playback/songToTrack';
import type { PlaylistSortKey, PlaylistSortDir } from '@/features/playlist/utils/playlistDisplayedSongs';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
interface Props {
// Column config / picker
allColumns: readonly ColDef[];
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
colVisible: Set<string>;
toggleColumn: (key: string) => void;
resetColumns: () => void;
pickerOpen: boolean;
setPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
pickerRef: React.RefObject<HTMLDivElement | null>;
startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void;
tracklistRef: React.RefObject<HTMLDivElement | null>;
// Data
songs: SubsonicSong[];
displayedSongs: SubsonicSong[];
displayedTracks: Track[];
isFiltered: boolean;
/** True only while a filter text is active distinct from `isFiltered`,
* which also goes true while sorting (displayedSongs !== songs). Drives the
* scroll-to-list effect so sorting doesn't snap the viewport (issue #840). */
hasActiveFilter: boolean;
id: string | undefined;
// Sort
sortKey: PlaylistSortKey;
setSortKey: React.Dispatch<React.SetStateAction<PlaylistSortKey>>;
sortDir: PlaylistSortDir;
setSortDir: React.Dispatch<React.SetStateAction<PlaylistSortDir>>;
sortClickCount: number;
setSortClickCount: React.Dispatch<React.SetStateAction<number>>;
// Selection
selectedIds: Set<string>;
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
allSelected: boolean;
toggleAll: () => void;
toggleSelect: (id: string, idx: number, shift: boolean) => void;
showBulkPlPicker: boolean;
setShowBulkPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
bulkRemove: () => void;
// Context menu + DnD visual
contextMenuSongId: string | null;
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
dropTargetIdx: { idx: number; before: boolean } | null;
// Rating / star / row mouse / delete
ratings: Record<string, number>;
starredSongs: Set<string>;
handleRate: (songId: string, rating: number) => void;
handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
handleRowMouseDown: (e: React.MouseEvent, idx: number) => void;
handleRowMouseEnter: (idx: number, e: React.MouseEvent) => void;
removeSong: (idx: number) => void;
// Empty state
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function PlaylistTracklist({
allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, startResize, tracklistRef,
songs, displayedSongs, displayedTracks, isFiltered, hasActiveFilter, id,
sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount,
selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect,
showBulkPlPicker, setShowBulkPlPicker, bulkRemove,
contextMenuSongId, setContextMenuSongId, dropTargetIdx,
ratings, starredSongs, handleRate, handleToggleStar,
handleRowMouseDown, handleRowMouseEnter, removeSong,
setSearchOpen,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate);
const { isDragging } = useDragDrop();
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
const latestVals = {
selectedIds, orbitActive, displayedTracks, isFiltered, id,
toggleSelect, handleRowMouseDown, handleRowMouseEnter, handleToggleStar,
handleRate, removeSong, playTrack, openContextMenu, setContextMenuSongId,
navigate, queueHint, addTrackToOrbit,
};
const latest = useRef(latestVals);
latest.current = latestVals;
const cb = useMemo<PlaylistRowCallbacks>(() => ({
activate: (song, index, e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
const L = latest.current;
if (e.ctrlKey || e.metaKey) L.toggleSelect(song.id, index, false);
else if (L.selectedIds.size > 0) L.toggleSelect(song.id, index, e.shiftKey);
else if (L.orbitActive) L.queueHint();
else L.playTrack(L.displayedTracks[index], L.displayedTracks);
},
dblOrbit: (songId, e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
const L = latest.current;
if (e.ctrlKey || e.metaKey || L.selectedIds.size > 0) return;
L.addTrackToOrbit(songId);
},
context: (song, rIdx, e) => {
e.preventDefault();
const L = latest.current;
L.setContextMenuSongId(song.id);
L.openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, L.id, rIdx);
},
mouseDownRow: (rIdx, e) => latest.current.handleRowMouseDown(e, rIdx),
mouseEnterRow: (index, e) => { const L = latest.current; if (!L.isFiltered) L.handleRowMouseEnter(index, e); },
toggleSelect: (songId, index, shift) => latest.current.toggleSelect(songId, index, shift),
play: (index) => {
const L = latest.current;
if (L.orbitActive) { L.queueHint(); return; }
L.playTrack(L.displayedTracks[index], L.displayedTracks);
},
startPreview: (song) => usePreviewStore.getState().startPreview(
previewInputFromSong(song),
'playlists',
),
toggleStar: (song, e) => latest.current.handleToggleStar(song, e),
rate: (songId, r) => latest.current.handleRate(songId, r),
remove: (rIdx) => latest.current.removeSong(rIdx),
navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`),
navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`),
}), []);
const listWrapRef = useRef<HTMLDivElement | null>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const viewportH = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
// Bulk bar show/hide shifts listWrapRef top — remeasure on that edge only.
const bulkBarVisible = selectedIds.size > 0;
useLayoutEffect(() => {
const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
const root = tracklistRef.current?.closest('.album-detail') as HTMLElement | null;
if (!sc) return;
const measure = () => {
const wrap = listWrapRef.current;
if (!wrap) return;
const m = wrap.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
setScrollMargin(prev => (Math.abs(prev - m) > 0.5 ? m : prev));
};
const ro = new ResizeObserver(measure);
ro.observe(sc);
if (root) ro.observe(root);
measure();
return () => ro.disconnect();
}, [tracklistRef, bulkBarVisible, pickerOpen, displayedSongs.length]);
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: displayedSongs.length,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => 48,
overscan: Math.max(8, Math.ceil(viewportH / 48)),
scrollMargin,
getItemKey: i => `${displayedSongs[i].id}:${i}`,
});
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) { firstRender.current = false; return; }
const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (sc) sc.scrollTop = scrollMargin;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, hasActiveFilter]);
const autoScrollRef = useRef(0);
const pointerYRef = useRef(0);
const runAutoScroll = useCallback(() => {
const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (!sc) { autoScrollRef.current = 0; return; }
const r = sc.getBoundingClientRect();
const EDGE = 60;
const MAX = 18;
const y = pointerYRef.current;
let dy = 0;
if (y < r.top + EDGE) dy = -MAX * (1 - (y - r.top) / EDGE);
else if (y > r.bottom - EDGE) dy = MAX * (1 - (r.bottom - y) / EDGE);
if (dy !== 0) sc.scrollTop += dy;
autoScrollRef.current = requestAnimationFrame(runAutoScroll);
}, []);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => { pointerYRef.current = e.clientY; };
window.addEventListener('mousemove', onMove);
autoScrollRef.current = requestAnimationFrame(runAutoScroll);
return () => {
window.removeEventListener('mousemove', onMove);
if (autoScrollRef.current) cancelAnimationFrame(autoScrollRef.current);
autoScrollRef.current = 0;
};
}, [isDragging, runAutoScroll]);
const virtualItems = rowVirtualizer.getVirtualItems();
let dropIndicatorY: number | null = null;
if (isDragging && !isFiltered && dropTargetIdx) {
const vi = virtualItems.find(v => v.index === dropTargetIdx.idx);
const start = vi ? vi.start : dropTargetIdx.idx * 48 + scrollMargin;
const size = vi ? vi.size : 48;
dropIndicatorY = (dropTargetIdx.before ? start : start + size) - scrollMargin;
}
return (
<>
<TracklistColumnPicker
allColumns={allColumns}
pickerRef={pickerRef}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
t={t}
/>
<div className="tracklist" data-preview-loc="playlists" ref={tracklistRef}>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedIds.size })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowBulkPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showBulkPlPicker && (
<AddToPlaylistSubmenu
songIds={[...selectedIds]}
onDone={() => { setShowBulkPlPicker(false); setSelectedIds(new Set()); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
style={{ color: 'var(--danger)' }}
onClick={bulkRemove}
>
<Trash2 size={14} />
{t('common.bulkRemoveFromPlaylist')}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setSelectedIds(new Set())}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* Header */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']);
const canSort = sortableCols.has(key);
const isSortActive = canSort && sortKey === key;
const handleSortClick = () => {
if (!canSort) return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
setSortKey(key as PlaylistSortKey);
setSortDir('asc');
setSortClickCount(1);
}
};
const renderSortIndicator = () => {
if (!isSortActive) return null;
return (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
if (key === 'num') return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div
key="title"
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'delete') return <div key="delete" />;
return (
<div
key={key}
onClick={handleSortClick}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
className={isSortActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
})}
</div>
</div>
{songs.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 0', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<span>{t('playlists.emptyPlaylist')}</span>
<button className="btn btn-primary" onClick={() => setSearchOpen(true)}>
<Search size={15} />
{t('playlists.addFirstSong')}
</button>
</div>
)}
<div ref={listWrapRef} style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
{dropIndicatorY !== null && (
<div
className="playlist-drop-indicator"
style={{ position: 'absolute', left: 0, right: 0, top: dropIndicatorY, pointerEvents: 'none' }}
/>
)}
{virtualItems.map(vi => {
const song = displayedSongs[vi.index];
const i = vi.index;
const realIdx = isFiltered ? songs.indexOf(song) : i;
return (
<div
key={vi.key}
data-index={i}
ref={rowVirtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start - scrollMargin}px)` }}
>
<PlaylistRow
song={song}
index={i}
realIdx={realIdx}
visibleCols={visibleCols}
gridStyle={gridStyle}
showBitrate={showBitrate}
isActive={currentTrack?.id === song.id}
showEq={currentTrack?.id === song.id && isPlaying}
isContextActive={contextMenuSongId === song.id}
isSelected={selectedIds.has(song.id)}
inSelectMode={selectedIds.size > 0}
isStarred={song.id in starredOverrides ? !!starredOverrides[song.id] : starredSongs.has(song.id)}
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isPreviewing={previewingId === song.id}
previewStarted={previewingId === song.id && previewAudioStarted}
orbitActive={orbitActive}
cb={cb}
/>
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,52 @@
import React from 'react';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
import { groupPlaylistsByFolder } from '@/features/playlist/utils/playlistFolders';
import { useDragDrop } from '@/contexts/DragDropContext';
import PlaylistFolderSection from '@/features/playlist/components/PlaylistFolderSection';
interface Props {
serverId: string;
playlists: SubsonicPlaylist[];
renderCard: (pl: SubsonicPlaylist) => React.ReactNode;
disableVirtualization: boolean;
}
/**
* Playlists page rendered as collapsible folder sections + an ungrouped
* remainder. Each section reuses `VirtualCardGrid`, so the card layout and
* 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) {
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;
return (
<div className="playlist-folder-view">
{grouped.folders.map(({ folder, playlists: items }) => (
<PlaylistFolderSection
key={folder.id}
serverId={serverId}
folder={folder}
items={items}
renderCard={renderCard}
disableVirtualization={disableVirtualization}
/>
))}
{showUngrouped && (
<PlaylistFolderSection
serverId={serverId}
folder={null}
items={grouped.ungrouped}
renderCard={renderCard}
disableVirtualization={disableVirtualization}
/>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FolderTree } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
/**
* Header toggle to switch the Playlists page between the grouped folder view
* and a single flat grid. Hidden until the active server has at least one
* folder (nothing to switch otherwise).
*/
export default function PlaylistsFolderViewToggle() {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const folderCount = usePlaylistFolderStore(
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
);
const groupView = usePlaylistFolderStore(s => s.groupView);
const toggleGroupView = usePlaylistFolderStore(s => s.toggleGroupView);
if (folderCount === 0) return null;
return (
<button
className={`btn btn-surface${groupView ? ' btn-sort-active' : ''}`}
onClick={toggleGroupView}
aria-pressed={groupView}
data-tooltip={t('playlists.folders.groupByFolders')}
data-tooltip-pos="bottom"
style={groupView ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<FolderTree size={15} /> {t('playlists.folders.groupByFolders')}
</button>
);
}
@@ -0,0 +1,126 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckSquare2, Plus, Sparkles, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import {
defaultSmartFilters, type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import PlaylistsNewFolderButton from '@/features/playlist/components/PlaylistsNewFolderButton';
import PlaylistsFolderViewToggle from '@/features/playlist/components/PlaylistsFolderViewToggle';
interface Props {
selectionMode: boolean;
selectedIds: Set<string>;
selectedPlaylists: SubsonicPlaylist[];
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
toggleSelectionMode: () => void;
handleDeleteSelected: () => void;
creating: boolean;
setCreating: React.Dispatch<React.SetStateAction<boolean>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
newName: string;
setNewName: React.Dispatch<React.SetStateAction<string>>;
nameInputRef: React.RefObject<HTMLInputElement | null>;
handleCreate: () => Promise<void>;
isNavidromeServer: boolean;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
actionPolicy?: OfflineActionPolicy;
}
export default function PlaylistsHeader({
selectionMode, selectedIds, selectedPlaylists, isPlaylistDeletable,
toggleSelectionMode, handleDeleteSelected,
creating, setCreating, setCreatingSmart,
newName, setNewName, nameInputRef, handleCreate,
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery,
actionPolicy,
}: Props) {
const { t } = useTranslation();
const policy = actionPolicy ?? offlineActionPolicy('playlistsHeader', false);
return (
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div className="compact-action-bar" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-start' }}>
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }} aria-label={t('playlists.newPlaylist')} data-tooltip={t('playlists.newPlaylist')}>
<Plus size={15} /> <span className="compact-btn-label">{t('playlists.newPlaylist')}</span>
</button>
)}
{!creating && isNavidromeServer && (
<button className="btn btn-surface" onClick={() => {
setCreating(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
setCreatingSmart(v => !v);
}} aria-label={t('smartPlaylists.create')} data-tooltip={t('smartPlaylists.create')}>
<Sparkles size={15} /> <span className="compact-btn-label">{t('smartPlaylists.create')}</span>
</button>
)}
</>
)}
{!(selectionMode && selectedIds.size > 0) && <PlaylistsFolderViewToggle />}
{!(selectionMode && selectedIds.size > 0) && <PlaylistsNewFolderButton />}
{selectionMode && selectedIds.size > 0 && (() => {
const deletableCount = selectedPlaylists.filter(isPlaylistDeletable).length;
return (
<button
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
aria-label={t('playlists.deleteSelected')}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
<span className="compact-btn-label">{t('playlists.deleteSelected')}</span>
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
aria-label={selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
<span className="compact-btn-label">{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FolderPlus } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
/**
* "New folder" action for the Playlists header row. Self-contained: toggles an
* inline name input and creates a local folder for the active server. Folder
* state is local-only, so this stays available offline.
*/
export default function PlaylistsNewFolderButton() {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId);
const createFolder = usePlaylistFolderStore(s => s.createFolder);
const folderCount = usePlaylistFolderStore(
s => (serverId ? s.byServer[serverId]?.folders.length ?? 0 : 0),
);
const groupView = usePlaylistFolderStore(s => s.groupView);
const [creating, setCreating] = useState(false);
const [name, setName] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (creating) inputRef.current?.focus();
}, [creating]);
if (!serverId) return null;
// Only offer folder creation in the grouped view; keep it available before the
// first folder exists (the toggle is hidden then, so this is the only entry).
if (folderCount > 0 && !groupView) return null;
const submit = () => {
const trimmed = name.trim();
if (trimmed) createFolder(serverId, trimmed);
setName('');
setCreating(false);
};
if (creating) {
return (
<>
<input
ref={inputRef}
className="input"
style={{ width: 180 }}
placeholder={t('playlists.folders.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') { setCreating(false); setName(''); }
}}
/>
<button className="btn btn-primary" onClick={submit}>{t('playlists.folders.create')}</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setName(''); }}>
{t('playlists.cancel')}
</button>
</>
);
}
return (
<button className="btn btn-surface" onClick={() => setCreating(true)} aria-label={t('playlists.folders.newFolder')} data-tooltip={t('playlists.folders.newFolder')}>
<FolderPlus size={15} /> <span className="compact-btn-label">{t('playlists.folders.newFolder')}</span>
</button>
);
}
@@ -0,0 +1,200 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import StarRating from '@/components/StarRating';
import CustomSelect from '@/ui/CustomSelect';
import {
LIMIT_MAX, YEAR_MAX, YEAR_MIN, clampYear, defaultSmartFilters,
type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
interface Props {
smartFilters: SmartFilters;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
availableGenres: string[];
genreQuery: string;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
editingSmartId: string | null;
creatingSmartBusy: boolean;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
onSave: () => void;
}
export default function PlaylistsSmartEditor({
smartFilters, setSmartFilters, availableGenres,
genreQuery, setGenreQuery, editingSmartId, creatingSmartBusy,
setCreatingSmart, setEditingSmartId, onSave,
}: Props) {
const { t } = useTranslation();
const sortOptions = useMemo(() => ([
{ value: '+random', label: t('smartPlaylists.sortRandom') },
{ value: '+title', label: t('smartPlaylists.sortTitleAsc') },
{ value: '-title', label: t('smartPlaylists.sortTitleDesc') },
{ value: '-year', label: t('smartPlaylists.sortYearDesc') },
{ value: '+year', label: t('smartPlaylists.sortYearAsc') },
{ value: '-playcount', label: t('smartPlaylists.sortPlayCountDesc') },
]), [t]);
const selectedGenreChipClass =
smartFilters.genreMode === 'include' ? 'btn btn-primary' : 'btn btn-danger';
const addGenre = (genre: string) => {
setSmartFilters(v => ({
...v,
untaggedGenresOnly: false,
selectedGenres: [...v.selectedGenres, genre],
}));
};
const removeGenre = (genre: string) => {
setSmartFilters(v => ({
...v,
untaggedGenresOnly: false,
selectedGenres: v.selectedGenres.filter(x => x !== genre),
}));
};
return (
<div style={{ marginBottom: '1rem', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '0.9rem', background: 'var(--bg-card)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionBasic')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
<input className="input" placeholder={t('smartPlaylists.name')} value={smartFilters.name} onChange={e => setSmartFilters(v => ({ ...v, name: e.target.value }))} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<input className="input" type="number" min={1} max={LIMIT_MAX} placeholder={t('smartPlaylists.limit')} value={smartFilters.limit} onChange={e => setSmartFilters(v => ({ ...v, limit: e.target.value }))} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.limitHint', { max: LIMIT_MAX })}</span>
</div>
<CustomSelect
value={smartFilters.sort}
options={sortOptions}
onChange={sort => setSmartFilters(v => ({ ...v, sort }))}
/>
</div>
</section>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionGenres')}</div>
<div className="smart-playlist-mode-toggle" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.genreMode')}</span>
<button
type="button"
className={`btn ${smartFilters.genreMode === 'include' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'include', untaggedGenresOnly: false }))}
>
{t('smartPlaylists.genreModeInclude')}
</button>
<button
type="button"
className={`btn ${smartFilters.genreMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, genreMode: 'exclude', untaggedGenresOnly: false }))}
>
{t('smartPlaylists.genreModeExclude')}
</button>
</div>
<input className="input" placeholder={t('smartPlaylists.genreSearchPlaceholder')} value={genreQuery} onChange={e => setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.5rem', minHeight: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.availableGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{availableGenres.map(g => (
<button
key={g}
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => addGenre(g)}
>
{g}
</button>
))}
</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.5rem', minHeight: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('smartPlaylists.selectedGenres')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{smartFilters.selectedGenres.map(g => (
<button
key={g}
type="button"
className={selectedGenreChipClass}
style={{ fontSize: 12, padding: '2px 8px' }}
onClick={() => removeGenre(g)}
>
× {g}
</button>
))}
</div>
</div>
</div>
</section>
<section style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: '0.75rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.65rem' }}>{t('smartPlaylists.sectionYearsAndFilters')}</div>
<div className="smart-playlist-mode-toggle" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('smartPlaylists.yearMode')}</span>
<button
type="button"
className={`btn ${smartFilters.yearMode === 'include' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'include' }))}
>
{t('smartPlaylists.yearModeInclude')}
</button>
<button
type="button"
className={`btn ${smartFilters.yearMode === 'exclude' ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setSmartFilters(v => ({ ...v, yearMode: 'exclude' }))}
>
{t('smartPlaylists.yearModeExclude')}
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)' }}>
<span>{t('smartPlaylists.fromYear')}: {smartFilters.yearFrom}</span>
<span>{t('smartPlaylists.toYear')}: {smartFilters.yearTo}</span>
</div>
<div className="dual-year-range">
<div className="dual-year-range__track" />
<div className="dual-year-range__selected" style={{ left: `${((smartFilters.yearFrom - YEAR_MIN) / (YEAR_MAX - YEAR_MIN)) * 100}%`, right: `${100 - ((smartFilters.yearTo - YEAR_MIN) / (YEAR_MAX - YEAR_MIN)) * 100}%` }} />
<input type="range" min={YEAR_MIN} max={YEAR_MAX} value={smartFilters.yearFrom} onChange={e => setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} />
<input type="range" min={YEAR_MIN} max={YEAR_MAX} value={smartFilters.yearTo} onChange={e => setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginTop: '0.75rem' }}>
<input className="input" placeholder={t('smartPlaylists.artistContains')} value={smartFilters.artistContains} onChange={e => setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} />
<input className="input" placeholder={t('smartPlaylists.albumContains')} value={smartFilters.albumContains} onChange={e => setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} />
<input className="input" placeholder={t('smartPlaylists.titleContains')} value={smartFilters.titleContains} onChange={e => setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} />
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.minRating')}: {smartFilters.minRating}</div>
<StarRating value={smartFilters.minRating} onChange={rating => setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('smartPlaylists.minRatingHint')}</span>
</div>
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" checked={smartFilters.excludeUnrated} onChange={e => setSmartFilters(v => ({ ...v, excludeUnrated: e.target.checked }))} />
{t('smartPlaylists.excludeUnrated')}
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" checked={smartFilters.compilationOnly} onChange={e => setSmartFilters(v => ({ ...v, compilationOnly: e.target.checked }))} />
{t('smartPlaylists.compilationOnly')}
</label>
</div>
</section>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button
type="button"
className="btn btn-surface"
onClick={() => {
setCreatingSmart(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
}}
>
{t('playlists.cancel')}
</button>
<button type="button" className="btn btn-primary" onClick={onSave} disabled={creatingSmartBusy}>
<Plus size={15} /> {editingSmartId ? t('smartPlaylists.save') : t('smartPlaylists.create')}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import type { Track } from '@/store/playerStoreTypes';
import { enqueuePlaylistAll, playPlaylistAll, shufflePlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
export interface PlaylistBulkPlayCallbacksDeps {
songsLength: number;
id: string | undefined;
tracks: Track[];
playTrack: (track: Track, queue: Track[]) => void;
enqueue: (tracks: Track[]) => void;
}
export interface PlaylistBulkPlayCallbacks {
handlePlayAll: () => void;
handleShuffleAll: () => void;
handleEnqueueAll: () => void;
}
export function usePlaylistBulkPlayCallbacks(deps: PlaylistBulkPlayCallbacksDeps): PlaylistBulkPlayCallbacks {
const { songsLength, id, tracks, playTrack, enqueue } = deps;
const handlePlayAll = useCallback(
() => playPlaylistAll({ songsLength, id, tracks, playTrack, enqueue }),
[songsLength, id, tracks, playTrack, enqueue],
);
const handleShuffleAll = useCallback(
() => shufflePlaylistAll({ songsLength, id, tracks, playTrack, enqueue }),
[songsLength, id, tracks, playTrack, enqueue],
);
const handleEnqueueAll = useCallback(
() => enqueuePlaylistAll({ songsLength, id, tracks, playTrack, enqueue }),
[songsLength, id, tracks, playTrack, enqueue],
);
return { handlePlayAll, handleShuffleAll, handleEnqueueAll };
}
@@ -0,0 +1,98 @@
import { useEffect, useMemo, useState } from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { CoverArtId, CoverArtRef } from '@/cover/types';
import { coverPrefetchRegister } from '@/cover/prefetchRegistry';
import { resolveAlbumCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { useCoverArt } from '@/cover/useCoverArt';
const PLAYLIST_HERO_BG_CSS_PX = 200;
const PLAYLIST_MAIN_COVER_CSS_PX = 200;
export interface PlaylistCovers {
coverQuadIds: (CoverArtId | null)[];
bgCoverId: CoverArtId | null;
resolvedBgUrl: string;
}
async function playlistCoverRefFromLibrary(
coverId: string,
songs: SubsonicSong[],
): Promise<CoverArtRef> {
const song = songs.find(s => s.coverArt === coverId || s.albumId === coverId);
if (song?.albumId) {
return resolveAlbumCoverRefFromLibrary(song.albumId, coverId);
}
return resolveAlbumCoverRefFromLibrary(coverId, coverId);
}
export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string | null): PlaylistCovers {
const coverQuad = useMemo(() => {
const seen = new Set<string>();
const result: string[] = [];
for (const s of songs) {
if (s.coverArt && !seen.has(s.coverArt)) {
seen.add(s.coverArt);
result.push(s.coverArt);
if (result.length === 4) break;
}
}
return result;
}, [songs]);
const coverQuadIds = useMemo(
() =>
Array.from({ length: 4 }, (_, i) => {
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
return coverId ?? null;
}),
[coverQuad],
);
const bgCoverId = customCoverId ?? coverQuad[0] ?? null;
const [bgCoverRef, setBgCoverRef] = useState<CoverArtRef | null>(null);
useEffect(() => {
if (!bgCoverId) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setBgCoverRef(null);
return;
}
let cancelled = false;
void playlistCoverRefFromLibrary(bgCoverId, songs).then(ref => {
if (!cancelled) setBgCoverRef(ref);
});
return () => {
cancelled = true;
};
}, [bgCoverId, songs]);
const { src: resolvedBgUrl } = useCoverArt(bgCoverRef, PLAYLIST_HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
useEffect(() => {
const ids = [
...coverQuadIds.filter((id): id is CoverArtId => !!id),
...(bgCoverId ? [bgCoverId] : []),
];
if (ids.length === 0) return;
let cancelled = false;
let unreg: (() => void) | undefined;
void (async () => {
const refs = await Promise.all(ids.map(id => playlistCoverRefFromLibrary(id, songs)));
if (!cancelled) {
unreg = coverPrefetchRegister(refs, { surface: 'dense', priority: 'middle' });
}
})();
return () => {
cancelled = true;
unreg?.();
};
}, [coverQuadIds, bgCoverId, songs]);
return { coverQuadIds, bgCoverId, resolvedBgUrl };
}
export { PLAYLIST_MAIN_COVER_CSS_PX };
@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import { usePlayerStore } from '@/store/playerStore';
import { songToTrack } from '@/utils/playback/songToTrack';
import { getDisplayedSongs, type PlaylistSortDir, type PlaylistSortKey } from '@/features/playlist/utils/playlistDisplayedSongs';
export interface PlaylistDerivedOptions {
filterText: string;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
ratings: Record<string, number>;
starredSongs: Set<string>;
}
export interface PlaylistDerived {
existingIds: Set<string>;
tracks: Track[];
displayedSongs: SubsonicSong[];
displayedTracks: Track[];
isFiltered: boolean;
}
export function usePlaylistDerived(songs: SubsonicSong[], opts: PlaylistDerivedOptions): PlaylistDerived {
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const { filterText, sortKey, sortDir, ratings, starredSongs } = opts;
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
const displayedSongs = useMemo(
() => getDisplayedSongs(songs, {
filterText, sortKey, sortDir,
ratings, userRatingOverrides, starredOverrides, starredSongs,
}),
[songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs],
);
const displayedTracks = useMemo(
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
[displayedSongs, songs, tracks],
);
const isFiltered = displayedSongs !== songs;
return { existingIds, tracks, displayedSongs, displayedTracks, isFiltered };
}
@@ -0,0 +1,44 @@
import React, { useEffect, useState } from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { useDragDrop } from '@/contexts/DragDropContext';
import { runPlaylistReorderDrop } from '@/features/playlist/utils/runPlaylistReorderDrop';
export interface PlaylistDnDReorderDeps {
tracklistRef: React.RefObject<HTMLDivElement | null>;
songs: SubsonicSong[];
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>;
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
}
export interface PlaylistDnDReorderResult {
dropTargetIdx: { idx: number; before: boolean } | null;
setDropTargetIdx: React.Dispatch<React.SetStateAction<{ idx: number; before: boolean } | null>>;
handleRowMouseEnter: (idx: number, e: React.MouseEvent) => void;
}
export function usePlaylistDnDReorder(deps: PlaylistDnDReorderDeps): PlaylistDnDReorderResult {
const { tracklistRef, songs, savePlaylist, setSongs } = deps;
const { isDragging } = useDragDrop();
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
useEffect(() => {
const container = tracklistRef.current;
if (!container) return;
const onPsyDrop = (e: Event) => {
runPlaylistReorderDrop({ e, songs, savePlaylist, setDropTargetIdx, setSongs });
};
container.addEventListener('psy-drop', onPsyDrop);
return () => container.removeEventListener('psy-drop', onPsyDrop);
}, [songs, savePlaylist, tracklistRef, setSongs]);
const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => {
if (!isDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
setDropTargetIdx({ idx, before });
};
return { dropTargetIdx, setDropTargetIdx, handleRowMouseEnter };
}
@@ -0,0 +1,23 @@
import { useCallback, useEffect } from 'react';
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
import type { SubsonicSong } from '@/api/subsonicTypes';
export function usePlaylistPreview(): {
startPreview: (song: SubsonicSong) => void;
} {
// Pause/resume of the main player + timer + cancel-on-supersede are all
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
// engine events so we just dispatch here and read `previewingId` for UI.
const startPreview = useCallback((song: SubsonicSong) => {
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
}, []);
// Cancel any in-flight preview when the user navigates away.
useEffect(() => () => {
if (usePreviewStore.getState().previewingId) {
usePreviewStore.getState().stopPreview();
}
}, []);
return { startPreview };
}
@@ -0,0 +1,27 @@
import React, { useEffect } from 'react';
import type { Location, NavigateFunction } from 'react-router-dom';
import { usePlayerStore } from '@/store/playerStore';
export interface PlaylistRouteEffectsDeps {
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
location: Location;
navigate: NavigateFunction;
}
export function usePlaylistRouteEffects(deps: PlaylistRouteEffectsDeps): void {
const { setContextMenuSongId, setEditingMeta, location, navigate } = deps;
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
useEffect(() => {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen, setContextMenuSongId]);
useEffect(() => {
const state = (location.state as { openEditMeta?: boolean } | null) ?? null;
if (state?.openEditMeta) {
setEditingMeta(true);
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state, location.pathname, navigate, setEditingMeta]);
}
@@ -0,0 +1,51 @@
import { useState } from 'react';
import type React from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
export interface PlaylistSelection {
selectedIds: Set<string>;
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
lastSelectedIdx: number | null;
allSelected: boolean;
toggleAll: () => void;
toggleSelect: (id: string, idx: number, shift: boolean) => void;
bulkRemove: () => void;
}
export function usePlaylistSelection(
songs: SubsonicSong[],
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>,
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>,
): PlaylistSelection {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const toggleSelect = (id: string, idx: number, shift: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
if (next.has(id)) next.delete(id);
else next.add(id);
}
return next;
});
setLastSelectedIdx(idx);
};
const allSelected = selectedIds.size === songs.length && songs.length > 0;
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
const bulkRemove = () => {
const prevCount = songs.length;
const next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next, prevCount);
setSelectedIds(new Set());
};
return { selectedIds, setSelectedIds, lastSelectedIdx, allSelected, toggleAll, toggleSelect, bulkRemove };
}
@@ -0,0 +1,47 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import type { SubsonicPlaylist, SubsonicSong } from '@/api/subsonicTypes';
import { showToast } from '@/utils/ui/toast';
export interface PlaylistSongMutationsDeps {
songs: SubsonicSong[];
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>;
setSuggestions: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
playlist: SubsonicPlaylist | null;
t: TFunction;
}
export interface PlaylistSongMutations {
removeSong: (idx: number) => void;
addSong: (song: SubsonicSong) => void;
}
export function usePlaylistSongMutations(deps: PlaylistSongMutationsDeps): PlaylistSongMutations {
const { songs, setSongs, savePlaylist, setSuggestions, setSearchResults, playlist, t } = deps;
const removeSong = (idx: number) => {
const prevCount = songs.length;
const next = songs.filter((_, i) => i !== idx);
setSongs(next);
savePlaylist(next, prevCount);
};
const addSong = (song: SubsonicSong) => {
if (songs.some(s => s.id === song.id)) return;
const scrollHost = document.querySelector('.main-content') as HTMLElement | null;
const savedScroll = scrollHost?.scrollTop ?? 0;
const next = [...songs, song];
setSongs(next);
savePlaylist(next);
setSuggestions(prev => prev.filter(s => s.id !== song.id));
setSearchResults(prev => prev.filter(s => s.id !== song.id));
if (scrollHost) {
requestAnimationFrame(() => { scrollHost.scrollTop = savedScroll; });
}
showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name }));
};
return { removeSong, addSong };
}
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react';
import type React from 'react';
import { search } from '@/api/subsonicSearch';
import type { SubsonicSong } from '@/api/subsonicTypes';
export interface PlaylistSongSearchResult {
searchResults: SubsonicSong[];
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
searching: boolean;
}
export function usePlaylistSongSearch(
songs: SubsonicSong[],
searchOpen: boolean,
searchQuery: string,
): PlaylistSongSearchResult {
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
const [searching, setSearching] = useState(false);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; }
if (searchDebounce.current) clearTimeout(searchDebounce.current);
searchDebounce.current = setTimeout(async () => {
setSearching(true);
try {
const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 });
const existingIds = new Set(songs.map(s => s.id));
setSearchResults(res.songs.filter(s => !existingIds.has(s.id)));
} catch { /* ignore: best-effort */ }
setSearching(false);
}, 350);
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchQuery, searchOpen, songs]);
return { searchResults, setSearchResults, searching };
}
@@ -0,0 +1,42 @@
import React from 'react';
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { usePlayerStore } from '@/store/playerStore';
export interface PlaylistStarRatingDeps {
ratings: Record<string, number>;
setRatings: React.Dispatch<React.SetStateAction<Record<string, number>>>;
starredSongs: Set<string>;
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
}
export interface PlaylistStarRatingActions {
handleRate: (songId: string, rating: number) => void;
handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
}
export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistStarRatingActions {
const { setRatings, starredSongs, setStarredSongs } = deps;
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating }));
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
};
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
setStarredSongs(prev => {
const next = new Set(prev);
if (isStarred) next.delete(song.id);
else next.add(song.id);
return next;
});
// F4: optimistic override + retried server sync via the central helper (no rollback).
queueSongStar(song.id, !isStarred, song.serverId);
};
return { handleRate, handleToggleStar };
}
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import type React from 'react';
import { getRandomSongs } from '@/api/subsonicLibrary';
import type { SubsonicSong } from '@/api/subsonicTypes';
export interface PlaylistSuggestionsResult {
suggestions: SubsonicSong[];
setSuggestions: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
loadingSuggestions: boolean;
loadSuggestions: (currentSongs: SubsonicSong[]) => Promise<void>;
}
export function usePlaylistSuggestions(songs: SubsonicSong[], playlistId: string | undefined): PlaylistSuggestionsResult {
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
if (!currentSongs.length) return;
// Count genres across playlist songs, pick the most common one
const genreCounts: Record<string, number> = {};
for (const s of currentSongs) {
if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1;
}
const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]);
// Fall back to no genre filter if none of the songs have genre tags
const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined;
const existingIds = new Set(currentSongs.map(s => s.id));
setLoadingSuggestions(true);
setSuggestions([]);
try {
const random = await getRandomSongs(25, genre);
setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10));
} catch { /* ignore: best-effort */ }
setLoadingSuggestions(false);
}, []);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (songs.length > 0) loadSuggestions(songs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlistId]);
return { suggestions, setSuggestions, loadingSuggestions, loadSuggestions };
}
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { filterSongsToActiveLibrary } from '@/api/subsonicLibrary';
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { useOfflineBrowseContext } from '@/features/offline';
export interface PlaylistsLibraryScopeCountsResult {
filteredSongCountByPlaylist: Record<string, number>;
filteredDurationByPlaylist: Record<string, number>;
}
/**
* Recompute song count + total duration for each playlist under the current
* library scope. Chunked into batches of 4 parallel fetches to avoid hammering
* Navidrome on large playlists. Re-runs when the playlist list changes or
* when the active library filter version bumps.
*/
export function usePlaylistsLibraryScopeCounts(
playlists: SubsonicPlaylist[],
musicLibraryFilterVersion: number,
): PlaylistsLibraryScopeCountsResult {
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
const offlineBrowseActive = useOfflineBrowseContext().active;
useEffect(() => {
let cancelled = false;
const run = async () => {
if (playlists.length === 0) {
if (!cancelled) {
setFilteredSongCountByPlaylist({});
setFilteredDurationByPlaylist({});
}
return;
}
if (offlineBrowseActive) {
const next: Record<string, number> = {};
const nextDuration: Record<string, number> = {};
for (const pl of playlists) {
next[pl.id] = pl.songCount;
nextDuration[pl.id] = pl.duration;
}
if (!cancelled) {
setFilteredSongCountByPlaylist(next);
setFilteredDurationByPlaylist(nextDuration);
}
return;
}
const ids = playlists.map((pl) => pl.id);
const next: Record<string, number> = {};
const nextDuration: Record<string, number> = {};
for (let i = 0; i < ids.length; i += 4) {
const chunk = ids.slice(i, i + 4);
const rows = await Promise.all(
chunk.map(async (id) => {
try {
const { songs } = await getPlaylist(id);
const filtered = await filterSongsToActiveLibrary(songs);
const duration = filtered.reduce((acc, s) => acc + (s.duration ?? 0), 0);
return [id, filtered.length, duration] as const;
} catch {
return [id, -1, -1] as const;
}
}),
);
for (const [id, count, duration] of rows) {
if (count >= 0) next[id] = count;
if (duration >= 0) nextDuration[id] = duration;
}
}
if (!cancelled) {
setFilteredSongCountByPlaylist(next);
setFilteredDurationByPlaylist(nextDuration);
}
};
run();
return () => { cancelled = true; };
}, [playlists, musicLibraryFilterVersion, offlineBrowseActive]);
return { filteredSongCountByPlaylist, filteredDurationByPlaylist };
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Playlist feature the Playlists overview + PlaylistDetail (lazy via deep
* `pages/*`, not re-exported), playlist/folder UI (cards, hero, tracklist,
* filter toolbar, smart editor, folder views, CSV import), the playlist +
* folder + layout stores, the playlist Subsonic API, and the playlist data/
* selection/DnD/search/mutation/star hooks + play + CSV/smart utils.
*
* Stays OUT: the playlist context-menu items + add/move submenus (context-menu
* subsystem), the queue-panel Save/Load-playlist modals (queue UI), and
* `playlistDetailHelpers` (shared with offline + favorites; keeping it here
* would create a playlistoffline barrel cycle lib/shared in M4).
*/
export * from './api/subsonicPlaylists';
export * from './hooks/usePlaylistBulkPlayCallbacks';
export * from './hooks/usePlaylistCovers';
export * from './hooks/usePlaylistDerived';
export * from './hooks/usePlaylistDnDReorder';
export * from './hooks/usePlaylistPreview';
export * from './hooks/usePlaylistRouteEffects';
export * from './hooks/usePlaylistSelection';
export * from './hooks/usePlaylistsLibraryScopeCounts';
export * from './hooks/usePlaylistSongMutations';
export * from './hooks/usePlaylistSongSearch';
export * from './hooks/usePlaylistStarRating';
export * from './hooks/usePlaylistSuggestions';
export * from './store/playlistFolderStore';
export * from './store/playlistLayoutStore';
export * from './store/playlistStore';
export * from './utils/playlistBulkPlayActions';
export * from './utils/playlistDisplayedSongs';
export * from './utils/playlistFolders';
export * from './utils/playlistsSmart';
export * from './utils/playPlaylistById';
export * from './utils/runPlaylistCsvImport';
export * from './utils/runPlaylistLoad';
export * from './utils/runPlaylistReorderDrop';
export * from './utils/runPlaylistsActions';
export * from './utils/runPlaylistSaveMeta';
export * from './utils/runPlaylistsOpenSmartEditor';
export * from './utils/runPlaylistsSaveSmart';
export * from './utils/runPlaylistZipDownload';
export * from './utils/spotifyCsvImport';
export * from './utils/spotifyCsvMatch';
export * from './utils/startPlaylistRowDrag';
@@ -0,0 +1,394 @@
import { updatePlaylist } from '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist, SubsonicSong } from '@/api/subsonicTypes';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useTracklistColumns, type ColDef } from '@/utils/useTracklistColumns';
import { usePlayerStore } from '@/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { useOfflineStore } from '@/features/offline';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useAlbumOfflineState } from '@/features/album';
import { useAuthStore } from '@/store/authStore';
import { useDownloadModalStore } from '@/features/offline';
import { useZipDownloadStore } from '@/features/offline';
import { useDragDrop } from '@/contexts/DragDropContext';
import { useTranslation } from 'react-i18next';
import type { SpotifyCsvTrack } from '@/features/playlist/utils/spotifyCsvImport';
import { runPlaylistCsvImport } from '@/features/playlist/utils/runPlaylistCsvImport';
import PlaylistEditModal from '@/features/playlist/components/PlaylistEditModal';
import CsvImportReportModal from '@/features/playlist/components/CsvImportReportModal';
import PlaylistSongSearchPanel from '@/features/playlist/components/PlaylistSongSearchPanel';
import PlaylistSuggestions from '@/features/playlist/components/PlaylistSuggestions';
import PlaylistHero from '@/features/playlist/components/PlaylistHero';
import PlaylistTracklist from '@/features/playlist/components/PlaylistTracklist';
import PlaylistFilterToolbar from '@/features/playlist/components/PlaylistFilterToolbar';
import type { PlaylistSortKey, PlaylistSortDir } from '@/features/playlist/utils/playlistDisplayedSongs';
import { runPlaylistZipDownload } from '@/features/playlist/utils/runPlaylistZipDownload';
import { runPlaylistSaveMeta } from '@/features/playlist/utils/runPlaylistSaveMeta';
import { runPlaylistLoad } from '@/features/playlist/utils/runPlaylistLoad';
import { startPlaylistRowDrag } from '@/features/playlist/utils/startPlaylistRowDrag';
import { usePlaylistCovers } from '@/features/playlist/hooks/usePlaylistCovers';
import { usePlaylistSelection } from '@/features/playlist/hooks/usePlaylistSelection';
import { usePlaylistSuggestions } from '@/features/playlist/hooks/usePlaylistSuggestions';
import { usePlaylistSongSearch } from '@/features/playlist/hooks/usePlaylistSongSearch';
import { usePlaylistSongMutations } from '@/features/playlist/hooks/usePlaylistSongMutations';
import { usePlaylistStarRating } from '@/features/playlist/hooks/usePlaylistStarRating';
import { usePlaylistPreview } from '@/features/playlist/hooks/usePlaylistPreview';
import { usePlaylistBulkPlayCallbacks } from '@/features/playlist/hooks/usePlaylistBulkPlayCallbacks';
import { usePlaylistDerived } from '@/features/playlist/hooks/usePlaylistDerived';
import { usePlaylistRouteEffects } from '@/features/playlist/hooks/usePlaylistRouteEffects';
import { useBulkPlPickerOutsideClick } from '@/hooks/useBulkPlPickerOutsideClick';
import { usePlaylistDnDReorder } from '@/features/playlist/hooks/usePlaylistDnDReorder';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'playCount', i18nKey: 'trackPlayCount', minWidth: 60, defaultWidth: 80, required: false },
{ key: 'lastPlayed', i18nKey: 'trackLastPlayed', minWidth: 90, defaultWidth: 130, required: false },
{ key: 'bpm', i18nKey: 'trackBpm', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { playTrack, enqueue } = usePlayerStore(
useShallow(s => ({
playTrack: s.playTrack,
enqueue: s.enqueue,
}))
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag } = useDragDrop();
const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
void useLocalPlaybackStore(s => s.entries);
const downloadFolder = useAuthStore(s => s.downloadFolder);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const offlineSongIds = useMemo(() => songs.map(s => s.id), [songs]);
const { resolvedOfflineStatus, offlineProgress } = useAlbumOfflineState(id ?? '', activeServerId, offlineSongIds);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [ratings, setRatings] = useState<Record<string, number>>({});
const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
const [filterText, setFilterText] = useState('');
const [sortKey, setSortKey] = useState<PlaylistSortKey>('natural');
const [sortDir, setSortDir] = useState<PlaylistSortDir>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const zipDownloads = useZipDownloadStore(s => s.downloads);
const [zipDownloadId, setZipDownloadId] = useState<string | null>(null);
const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined;
// ── CSV Import ───────────────────────────────────────────────────
const [csvImporting, setCsvImporting] = useState(false);
const [csvImportReport, setCsvImportReport] = useState<{
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
} | null>(null);
// ── Save ──────────────────────────────────────────────────────
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
if (!id) return;
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
if (id) touchPlaylist(id);
} catch { /* ignore: best-effort */ }
setSaving(false);
}, [id, touchPlaylist]);
// ── Bulk select ───────────────────────────────────────────────────
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
const { selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, bulkRemove } =
usePlaylistSelection(songs, setSongs, savePlaylist);
useBulkPlPickerOutsideClick(showBulkPlPicker, setShowBulkPlPicker);
// ── 2×2 cover quad (first 4 unique album covers) ─────────────
const { coverQuadIds, resolvedBgUrl } = usePlaylistCovers(songs, customCoverId);
// Song search
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSearchIds, setSelectedSearchIds] = useState<Set<string>>(new Set());
const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false);
const { searchResults, setSearchResults, searching } =
usePlaylistSongSearch(songs, searchOpen, searchQuery);
// Suggestions
const { suggestions, setSuggestions, loadingSuggestions, loadSuggestions } =
usePlaylistSuggestions(songs, playlist?.id);
// ── Column resize/visibility ──────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
usePlaylistRouteEffects({ setContextMenuSongId, setEditingMeta, location, navigate });
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const actionPolicy = offlineActionPolicy('playlistDetail', offlineBrowseActive);
useEffect(() => {
if (!id) return;
runPlaylistLoad({
id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs,
});
}, [id, lastModified, offlineBrowseActive]);
// ── Meta edit ─────────────────────────────────────────────────
const handleSaveMeta = async (opts: {
name: string; comment: string; isPublic: boolean;
coverFile: File | null; coverRemoved: boolean;
}) => {
if (!id || !playlist) return;
await runPlaylistSaveMeta(
{ id, playlist, t, setPlaylist, setCustomCoverId, setEditingMeta },
opts,
);
};
// ── ZIP Download ──────────────────────────────────────────────
const handleDownload = async () => {
if (!playlist || !id) return;
await runPlaylistZipDownload({
playlist, id, downloadFolder, requestDownloadFolder, setZipDownloadId,
});
};
// ── CSV Import ────────────────────────────────────────────────
const handleImportCsv = async () => {
if (!id || csvImporting) return;
await runPlaylistCsvImport({
songs, t, savePlaylist,
setSongs, setCsvImporting, setCsvImportReport,
});
};
// ── Remove ────────────────────────────────────────────────────
const { removeSong, addSong } = usePlaylistSongMutations({
songs, setSongs, savePlaylist, setSuggestions, setSearchResults, playlist, t,
});
// ── Preview (30s mid-song sample via Rust audio engine) ────────
const { startPreview } = usePlaylistPreview();
// ── Rating / Star ─────────────────────────────────────────────
const { handleRate, handleToggleStar } = usePlaylistStarRating({
ratings, setRatings, starredSongs, setStarredSongs,
});
// ── DnD reorder listener + drag-over visual feedback ──────────
const { dropTargetIdx, handleRowMouseEnter } = usePlaylistDnDReorder({
tracklistRef, songs, savePlaylist, setSongs,
});
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
startPlaylistRowDrag({ e, idx, songs, selectedIds, isFiltered, startDrag });
};
// ── Memoized derivations ──────────────────────────────────────
const { existingIds, tracks, displayedSongs, displayedTracks, isFiltered } = usePlaylistDerived(songs, {
filterText, sortKey, sortDir, ratings, starredSongs,
});
// ── Playback actions (encapsulated like AlbumHeader) ─────────
const { handlePlayAll, handleShuffleAll, handleEnqueueAll } = usePlaylistBulkPlayCallbacks({
songsLength: songs.length, id, tracks, playTrack, enqueue,
});
// ── Render ────────────────────────────────────────────────────
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
if (!playlist) {
return <div className="content-body"><div className="empty-state">{t('playlists.notFound')}</div></div>;
}
return (
<div className="album-detail animate-fade-in">
{/* ── Hero ── */}
<PlaylistHero
playlist={playlist}
songs={songs}
id={id}
customCoverId={customCoverId}
coverQuadIds={coverQuadIds}
resolvedBgUrl={resolvedBgUrl}
saving={saving}
searchOpen={searchOpen}
csvImporting={csvImporting}
activeZip={activeZip}
offlineStatus={resolvedOfflineStatus}
offlineProgress={offlineProgress}
activeServerId={activeServerId}
actionPolicy={actionPolicy}
setEditingMeta={setEditingMeta}
setSearchOpen={setSearchOpen}
setSearchQuery={setSearchQuery}
setSearchResults={setSearchResults}
setSelectedSearchIds={setSelectedSearchIds}
setSearchPlPickerOpen={setSearchPlPickerOpen}
handlePlayAll={handlePlayAll}
handleShuffleAll={handleShuffleAll}
handleEnqueueAll={handleEnqueueAll}
handleImportCsv={handleImportCsv}
handleDownload={handleDownload}
deleteAlbum={deleteAlbum}
downloadPlaylist={downloadPlaylist}
/>
{/* ── Song search panel ── */}
{searchOpen && (
<PlaylistSongSearchPanel
query={searchQuery}
setQuery={setSearchQuery}
searching={searching}
searchResults={searchResults}
setSearchResults={setSearchResults}
selectedSearchIds={selectedSearchIds}
setSelectedSearchIds={setSelectedSearchIds}
searchPlPickerOpen={searchPlPickerOpen}
setSearchPlPickerOpen={setSearchPlPickerOpen}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
addSong={addSong}
/>
)}
{/* ── Filter / sort toolbar ── */}
{songs.length > 0 && (
<PlaylistFilterToolbar
filterText={filterText}
setFilterText={setFilterText}
sortKey={sortKey}
sortDir={sortDir}
setSortKey={setSortKey}
setSortDir={setSortDir}
setSortClickCount={setSortClickCount}
/>
)}
{/* ── Tracklist ── */}
<PlaylistTracklist
allColumns={PL_COLUMNS}
visibleCols={visibleCols}
gridStyle={gridStyle}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
pickerRef={pickerRef}
startResize={startResize}
tracklistRef={tracklistRef}
songs={songs}
displayedSongs={displayedSongs}
displayedTracks={displayedTracks}
isFiltered={isFiltered}
hasActiveFilter={filterText.trim().length > 0}
id={id}
sortKey={sortKey}
setSortKey={setSortKey}
sortDir={sortDir}
setSortDir={setSortDir}
sortClickCount={sortClickCount}
setSortClickCount={setSortClickCount}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
allSelected={allSelected}
toggleAll={toggleAll}
toggleSelect={toggleSelect}
showBulkPlPicker={showBulkPlPicker}
setShowBulkPlPicker={setShowBulkPlPicker}
bulkRemove={bulkRemove}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
dropTargetIdx={dropTargetIdx}
ratings={ratings}
starredSongs={starredSongs}
handleRate={handleRate}
handleToggleStar={handleToggleStar}
handleRowMouseDown={handleRowMouseDown}
handleRowMouseEnter={handleRowMouseEnter}
removeSong={removeSong}
setSearchOpen={setSearchOpen}
/>
{/* ── Suggestions ── */}
<PlaylistSuggestions
songs={songs}
suggestions={suggestions}
existingIds={existingIds}
loadingSuggestions={loadingSuggestions}
loadSuggestions={loadSuggestions}
visibleCols={visibleCols}
gridStyle={gridStyle}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
hoveredSuggestionId={hoveredSuggestionId}
setHoveredSuggestionId={setHoveredSuggestionId}
addSong={addSong}
startPreview={startPreview}
ratings={ratings}
starredSongs={starredSongs}
handleRate={handleRate}
handleToggleStar={handleToggleStar}
/>
{editingMeta && playlist && (
<PlaylistEditModal
playlist={playlist}
customCoverId={customCoverId}
coverQuadIds={coverQuadIds}
onClose={() => setEditingMeta(false)}
onSave={handleSaveMeta}
/>
)}
{csvImportReport && (
<CsvImportReportModal
report={csvImportReport}
playlistName={playlist?.name || 'Unknown Playlist'}
onClose={() => setCsvImportReport(null)}
/>
)}
</div>
);
}
+316
View File
@@ -0,0 +1,316 @@
import { resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { getGenres } from '@/api/subsonicGenres';
import { filterSongsToActiveLibrary } from '@/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '@/api/subsonicTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { usePlayerStore } from '@/store/playerStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import { useRangeSelection } from '@/hooks/useRangeSelection';
import {
defaultSmartFilters,
type SmartFilters, type PendingSmartPlaylist,
} from '@/features/playlist/utils/playlistsSmart';
import { useSmartCoverCollage } from '@/hooks/useSmartCoverCollage';
import { usePlaylistsLibraryScopeCounts } from '@/features/playlist/hooks/usePlaylistsLibraryScopeCounts';
import { usePendingSmartPolling } from '@/hooks/usePendingSmartPolling';
import { runPlaylistsOpenSmartEditor } from '@/features/playlist/utils/runPlaylistsOpenSmartEditor';
import { runPlaylistsSaveSmart } from '@/features/playlist/utils/runPlaylistsSaveSmart';
import {
runPlaylistDelete, runPlaylistDeleteSelected,
} from '@/features/playlist/utils/runPlaylistsActions';
import PlaylistsSmartEditor from '@/features/playlist/components/PlaylistsSmartEditor';
import PlaylistsHeader from '@/features/playlist/components/PlaylistsHeader';
import PlaylistCard from '@/features/playlist/components/PlaylistCard';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
import { Info } from 'lucide-react';
import PlaylistsFolderView from '@/features/playlist/components/PlaylistsFolderView';
import { usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
export default function Playlists() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const playTrack = usePlayerStore(s => s.playTrack);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
const playlists = usePlaylistStore((s) => s.playlists);
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const activeServerId = useAuthStore(s => s.activeServerId);
const folderCount = usePlaylistFolderStore(
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
);
const folderGroupView = usePlaylistFolderStore(s => s.groupView);
const showFolderView = Boolean(activeServerId) && folderCount > 0 && folderGroupView;
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const offlineCtx = useOfflineBrowseContext();
const offlineBrowseActive = offlineCtx.active;
const playlistsActionPolicy = offlineActionPolicy('playlistsHeader', offlineCtx.active);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [creatingSmart, setCreatingSmart] = useState(false);
const [newName, setNewName] = useState('');
const [smartFilters, setSmartFilters] = useState<SmartFilters>(defaultSmartFilters);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [genreQuery, setGenreQuery] = useState('');
const [creatingSmartBusy, setCreatingSmartBusy] = useState(false);
const [editingSmartId, setEditingSmartId] = useState<string | null>(null);
const [pendingSmart, setPendingSmart] = useState<PendingSmartPlaylist[]>([]);
const smartCoverIdsByPlaylist = useSmartCoverCollage(playlists, musicLibraryFilterVersion);
const { filteredSongCountByPlaylist, filteredDurationByPlaylist } =
usePlaylistsLibraryScopeCounts(playlists, musicLibraryFilterVersion);
const [playingId, setPlayingId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists);
const isNavidromeServer = Boolean(
activeServerId &&
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
resetSelection();
};
const clearSelection = () => {
setSelectionMode(false);
resetSelection();
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => {
if (!pl.owner) return true;
if (!activeUsername) return false;
return pl.owner === activeUsername;
}, [activeUsername]);
useEffect(() => {
fetchPlaylists().finally(() => setLoading(false));
if (!offlineBrowseActive) {
getGenres().then(setGenres).catch(() => {});
}
}, [fetchPlaylists, offlineBrowseActive]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
}, [creating]);
const createPlaylist = usePlaylistStore(s => s.createPlaylist);
const availableGenres = genres
.map(g => g.value)
.filter(v => !smartFilters.selectedGenres.includes(v))
.filter(v => !genreQuery.trim() || v.toLowerCase().includes(genreQuery.trim().toLowerCase()))
.sort((a, b) => a.localeCompare(b));
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
await createPlaylist(name);
// Refresh playlists from API to get the new one
await fetchPlaylists();
setCreating(false);
setNewName('');
};
const handleOpenSmartEditor = (pl: SubsonicPlaylist) => runPlaylistsOpenSmartEditor({
pl, isNavidromeServer, allGenres: genres, t,
setSmartFilters, setEditingSmartId, setGenreQuery,
setCreating, setCreatingSmart, setCreatingSmartBusy,
});
const handleCreateSmart = () => runPlaylistsSaveSmart({
isNavidromeServer, smartFilters, allGenres: genres.map(g => g.value), editingSmartId, playlists, fetchPlaylists, t,
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
setGenreQuery, setCreatingSmartBusy,
});
// Smart playlist rules are processed asynchronously on server.
usePendingSmartPolling(pendingSmart, setPendingSmart, fetchPlaylists);
const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => {
e.stopPropagation();
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);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
}
} catch { /* ignore: best-effort */ }
setPlayingId(null);
};
const handleDelete = (e: React.MouseEvent, pl: SubsonicPlaylist) => runPlaylistDelete({
e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t,
});
const handleDeleteSelected = () => runPlaylistDeleteSelected({
selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t,
});
const renderCard = (pl: SubsonicPlaylist) => (
<PlaylistCard
pl={pl}
selectionMode={selectionMode}
draggable={showFolderView}
selectedIds={selectedIds}
selectedPlaylists={selectedPlaylists}
toggleSelect={toggleSelect}
isPlaylistDeletable={isPlaylistDeletable}
deleteConfirmId={deleteConfirmId}
setDeleteConfirmId={setDeleteConfirmId}
handleOpenSmartEditor={handleOpenSmartEditor}
handleDelete={handleDelete}
handlePlay={handlePlay}
playingId={playingId}
smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
pendingSmart={pendingSmart}
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
filteredDurationByPlaylist={filteredDurationByPlaylist}
/>
);
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
return (
<div className="content-body animate-fade-in">
<style>{`
.dual-year-range {
position: relative;
height: 34px;
}
.dual-year-range__track,
.dual-year-range__selected {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
transform: translateY(-50%);
border-radius: 999px;
}
.dual-year-range__track { background: var(--border); }
.dual-year-range__selected { background: var(--accent); }
.dual-year-range input[type='range'] {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 34px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
pointer-events: none;
}
.dual-year-range input[type='range']::-webkit-slider-runnable-track { height: 4px; background: transparent; }
.dual-year-range input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-card);
pointer-events: auto;
cursor: pointer;
}
`}</style>
<PlaylistsHeader
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedPlaylists={selectedPlaylists}
isPlaylistDeletable={isPlaylistDeletable}
toggleSelectionMode={toggleSelectionMode}
handleDeleteSelected={handleDeleteSelected}
creating={creating}
setCreating={setCreating}
setCreatingSmart={setCreatingSmart}
newName={newName}
setNewName={setNewName}
nameInputRef={nameInputRef}
handleCreate={handleCreate}
isNavidromeServer={isNavidromeServer}
setEditingSmartId={setEditingSmartId}
setSmartFilters={setSmartFilters}
setGenreQuery={setGenreQuery}
actionPolicy={playlistsActionPolicy}
/>
{creatingSmart && (
<PlaylistsSmartEditor
smartFilters={smartFilters}
setSmartFilters={setSmartFilters}
availableGenres={availableGenres}
genreQuery={genreQuery}
setGenreQuery={setGenreQuery}
editingSmartId={editingSmartId}
creatingSmartBusy={creatingSmartBusy}
setCreatingSmart={setCreatingSmart}
setEditingSmartId={setEditingSmartId}
onSave={handleCreateSmart}
/>
)}
{/* ── Grid ── */}
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : (
<>
{showFolderView && (
<p className="playlist-folder-notice playlist-folder-notice--page">
<Info size={13} /> {t('playlists.folders.localOnlyNotice')}
</p>
)}
{showFolderView && activeServerId ? (
<PlaylistsFolderView
serverId={activeServerId}
playlists={playlists}
renderCard={renderCard}
disableVirtualization={perfFlags.disableMainstageVirtualLists}
/>
) : (
<VirtualCardGrid
items={playlists}
itemKey={(pl, _i) => pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={playlists.length}
renderItem={renderCard}
/>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { usePlaylistFolderStore } from '@/features/playlist/store/playlistFolderStore';
const get = () => usePlaylistFolderStore.getState();
const server = (id: string) => get().byServer[id] ?? { folders: [], assignments: {} };
beforeEach(() => {
usePlaylistFolderStore.setState({ byServer: {}, groupView: true });
});
describe('playlistFolderStore', () => {
it('creates a folder and returns its id', () => {
const id = get().createFolder('s1', ' Rock ');
const { folders } = server('s1');
expect(folders).toHaveLength(1);
expect(folders[0]).toMatchObject({ id, name: 'Rock', collapsed: false });
});
it('assigns and reassigns a playlist, and clears assignment with null', () => {
const f1 = get().createFolder('s1', 'A');
const f2 = get().createFolder('s1', 'B');
get().setPlaylistFolder('s1', 'p1', f1);
expect(server('s1').assignments.p1).toBe(f1);
get().setPlaylistFolder('s1', 'p1', f2);
expect(server('s1').assignments.p1).toBe(f2);
get().setPlaylistFolder('s1', 'p1', null);
expect(server('s1').assignments.p1).toBeUndefined();
});
it('deleting a folder drops its assignments (playlists become ungrouped)', () => {
const f1 = get().createFolder('s1', 'A');
get().setPlaylistFolder('s1', 'p1', f1);
get().setPlaylistFolder('s1', 'p2', f1);
get().deleteFolder('s1', f1);
expect(server('s1').folders).toHaveLength(0);
expect(server('s1').assignments).toEqual({});
});
it('renames and toggles collapse', () => {
const f1 = get().createFolder('s1', 'A');
get().renameFolder('s1', f1, ' Jazz ');
get().toggleFolderCollapsed('s1', f1);
expect(server('s1').folders[0]).toMatchObject({ name: 'Jazz', collapsed: true });
});
it('toggles the grouped view (default on, global)', () => {
expect(get().groupView).toBe(true);
get().toggleGroupView();
expect(get().groupView).toBe(false);
get().toggleGroupView();
expect(get().groupView).toBe(true);
});
it('scopes folders per server', () => {
get().createFolder('s1', 'A');
get().createFolder('s2', 'B');
expect(server('s1').folders).toHaveLength(1);
expect(server('s2').folders).toHaveLength(1);
expect(server('s1').folders[0].name).toBe('A');
expect(server('s2').folders[0].name).toBe('B');
});
});
@@ -0,0 +1,101 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { nextFolderOrder, type PlaylistFolder } from '@/features/playlist/utils/playlistFolders';
/** Folder state for a single server (playlist ids are server-scoped). */
export interface ServerPlaylistFolders {
folders: PlaylistFolder[];
/** playlistId → folderId */
assignments: Record<string, string>;
}
interface PlaylistFolderState {
byServer: Record<string, ServerPlaylistFolders>;
/** Whether the Playlists page / sidebar group playlists into folders. */
groupView: boolean;
toggleGroupView: () => void;
createFolder: (serverId: string, name: string) => string;
renameFolder: (serverId: string, folderId: string, name: string) => void;
/** Removes the folder; its playlists fall back to ungrouped. */
deleteFolder: (serverId: string, folderId: string) => void;
/** Assign a playlist to a folder, or pass `null` to move it back to ungrouped. */
setPlaylistFolder: (serverId: string, playlistId: string, folderId: string | null) => void;
toggleFolderCollapsed: (serverId: string, folderId: string) => void;
}
const EMPTY_SERVER: ServerPlaylistFolders = { folders: [], assignments: {} };
function mutateServer(
byServer: Record<string, ServerPlaylistFolders>,
serverId: string,
fn: (s: ServerPlaylistFolders) => ServerPlaylistFolders,
): Record<string, ServerPlaylistFolders> {
return { ...byServer, [serverId]: fn(byServer[serverId] ?? EMPTY_SERVER) };
}
export const usePlaylistFolderStore = create<PlaylistFolderState>()(
persist(
set => ({
byServer: {},
groupView: true,
toggleGroupView: () => set(state => ({ groupView: !state.groupView })),
createFolder: (serverId, name) => {
const id = crypto.randomUUID();
set(state => ({
byServer: mutateServer(state.byServer, serverId, s => ({
...s,
folders: [
...s.folders,
{ id, name: name.trim(), order: nextFolderOrder(s.folders), collapsed: false },
],
})),
}));
return id;
},
renameFolder: (serverId, folderId, name) =>
set(state => ({
byServer: mutateServer(state.byServer, serverId, s => ({
...s,
folders: s.folders.map(f => (f.id === folderId ? { ...f, name: name.trim() } : f)),
})),
})),
deleteFolder: (serverId, folderId) =>
set(state => ({
byServer: mutateServer(state.byServer, serverId, s => ({
folders: s.folders.filter(f => f.id !== folderId),
assignments: Object.fromEntries(
Object.entries(s.assignments).filter(([, fid]) => fid !== folderId),
),
})),
})),
setPlaylistFolder: (serverId, playlistId, folderId) =>
set(state => ({
byServer: mutateServer(state.byServer, serverId, s => {
const assignments = { ...s.assignments };
if (folderId == null) delete assignments[playlistId];
else assignments[playlistId] = folderId;
return { ...s, assignments };
}),
})),
toggleFolderCollapsed: (serverId, folderId) =>
set(state => ({
byServer: mutateServer(state.byServer, serverId, s => ({
...s,
folders: s.folders.map(f =>
f.id === folderId ? { ...f, collapsed: !f.collapsed } : f,
),
})),
})),
}),
{ name: 'psysonic_playlist_folders' },
),
);
/** Stable empty fallback so selectors don't churn refs for serverless states. */
export const EMPTY_SERVER_FOLDERS: ServerPlaylistFolders = EMPTY_SERVER;
@@ -0,0 +1,68 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type PlaylistLayoutItemId =
| 'addSongs'
| 'importCsv'
| 'downloadZip'
| 'offlineCache'
| 'suggestions';
export interface PlaylistLayoutItemConfig {
id: PlaylistLayoutItemId;
visible: boolean;
}
export const DEFAULT_PLAYLIST_LAYOUT_ITEMS: PlaylistLayoutItemConfig[] = [
{ id: 'addSongs', visible: true },
{ id: 'importCsv', visible: true },
{ id: 'downloadZip', visible: true },
{ id: 'offlineCache', visible: true },
{ id: 'suggestions', visible: true },
];
interface PlaylistLayoutStore {
items: PlaylistLayoutItemConfig[];
setItems: (items: PlaylistLayoutItemConfig[]) => void;
toggleItem: (id: PlaylistLayoutItemId) => void;
reset: () => void;
}
export const usePlaylistLayoutStore = create<PlaylistLayoutStore>()(
persist(
(set) => ({
items: DEFAULT_PLAYLIST_LAYOUT_ITEMS,
setItems: (items) => set({ items }),
toggleItem: (id) => set((s) => ({
items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it),
})),
reset: () => set({ items: DEFAULT_PLAYLIST_LAYOUT_ITEMS }),
}),
{
name: 'psysonic_playlist_layout',
onRehydrateStorage: () => (state) => {
if (!state) return;
const knownIds = new Set(DEFAULT_PLAYLIST_LAYOUT_ITEMS.map(i => i.id));
const safe = (state.items ?? [])
.filter((i): i is PlaylistLayoutItemConfig =>
i != null && typeof i.id === 'string' && knownIds.has(i.id as PlaylistLayoutItemId));
const seen = new Set(safe.map(i => i.id));
const missing = DEFAULT_PLAYLIST_LAYOUT_ITEMS.filter(i => !seen.has(i.id));
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
},
}
)
);
export function isPlaylistLayoutCustomized(items: PlaylistLayoutItemConfig[]): boolean {
if (items.length !== DEFAULT_PLAYLIST_LAYOUT_ITEMS.length) return true;
for (let i = 0; i < items.length; i++) {
const cur = items[i];
const def = DEFAULT_PLAYLIST_LAYOUT_ITEMS[i];
if (cur.id !== def.id || cur.visible !== def.visible) return true;
}
return false;
}
@@ -0,0 +1,70 @@
import { getPlaylists } from '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPlaylist as apiCreatePlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { useAuthStore } from '@/store/authStore';
import { isOfflineBrowseActive } from '@/features/offline';
import { fetchOfflineBrowsablePlaylists } from '@/features/offline';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
playlistsLoading: boolean;
lastModified: Record<string, number>;
touchPlaylist: (id: string) => void;
removeId: (id: string) => void;
fetchPlaylists: () => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<SubsonicPlaylist | null>;
addPlaylist: (playlist: SubsonicPlaylist) => void;
}
export const usePlaylistStore = create<PlaylistStore>()(
persist(
(set) => ({
recentIds: [],
playlists: [],
playlistsLoading: false,
lastModified: {},
touchPlaylist: (id) =>
set((s) => ({
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
lastModified: { ...s.lastModified, [id]: Date.now() },
})),
removeId: (id) =>
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
fetchPlaylists: async () => {
set({ playlistsLoading: true });
try {
const serverId = useAuthStore.getState().activeServerId;
if (isOfflineBrowseActive() && serverId) {
const playlists = await fetchOfflineBrowsablePlaylists(serverId);
set({ playlists, playlistsLoading: false });
return;
}
const playlists = await getPlaylists();
set({ playlists, playlistsLoading: false });
} catch {
set({ playlistsLoading: false });
}
},
createPlaylist: async (name: string, songIds?: string[]) => {
try {
const playlist = await apiCreatePlaylist(name, songIds);
set((s) => ({
playlists: [...s.playlists, playlist],
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
}));
return playlist;
} catch {
return null;
}
},
addPlaylist: (playlist) => {
set((s) => ({
playlists: [...s.playlists, playlist],
}));
},
}),
{ name: 'psysonic_playlists_recent' }
)
);
@@ -0,0 +1,19 @@
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { songToTrack } from '@/utils/playback/songToTrack';
import { usePlayerStore } from '@/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,49 @@
import { describe, expect, it, vi } from 'vitest';
import { enqueuePlaylistAll, playPlaylistAll, shufflePlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
import type { Track } from '@/store/playerStoreTypes';
// Only id/queue identity matters for these actions.
const tracks = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] as unknown as Track[];
describe('playlistBulkPlayActions', () => {
it('playPlaylistAll starts the first track with the full queue', () => {
const playTrack = vi.fn();
const enqueue = vi.fn();
playPlaylistAll({ songsLength: tracks.length, id: 'p1', tracks, playTrack, enqueue });
expect(playTrack).toHaveBeenCalledWith(tracks[0], tracks);
expect(enqueue).not.toHaveBeenCalled();
});
it('enqueuePlaylistAll appends every track without starting playback', () => {
const playTrack = vi.fn();
const enqueue = vi.fn();
enqueuePlaylistAll({ songsLength: tracks.length, id: 'p1', tracks, playTrack, enqueue });
expect(enqueue).toHaveBeenCalledWith(tracks);
expect(playTrack).not.toHaveBeenCalled();
});
it('shufflePlaylistAll plays a track from the playlist with the full queue', () => {
const playTrack = vi.fn();
shufflePlaylistAll({ songsLength: tracks.length, id: 'p1', tracks, playTrack, enqueue: vi.fn() });
expect(playTrack).toHaveBeenCalledTimes(1);
const [first, queue] = playTrack.mock.calls[0];
expect(tracks).toContain(first);
expect(queue).toHaveLength(tracks.length);
});
it('no-ops on an empty playlist', () => {
const playTrack = vi.fn();
const enqueue = vi.fn();
playPlaylistAll({ songsLength: 0, id: 'p1', tracks: [], playTrack, enqueue });
shufflePlaylistAll({ songsLength: 0, id: 'p1', tracks: [], playTrack, enqueue });
enqueuePlaylistAll({ songsLength: 0, id: 'p1', tracks: [], playTrack, enqueue });
expect(playTrack).not.toHaveBeenCalled();
expect(enqueue).not.toHaveBeenCalled();
});
it('no-ops without a playlist id', () => {
const playTrack = vi.fn();
playPlaylistAll({ songsLength: tracks.length, id: undefined, tracks, playTrack, enqueue: vi.fn() });
expect(playTrack).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import type { Track } from '@/store/playerStoreTypes';
// No `touchPlaylist` here: playing/shuffling/enqueuing does not modify the
// playlist. Touching it bumps `lastModified`, which is the playlist detail
// page's load-effect trigger, so it would re-fetch and flash the whole
// container on every Play click. Real mutations (add/remove/save) still touch.
export interface BulkPlayDeps {
songsLength: number;
id: string | undefined;
tracks: Track[];
playTrack: (track: Track, queue: Track[]) => void;
enqueue: (tracks: Track[]) => void;
}
export function playPlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, playTrack } = deps;
if (!songsLength || !id) return;
playTrack(tracks[0], tracks);
}
export function shufflePlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, playTrack } = deps;
if (!songsLength || !id) return;
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
playTrack(shuffled[0], shuffled);
}
export function enqueuePlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, enqueue } = deps;
if (!songsLength || !id) return;
enqueue(tracks);
}
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import type { SubsonicSong } from '@/api/subsonicTypes';
import {
getDisplayedSongs,
type DisplayedSongsOptions,
type PlaylistSortKey,
type PlaylistSortDir,
} from '@/features/playlist/utils/playlistDisplayedSongs';
const song = (id: string, title = id, artist = ''): SubsonicSong =>
({ id, title, artist }) as SubsonicSong;
const opts = (over: Partial<DisplayedSongsOptions> = {}): DisplayedSongsOptions => ({
filterText: '',
sortKey: 'natural',
sortDir: 'asc',
ratings: {},
userRatingOverrides: {},
starredOverrides: {},
starredSongs: new Set<string>(),
...over,
});
const ids = (songs: SubsonicSong[]) => songs.map(s => s.id);
describe('getDisplayedSongs — position (date added)', () => {
// Playlist load order is oldest→newest (servers append new tracks at the end).
const list = [song('a'), song('b'), song('c'), song('d')];
it('ascending keeps the playlist load order (oldest → newest)', () => {
expect(ids(getDisplayedSongs(list, opts({ sortKey: 'position', sortDir: 'asc' })))).toEqual([
'a',
'b',
'c',
'd',
]);
});
it('descending reverses it (newest added first) — the requested behaviour', () => {
expect(ids(getDisplayedSongs(list, opts({ sortKey: 'position', sortDir: 'desc' })))).toEqual([
'd',
'c',
'b',
'a',
]);
});
it('never mutates the input array', () => {
const input = [song('a'), song('b'), song('c')];
getDisplayedSongs(input, opts({ sortKey: 'position', sortDir: 'desc' }));
expect(ids(input)).toEqual(['a', 'b', 'c']);
});
it('filters first, then reverses the surviving rows', () => {
const mixed = [song('1', 'alpha'), song('2', 'beta'), song('3', 'alphabet')];
const out = getDisplayedSongs(
mixed,
opts({ sortKey: 'position', sortDir: 'desc', filterText: 'alpha' }),
);
expect(ids(out)).toEqual(['3', '1']);
});
it("natural still ignores sortDir (it is the reset state, not a position sort)", () => {
const k: PlaylistSortKey = 'natural';
const d: PlaylistSortDir = 'desc';
expect(ids(getDisplayedSongs(list, opts({ sortKey: k, sortDir: d })))).toEqual([
'a',
'b',
'c',
'd',
]);
});
});
@@ -0,0 +1,53 @@
import type { SubsonicSong } from '@/api/subsonicTypes';
export type PlaylistSortKey = 'natural' | 'position' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
export type PlaylistSortDir = 'asc' | 'desc';
export interface DisplayedSongsOptions {
filterText: string;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
starredOverrides: Record<string, boolean>;
starredSongs: Set<string>;
}
export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOptions): SubsonicSong[] {
const q = opts.filterText.trim().toLowerCase();
if (!q && opts.sortKey === 'natural') return songs;
let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (opts.sortKey === 'position') {
// Playlist position is the "date added" proxy: servers append new tracks at
// the end, so ascending = oldest→newest (load order) and descending =
// newest→oldest. Reverse rather than compare — stable and O(n), and the
// Subsonic playlist response carries no per-entry timestamp to compare on.
return opts.sortDir === 'desc' ? result.reverse() : result;
}
if (opts.sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
const effectiveRating = (s: SubsonicSong) => opts.ratings[s.id] ?? opts.userRatingOverrides[s.id] ?? s.userRating ?? 0;
const effectiveStarred = (s: SubsonicSong) => (s.id in opts.starredOverrides ? opts.starredOverrides[s.id] : opts.starredSongs.has(s.id)) ? 1 : 0;
switch (opts.sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
case 'playCount': av = a.playCount ?? 0; bv = b.playCount ?? 0; break;
case 'lastPlayed': av = a.played ? Date.parse(a.played) || 0 : 0; bv = b.played ? Date.parse(b.played) || 0 : 0; break;
case 'bpm': av = a.bpm ?? 0; bv = b.bpm ?? 0; break;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return opts.sortDir === 'asc' ? av - bv : bv - av;
}
return opts.sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
groupPlaylistsByFolder,
nextFolderOrder,
type PlaylistFolder,
} from '@/features/playlist/utils/playlistFolders';
const folder = (id: string, order: number, name = id): PlaylistFolder =>
({ id, name, order, collapsed: false });
const pl = (id: string) => ({ id });
describe('groupPlaylistsByFolder', () => {
it('places playlists into their assigned folder and the rest in ungrouped', () => {
const folders = [folder('f1', 0), folder('f2', 1)];
const result = groupPlaylistsByFolder(
[pl('a'), pl('b'), pl('c'), pl('d')],
folders,
{ a: 'f1', c: 'f2' },
);
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['a']);
expect(result.folders[1].playlists.map(p => p.id)).toEqual(['c']);
expect(result.ungrouped.map(p => p.id)).toEqual(['b', 'd']);
});
it('returns folders in order, including empty ones', () => {
const result = groupPlaylistsByFolder(
[pl('a')],
[folder('f2', 1, 'B'), folder('f1', 0, 'A')],
{ a: 'f1' },
);
expect(result.folders.map(g => g.folder.id)).toEqual(['f1', 'f2']);
expect(result.folders[1].playlists).toEqual([]);
});
it('treats assignments to a missing folder as ungrouped', () => {
const result = groupPlaylistsByFolder([pl('a')], [folder('f1', 0)], { a: 'gone' });
expect(result.ungrouped.map(p => p.id)).toEqual(['a']);
expect(result.folders[0].playlists).toEqual([]);
});
it('preserves input order within a bucket', () => {
const result = groupPlaylistsByFolder(
[pl('c'), pl('a'), pl('b')],
[folder('f1', 0)],
{ a: 'f1', b: 'f1', c: 'f1' },
);
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['c', 'a', 'b']);
});
});
describe('nextFolderOrder', () => {
it('is 0 for an empty list', () => {
expect(nextFolderOrder([])).toBe(0);
});
it('is one past the highest existing order', () => {
expect(nextFolderOrder([folder('a', 0), folder('b', 5)])).toBe(6);
});
});
@@ -0,0 +1,68 @@
/**
* Playlist folders a local, client-side organisation layer over the server's
* flat playlist list. The Subsonic API has no folder concept, so folders and
* their playlist assignments live only in Psysonic (see `playlistFolderStore`),
* scoped per server. This module holds the shared types and the pure grouping
* function used by every surface that renders folders (sidebar, Playlists page).
*/
export interface PlaylistFolder {
id: string;
name: string;
/** Stable sort order among folders (assigned at creation). */
order: number;
collapsed: boolean;
}
export interface PlaylistFolderGroup<T> {
folder: PlaylistFolder;
playlists: T[];
}
export interface GroupedPlaylists<T> {
/** Folders in display order; each carries its playlists (possibly empty). */
folders: PlaylistFolderGroup<T>[];
/** Playlists not assigned to any (existing) folder, in input order. */
ungrouped: T[];
}
/**
* Split `playlists` into folder groups + an ungrouped remainder.
*
* Folders are returned in `order` (then name) order and always appear, even
* when empty, so a freshly created folder is visible. Playlists keep their
* incoming order within each bucket callers sort the input upstream. An
* assignment pointing at a folder that no longer exists falls back to ungrouped.
*/
export function groupPlaylistsByFolder<T extends { id: string }>(
playlists: readonly T[],
folders: readonly PlaylistFolder[],
assignments: Readonly<Record<string, string>>,
): GroupedPlaylists<T> {
const orderedFolders = [...folders].sort(
(a, b) => a.order - b.order || a.name.localeCompare(b.name),
);
const byFolder = new Map<string, T[]>();
for (const folder of orderedFolders) byFolder.set(folder.id, []);
const ungrouped: T[] = [];
for (const playlist of playlists) {
const folderId = assignments[playlist.id];
const bucket = folderId != null ? byFolder.get(folderId) : undefined;
if (bucket) bucket.push(playlist);
else ungrouped.push(playlist);
}
return {
folders: orderedFolders.map(folder => ({
folder,
playlists: byFolder.get(folder.id) ?? [],
})),
ungrouped,
};
}
/** Next stable `order` value for a new folder appended to `folders`. */
export function nextFolderOrder(folders: readonly PlaylistFolder[]): number {
return folders.reduce((max, f) => Math.max(max, f.order + 1), 0);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { buildSmartRulesPayload, defaultSmartFilters, parseSmartRulesToFilters } from '@/features/playlist/utils/playlistsSmart';
describe('buildSmartRulesPayload', () => {
it('collapses exclude-all-genres into an untagged-only rule', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock', 'Jazz', 'Pop'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz', 'Pop'] });
const all = rules.all as Record<string, unknown>[];
expect(all.some(r => (r as { is?: { genre?: string } }).is?.genre === '')).toBe(true);
expect(all.filter(r => 'notContains' in r)).toHaveLength(0);
});
it('keeps per-genre exclusions when only some genres are selected', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz'] });
const all = rules.all as Record<string, unknown>[];
expect(all).toContainEqual({ notContains: { genre: 'Rock' } });
});
});
describe('parseSmartRulesToFilters', () => {
it('restores untagged-only exclude rules', () => {
const parsed = parseSmartRulesToFilters(
{ all: [{ is: { genre: '' } }], limit: 50, sort: '+random' },
'psy-smart-test',
);
expect(parsed.untaggedGenresOnly).toBe(true);
expect(parsed.genreMode).toBe('exclude');
});
});
@@ -0,0 +1,204 @@
export const SMART_PREFIX = 'psy-smart-';
export const LIMIT_MAX = 500;
export const YEAR_MIN = 1950;
export const YEAR_MAX = new Date().getFullYear() + 1;
export type GenreMode = 'include' | 'exclude';
export type YearMode = 'include' | 'exclude';
export type SmartFilters = {
name: string;
limit: string;
sort: string;
artistContains: string;
albumContains: string;
titleContains: string;
minRating: number;
excludeUnrated: boolean;
compilationOnly: boolean;
selectedGenres: string[];
genreMode: GenreMode;
yearFrom: number;
yearTo: number;
yearMode: YearMode;
/** Navidrome `{ is: { genre: '' } }` — tracks with no genre tag. */
untaggedGenresOnly: boolean;
};
export type BuildSmartRulesOptions = {
/** Full genre catalog — used to collapse “exclude every genre” into an untagged-only rule. */
allGenres?: string[];
};
export type PendingSmartPlaylist = {
name: string;
id?: string;
firstSeenCoverArt?: string;
attempts: number;
};
export type NdSmartRuleNode = Record<string, unknown>;
export const defaultSmartFilters: SmartFilters = {
name: '',
limit: '50',
sort: '+random',
artistContains: '',
albumContains: '',
titleContains: '',
minRating: 0,
excludeUnrated: false,
compilationOnly: false,
selectedGenres: [],
genreMode: 'include',
yearFrom: YEAR_MIN,
yearTo: YEAR_MAX,
yearMode: 'include',
untaggedGenresOnly: false,
};
export function clampYear(v: number): number {
return Math.max(YEAR_MIN, Math.min(YEAR_MAX, v));
}
export function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
}
export function displayPlaylistName(name: string): string {
const n = name ?? '';
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
return n;
}
export function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
export function parseSmartRulesToFilters(
rules: Record<string, unknown> | undefined,
playlistName: string,
): SmartFilters {
const next: SmartFilters = {
...defaultSmartFilters,
name: displayPlaylistName(playlistName),
};
if (!rules) return next;
if (typeof rules.limit === 'number' && Number.isFinite(rules.limit)) {
next.limit = String(Math.max(1, Math.min(LIMIT_MAX, Number(rules.limit))));
}
if (typeof rules.sort === 'string' && rules.sort.trim()) next.sort = rules.sort;
const includeGenres: string[] = [];
const excludeGenres: string[] = [];
const all = Array.isArray(rules.all) ? rules.all : [];
for (const node of all) {
const obj = asRecord(node);
if (!obj) continue;
const contains = asRecord(obj.contains);
if (contains) {
if (typeof contains.artist === 'string') next.artistContains = contains.artist;
if (typeof contains.album === 'string') next.albumContains = contains.album;
if (typeof contains.title === 'string') next.titleContains = contains.title;
}
const gt = asRecord(obj.gt);
if (gt && typeof gt.rating === 'number') {
if (gt.rating > 0) next.minRating = Math.max(0, Math.min(5, Math.floor(gt.rating)));
else if (gt.rating === 0) next.excludeUnrated = true;
}
const is = asRecord(obj.is);
if (is?.compilation === true) next.compilationOnly = true;
if (is && is.genre === '') {
next.genreMode = 'exclude';
next.untaggedGenresOnly = true;
}
const notContains = asRecord(obj.notContains);
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
const inTheRange = asRecord(obj.inTheRange);
if (inTheRange && Array.isArray(inTheRange.year) && inTheRange.year.length === 2) {
const from = Number(inTheRange.year[0]);
const to = Number(inTheRange.year[1]);
if (Number.isFinite(from) && Number.isFinite(to)) {
next.yearMode = 'include';
next.yearFrom = clampYear(Math.min(from, to));
next.yearTo = clampYear(Math.max(from, to));
}
}
const any = Array.isArray(obj.any) ? (obj.any as NdSmartRuleNode[]) : [];
if (any.length > 0) {
const parsedGenreIncludes = any
.map((item) => asRecord(asRecord(item)?.contains)?.genre)
.filter((v): v is string => typeof v === 'string');
if (parsedGenreIncludes.length > 0) includeGenres.push(...parsedGenreIncludes);
const ltYear = any.map((item) => asRecord(asRecord(item)?.lt)?.year).find((v) => typeof v === 'number');
const gtYear = any.map((item) => asRecord(asRecord(item)?.gt)?.year).find((v) => typeof v === 'number');
if (typeof ltYear === 'number' && typeof gtYear === 'number') {
next.yearMode = 'exclude';
next.yearFrom = clampYear(Math.min(ltYear, gtYear));
next.yearTo = clampYear(Math.max(ltYear, gtYear));
}
}
}
if (includeGenres.length > 0) {
next.genreMode = 'include';
next.selectedGenres = [...new Set(includeGenres)];
} else if (excludeGenres.length > 0) {
next.genreMode = 'exclude';
next.selectedGenres = [...new Set(excludeGenres)];
}
return next;
}
function shouldUseUntaggedGenreRule(filters: SmartFilters, allGenres?: string[]): boolean {
if (filters.untaggedGenresOnly) return true;
if (filters.genreMode !== 'exclude' || filters.selectedGenres.length === 0) return false;
if (!allGenres || allGenres.length === 0) return false;
const selected = new Set(filters.selectedGenres);
return allGenres.every(g => selected.has(g));
}
export function buildSmartRulesPayload(
filters: SmartFilters,
opts?: BuildSmartRulesOptions,
): Record<string, unknown> {
const all: Record<string, unknown>[] = [];
if (filters.artistContains.trim()) all.push({ contains: { artist: filters.artistContains.trim() } });
if (filters.albumContains.trim()) all.push({ contains: { album: filters.albumContains.trim() } });
if (filters.titleContains.trim()) all.push({ contains: { title: filters.titleContains.trim() } });
const minRating = Number(filters.minRating);
if (Number.isFinite(minRating) && minRating > 0) all.push({ gt: { rating: minRating } });
else if (filters.excludeUnrated) all.push({ gt: { rating: 0 } });
if (filters.compilationOnly) all.push({ is: { compilation: true } });
if (shouldUseUntaggedGenreRule(filters, opts?.allGenres)) {
all.push({ is: { genre: '' } });
} else if (filters.selectedGenres.length > 0) {
if (filters.genreMode === 'include') {
all.push({ any: filters.selectedGenres.map(v => ({ contains: { genre: v } })) });
} else {
for (const g of filters.selectedGenres) all.push({ notContains: { genre: g } });
}
}
if (filters.yearMode === 'include') {
all.push({ inTheRange: { year: [filters.yearFrom, filters.yearTo] } });
} else {
all.push({ any: [{ lt: { year: filters.yearFrom } }, { gt: { year: filters.yearTo } }] });
}
const rules: Record<string, unknown> = { all };
rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(filters.limit) || 50));
rules.sort = filters.sort;
return rules;
}
@@ -0,0 +1,221 @@
import type { TFunction } from 'i18next';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { search } from '@/api/subsonicSearch';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { showToast } from '@/utils/ui/toast';
import { parseSpotifyCsv, type SpotifyCsvTrack } from '@/features/playlist/utils/spotifyCsvImport';
import {
cleanTrackTitle,
similarityScore,
calculateDynamicThreshold,
processBatch,
} from '@/features/playlist/utils/spotifyCsvMatch';
export interface CsvImportReport {
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
}
export interface RunPlaylistCsvImportDeps {
songs: SubsonicSong[];
t: TFunction;
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>;
setSongs: (next: SubsonicSong[]) => void;
setCsvImporting: (v: boolean) => void;
setCsvImportReport: (r: CsvImportReport | null) => void;
}
export async function runPlaylistCsvImport(deps: RunPlaylistCsvImportDeps): Promise<void> {
const { songs, t, savePlaylist, setSongs, setCsvImporting, setCsvImportReport } = deps;
try {
const selected = await openDialog({
filters: [{ name: 'CSV Files', extensions: ['csv'] }],
multiple: false,
title: 'Import Spotify Playlist CSV',
});
if (!selected || typeof selected !== 'string') return;
setCsvImporting(true);
const content = await readTextFile(selected);
const csvTracks = parseSpotifyCsv(content);
if (csvTracks.length === 0) {
showToast(t('playlists.csvImportNoValidTracks'), 3000, 'error');
setCsvImporting(false);
return;
}
const existingIds = new Set(songs.map(s => s.id));
const addedSongs: SubsonicSong[] = [];
const notFound: SpotifyCsvTrack[] = [];
const searchErrors: SpotifyCsvTrack[] = [];
const duplicateTracks: SpotifyCsvTrack[] = [];
let duplicateCount = 0;
// Process in batches of 10 to balance speed/server load
await processBatch(csvTracks, 10, async (track) => {
try {
// Retry: 2 attempts in case of network error
let searchResult;
let attempts = 0;
const maxAttempts = 2;
// Clean title before search to find matches despite version suffixes
const cleanTitleForSearch = cleanTrackTitle(track.trackName);
while (attempts < maxAttempts) {
try {
searchResult = await search(cleanTitleForSearch, { songCount: 40, artistCount: 0, albumCount: 0 });
break;
} catch (err) {
attempts++;
if (attempts >= maxAttempts) throw err;
// Wait 500ms before retrying
await new Promise(r => setTimeout(r, 500));
}
}
if (!searchResult || searchResult.songs.length === 0) {
notFound.push({
...track,
score: 0,
thresholdNeeded: 0.6, // Minimum threshold, nothing to compare
});
return null;
}
// Confidence scoring for each result
// Clean CSV title for fair comparison
const cleanCsvTitle = cleanTrackTitle(track.trackName);
const scoredMatches = searchResult.songs.map(s => {
// Fast ISRC path: if both have ISRC and they match, perfect score
if (track.isrc && s.isrc && typeof s.isrc === 'string' && track.isrc.toUpperCase() === s.isrc.toUpperCase()) {
return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true };
}
// Clean the result title as well
const cleanResultTitle = cleanTrackTitle(s.title);
const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle);
// Artist scoring: maximum score against any of the CSV artists
const artistScore = s.artist
? Math.max(...track.artistNames.map(csvArtist =>
similarityScore(s.artist || '', csvArtist)
))
: 0;
// If no album in CSV or local, use 1.0 (neutral) to avoid penalizing
const albumScore = (s.album && track.albumName)
? similarityScore(s.album, track.albumName)
: 1.0;
// Dynamic weight: specific titles (>4 words) → more weight to title
const titleWords = cleanCsvTitle.split(/\s+/).length;
const isSpecificTitle = titleWords > 4;
const titleWeight = isSpecificTitle ? 0.55 : 0.4;
const artistWeight = isSpecificTitle ? 0.25 : 0.4;
const totalScore = artistScore * artistWeight + titleScore * titleWeight + albumScore * 0.2;
return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false };
}).sort((a, b) => b.score - a.score);
// Use dynamic threshold based on match quality signals
const bestMatch = scoredMatches[0];
const secondMatch = scoredMatches[1];
const titleWords = cleanCsvTitle.split(/\s+/).length;
const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords);
if (bestMatch.score < threshold) {
notFound.push({
...track,
score: bestMatch.score,
thresholdNeeded: threshold,
});
return null;
}
// Check for duplicates
if (existingIds.has(bestMatch.song.id)) {
duplicateCount++;
duplicateTracks.push(track);
return null;
}
// Check for duplicates in tracks already queued for addition
if (addedSongs.some(s => s.id === bestMatch.song.id)) {
duplicateCount++;
duplicateTracks.push(track);
return null;
}
addedSongs.push(bestMatch.song);
existingIds.add(bestMatch.song.id);
return bestMatch.song;
} catch {
searchErrors.push(track);
return null;
}
});
if (addedSongs.length > 0) {
const next = [...songs, ...addedSongs];
setSongs(next);
await savePlaylist(next);
}
// Auto-show report if there are not found tracks, duplicates, or search errors
if (notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0) {
// Small delay to let the toast appear first
setTimeout(() => {
setCsvImportReport({
added: addedSongs.length,
notFound,
duplicates: duplicateCount,
duplicateTracks,
total: csvTracks.length,
searchErrors,
});
}, 500);
}
const errorMsg = searchErrors.length > 0
? ` (${searchErrors.length} network errors - may retry)`
: '';
// Determine toast type based on results:
// - success: all songs were added successfully
// - warning: at least one added, but some not found/duplicates
// - error: none added (all duplicates or not found)
const hasAdded = addedSongs.length > 0;
const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0;
let toastVariant: 'success' | 'warning' | 'error';
if (hasAdded && !hasIssues) {
toastVariant = 'success';
} else if (hasAdded && hasIssues) {
toastVariant = 'warning';
} else {
toastVariant = 'error';
}
showToast(
t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg,
5000,
toastVariant
);
} catch (err) {
console.error('CSV import failed:', err);
showToast(t('playlists.csvImportFailed'), 3000, 'error');
} finally {
setCsvImporting(false);
}
}
@@ -0,0 +1,64 @@
import type React from 'react';
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { filterSongsToActiveLibrary } from '@/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '@/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { isOfflineBrowseActive } from '@/features/offline';
import { resolvePlaylist } from '@/features/offline';
export interface RunPlaylistLoadDeps {
id: string;
setLoading: (v: boolean) => void;
setPlaylist: React.Dispatch<React.SetStateAction<SubsonicPlaylist | null>>;
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
setCustomCoverId: React.Dispatch<React.SetStateAction<string | null>>;
setRatings: React.Dispatch<React.SetStateAction<Record<string, number>>>;
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
}
function applyLoadedPlaylist(
deps: RunPlaylistLoadDeps,
playlist: SubsonicPlaylist,
songs: SubsonicSong[],
): void {
const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
setPlaylist(playlist);
setSongs(songs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {};
const starred = new Set<string>();
songs.forEach(s => {
if (s.userRating) init[s.id] = s.userRating;
if (s.starred) starred.add(s.id);
});
setRatings(init);
setStarredSongs(starred);
}
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
const { id, setLoading, setPlaylist, setSongs } = deps;
setLoading(true);
try {
const serverId = useAuthStore.getState().activeServerId ?? '';
if (isOfflineBrowseActive() && serverId) {
const loaded = await resolvePlaylist(serverId, id);
if (loaded) {
applyLoadedPlaylist(deps, loaded.playlist, loaded.songs);
return;
}
}
const { playlist, songs } = await getPlaylist(id);
const filteredSongs = await filterSongsToActiveLibrary(songs);
applyLoadedPlaylist(deps, playlist, filteredSongs);
} catch {
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
if (stub) {
setPlaylist(stub);
setSongs([]);
}
} finally {
setLoading(false);
}
}
@@ -0,0 +1,45 @@
import type React from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
export interface RunPlaylistReorderDropDeps {
e: Event;
songs: SubsonicSong[];
savePlaylist: (next: SubsonicSong[], prevCount?: number) => Promise<void>;
setDropTargetIdx: React.Dispatch<React.SetStateAction<{ idx: number; before: boolean } | null>>;
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
}
export function runPlaylistReorderDrop(deps: RunPlaylistReorderDropDeps): void {
const { e, songs, savePlaylist, setDropTargetIdx, setSongs } = deps;
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'playlist_reorder') return;
setDropTargetIdx(null);
const fromIdx = parsed.index as number;
// Determine drop index from the event target row
const target = (e.target as HTMLElement).closest('[data-track-idx]');
let toIdx = songs.length;
if (target) {
const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10);
const rect = target.getBoundingClientRect();
const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2);
const before = cursorY < rect.top + rect.height / 2;
toIdx = before ? targetIdx : targetIdx + 1;
}
if (fromIdx === toIdx || fromIdx === toIdx - 1) return;
setSongs(prev => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx;
next.splice(insertAt, 0, moved);
savePlaylist(next);
return next;
});
}
@@ -0,0 +1,46 @@
import type { TFunction } from 'i18next';
import { getPlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { showToast } from '@/utils/ui/toast';
export interface RunPlaylistSaveMetaDeps {
id: string;
playlist: SubsonicPlaylist;
t: TFunction;
setPlaylist: (updater: (p: SubsonicPlaylist | null) => SubsonicPlaylist | null) => void;
setCustomCoverId: (id: string | null) => void;
setEditingMeta: (v: boolean) => void;
}
export async function runPlaylistSaveMeta(
deps: RunPlaylistSaveMetaDeps,
opts: {
name: string;
comment: string;
isPublic: boolean;
coverFile: File | null;
coverRemoved: boolean;
},
): Promise<void> {
const { id, playlist, t, setPlaylist, setCustomCoverId, setEditingMeta } = deps;
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
setPlaylist(p => p
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
: p
);
if (opts.coverFile) {
try {
await uploadPlaylistCoverArt(id, opts.coverFile);
const { playlist: refreshed } = await getPlaylist(id);
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
showToast(t('playlists.coverUpdated'));
} catch (err) {
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
}
} else if (opts.coverRemoved) {
setCustomCoverId(null);
}
showToast(t('playlists.metaSaved'));
setEditingMeta(false);
}
@@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { buildDownloadUrl } from '@/api/subsonicStreamUrl';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { useZipDownloadStore } from '@/features/offline';
import { sanitizeFilename } from '@/utils/componentHelpers/playlistDetailHelpers';
export interface RunPlaylistZipDownloadDeps {
playlist: SubsonicPlaylist;
id: string;
downloadFolder: string | null;
requestDownloadFolder: () => Promise<string | null>;
setZipDownloadId: (id: string | null) => void;
}
export async function runPlaylistZipDownload(deps: RunPlaylistZipDownloadDeps): Promise<void> {
const { playlist, id, downloadFolder, requestDownloadFolder, setZipDownloadId } = deps;
const folder = downloadFolder || await requestDownloadFolder();
if (!folder) return;
const filename = `${sanitizeFilename(playlist.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(id);
const downloadId = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
setZipDownloadId(downloadId);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed:', e);
}
}
@@ -0,0 +1,110 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { deletePlaylist, getPlaylist, updatePlaylist } from '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { showToast } from '@/utils/ui/toast';
export interface RunPlaylistDeleteDeps {
e: React.MouseEvent;
pl: SubsonicPlaylist;
deleteConfirmId: string | null;
setDeleteConfirmId: React.Dispatch<React.SetStateAction<string | null>>;
removeId: (id: string) => void;
t: TFunction;
}
export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise<void> {
const { e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t } = deps;
e.stopPropagation();
if (deleteConfirmId !== pl.id) {
setDeleteConfirmId(pl.id);
const btn = e.currentTarget as HTMLElement;
requestAnimationFrame(() => {
btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
return;
}
try {
await deletePlaylist(pl.id);
removeId(pl.id);
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== pl.id),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
setDeleteConfirmId(null);
}
export interface RunPlaylistDeleteSelectedDeps {
selectedPlaylists: SubsonicPlaylist[];
selectedIds: Set<string>;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
removeId: (id: string) => void;
clearSelection: () => void;
t: TFunction;
}
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise<void> {
const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps;
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
if (deletable.length === 0) return;
let deleted = 0;
for (const pl of deletable) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
} 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');
}
}
export interface RunPlaylistMergeSelectedDeps {
targetPlaylist: SubsonicPlaylist;
selectedPlaylists: SubsonicPlaylist[];
touchPlaylist: (id: string) => void;
clearSelection: () => void;
t: TFunction;
}
export async function runPlaylistMergeSelected(deps: RunPlaylistMergeSelectedDeps): Promise<void> {
const { targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t } = deps;
if (selectedPlaylists.length === 0) return;
try {
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
const targetIds = new Set(targetSongs.map(s => s.id));
let totalAdded = 0;
for (const pl of selectedPlaylists) {
if (pl.id === targetPlaylist.id) continue;
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter(s => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach(s => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
touchPlaylist(targetPlaylist.id);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
clearSelection();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
}
}
@@ -0,0 +1,83 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '@/api/navidromeSmart';
import type { SubsonicGenre, SubsonicPlaylist } from '@/api/subsonicTypes';
import {
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
parseSmartRulesToFilters, type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
import { showToast } from '@/utils/ui/toast';
export interface RunPlaylistsOpenSmartEditorDeps {
pl: SubsonicPlaylist;
isNavidromeServer: boolean;
allGenres: SubsonicGenre[];
t: TFunction;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
setCreating: React.Dispatch<React.SetStateAction<boolean>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setCreatingSmartBusy: React.Dispatch<React.SetStateAction<boolean>>;
}
export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise<void> {
const {
pl, isNavidromeServer, allGenres, t,
setSmartFilters, setEditingSmartId, setGenreQuery,
setCreating, setCreatingSmart, setCreatingSmartBusy,
} = deps;
if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return;
setCreatingSmartBusy(true);
try {
let target: { id: string; name: string; rules?: Record<string, unknown> } | null = null;
try {
// Prefer direct endpoint for this playlist: returns freshest rules.
const direct = await ndGetSmartPlaylist(pl.id);
if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct;
} catch {
// Fallback to list endpoint below.
}
if (!target) {
const smart = await ndListSmartPlaylists();
target = smart.find((v) =>
v.id === pl.id ||
v.name === pl.name ||
displayPlaylistName(v.name) === displayPlaylistName(pl.name),
) ?? null;
}
if (target) {
const parsed = parseSmartRulesToFilters(target.rules, target.name);
if (parsed.untaggedGenresOnly) {
parsed.selectedGenres = allGenres.map(g => g.value);
}
setSmartFilters(parsed);
setEditingSmartId(target.id);
} else {
// Fallback: allow editing even if Navidrome smart list endpoint
// doesn't return this playlist (shared/migrated/legacy edge cases).
setSmartFilters({
...defaultSmartFilters,
name: displayPlaylistName(pl.name),
});
setEditingSmartId(pl.id);
}
setGenreQuery('');
setCreating(false);
setCreatingSmart(true);
} catch {
// Degrade gracefully instead of blocking the editor on transient/API errors.
setSmartFilters({
...defaultSmartFilters,
name: displayPlaylistName(pl.name),
});
setGenreQuery('');
setEditingSmartId(pl.id);
setCreating(false);
setCreatingSmart(true);
showToast(t('smartPlaylists.loadFailed'), 3500, 'warning');
} finally {
setCreatingSmartBusy(false);
}
}
@@ -0,0 +1,86 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndCreateSmartPlaylist, ndUpdateSmartPlaylist } from '@/api/navidromeSmart';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import {
buildSmartRulesPayload, defaultSmartFilters, SMART_PREFIX,
type PendingSmartPlaylist, type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
import { showToast } from '@/utils/ui/toast';
export interface RunPlaylistsSaveSmartDeps {
isNavidromeServer: boolean;
smartFilters: SmartFilters;
allGenres: string[];
editingSmartId: string | null;
playlists: SubsonicPlaylist[];
fetchPlaylists: () => Promise<void>;
t: TFunction;
setPendingSmart: React.Dispatch<React.SetStateAction<PendingSmartPlaylist[]>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
setCreatingSmartBusy: React.Dispatch<React.SetStateAction<boolean>>;
}
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
const {
isNavidromeServer, smartFilters, allGenres, editingSmartId, playlists, fetchPlaylists, t,
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
setGenreQuery, setCreatingSmartBusy,
} = deps;
if (!isNavidromeServer) {
showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error');
return;
}
setCreatingSmartBusy(true);
try {
let baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`;
if (!editingSmartId) {
const existingNames = new Set(playlists.map((p) => (p.name ?? '').toLowerCase()));
const requestedBaseName = baseName;
let ordinal = 2;
while (existingNames.has(`${SMART_PREFIX}${baseName}`.toLowerCase())) {
baseName = `${requestedBaseName}-${ordinal}`;
ordinal += 1;
}
}
const rules = buildSmartRulesPayload(smartFilters, { allGenres });
const fullName = `${SMART_PREFIX}${baseName}`;
if (editingSmartId) {
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);
} else {
await ndCreateSmartPlaylist(fullName, rules, true);
}
await fetchPlaylists();
const createdName = fullName;
const updatedId = editingSmartId;
setPendingSmart(prev => {
const existing = prev.find(p => p.id === updatedId || p.name === createdName);
if (existing) return prev;
const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName);
return [
...prev,
{
name: createdName,
id: updatedId ?? created?.id,
firstSeenCoverArt: created?.coverArt,
attempts: 0,
},
];
});
setCreatingSmart(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success');
else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success');
} catch {
showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error');
} finally {
setCreatingSmartBusy(false);
}
}
@@ -0,0 +1,144 @@
import Papa from 'papaparse';
export interface SpotifyCsvTrack {
trackName: string;
artistName: string;
artistNames: string[]; // Array of all artists for better matching
albumName: string;
isrc?: string;
score?: number; // Match score when track not found
thresholdNeeded?: number; // Threshold required to pass
}
// Header mapping to canonical fields (supports English and Spanish)
const HEADER_MAPPINGS: Record<string, string> = {
// Track name
'track name': 'trackName',
'track name(s)': 'trackName',
'track': 'trackName',
'name': 'trackName',
'nombre de la cancion': 'trackName',
'nombre de cancion': 'trackName',
'nombre de la canci\u00f3n': 'trackName',
'nombre cancion': 'trackName',
't\u00edtulo': 'trackName',
'titulo': 'trackName',
// Artist name
'artist name': 'artistName',
'artist name(s)': 'artistName',
'artists name': 'artistName',
'artists name(s)': 'artistName',
'artist': 'artistName',
'artists': 'artistName',
'nombre del artista': 'artistName',
'nombres del artista': 'artistName',
'nombre artista': 'artistName',
'artista': 'artistName',
// Album name
'album name': 'albumName',
'album name(s)': 'albumName',
'album': 'albumName',
'nombre del album': 'albumName',
'nombre del \u00e1lbum': 'albumName',
'nombre album': 'albumName',
// ISRC
'isrc': 'isrc',
'isrc code': 'isrc',
'codigo isrc': 'isrc',
'c\u00f3digo isrc': 'isrc',
};
function normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/\(s\)/g, '')
.replace(/[()]/g, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
}
function findColumnField(header: string): string | undefined {
const normalized = normalizeHeader(header);
return HEADER_MAPPINGS[normalized];
}
function parseArtists(artistField: string): string[] {
// Spotify uses commas in extended format, semicolons in simple format
const separator = artistField.includes(';') ? ';' : ',';
return artistField
.split(separator)
.map(a => a.trim())
.filter(a => a.length > 0);
}
function extractFeaturedArtists(title: string): string[] {
const patterns = [
/\(feat\.?\s+([^)]+)\)/i,
/\(ft\.?\s+([^)]+)\)/i,
/\(featuring\s+([^)]+)\)/i,
/\(with\s+([^)]+)\)/i,
];
for (const regex of patterns) {
const match = title.match(regex);
if (match) {
return match[1].split(/,|\sand\s|\s&\s/).map(s => s.trim()).filter(Boolean);
}
}
return [];
}
export function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
// Strip BOM and parse with Papa Parse
const cleanContent = csvContent.replace(/^\uFEFF/, '');
const parseResult = Papa.parse(cleanContent, {
header: true,
skipEmptyLines: true,
transformHeader: (header: string) => {
const field = findColumnField(header);
return field || header;
},
});
if (parseResult.errors.length > 0) {
console.warn('CSV parse warnings:', parseResult.errors);
}
const data = parseResult.data as Record<string, string>[];
// Verify required columns
if (!data.length || !data[0].trackName || !data[0].artistName) {
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
return [];
}
console.log('CSV parsed with Papa Parse:', {
rows: data.length,
sample: data[0],
});
const tracks: SpotifyCsvTrack[] = [];
for (const row of data) {
const trackName = row.trackName?.trim();
const artistField = row.artistName?.trim() || '';
if (!trackName || !artistField) continue;
// Parse multiple artists from field + extract collaborators from title
const artistNames = parseArtists(artistField);
const featuredArtists = extractFeaturedArtists(trackName);
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
const primaryArtist = allArtists[0] || '';
tracks.push({
trackName,
artistName: primaryArtist,
artistNames: allArtists,
albumName: row.albumName?.trim() || '',
isrc: row.isrc?.trim() || undefined,
});
}
return tracks;
}
@@ -0,0 +1,177 @@
// Normalize strings for matching: remove accents, special chars, lowercase, trim
export function normalizeForMatching(s: string): string {
return s.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[ø]/gi, 'o')
.replace(/[æ]/gi, 'ae')
.trim();
}
// Clean common title suffixes (remastered, live, editions, etc.)
export function cleanTrackTitle(title: string): string {
const suffixes = [
// Remastered variants
/\s*-\s*remasterizado$/i,
/\s*-\s*remaster$/i,
/\s*-\s*remastered$/i,
/\s*\(remasterizado\)$/i,
/\s*\(remaster\)$/i,
/\s*\(remastered\)$/i,
/\s*\[remasterizado\]$/i,
/\s*\[remaster\]$/i,
/\s*\[remastered\]$/i,
/\s*-\s*remasterizado\s+\d{4}$/i,
/\s*-\s*remastered\s+\d{4}$/i,
/\s*\(\d{4}\s+remaster\)$/i,
/\s*\(\d{4}\s+remastered\)$/i,
// Live variants
/\s*-\s*en vivo$/i,
/\s*-\s*live$/i,
/\s*-\s*version en vivo$/i,
/\s*-\s*studio version$/i,
/\s*-\s*version de estudio$/i,
/\s*\(en vivo\)$/i,
/\s*\(live\)$/i,
/\s*\(live .*\)$/i,
/\s*\[en vivo\]$/i,
/\s*\[live\]$/i,
/\s*\[live .*\]$/i,
/\s*-\s*live at.*$/i,
/\s*\(live at.*\)$/i,
// Version/Edition variants
/\s*-\s*version$/i,
/\s*-\s*versión$/i,
/\s*\(version\)$/i,
/\s*\(versión\)$/i,
/\s*\[version\]$/i,
/\s*\[versión\]$/i,
/\s*-\s*album version$/i,
/\s*\(album version\)$/i,
/\s*\[album version\]$/i,
// Radio/Edit variants
/\s*-\s*radio edit$/i,
/\s*-\s*radio version$/i,
/\s*\(radio edit\)$/i,
/\s*\(radio version\)$/i,
/\s*\[radio edit\]$/i,
/\s*\[radio version\]$/i,
/\s*-\s*edit$/i,
/\s*\(edit\)$/i,
/\s*\[edit\]$/i,
// Acoustic/Instrumental variants
/\s*-\s*acoustic$/i,
/\s*-\s*acústico$/i,
/\s*\(acoustic\)$/i,
/\s*\(acústico\)$/i,
/\s*\[acoustic\]$/i,
/\s*\[acústico\]$/i,
/\s*-\s*instrumental$/i,
/\s*\(instrumental\)$/i,
/\s*\[instrumental\]$/i,
// Featuring/Feat/Ft/With variants
/\s*\(feat\.?\s+.*\)$/i,
/\s*\[feat\.?\s+.*\]$/i,
/\s*-\s*feat\.?\s+.*$/i,
/\s*\(featuring\s+.*\)$/i,
/\s*\[featuring\s+.*\]$/i,
/\s*\(ft\.?\s+.*\)$/i,
/\s*\[ft\.?\s+.*\]$/i,
/\s*-\s*ft\.?\s+.*$/i,
/\s*\(with\s+.*\)$/i,
/\s*\[with\s+.*\]$/i,
/\s*-\s*with\s+.*$/i,
/\s*ft\.?\s+.*$/i,
// Explicit/Clean tags
/\s*\(explicit\)$/i,
/\s*\[explicit\]$/i,
/\s*\(clean\)$/i,
/\s*\[clean\]$/i,
// Mono/Stereo
/\s*\(mono\)$/i,
/\s*\[mono\]$/i,
/\s*\(stereo\)$/i,
/\s*\[stereo\]$/i,
// Deluxe/Special editions
/\s*-\s*deluxe$/i,
/\s*\(deluxe\)$/i,
/\s*\[deluxe\]$/i,
/\s*-\s*special edition$/i,
/\s*\(special edition\)$/i,
/\s*\[special edition\]$/i,
// Year in parentheses (common in remasters)
/\s*\(\d{4}\)$/i,
];
let cleaned = title.trim();
// Apply patterns multiple times for nested cases
for (let i = 0; i < 3; i++) {
const previous = cleaned;
for (const regex of suffixes) {
cleaned = cleaned.replace(regex, '');
}
cleaned = cleaned.trim();
if (previous === cleaned) break; // No more changes
}
return cleaned;
}
// Levenshtein distance for similarity scoring
export function levenshtein(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
matrix[i][j] = b[i - 1] === a[j - 1]
? matrix[i - 1][j - 1]
: Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
return matrix[b.length][a.length];
}
export function similarityScore(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
// Use normalized strings (without accents) for comparison
const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b));
return 1 - dist / maxLen;
}
// Calculate dynamic threshold based on match quality signals
export function calculateDynamicThreshold(
bestMatch: { score: number; artistScore: number },
secondMatch: { score: number } | undefined,
titleWords: number
): number {
const baseThreshold = 0.6; // Minimum acceptable score
// Bonus if there's a large gap between best and second match (clear winner)
const gap = secondMatch ? bestMatch.score - secondMatch.score : 0.3;
const gapBonus = gap > 0.15 ? 0.1 : gap > 0.08 ? 0.05 : 0;
// Short titles (< 3 words) are more ambiguous, need higher threshold
// Long titles (> 4 words) are more specific, can use lower threshold
const lengthBonus = titleWords > 4 ? 0.05 : titleWords < 3 ? -0.05 : 0;
// Strong artist match gives confidence to accept lower overall score
const artistBonus = bestMatch.artistScore > 0.85 ? 0.08 : bestMatch.artistScore > 0.7 ? 0.04 : 0;
// Calculate final threshold, clamp between 0.55 and 0.75
return Math.max(0.55, Math.min(0.75, baseThreshold - gapBonus - lengthBonus - artistBonus));
}
// Process searches in batches to avoid overloading the server
export async function processBatch<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R | null>
): Promise<(R | null)[]> {
const results: (R | null)[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
}
@@ -0,0 +1,47 @@
import type React from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
export interface StartPlaylistRowDragDeps {
e: React.MouseEvent;
idx: number;
songs: SubsonicSong[];
selectedIds: Set<string>;
isFiltered: boolean;
startDrag: (payload: { data: string; label: string }, x: number, y: number) => void;
}
export function startPlaylistRowDrag(deps: StartPlaylistRowDragDeps): void {
const { e, idx, songs, selectedIds, isFiltered, startDrag } = deps;
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, input')) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) {
const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack);
startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else if (!isFiltered) {
startDrag(
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
} else {
// filtered view: single-song drag to queue
startDrag(
{ data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
}
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/store/authStore';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import { useHomeStore } from '@/store/homeStore';
import { usePlayerBarLayoutStore } from '@/store/playerBarLayoutStore';
import { usePlaylistLayoutStore } from '@/store/playlistLayoutStore';
import { usePlaylistLayoutStore } from '@/features/playlist';
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
import { useSidebarStore } from '@/features/sidebar';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Download, FileUp, HardDrive, Lightbulb, Search } from 'lucide-react';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/store/playlistLayoutStore';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/features/playlist';
const PLAYLIST_LAYOUT_ICONS: Record<PlaylistLayoutItemId, typeof Search> = {
addSongs: Search,
+1 -1
View File
@@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
import { PanelLeft, PanelLeftClose, Trash2 } from 'lucide-react';
import PsysonicLogo from '@/components/PsysonicLogo';
import PSmallLogo from '@/components/PSmallLogo';
import { usePlaylistStore } from '@/store/playlistStore';
import { usePlaylistStore } from '@/features/playlist';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import {
getLibraryItemsForReorder,
@@ -5,9 +5,9 @@ import { ChevronRight, Folder, PlayCircle, Sparkles } from 'lucide-react';
import { displayPlaylistName, isSmartPlaylistName } from '@/features/sidebar/utils/sidebarHelpers';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { usePlaylistStore } from '@/store/playlistStore';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/store/playlistFolderStore';
import { groupPlaylistsByFolder } from '@/utils/playlist/playlistFolders';
import { usePlaylistStore } from '@/features/playlist';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/features/playlist';
import { groupPlaylistsByFolder } from '@/features/playlist';
interface SidebarPlaylist {
id: string;