mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat: add multi-selection and context menu for playlist management
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,10 @@ interface AlbumCardProps {
|
|||||||
selectionMode?: boolean;
|
selectionMode?: boolean;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
showRating?: boolean;
|
showRating?: boolean;
|
||||||
|
selectedAlbums?: SubsonicAlbum[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
|
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
@@ -44,7 +45,11 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
|||||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
if (selectionMode && selectedAlbums.length > 0) {
|
||||||
|
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
|
||||||
|
} else {
|
||||||
|
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseDown={e => {
|
onMouseDown={e => {
|
||||||
if (selectionMode || e.button !== 0) return;
|
if (selectionMode || e.button !== 0) return;
|
||||||
|
|||||||
@@ -198,6 +198,733 @@ function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: { artistId: st
|
|||||||
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
|
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves all songs from multiple albums and adds them to playlist with detailed toast notifications
|
||||||
|
function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds: string[]; onDone: () => void; triggerId?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||||
|
const [totalAlbums, setTotalAlbums] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTotalAlbums(albumIds.length);
|
||||||
|
(async () => {
|
||||||
|
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
|
||||||
|
const allSongs = albumSongs.flat();
|
||||||
|
setResolvedIds(allSongs.map(s => s.id));
|
||||||
|
})().catch(() => setResolvedIds([]));
|
||||||
|
}, [albumIds]);
|
||||||
|
|
||||||
|
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||||
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||||
|
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||||
|
|
||||||
|
const newIds: string[] = [];
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
|
||||||
|
for (const id of songIds) {
|
||||||
|
if (existingIds.has(id)) {
|
||||||
|
duplicateIds.push(id);
|
||||||
|
} else {
|
||||||
|
newIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||||
|
touchPlaylist(pl.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detailed toast notification
|
||||||
|
const totalSongs = songIds.length;
|
||||||
|
const addedCount = newIds.length;
|
||||||
|
const duplicateCount = duplicateIds.length;
|
||||||
|
|
||||||
|
if (addedCount === 0 && duplicateCount > 0) {
|
||||||
|
showToast(
|
||||||
|
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||||
|
4000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
} else if (duplicateCount > 0) {
|
||||||
|
showToast(
|
||||||
|
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||||
|
4000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||||
|
3000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('playlists.addError'), 4000, 'error');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWithToast = async (songIds: string[]) => {
|
||||||
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = t('playlists.unnamed');
|
||||||
|
const pl = await createPlaylist(name, songIds);
|
||||||
|
if (pl?.id) {
|
||||||
|
touchPlaylist(pl.id);
|
||||||
|
showToast(
|
||||||
|
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||||
|
3000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.createError'), 4000, 'error');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom AddToPlaylistSubmenu with toast notifications for multiple albums
|
||||||
|
function MultiAddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subRef = useRef<HTMLDivElement>(null);
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||||
|
const [adding, setAdding] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [flipLeft, setFlipLeft] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPlaylists().then((all) => {
|
||||||
|
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (subRef.current) {
|
||||||
|
const rect = subRef.current.getBoundingClientRect();
|
||||||
|
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creating) newNameRef.current?.focus();
|
||||||
|
}, [creating]);
|
||||||
|
|
||||||
|
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||||
|
setAdding(pl.id);
|
||||||
|
await handleAddWithToast(pl, songIds);
|
||||||
|
setAdding(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newName.trim() || t('playlists.unnamed');
|
||||||
|
try {
|
||||||
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
|
const pl = await createPlaylist(name, songIds);
|
||||||
|
if (pl?.id) {
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
usePlaylistStore.getState().touchPlaylist(pl.id);
|
||||||
|
showToast(
|
||||||
|
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||||
|
3000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.createError'), 4000, 'error');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const subStyle: React.CSSProperties = flipLeft
|
||||||
|
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||||
|
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||||
|
{!creating ? (
|
||||||
|
<div
|
||||||
|
className="context-menu-item context-submenu-new"
|
||||||
|
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
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="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
|
||||||
|
{playlists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||||
|
)}
|
||||||
|
{playlists.map((pl) => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl)}
|
||||||
|
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedIds === null) {
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('playlists.loadingAlbums', { count: totalAlbums })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedIds.length === 0) return null;
|
||||||
|
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves all songs from multiple artists and adds them to playlist with detailed toast notifications
|
||||||
|
function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artistIds: string[]; onDone: () => void; triggerId?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||||
|
const [totalArtists, setTotalArtists] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTotalArtists(artistIds.length);
|
||||||
|
(async () => {
|
||||||
|
const allSongs: string[] = [];
|
||||||
|
for (const artistId of artistIds) {
|
||||||
|
try {
|
||||||
|
const { albums } = await getArtist(artistId);
|
||||||
|
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
|
||||||
|
allSongs.push(...albumSongs.flat().map(s => s.id));
|
||||||
|
} catch {
|
||||||
|
// Skip failed artists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResolvedIds(allSongs);
|
||||||
|
})().catch(() => setResolvedIds([]));
|
||||||
|
}, [artistIds]);
|
||||||
|
|
||||||
|
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||||
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||||
|
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||||
|
|
||||||
|
const newIds: string[] = [];
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
|
||||||
|
for (const id of songIds) {
|
||||||
|
if (existingIds.has(id)) {
|
||||||
|
duplicateIds.push(id);
|
||||||
|
} else {
|
||||||
|
newIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||||
|
touchPlaylist(pl.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show detailed toast notification
|
||||||
|
const addedCount = newIds.length;
|
||||||
|
const duplicateCount = duplicateIds.length;
|
||||||
|
|
||||||
|
if (addedCount === 0 && duplicateCount > 0) {
|
||||||
|
showToast(
|
||||||
|
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||||
|
4000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
} else if (duplicateCount > 0) {
|
||||||
|
showToast(
|
||||||
|
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||||
|
4000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||||
|
3000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('playlists.addError'), 4000, 'error');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom AddToPlaylistSubmenu with toast notifications for multiple artists
|
||||||
|
function MultiAddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subRef = useRef<HTMLDivElement>(null);
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||||
|
const [adding, setAdding] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [flipLeft, setFlipLeft] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPlaylists().then((all) => {
|
||||||
|
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (subRef.current) {
|
||||||
|
const rect = subRef.current.getBoundingClientRect();
|
||||||
|
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creating) newNameRef.current?.focus();
|
||||||
|
}, [creating]);
|
||||||
|
|
||||||
|
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||||
|
setAdding(pl.id);
|
||||||
|
await handleAddWithToast(pl, songIds);
|
||||||
|
setAdding(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newName.trim() || t('playlists.unnamed');
|
||||||
|
try {
|
||||||
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
|
const pl = await createPlaylist(name, songIds);
|
||||||
|
if (pl?.id) {
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
usePlaylistStore.getState().touchPlaylist(pl.id);
|
||||||
|
showToast(
|
||||||
|
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||||
|
3000,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.createError'), 4000, 'error');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
const subStyle: React.CSSProperties = flipLeft
|
||||||
|
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||||
|
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||||
|
{!creating ? (
|
||||||
|
<div
|
||||||
|
className="context-menu-item context-submenu-new"
|
||||||
|
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
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="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
|
||||||
|
{playlists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||||
|
)}
|
||||||
|
{playlists.map((pl) => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl)}
|
||||||
|
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedIds === null) {
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('playlists.loadingArtists', { count: totalArtists })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedIds.length === 0) return null;
|
||||||
|
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submenu for adding a single playlist to another playlist
|
||||||
|
function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { playlist: { id: string; name: string }; onDone: () => void; triggerId?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [allPlaylists, setAllPlaylists] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const { getPlaylists } = await import('../api/subsonic');
|
||||||
|
const pls = await getPlaylists().catch(() => []);
|
||||||
|
setAllPlaylists(pls.filter((p: { id: string }) => p.id !== playlist.id));
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [playlist]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creating && newNameRef.current) {
|
||||||
|
newNameRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [creating]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
try {
|
||||||
|
const newPl = await createPlaylist(newName.trim(), []);
|
||||||
|
if (newPl?.id) {
|
||||||
|
await handleAddToNewPlaylist(newPl.id, newPl.name || newName.trim());
|
||||||
|
}
|
||||||
|
setCreating(false);
|
||||||
|
setNewName('');
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.createError'), 3000, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||||
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songs: sourceSongs } = await getPlaylist(playlist.id);
|
||||||
|
if (sourceSongs.length > 0) {
|
||||||
|
await updatePlaylist(targetId, sourceSongs.map((s: { id: string }) => s.id));
|
||||||
|
touchPlaylist(targetId);
|
||||||
|
showToast(t('playlists.createAndAddSuccess', { count: sourceSongs.length, playlist: targetName }), 3000, 'info');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (targetId: string, targetName: string) => {
|
||||||
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songs: targetSongs } = await getPlaylist(targetId);
|
||||||
|
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
|
||||||
|
const { songs: sourceSongs } = await getPlaylist(playlist.id);
|
||||||
|
const newSongs = sourceSongs.filter((s: { id: string }) => !targetIds.has(s.id));
|
||||||
|
|
||||||
|
if (newSongs.length > 0) {
|
||||||
|
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
||||||
|
await updatePlaylist(targetId, Array.from(targetIds));
|
||||||
|
touchPlaylist(targetId);
|
||||||
|
showToast(t('playlists.addToPlaylistSuccess', { count: newSongs.length, playlist: targetName }), 3000, 'info');
|
||||||
|
} else {
|
||||||
|
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ minWidth: 190 }}>
|
||||||
|
<div style={{ padding: '0.75rem', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subStyle: React.CSSProperties = { left: 'calc(100% + 4px)', right: 'auto' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||||
|
{/* New Playlist row */}
|
||||||
|
{!creating ? (
|
||||||
|
<div
|
||||||
|
className="context-menu-item context-submenu-new"
|
||||||
|
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
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="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
|
||||||
|
{allPlaylists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
|
||||||
|
)}
|
||||||
|
{allPlaylists.map(pl => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl.id, pl.name)}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submenu for merging multiple playlists into another playlist
|
||||||
|
function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { playlists: { id: string; name: string }[]; onDone: () => void; triggerId?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [allPlaylists, setAllPlaylists] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const { getPlaylists } = await import('../api/subsonic');
|
||||||
|
const pls = await getPlaylists().catch(() => []);
|
||||||
|
const selectedIds = new Set(playlists.map(p => p.id));
|
||||||
|
setAllPlaylists(pls.filter((p: { id: string }) => !selectedIds.has(p.id)));
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [playlists]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creating && newNameRef.current) {
|
||||||
|
newNameRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [creating]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
const { createPlaylist } = await import('../api/subsonic');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
try {
|
||||||
|
const newPl = await createPlaylist(newName.trim(), []);
|
||||||
|
if (newPl?.id) {
|
||||||
|
await handleMergeToNewPlaylist(newPl.id, newPl.name || newName.trim());
|
||||||
|
}
|
||||||
|
setCreating(false);
|
||||||
|
setNewName('');
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.createError'), 3000, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||||
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetIds = new Set<string>();
|
||||||
|
let totalAdded = 0;
|
||||||
|
|
||||||
|
for (const pl of playlists) {
|
||||||
|
const { songs } = await getPlaylist(pl.id);
|
||||||
|
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
|
||||||
|
if (newSongs.length > 0) {
|
||||||
|
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
||||||
|
totalAdded += newSongs.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAdded > 0) {
|
||||||
|
await updatePlaylist(targetId, Array.from(targetIds));
|
||||||
|
touchPlaylist(targetId);
|
||||||
|
showToast(t('playlists.createAndAddSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMerge = async (targetId: string, targetName: string) => {
|
||||||
|
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songs: targetSongs } = await getPlaylist(targetId);
|
||||||
|
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
|
||||||
|
let totalAdded = 0;
|
||||||
|
|
||||||
|
for (const pl of playlists) {
|
||||||
|
const { songs } = await getPlaylist(pl.id);
|
||||||
|
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
|
||||||
|
if (newSongs.length > 0) {
|
||||||
|
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
||||||
|
totalAdded += newSongs.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAdded > 0) {
|
||||||
|
await updatePlaylist(targetId, Array.from(targetIds));
|
||||||
|
touchPlaylist(targetId);
|
||||||
|
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
|
||||||
|
} else {
|
||||||
|
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ minWidth: 190 }}>
|
||||||
|
<div style={{ padding: '0.75rem', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subStyle: React.CSSProperties = { left: 'calc(100% + 4px)', right: 'auto' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||||
|
{/* New Playlist row */}
|
||||||
|
{!creating ? (
|
||||||
|
<div
|
||||||
|
className="context-menu-item context-submenu-new"
|
||||||
|
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
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="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
|
||||||
|
{allPlaylists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
|
||||||
|
)}
|
||||||
|
{allPlaylists.map(pl => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleMerge(pl.id, pl.name)}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContextMenu() {
|
export default function ContextMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||||
@@ -820,6 +1547,72 @@ export default function ContextMenu() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{type === 'playlist' && (() => {
|
||||||
|
const playlist = item as SubsonicPlaylist;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/playlists/${playlist.id}`))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||||
|
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const { deletePlaylist } = await import('../api/subsonic');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const removeId = usePlaylistStore.getState().removeId;
|
||||||
|
try {
|
||||||
|
await deletePlaylist(playlist.id);
|
||||||
|
removeId(playlist.id);
|
||||||
|
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
|
||||||
|
window.location.reload();
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.deleteFailed', { name: playlist.name }), 3000, 'error');
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{type === 'multi-album' && (() => {
|
||||||
|
const albums = item as SubsonicAlbum[];
|
||||||
|
const albumIds = albums.map(a => a.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
{t('contextMenu.selectedAlbums', { count: albums.length })}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||||
|
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{type === 'artist' && (() => {
|
{type === 'artist' && (() => {
|
||||||
const artist = item as SubsonicArtist;
|
const artist = item as SubsonicArtist;
|
||||||
const artistRatingDisabled = entityRatingSupport === 'track_only';
|
const artistRatingDisabled = entityRatingSupport === 'track_only';
|
||||||
@@ -870,6 +1663,78 @@ export default function ContextMenu() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{type === 'multi-artist' && (() => {
|
||||||
|
const artists = item as SubsonicArtist[];
|
||||||
|
const artistIds = artists.map(a => a.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||||
|
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{type === 'multi-playlist' && (() => {
|
||||||
|
const selectedPlaylists = item as SubsonicPlaylist[];
|
||||||
|
const playlistIds = selectedPlaylists.map(p => p.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||||
|
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
|
const { showToast } = await import('../utils/toast');
|
||||||
|
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||||
|
const { deletePlaylist } = await import('../api/subsonic');
|
||||||
|
const removeId = usePlaylistStore.getState().removeId;
|
||||||
|
let deleted = 0;
|
||||||
|
for (const pl of selectedPlaylists) {
|
||||||
|
try {
|
||||||
|
await deletePlaylist(pl.id);
|
||||||
|
removeId(pl.id);
|
||||||
|
deleted++;
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
})}>
|
||||||
|
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{type === 'queue-item' && (() => {
|
{type === 'queue-item' && (() => {
|
||||||
const song = item as Track;
|
const song = item as Track;
|
||||||
return (
|
return (
|
||||||
|
|||||||
+78
-12
@@ -10,10 +10,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
|
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
|
||||||
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2,
|
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PsysonicLogo from './PsysonicLogo';
|
import PsysonicLogo from './PsysonicLogo';
|
||||||
import PSmallLogo from './PSmallLogo';
|
import PSmallLogo from './PSmallLogo';
|
||||||
|
import { getPlaylists, SubsonicPlaylist } from '../api/subsonic';
|
||||||
|
|
||||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||||
// Exported so Settings can render the same item metadata.
|
// Exported so Settings can render the same item metadata.
|
||||||
@@ -56,6 +57,9 @@ export default function Sidebar({
|
|||||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||||
const sidebarItems = useSidebarStore(s => s.items);
|
const sidebarItems = useSidebarStore(s => s.items);
|
||||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||||
|
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||||
|
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||||
|
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||||
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
|
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
|
||||||
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
|
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||||
@@ -113,6 +117,16 @@ export default function Sidebar({
|
|||||||
setLibraryDropdownOpen(false);
|
setLibraryDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch playlists when expanded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playlistsExpanded || !isLoggedIn) return;
|
||||||
|
setPlaylistsLoading(true);
|
||||||
|
getPlaylists()
|
||||||
|
.then(setPlaylists)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setPlaylistsLoading(false));
|
||||||
|
}, [playlistsExpanded, isLoggedIn]);
|
||||||
|
|
||||||
// Resolve ordered, visible items per section from store config
|
// Resolve ordered, visible items per section from store config
|
||||||
const visibleLibrary = sidebarItems
|
const visibleLibrary = sidebarItems
|
||||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||||
@@ -216,17 +230,69 @@ export default function Sidebar({
|
|||||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||||
))}
|
))}
|
||||||
{visibleLibrary.map(item => (
|
{visibleLibrary.map(item => (
|
||||||
<NavLink
|
item.to === '/playlists' ? (
|
||||||
key={item.to}
|
// Playlists item with expand button
|
||||||
to={item.to}
|
<div key={item.to} className="sidebar-playlists-wrapper">
|
||||||
end={item.to === '/'}
|
<div className="sidebar-playlists-header-row">
|
||||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
<NavLink
|
||||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
to={item.to}
|
||||||
data-tooltip-pos="bottom"
|
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
|
||||||
>
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||||
<item.icon size={isCollapsed ? 22 : 18} />
|
data-tooltip-pos="bottom"
|
||||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon size={isCollapsed ? 22 : 18} />
|
||||||
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
|
||||||
|
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
|
||||||
|
aria-expanded={playlistsExpanded}
|
||||||
|
aria-label={playlistsExpanded ? 'Colapsar listas' : 'Expandir listas'}
|
||||||
|
title={playlistsExpanded ? 'Colapsar listas' : 'Expandir listas'}
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && playlistsExpanded && (
|
||||||
|
<div className="sidebar-playlists-list">
|
||||||
|
{playlistsLoading ? (
|
||||||
|
<div className="sidebar-playlists-loading">
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
</div>
|
||||||
|
) : playlists.length === 0 ? (
|
||||||
|
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||||
|
) : (
|
||||||
|
playlists.map(pl => (
|
||||||
|
<NavLink
|
||||||
|
key={pl.id}
|
||||||
|
to={`/playlists/${pl.id}`}
|
||||||
|
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? pl.name : undefined}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<PlayCircle size={12} />
|
||||||
|
<span>{pl.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||||
|
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<item.icon size={isCollapsed ? 22 : 18} />
|
||||||
|
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Now Playing — fixed, always visible */}
|
{/* Now Playing — fixed, always visible */}
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export const esTranslation = {
|
|||||||
goToArtist: 'Ir al Artista',
|
goToArtist: 'Ir al Artista',
|
||||||
download: 'Descargar (ZIP)',
|
download: 'Descargar (ZIP)',
|
||||||
addToPlaylist: 'Agregar a Lista de Reproducción',
|
addToPlaylist: 'Agregar a Lista de Reproducción',
|
||||||
|
selectedPlaylists: '{{count}} listas seleccionadas',
|
||||||
|
selectedAlbums: '{{count}} álbumes seleccionados',
|
||||||
|
selectedArtists: '{{count}} artistas seleccionados',
|
||||||
songInfo: 'Información de la Canción',
|
songInfo: 'Información de la Canción',
|
||||||
},
|
},
|
||||||
albumDetail: {
|
albumDetail: {
|
||||||
@@ -276,6 +279,7 @@ export const esTranslation = {
|
|||||||
downloadZipFailed: 'Error al descargar {{name}}',
|
downloadZipFailed: 'Error al descargar {{name}}',
|
||||||
offlineQueuing: 'Encolando {{count}} álbum(es) para offline…',
|
offlineQueuing: 'Encolando {{count}} álbum(es) para offline…',
|
||||||
offlineFailed: 'Error al agregar {{name}} offline',
|
offlineFailed: 'Error al agregar {{name}} offline',
|
||||||
|
addToPlaylist: 'Agregar a Lista',
|
||||||
},
|
},
|
||||||
artists: {
|
artists: {
|
||||||
title: 'Artistas',
|
title: 'Artistas',
|
||||||
@@ -289,6 +293,11 @@ export const esTranslation = {
|
|||||||
notFound: 'No se encontraron artistas.',
|
notFound: 'No se encontraron artistas.',
|
||||||
albumCount_one: '{{count}} Álbum',
|
albumCount_one: '{{count}} Álbum',
|
||||||
albumCount_other: '{{count}} Álbumes',
|
albumCount_other: '{{count}} Álbumes',
|
||||||
|
selectionCount: '{{count}} seleccionados',
|
||||||
|
select: 'Seleccionar',
|
||||||
|
startSelect: 'Activar selección',
|
||||||
|
cancelSelect: 'Cancelar',
|
||||||
|
addToPlaylist: 'Agregar a Lista',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
subtitle: 'Tu Reproductor Navidrome para Escritorio',
|
subtitle: 'Tu Reproductor Navidrome para Escritorio',
|
||||||
@@ -910,6 +919,29 @@ export const esTranslation = {
|
|||||||
coverUpdated: 'Portada actualizada',
|
coverUpdated: 'Portada actualizada',
|
||||||
metaSaved: 'Lista actualizada',
|
metaSaved: 'Lista actualizada',
|
||||||
downloadZip: 'Descargar (ZIP)',
|
downloadZip: 'Descargar (ZIP)',
|
||||||
|
// Toast notifications for multi-select
|
||||||
|
addSuccess: '{{count}} canciones agregadas a {{playlist}}',
|
||||||
|
addPartial: '{{added}} agregadas, {{skipped}} skippeadas por duplicadas',
|
||||||
|
addAllSkipped: 'Todas skippeadas ({{count}} duplicadas)',
|
||||||
|
addError: 'Error al agregar canciones',
|
||||||
|
createAndAddSuccess: 'Lista "{{playlist}}" creada con {{count}} canciones',
|
||||||
|
createError: 'Error al crear lista',
|
||||||
|
deleteSuccess: '{{count}} listas eliminadas',
|
||||||
|
deleteFailed: 'Error al eliminar {{name}}',
|
||||||
|
deleteSelected: 'Eliminar seleccionadas',
|
||||||
|
mergeSuccess: '{{count}} canciones unificadas en {{playlist}}',
|
||||||
|
mergeNoNewSongs: 'No hay canciones nuevas para agregar',
|
||||||
|
mergeError: 'Error al unificar listas',
|
||||||
|
mergeInto: 'Unificar en',
|
||||||
|
selectionCount: '{{count}} seleccionadas',
|
||||||
|
select: 'Seleccionar',
|
||||||
|
startSelect: 'Activar selección',
|
||||||
|
cancelSelect: 'Cancelar',
|
||||||
|
myPlaylists: 'Mis Listas',
|
||||||
|
noOtherPlaylists: 'No hay otras listas disponibles',
|
||||||
|
addToPlaylistSuccess: '{{count}} canciones agregadas a {{playlist}}',
|
||||||
|
addToPlaylistNoNew: 'No hay canciones nuevas para agregar a {{playlist}}',
|
||||||
|
addToPlaylistError: 'Error al agregar a la lista',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Más Reproducidos',
|
title: 'Más Reproducidos',
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||||
|
|
||||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export default function Albums() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||||
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
|
|
||||||
const handleDownloadZips = async () => {
|
const handleDownloadZips = async () => {
|
||||||
if (selectedAlbums.length === 0) return;
|
if (selectedAlbums.length === 0) return;
|
||||||
@@ -284,6 +286,7 @@ export default function Albums() {
|
|||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(a.id)}
|
selected={selectedIds.has(a.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
|
selectedAlbums={selectedAlbums}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+131
-42
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
|
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
@@ -81,25 +81,55 @@ export default function Artists() {
|
|||||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
|
||||||
const [visibleCount, setVisibleCount] = useState(50);
|
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||||
|
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
|
||||||
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
|
||||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
|
||||||
|
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
setSelectionMode(v => !v);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectionMode(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
setVisibleCount(prev => prev + 50);
|
if (loadingMore) return;
|
||||||
}, []);
|
setLoadingMore(true);
|
||||||
|
setVisibleCount(prev => prev + PAGE_SIZE);
|
||||||
|
setTimeout(() => setLoadingMore(false), 100);
|
||||||
|
}, [loadingMore, PAGE_SIZE]);
|
||||||
|
|
||||||
// Reset infinite scroll when filters change
|
// Reset infinite scroll when filters or image setting change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleCount(50);
|
setVisibleCount(PAGE_SIZE);
|
||||||
}, [filter, letterFilter, viewMode]);
|
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
|
||||||
|
|
||||||
// Filter pipeline
|
// Filter pipeline
|
||||||
let filtered = artists;
|
let filtered = artists;
|
||||||
@@ -120,6 +150,16 @@ export default function Artists() {
|
|||||||
const visible = filtered.slice(0, visibleCount);
|
const visible = filtered.slice(0, visibleCount);
|
||||||
const hasMore = visibleCount < filtered.length;
|
const hasMore = visibleCount < filtered.length;
|
||||||
|
|
||||||
|
// Intersection Observer for infinite scroll (after hasMore declaration)
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loadMore, hasMore]);
|
||||||
|
|
||||||
// Group by first letter (for list view)
|
// Group by first letter (for list view)
|
||||||
const groups: Record<string, SubsonicArtist[]> = {};
|
const groups: Record<string, SubsonicArtist[]> = {};
|
||||||
visible.forEach(a => {
|
visible.forEach(a => {
|
||||||
@@ -134,7 +174,11 @@ export default function Artists() {
|
|||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
|
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||||
|
{selectionMode && selectedIds.size > 0
|
||||||
|
? t('artists.selectionCount', { count: selectedIds.size })
|
||||||
|
: t('artists.title')}
|
||||||
|
</h1>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
style={{ maxWidth: 220 }}
|
style={{ maxWidth: 220 }}
|
||||||
@@ -146,30 +190,43 @@ export default function Artists() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||||
|
<button
|
||||||
|
className={`btn btn-surface`}
|
||||||
|
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||||
|
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||||
|
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||||
|
data-tooltip-wrap
|
||||||
|
>
|
||||||
|
<Images size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||||
|
data-tooltip={t('artists.gridView')}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||||
|
data-tooltip={t('artists.listView')}
|
||||||
|
>
|
||||||
|
<List size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`btn btn-surface`}
|
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
onClick={toggleSelectionMode}
|
||||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
|
||||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
data-tooltip-pos="bottom"
|
||||||
data-tooltip-wrap
|
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||||
>
|
>
|
||||||
<Images size={20} />
|
<CheckSquare2 size={15} />
|
||||||
</button>
|
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
||||||
<button
|
|
||||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
|
||||||
data-tooltip={t('artists.gridView')}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={20} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
|
||||||
data-tooltip={t('artists.listView')}
|
|
||||||
>
|
|
||||||
<List size={20} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,13 +250,33 @@ export default function Artists() {
|
|||||||
{visible.map(artist => (
|
{visible.map(artist => (
|
||||||
<div
|
<div
|
||||||
key={artist.id}
|
key={artist.id}
|
||||||
className="artist-card"
|
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
||||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
onClick={() => {
|
||||||
|
if (selectionMode) {
|
||||||
|
toggleSelect(artist.id);
|
||||||
|
} else {
|
||||||
|
navigate(`/artist/${artist.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
if (selectionMode && selectedIds.size > 0) {
|
||||||
|
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||||
|
} else {
|
||||||
|
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||||
|
outline: '2px solid var(--accent)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
borderRadius: 'var(--radius-md)'
|
||||||
|
} : {}}
|
||||||
>
|
>
|
||||||
|
{selectionMode && (
|
||||||
|
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
||||||
|
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div className="artist-card-name">{artist.name}</div>
|
<div className="artist-card-name">{artist.name}</div>
|
||||||
@@ -221,13 +298,27 @@ export default function Artists() {
|
|||||||
{groups[letter].map(artist => (
|
{groups[letter].map(artist => (
|
||||||
<button
|
<button
|
||||||
key={artist.id}
|
key={artist.id}
|
||||||
className="artist-row"
|
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
||||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
onClick={() => {
|
||||||
|
if (selectionMode) {
|
||||||
|
toggleSelect(artist.id);
|
||||||
|
} else {
|
||||||
|
navigate(`/artist/${artist.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
if (selectionMode && selectedIds.size > 0) {
|
||||||
|
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||||
|
} else {
|
||||||
|
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
id={`artist-${artist.id}`}
|
id={`artist-${artist.id}`}
|
||||||
|
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||||
|
background: 'var(--accent-dim)',
|
||||||
|
color: 'var(--accent)'
|
||||||
|
} : {}}
|
||||||
>
|
>
|
||||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||||
<div style={{ textAlign: 'left' }}>
|
<div style={{ textAlign: 'left' }}>
|
||||||
@@ -245,10 +336,8 @@ export default function Artists() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && hasMore && (
|
{!loading && hasMore && (
|
||||||
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
<button className="btn btn-primary" onClick={loadMore}>
|
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||||
<ChevronDown size={16} /> {t('artists.loadMore')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
@@ -50,6 +51,7 @@ export default function NewReleases() {
|
|||||||
}, []);
|
}, []);
|
||||||
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
|
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
|
||||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||||
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
|
|
||||||
const handleDownloadZips = async () => {
|
const handleDownloadZips = async () => {
|
||||||
if (selectedAlbums.length === 0) return;
|
if (selectedAlbums.length === 0) return;
|
||||||
@@ -183,6 +185,7 @@ export default function NewReleases() {
|
|||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(a.id)}
|
selected={selectedIds.has(a.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
|
selectedAlbums={selectedAlbums}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+146
-39
@@ -1,12 +1,13 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
|
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
|
||||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||||
|
import { showToast } from '../utils/toast';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
return formatHumanHoursMinutes(seconds);
|
return formatHumanHoursMinutes(seconds);
|
||||||
@@ -16,6 +17,7 @@ export default function Playlists() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||||
const removeId = usePlaylistStore((s) => s.removeId);
|
const removeId = usePlaylistStore((s) => s.removeId);
|
||||||
|
|
||||||
@@ -27,6 +29,30 @@ export default function Playlists() {
|
|||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
setSelectionMode(v => !v);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectionMode(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPlaylists()
|
getPlaylists()
|
||||||
.then(setPlaylists)
|
.then(setPlaylists)
|
||||||
@@ -78,6 +104,55 @@ export default function Playlists() {
|
|||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelected = async () => {
|
||||||
|
if (selectedPlaylists.length === 0) return;
|
||||||
|
let deleted = 0;
|
||||||
|
for (const pl of selectedPlaylists) {
|
||||||
|
try {
|
||||||
|
await deletePlaylist(pl.id);
|
||||||
|
removeId(pl.id);
|
||||||
|
deleted++;
|
||||||
|
} catch {
|
||||||
|
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPlaylists((prev) => prev.filter((p) => !selectedIds.has(p.id)));
|
||||||
|
clearSelection();
|
||||||
|
if (deleted > 0) {
|
||||||
|
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||||
@@ -91,34 +166,51 @@ export default function Playlists() {
|
|||||||
|
|
||||||
{/* ── Header row ── */}
|
{/* ── Header row ── */}
|
||||||
<div className="playlists-header">
|
<div className="playlists-header">
|
||||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
|
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||||
|
{selectionMode && selectedIds.size > 0
|
||||||
|
? t('playlists.selectionCount', { count: selectedIds.size })
|
||||||
|
: t('playlists.title')}
|
||||||
|
</h1>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
{creating ? (
|
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||||
<>
|
{creating ? (
|
||||||
<input
|
<>
|
||||||
ref={nameInputRef}
|
<input
|
||||||
className="input"
|
ref={nameInputRef}
|
||||||
style={{ width: 220 }}
|
className="input"
|
||||||
placeholder={t('playlists.createName')}
|
style={{ width: 220 }}
|
||||||
value={newName}
|
placeholder={t('playlists.createName')}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
value={newName}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
if (e.key === 'Enter') handleCreate();
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
if (e.key === 'Enter') handleCreate();
|
||||||
}}
|
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||||
/>
|
}}
|
||||||
<button className="btn btn-primary" onClick={handleCreate}>
|
/>
|
||||||
{t('playlists.create')}
|
<button className="btn btn-primary" onClick={handleCreate}>
|
||||||
</button>
|
{t('playlists.create')}
|
||||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
</button>
|
||||||
{t('playlists.cancel')}
|
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
||||||
</button>
|
{t('playlists.cancel')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
|
||||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||||
|
>
|
||||||
|
<CheckSquare2 size={15} />
|
||||||
|
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,10 +222,34 @@ export default function Playlists() {
|
|||||||
{playlists.map((pl) => (
|
{playlists.map((pl) => (
|
||||||
<div
|
<div
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
className="album-card"
|
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
|
||||||
onClick={() => navigate(`/playlists/${pl.id}`)}
|
onClick={() => {
|
||||||
|
if (selectionMode) {
|
||||||
|
toggleSelect(pl.id);
|
||||||
|
} 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); }}
|
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
|
||||||
|
style={selectionMode && selectedIds.has(pl.id) ? {
|
||||||
|
outline: '2px solid var(--accent)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
borderRadius: 'var(--radius-md)'
|
||||||
|
} : {}}
|
||||||
>
|
>
|
||||||
|
{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 */}
|
{/* Cover area — server collage or fallback icon */}
|
||||||
<div className="album-card-cover">
|
<div className="album-card-cover">
|
||||||
{pl.coverArt ? (
|
{pl.coverArt ? (
|
||||||
@@ -163,15 +279,6 @@ export default function Playlists() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete button — top-right corner */}
|
|
||||||
<button
|
|
||||||
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
|
|
||||||
onClick={(e) => handleDelete(e, pl)}
|
|
||||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
|
|
||||||
data-tooltip-pos="bottom"
|
|
||||||
>
|
|
||||||
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="album-card-info">
|
<div className="album-card-info">
|
||||||
|
|||||||
@@ -174,10 +174,10 @@ interface PlayerState {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
item: any;
|
item: any;
|
||||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
|
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
|
||||||
queueIndex?: number;
|
queueIndex?: number;
|
||||||
};
|
};
|
||||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number) => void;
|
||||||
closeContextMenu: () => void;
|
closeContextMenu: () => void;
|
||||||
|
|
||||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||||
|
|||||||
@@ -198,6 +198,7 @@
|
|||||||
|
|
||||||
/* ─ Album Card ─ */
|
/* ─ Album Card ─ */
|
||||||
.album-card {
|
.album-card {
|
||||||
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -519,6 +520,33 @@
|
|||||||
color: var(--ctp-crust);
|
color: var(--ctp-crust);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Artist Card Selection Checkbox ─── */
|
||||||
|
.artist-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card-select-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 2px solid var(--border-subtle);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card-select-check--on {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Albums selection bar (portal) ── */
|
/* ── Albums selection bar (portal) ── */
|
||||||
.albums-selection-bar {
|
.albums-selection-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -2306,3 +2306,143 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Sidebar Expandable Playlists Section ─── */
|
||||||
|
.sidebar-playlists-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-right: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-main-link {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-toggle:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-toggle.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-header.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-chevron.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlist-item span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlist-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlist-item.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-playlists-empty {
|
||||||
|
padding: var(--space-2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user