mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -5,7 +5,7 @@ import StarRating from './StarRating';
|
|||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
|
||||||
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
@@ -175,6 +175,29 @@ function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: { albumId: strin
|
|||||||
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
|
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() {
|
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(
|
||||||
@@ -800,6 +823,18 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</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-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
const starred = isStarred(artist.id, artist.starred);
|
const starred = isStarred(artist.id, artist.starred);
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const deTranslation = {
|
|||||||
filterNoGenres: 'Keine Genres gefunden',
|
filterNoGenres: 'Keine Genres gefunden',
|
||||||
filterClear: 'Zurücksetzen',
|
filterClear: 'Zurücksetzen',
|
||||||
bulkSelected: '{{count}} ausgewählt',
|
bulkSelected: '{{count}} ausgewählt',
|
||||||
|
clearSelection: 'Auswahl aufheben',
|
||||||
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
bulkAddToPlaylist: 'Zur Playlist hinzufügen',
|
||||||
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
bulkRemoveFromPlaylist: 'Aus Playlist entfernen',
|
||||||
bulkClear: 'Auswahl aufheben',
|
bulkClear: 'Auswahl aufheben',
|
||||||
@@ -901,6 +902,7 @@ export const deTranslation = {
|
|||||||
titleBadge: 'Playlist',
|
titleBadge: 'Playlist',
|
||||||
refreshSuggestions: 'Neue Vorschläge',
|
refreshSuggestions: 'Neue Vorschläge',
|
||||||
addSong: 'Zur Playlist hinzufügen',
|
addSong: 'Zur Playlist hinzufügen',
|
||||||
|
addSelected: 'Ausgewählte hinzufügen',
|
||||||
cacheOffline: 'Playlist offline speichern',
|
cacheOffline: 'Playlist offline speichern',
|
||||||
offlineCached: 'Playlist gecacht',
|
offlineCached: 'Playlist gecacht',
|
||||||
removeOffline: 'Aus Offline-Cache entfernen',
|
removeOffline: 'Aus Offline-Cache entfernen',
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ export const enTranslation = {
|
|||||||
filterNoGenres: 'No genres match',
|
filterNoGenres: 'No genres match',
|
||||||
filterClear: 'Clear',
|
filterClear: 'Clear',
|
||||||
bulkSelected: '{{count}} selected',
|
bulkSelected: '{{count}} selected',
|
||||||
|
clearSelection: 'Clear selection',
|
||||||
bulkAddToPlaylist: 'Add to Playlist',
|
bulkAddToPlaylist: 'Add to Playlist',
|
||||||
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
bulkRemoveFromPlaylist: 'Remove from Playlist',
|
||||||
bulkClear: 'Clear selection',
|
bulkClear: 'Clear selection',
|
||||||
@@ -903,6 +904,7 @@ export const enTranslation = {
|
|||||||
titleBadge: 'Playlist',
|
titleBadge: 'Playlist',
|
||||||
refreshSuggestions: 'New suggestions',
|
refreshSuggestions: 'New suggestions',
|
||||||
addSong: 'Add to playlist',
|
addSong: 'Add to playlist',
|
||||||
|
addSelected: 'Add selected',
|
||||||
cacheOffline: 'Cache playlist offline',
|
cacheOffline: 'Cache playlist offline',
|
||||||
offlineCached: 'Playlist cached',
|
offlineCached: 'Playlist cached',
|
||||||
removeOffline: 'Remove from offline cache',
|
removeOffline: 'Remove from offline cache',
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export const esTranslation = {
|
|||||||
filterNoGenres: 'Ningún género coincide',
|
filterNoGenres: 'Ningún género coincide',
|
||||||
filterClear: 'Limpiar',
|
filterClear: 'Limpiar',
|
||||||
bulkSelected: '{{count}} seleccionados',
|
bulkSelected: '{{count}} seleccionados',
|
||||||
|
clearSelection: 'Limpiar selección',
|
||||||
bulkAddToPlaylist: 'Agregar a Lista',
|
bulkAddToPlaylist: 'Agregar a Lista',
|
||||||
bulkRemoveFromPlaylist: 'Quitar de Lista',
|
bulkRemoveFromPlaylist: 'Quitar de Lista',
|
||||||
bulkClear: 'Limpiar selección',
|
bulkClear: 'Limpiar selección',
|
||||||
@@ -890,6 +891,7 @@ export const esTranslation = {
|
|||||||
titleBadge: 'Lista',
|
titleBadge: 'Lista',
|
||||||
refreshSuggestions: 'Nuevas sugerencias',
|
refreshSuggestions: 'Nuevas sugerencias',
|
||||||
addSong: 'Agregar a lista',
|
addSong: 'Agregar a lista',
|
||||||
|
addSelected: 'Agregar seleccionados',
|
||||||
cacheOffline: 'Guardar lista offline',
|
cacheOffline: 'Guardar lista offline',
|
||||||
offlineCached: 'Lista guardada',
|
offlineCached: 'Lista guardada',
|
||||||
removeOffline: 'Quitar de caché offline',
|
removeOffline: 'Quitar de caché offline',
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const frTranslation = {
|
|||||||
filterNoGenres: 'Aucun genre trouvé',
|
filterNoGenres: 'Aucun genre trouvé',
|
||||||
filterClear: 'Effacer',
|
filterClear: 'Effacer',
|
||||||
bulkSelected: '{{count}} sélectionné(s)',
|
bulkSelected: '{{count}} sélectionné(s)',
|
||||||
|
clearSelection: 'Effacer la sélection',
|
||||||
bulkAddToPlaylist: 'Ajouter à la playlist',
|
bulkAddToPlaylist: 'Ajouter à la playlist',
|
||||||
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
bulkRemoveFromPlaylist: 'Retirer de la playlist',
|
||||||
bulkClear: 'Désélectionner',
|
bulkClear: 'Désélectionner',
|
||||||
@@ -899,6 +900,7 @@ export const frTranslation = {
|
|||||||
titleBadge: 'Playlist',
|
titleBadge: 'Playlist',
|
||||||
refreshSuggestions: 'Nouvelles suggestions',
|
refreshSuggestions: 'Nouvelles suggestions',
|
||||||
addSong: 'Ajouter à la playlist',
|
addSong: 'Ajouter à la playlist',
|
||||||
|
addSelected: 'Ajouter la sélection',
|
||||||
cacheOffline: 'Mettre la playlist hors ligne',
|
cacheOffline: 'Mettre la playlist hors ligne',
|
||||||
offlineCached: 'Playlist en cache',
|
offlineCached: 'Playlist en cache',
|
||||||
removeOffline: 'Retirer du cache hors ligne',
|
removeOffline: 'Retirer du cache hors ligne',
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const nbTranslation = {
|
|||||||
filterNoGenres: 'Ingen sjangre samsvarer',
|
filterNoGenres: 'Ingen sjangre samsvarer',
|
||||||
filterClear: 'Tøm',
|
filterClear: 'Tøm',
|
||||||
bulkSelected: '{{count}} valgt',
|
bulkSelected: '{{count}} valgt',
|
||||||
|
clearSelection: 'Fjern utvalg',
|
||||||
bulkAddToPlaylist: 'Legg til i spilleliste',
|
bulkAddToPlaylist: 'Legg til i spilleliste',
|
||||||
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
|
bulkRemoveFromPlaylist: 'Fjern fra spilleliste',
|
||||||
bulkClear: 'Tøm utvalg',
|
bulkClear: 'Tøm utvalg',
|
||||||
@@ -898,6 +899,7 @@ export const nbTranslation = {
|
|||||||
titleBadge: 'Spilleliste',
|
titleBadge: 'Spilleliste',
|
||||||
refreshSuggestions: 'Nye forslag',
|
refreshSuggestions: 'Nye forslag',
|
||||||
addSong: 'Legg til i spilleliste',
|
addSong: 'Legg til i spilleliste',
|
||||||
|
addSelected: 'Legg til valgte',
|
||||||
cacheOffline: 'Bufre spilleliste offline',
|
cacheOffline: 'Bufre spilleliste offline',
|
||||||
offlineCached: 'Spilleliste bufret',
|
offlineCached: 'Spilleliste bufret',
|
||||||
removeOffline: 'Fjern fra offline-buffer',
|
removeOffline: 'Fjern fra offline-buffer',
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export const nlTranslation = {
|
|||||||
filterNoGenres: 'Geen genres gevonden',
|
filterNoGenres: 'Geen genres gevonden',
|
||||||
filterClear: 'Wissen',
|
filterClear: 'Wissen',
|
||||||
bulkSelected: '{{count}} geselecteerd',
|
bulkSelected: '{{count}} geselecteerd',
|
||||||
|
clearSelection: 'Selectie wissen',
|
||||||
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
bulkAddToPlaylist: 'Toevoegen aan afspeellijst',
|
||||||
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst',
|
||||||
bulkClear: 'Selectie wissen',
|
bulkClear: 'Selectie wissen',
|
||||||
@@ -899,6 +900,7 @@ export const nlTranslation = {
|
|||||||
titleBadge: 'Playlist',
|
titleBadge: 'Playlist',
|
||||||
refreshSuggestions: 'Nieuwe suggesties',
|
refreshSuggestions: 'Nieuwe suggesties',
|
||||||
addSong: 'Toevoegen aan playlist',
|
addSong: 'Toevoegen aan playlist',
|
||||||
|
addSelected: 'Geselecteerde toevoegen',
|
||||||
cacheOffline: 'Playlist offline opslaan',
|
cacheOffline: 'Playlist offline opslaan',
|
||||||
offlineCached: 'Playlist gecached',
|
offlineCached: 'Playlist gecached',
|
||||||
removeOffline: 'Verwijder uit offline cache',
|
removeOffline: 'Verwijder uit offline cache',
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ export const ruTranslation = {
|
|||||||
filterNoGenres: 'Нет совпадений',
|
filterNoGenres: 'Нет совпадений',
|
||||||
filterClear: 'Сбросить',
|
filterClear: 'Сбросить',
|
||||||
bulkSelected: 'Выбрано: {{count}}',
|
bulkSelected: 'Выбрано: {{count}}',
|
||||||
|
clearSelection: 'Сбросить выбор',
|
||||||
bulkAddToPlaylist: 'В плейлист',
|
bulkAddToPlaylist: 'В плейлист',
|
||||||
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
bulkRemoveFromPlaylist: 'Убрать из плейлиста',
|
||||||
bulkClear: 'Снять выделение',
|
bulkClear: 'Снять выделение',
|
||||||
@@ -958,6 +959,7 @@ export const ruTranslation = {
|
|||||||
titleBadge: 'Плейлист',
|
titleBadge: 'Плейлист',
|
||||||
refreshSuggestions: 'Обновить подборку',
|
refreshSuggestions: 'Обновить подборку',
|
||||||
addSong: 'В плейлист',
|
addSong: 'В плейлист',
|
||||||
|
addSelected: 'Добавить выбранные',
|
||||||
cacheOffline: 'Сохранить плейлист офлайн',
|
cacheOffline: 'Сохранить плейлист офлайн',
|
||||||
offlineCached: 'Плейлист сохранён',
|
offlineCached: 'Плейлист сохранён',
|
||||||
removeOffline: 'Удалить из офлайн-кэша',
|
removeOffline: 'Удалить из офлайн-кэша',
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ export const zhTranslation = {
|
|||||||
filterNoGenres: '未找到匹配流派',
|
filterNoGenres: '未找到匹配流派',
|
||||||
filterClear: '清除',
|
filterClear: '清除',
|
||||||
bulkSelected: '已选 {{count}} 首',
|
bulkSelected: '已选 {{count}} 首',
|
||||||
|
clearSelection: '清除选择',
|
||||||
bulkAddToPlaylist: '添加到播放列表',
|
bulkAddToPlaylist: '添加到播放列表',
|
||||||
bulkRemoveFromPlaylist: '从播放列表移除',
|
bulkRemoveFromPlaylist: '从播放列表移除',
|
||||||
bulkClear: '取消选择',
|
bulkClear: '取消选择',
|
||||||
@@ -895,6 +896,7 @@ export const zhTranslation = {
|
|||||||
titleBadge: '播放列表',
|
titleBadge: '播放列表',
|
||||||
refreshSuggestions: '新建议',
|
refreshSuggestions: '新建议',
|
||||||
addSong: '添加到播放列表',
|
addSong: '添加到播放列表',
|
||||||
|
addSelected: '添加所选',
|
||||||
cacheOffline: '离线缓存播放列表',
|
cacheOffline: '离线缓存播放列表',
|
||||||
offlineCached: '播放列表已缓存',
|
offlineCached: '播放列表已缓存',
|
||||||
removeOffline: '从离线缓存中移除',
|
removeOffline: '从离线缓存中移除',
|
||||||
|
|||||||
+120
-6
@@ -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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
@@ -16,6 +16,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { unstar } from '../api/subsonic';
|
import { unstar } from '../api/subsonic';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
|
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||||
|
|
||||||
const FAV_COLUMNS: readonly ColDef[] = [
|
const FAV_COLUMNS: readonly ColDef[] = [
|
||||||
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
|
||||||
@@ -42,6 +44,12 @@ export default function Favorites() {
|
|||||||
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
|
||||||
|
|
||||||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
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 playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
@@ -80,6 +88,44 @@ export default function Favorites() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
const [starredResult] = await Promise.allSettled([
|
const [starredResult] = await Promise.allSettled([
|
||||||
@@ -173,14 +219,68 @@ export default function Favorites() {
|
|||||||
{t('favorites.enqueueAll')}
|
{t('favorites.enqueueAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 style={{ position: 'relative' }}>
|
||||||
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
<div className="tracklist-header tracklist-va" style={gridStyle}>
|
||||||
{visibleCols.map((colDef, colIndex) => {
|
{visibleCols.map((colDef, colIndex) => {
|
||||||
const key = colDef.key;
|
const key = colDef.key;
|
||||||
const isLastCol = colIndex === visibleCols.length - 1;
|
const isLastCol = colIndex === visibleCols.length - 1;
|
||||||
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
|
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') {
|
if (key === 'title') {
|
||||||
const hasNextCol = colIndex + 1 < visibleCols.length;
|
const hasNextCol = colIndex + 1 < visibleCols.length;
|
||||||
return (
|
return (
|
||||||
@@ -236,14 +336,21 @@ export default function Favorites() {
|
|||||||
</div>
|
</div>
|
||||||
{visibleSongs.map((song, i) => {
|
{visibleSongs.map((song, i) => {
|
||||||
const track = songToTrack(song);
|
const track = songToTrack(song);
|
||||||
|
const isSelected = selectedIds.has(song.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={song.id}
|
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}
|
style={gridStyle}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if ((e.target as HTMLElement).closest('button, a, input')) 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'); }}
|
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
|
||||||
role="row"
|
role="row"
|
||||||
@@ -255,7 +362,13 @@ export default function Favorites() {
|
|||||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
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); };
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||||
@@ -267,6 +380,7 @@ export default function Favorites() {
|
|||||||
switch (colDef.key) {
|
switch (colDef.key) {
|
||||||
case 'num': return (
|
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)); }}>
|
<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>}
|
{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-play"><Play size={13} fill="currentColor" /></span>
|
||||||
<span className="track-num-number">{i + 1}</span>
|
<span className="track-num-number">{i + 1}</span>
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export default function PlaylistDetail() {
|
|||||||
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
|
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [selectedSearchIds, setSelectedSearchIds] = useState<Set<string>>(new Set());
|
||||||
|
const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false);
|
||||||
|
|
||||||
// Suggestions
|
// Suggestions
|
||||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||||
@@ -439,7 +441,6 @@ export default function PlaylistDetail() {
|
|||||||
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
|
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
|
||||||
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
|
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (isFiltered) return;
|
|
||||||
if ((e.target as HTMLElement).closest('button, input')) return;
|
if ((e.target as HTMLElement).closest('button, input')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sx = e.clientX, sy = e.clientY;
|
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) {
|
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
document.removeEventListener('mouseup', onUp);
|
document.removeEventListener('mouseup', onUp);
|
||||||
startDrag(
|
if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) {
|
||||||
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
|
const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack);
|
||||||
me.clientX, me.clientY
|
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 = () => {
|
const onUp = () => {
|
||||||
@@ -624,10 +636,11 @@ export default function PlaylistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
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')}
|
<Search size={16} /> {t('playlists.addSongs')}
|
||||||
</button>
|
</button>
|
||||||
|
{/* search close resets selection */}
|
||||||
{songs.length > 0 && id && (
|
{songs.length > 0 && id && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||||
@@ -690,16 +703,77 @@ export default function PlaylistDetail() {
|
|||||||
{!searching && searchQuery && searchResults.length === 0 && (
|
{!searching && searchQuery && searchResults.length === 0 && (
|
||||||
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
<div className="empty-state" style={{ padding: '0.5rem 0' }}>{t('playlists.noResults')}</div>
|
||||||
)}
|
)}
|
||||||
{searchResults.map(song => (
|
{selectedSearchIds.size > 0 && (
|
||||||
<div key={song.id} className="playlist-search-row" style={{ cursor: 'pointer' }} onClick={() => addSong(song)}>
|
<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' }}>
|
||||||
<CachedImage src={buildCoverArtUrl(song.coverArt ?? '', 40)} cacheKey={coverArtCacheKey(song.coverArt ?? '', 40)} alt="" className="playlist-search-thumb" />
|
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, flex: 1 }}>
|
||||||
<div className="playlist-search-info">
|
{t('common.bulkSelected', { count: selectedSearchIds.size })}
|
||||||
<span className="playlist-search-title">{song.title}</span>
|
</span>
|
||||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></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>
|
</div>
|
||||||
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
<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>
|
</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>
|
||||||
|
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||||
|
</div>
|
||||||
|
<span className="playlist-search-duration">{formatDuration(song.duration ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -933,7 +1007,9 @@ export default function PlaylistDetail() {
|
|||||||
onMouseDown={e => handleRowMouseDown(e, realIdx)}
|
onMouseDown={e => handleRowMouseDown(e, realIdx)}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
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);
|
toggleSelect(song.id, i, e.shiftKey);
|
||||||
} else {
|
} else {
|
||||||
playTrack(displayedTracks[i], displayedTracks);
|
playTrack(displayedTracks[i], displayedTracks);
|
||||||
|
|||||||
@@ -6187,7 +6187,7 @@ html.no-compositing .fs-lyrics-rail {
|
|||||||
|
|
||||||
.playlist-search-row {
|
.playlist-search-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px 1fr auto 52px 28px;
|
grid-template-columns: 18px 36px 1fr auto 52px 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: 3px var(--space-1);
|
padding: 3px var(--space-1);
|
||||||
@@ -6199,6 +6199,18 @@ html.no-compositing .fs-lyrics-rail {
|
|||||||
background: var(--bg-hover);
|
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 {
|
.playlist-search-thumb {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|||||||
Reference in New Issue
Block a user