(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 (
+
+ );
+ }
+ if (resolvedIds.length === 0) return null;
+ return ;
+}
+
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() {
handleAction(() => startRadio(artist.id, artist.name))}>
{t('contextMenu.startRadio')}
+ { setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={() => setPlaylistSubmenuOpen(false)}
+ >
+
{t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
+
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 92d20943..14156698 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -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',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 56948021..63a2f856 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -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',
diff --git a/src/locales/es.ts b/src/locales/es.ts
index 304f9c90..7de99911 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -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',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index f01d3058..0b32a350 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -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',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index cbce3148..325eedf4 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -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',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index 3bf5fb03..5d5bfe8c 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -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',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index 0fc92e79..e9e26004 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -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: 'Удалить из офлайн-кэша',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 9db55e56..122385e4 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -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: '从离线缓存中移除',
diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx
index b333592e..bf2de86d 100644
--- a/src/pages/Favorites.tsx
+++ b/src/pages/Favorites.tsx
@@ -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>({});
+ 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(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')}
-
+
{
+ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
+ }}>
+
+ {/* ── Bulk action bar ── */}
+ {inSelectMode && (
+
+
+ {t('common.bulkSelected', { count: selectedCount })}
+
+
+
+ {showPlPicker && (
+
{ setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
+ dropDown
+ />
+ )}
+
+
+
+ )}
+
{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
#
;
+ if (key === 'num') {
+ const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0;
+ return (
+
+ {
+ e.stopPropagation();
+ if (allSelected) {
+ useSelectionStore.getState().clearAll();
+ } else {
+ useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id)));
+ }
+ }}
+ />
+ #
+
+ );
+ }
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
@@ -236,14 +336,21 @@ export default function Favorites() {
{visibleSongs.map((song, i) => {
const track = songToTrack(song);
+ const isSelected = selectedIds.has(song.id);
return (
{
if ((e.target as HTMLElement).closest('button, a, input')) return;
- playTrack(track, visibleSongs.map(songToTrack));
+ 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,7 +362,13 @@ 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);
- psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
+ 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); };
@@ -267,6 +380,7 @@ export default function Favorites() {
switch (colDef.key) {
case 'num': return (
{ e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
+
{ e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying &&
}
{i + 1}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx
index 122c8a3e..50203656 100644
--- a/src/pages/PlaylistDetail.tsx
+++ b/src/pages/PlaylistDetail.tsx
@@ -211,6 +211,8 @@ export default function PlaylistDetail() {
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const searchDebounce = useRef | null>(null);
+ const [selectedSearchIds, setSelectedSearchIds] = useState>(new Set());
+ const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false);
// Suggestions
const [suggestions, setSuggestions] = useState([]);
@@ -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);
- startDrag(
- { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
- me.clientX, me.clientY
- );
+ 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() {
+ {/* search close resets selection */}
{songs.length > 0 && id && (
)}
- {searchResults.map(song => (
-
addSong(song)}>
-
-
-
{song.title}
-
{song.artist} · {song.album}
+ {selectedSearchIds.size > 0 && (
+
+
+ {t('common.bulkSelected', { count: selectedSearchIds.size })}
+
+
+
+
+ {searchPlPickerOpen && (
+
{ setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }}
+ />
+ )}
-
{formatDuration(song.duration ?? 0)}
+
- ))}
+ )}
+ {searchResults.map(song => {
+ const isSelected = selectedSearchIds.has(song.id);
+ return (
+
addSong(song)}
+ >
+
e.stopPropagation()}
+ onChange={() => setSelectedSearchIds(prev => {
+ const next = new Set(prev);
+ next.has(song.id) ? next.delete(song.id) : next.add(song.id);
+ return next;
+ })}
+ />
+
+
+ {song.title}
+ {song.artist} · {song.album}
+
+
{formatDuration(song.duration ?? 0)}
+
+ );
+ })}
)}
@@ -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);
diff --git a/src/styles/components.css b/src/styles/components.css
index c4d20fcb..87f5f269 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -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;