feat: bulk multi-select + drag in PlaylistDetail, Favorites, and artist context menu (#157)

- PlaylistDetail: Ctrl/Cmd+Click enters select mode; bulk drag emits
  { type: 'songs', tracks } when ≥2 selected; filtered-view rows now
  also draggable as single songs
- Favorites songs: full multi-select system — Ctrl+Click, Shift+Click,
  header toggle-all checkbox, bulk-selected highlight, bulk drag to
  queue, bulk-bar with Add to Playlist + Clear
- ContextMenu: ArtistToPlaylistSubmenu resolves all artist album songs
  and forwards to AddToPlaylistSubmenu
- Locale: common.clearSelection + playlists.addSelected in all 7 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-12 12:29:39 +02:00
parent 2dd993257a
commit 600af0e527
12 changed files with 276 additions and 23 deletions
+36 -1
View File
@@ -5,7 +5,7 @@ import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -175,6 +175,29 @@ function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: { albumId: strin
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
}
// Resolves all songs from all of an artist's albums, then hands off to AddToPlaylistSubmenu.
function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: { artistId: string; onDone: () => void; triggerId?: string }) {
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
useEffect(() => {
(async () => {
const { albums } = await getArtist(artistId);
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs)));
setResolvedIds(albumSongs.flat().map(s => s.id));
})().catch(() => setResolvedIds([]));
}, [artistId]);
if (resolvedIds === null) {
return (
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
);
}
if (resolvedIds.length === 0) return null;
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
}
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
@@ -800,6 +823,18 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`artist:${artist.id}`}
onMouseEnter={() => { setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
+2
View File
@@ -363,6 +363,7 @@ export const deTranslation = {
filterNoGenres: 'Keine Genres gefunden',
filterClear: 'Zurücksetzen',
bulkSelected: '{{count}} ausgewählt',
clearSelection: 'Auswahl aufheben',
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
bulkClear: 'Auswahl aufheben',
@@ -901,6 +902,7 @@ export const deTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Neue Vorschläge',
addSong: 'Zur Playlist hinzufügen',
addSelected: 'Ausgewählte hinzufügen',
cacheOffline: 'Playlist offline speichern',
offlineCached: 'Playlist gecacht',
removeOffline: 'Aus Offline-Cache entfernen',
+2
View File
@@ -364,6 +364,7 @@ export const enTranslation = {
filterNoGenres: 'No genres match',
filterClear: 'Clear',
bulkSelected: '{{count}} selected',
clearSelection: 'Clear selection',
bulkAddToPlaylist: 'Add to Playlist',
bulkRemoveFromPlaylist: 'Remove from Playlist',
bulkClear: 'Clear selection',
@@ -903,6 +904,7 @@ export const enTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'New suggestions',
addSong: 'Add to playlist',
addSelected: 'Add selected',
cacheOffline: 'Cache playlist offline',
offlineCached: 'Playlist cached',
removeOffline: 'Remove from offline cache',
+2
View File
@@ -356,6 +356,7 @@ export const esTranslation = {
filterNoGenres: 'Ningún género coincide',
filterClear: 'Limpiar',
bulkSelected: '{{count}} seleccionados',
clearSelection: 'Limpiar selección',
bulkAddToPlaylist: 'Agregar a Lista',
bulkRemoveFromPlaylist: 'Quitar de Lista',
bulkClear: 'Limpiar selección',
@@ -890,6 +891,7 @@ export const esTranslation = {
titleBadge: 'Lista',
refreshSuggestions: 'Nuevas sugerencias',
addSong: 'Agregar a lista',
addSelected: 'Agregar seleccionados',
cacheOffline: 'Guardar lista offline',
offlineCached: 'Lista guardada',
removeOffline: 'Quitar de caché offline',
+2
View File
@@ -363,6 +363,7 @@ export const frTranslation = {
filterNoGenres: 'Aucun genre trouvé',
filterClear: 'Effacer',
bulkSelected: '{{count}} sélectionné(s)',
clearSelection: 'Effacer la sélection',
bulkAddToPlaylist: 'Ajouter à la playlist',
bulkRemoveFromPlaylist: 'Retirer de la playlist',
bulkClear: 'Désélectionner',
@@ -899,6 +900,7 @@ export const frTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Nouvelles suggestions',
addSong: 'Ajouter à la playlist',
addSelected: 'Ajouter la sélection',
cacheOffline: 'Mettre la playlist hors ligne',
offlineCached: 'Playlist en cache',
removeOffline: 'Retirer du cache hors ligne',
+2
View File
@@ -363,6 +363,7 @@ export const nbTranslation = {
filterNoGenres: 'Ingen sjangre samsvarer',
filterClear: 'Tøm',
bulkSelected: '{{count}} valgt',
clearSelection: 'Fjern utvalg',
bulkAddToPlaylist: 'Legg til i spilleliste',
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
bulkClear: 'Tøm utvalg',
@@ -898,6 +899,7 @@ export const nbTranslation = {
titleBadge: 'Spilleliste',
refreshSuggestions: 'Nye forslag',
addSong: 'Legg til i spilleliste',
addSelected: 'Legg til valgte',
cacheOffline: 'Bufre spilleliste offline',
offlineCached: 'Spilleliste bufret',
removeOffline: 'Fjern fra offline-buffer',
+2
View File
@@ -363,6 +363,7 @@ export const nlTranslation = {
filterNoGenres: 'Geen genres gevonden',
filterClear: 'Wissen',
bulkSelected: '{{count}} geselecteerd',
clearSelection: 'Selectie wissen',
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
bulkClear: 'Selectie wissen',
@@ -899,6 +900,7 @@ export const nlTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Nieuwe suggesties',
addSong: 'Toevoegen aan playlist',
addSelected: 'Geselecteerde toevoegen',
cacheOffline: 'Playlist offline opslaan',
offlineCached: 'Playlist gecached',
removeOffline: 'Verwijder uit offline cache',
+2
View File
@@ -377,6 +377,7 @@ export const ruTranslation = {
filterNoGenres: 'Нет совпадений',
filterClear: 'Сбросить',
bulkSelected: 'Выбрано: {{count}}',
clearSelection: 'Сбросить выбор',
bulkAddToPlaylist: 'В плейлист',
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
bulkClear: 'Снять выделение',
@@ -958,6 +959,7 @@ export const ruTranslation = {
titleBadge: 'Плейлист',
refreshSuggestions: 'Обновить подборку',
addSong: 'В плейлист',
addSelected: 'Добавить выбранные',
cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён',
removeOffline: 'Удалить из офлайн-кэша',
+2
View File
@@ -359,6 +359,7 @@ export const zhTranslation = {
filterNoGenres: '未找到匹配流派',
filterClear: '清除',
bulkSelected: '已选 {{count}} 首',
clearSelection: '清除选择',
bulkAddToPlaylist: '添加到播放列表',
bulkRemoveFromPlaylist: '从播放列表移除',
bulkClear: '取消选择',
@@ -895,6 +896,7 @@ export const zhTranslation = {
titleBadge: '播放列表',
refreshSuggestions: '新建议',
addSong: '添加到播放列表',
addSelected: '添加所选',
cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存',
removeOffline: '从离线缓存中移除',
+118 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
@@ -16,6 +16,8 @@ import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore';
import { useSelectionStore } from '../store/selectionStore';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -42,6 +44,12 @@ export default function Favorites() {
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
const [ratings, setRatings] = useState<Record<string, number>>({});
const [showPlPicker, setShowPlPicker] = useState(false);
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const selectedIds = useSelectionStore(s => s.selectedIds);
const inSelectMode = selectedCount > 0;
const lastSelectedIdxRef = useRef<number | null>(null);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
@@ -80,6 +88,44 @@ export default function Favorites() {
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
// Clear selection when song list changes
useEffect(() => {
useSelectionStore.getState().clearAll();
lastSelectedIdxRef.current = null;
}, [songs]);
// Clear selection on click outside tracklist
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
useSelectionStore.getState().clearAll();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode]);
const toggleSelect = useCallback((id: string, idx: number, shift: boolean) => {
useSelectionStore.getState().setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, idx);
const to = Math.max(lastSelectedIdxRef.current, idx);
// we need visibleSongs here — read from latest closure via ref trick
// Instead, just toggle range based on idx into songs array
for (let j = from; j <= to; j++) {
const sid = songs[j]?.id;
if (sid) next.add(sid);
}
} else {
if (next.has(id)) { next.delete(id); }
else { next.add(id); lastSelectedIdxRef.current = idx; }
}
return next;
});
}, [songs]);
useEffect(() => {
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
@@ -173,14 +219,68 @@ export default function Favorites() {
{t('favorites.enqueueAll')}
</button>
</div>
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
<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 label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="track-num"><span className="track-num-number">#</span></div>;
if (key === 'num') {
const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0;
return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id)));
}
}}
/>
<span className="track-num-number">#</span>
</div>
);
}
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
@@ -236,14 +336,21 @@ export default function Favorites() {
</div>
{visibleSongs.map((song, i) => {
const track = songToTrack(song);
const isSelected = selectedIds.has(song.id);
return (
<div
key={song.id}
className="track-row track-row-va"
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
toggleSelect(song.id, i, false);
} else if (inSelectMode) {
toggleSelect(song.id, i, e.shiftKey);
} else {
playTrack(track, visibleSongs.map(songToTrack));
}
}}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
@@ -255,8 +362,14 @@ export default function Favorites() {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const { selectedIds: selIds } = useSelectionStore.getState();
if (selIds.has(song.id) && selIds.size > 1) {
const bulkTracks = visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack);
psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else {
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
@@ -267,6 +380,7 @@ export default function Favorites() {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{i + 1}</span>
+82 -6
View File
@@ -211,6 +211,8 @@ export default function PlaylistDetail() {
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
const [searching, setSearching] = useState(false);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const [selectedSearchIds, setSelectedSearchIds] = useState<Set<string>>(new Set());
const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false);
// Suggestions
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
@@ -439,7 +441,6 @@ export default function PlaylistDetail() {
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
if (e.button !== 0) return;
if (isFiltered) return;
if ((e.target as HTMLElement).closest('button, input')) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
@@ -447,10 +448,21 @@ export default function PlaylistDetail() {
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 = () => {
@@ -624,10 +636,11 @@ export default function PlaylistDetail() {
</div>
<button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); }}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
{/* search close resets selection */}
{songs.length > 0 && id && (
<button
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
@@ -690,8 +703,68 @@ export default function PlaylistDetail() {
{!searching && searchQuery && searchResults.length === 0 && (
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
)}
{searchResults.map(song => (
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
{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' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => addSong(song)}
>
<input
type="checkbox"
className="playlist-search-checkbox"
checked={isSelected}
onClick={e => e.stopPropagation()}
onChange={() => setSelectedSearchIds(prev => {
const next = new Set(prev);
next.has(song.id) ? next.delete(song.id) : next.add(song.id);
return next;
})}
/>
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
<div className="playlist-search-info">
<span className="playlist-search-title">{song.title}</span>
@@ -699,7 +772,8 @@ export default function PlaylistDetail() {
</div>
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
</div>
))}
);
})}
</div>
)}
@@ -933,7 +1007,9 @@ export default function PlaylistDetail() {
onMouseDown={e => handleRowMouseDown(e, realIdx)}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (selectedIds.size > 0) {
if (e.ctrlKey || e.metaKey) {
toggleSelect(song.id, i, false);
} else if (selectedIds.size > 0) {
toggleSelect(song.id, i, e.shiftKey);
} else {
playTrack(displayedTracks[i], displayedTracks);
+13 -1
View File
@@ -6187,7 +6187,7 @@ html.no-compositing .fs-lyrics-rail {
.playlist-search-row {
display: grid;
grid-template-columns: 36px 1fr auto 52px 28px;
grid-template-columns: 18px 36px 1fr auto 52px 28px;
align-items: center;
gap: var(--space-2);
padding: 3px var(--space-1);
@@ -6199,6 +6199,18 @@ html.no-compositing .fs-lyrics-rail {
background: var(--bg-hover);
}
.playlist-search-row--selected {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.playlist-search-checkbox {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.playlist-search-thumb {
width: 36px;
height: 36px;