mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(playlist): co-locate playlist feature into features/playlist
This commit is contained in:
@@ -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"> · </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 };
|
||||
}
|
||||
@@ -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 playlist⟷offline 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user