refactor(favorites): G.83 — extract SongsSectionHeader + SongsTracklist + selection hook (cluster) (#650)

Three-cut cluster pulling the dominant songs section out of
Favorites.tsx. 645 → 217 LOC (−428).

FavoritesSongsSectionHeader — the section above the tracklist:
title with showing-N-of-M indicator, Play-All / Enqueue-All
buttons, filter toggle, clear-all button (resets artist + genre +
year + sort), filters panel with GenreFilterBar + dual-range
year sliders, and the "clear artist filter" button when an
artist filter is active. Takes the minYear / currentYear
constants explicitly so the page still owns them.

FavoritesSongsTracklist — the tracklist below: bulk-action bar
(N selected + Add-to-playlist submenu + clear), column-visibility
picker, sortable column header, song rows (selection check + bulk
toggle, currentTrack highlight, inline play + preview buttons in
the title cell, artist/album link cells, genre/format/duration/
rating cells, remove button), and the no-filter-results empty
state. Subscribes to playerStore / previewStore / selectionStore /
useDragDrop / useOrbitSongRowBehavior directly.

useFavoritesSelection — owns lastSelectedIdxRef and the two
useEffects (clear-on-songs-change + clear-on-click-outside) plus
the toggleSelect callback with shift-range support.

Favorites drops the inline definitions and removes the now-unused
direct useRef / useCallback declarations. Pure code move
otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 16:54:48 +02:00
committed by GitHub
parent a4b1b29dd6
commit c207f748da
4 changed files with 643 additions and 473 deletions
@@ -0,0 +1,163 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from '../../utils/songToTrack';
import GenreFilterBar from '../GenreFilterBar';
interface Props {
visibleSongs: SubsonicSong[];
songs: SubsonicSong[];
selectedArtist: string | null;
setSelectedArtist: React.Dispatch<React.SetStateAction<string | null>>;
selectedGenres: string[];
setSelectedGenres: React.Dispatch<React.SetStateAction<string[]>>;
yearRange: [number, number];
setYearRange: React.Dispatch<React.SetStateAction<[number, number]>>;
showFilters: boolean;
setShowFilters: React.Dispatch<React.SetStateAction<boolean>>;
setSortKey: React.Dispatch<React.SetStateAction<string>>;
setSortClickCount: React.Dispatch<React.SetStateAction<number>>;
playTrack: ReturnType<typeof usePlayerStore.getState>['playTrack'];
enqueue: ReturnType<typeof usePlayerStore.getState>['enqueue'];
starredOverrides: Record<string, boolean>;
minYear: number;
currentYear: number;
}
export default function FavoritesSongsSectionHeader({
visibleSongs, songs, selectedArtist, setSelectedArtist,
selectedGenres, setSelectedGenres, yearRange, setYearRange,
showFilters, setShowFilters, setSortKey, setSortClickCount,
playTrack, enqueue, starredOverrides, minYear, currentYear,
}: Props) {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
{/* Title Row with showing X of Y indicator */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear) && (
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontStyle: 'italic' }}>
{selectedArtist
? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length, artist: selectedArtist })
: t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })}
</span>
)}
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary"
disabled={visibleSongs.length === 0}
onClick={() => {
if (visibleSongs.length === 0) return;
const tracks = visibleSongs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
<Play size={15} />
{t('favorites.playAll')}
</button>
<button
className="btn btn-surface"
disabled={visibleSongs.length === 0}
onClick={() => {
if (visibleSongs.length === 0) return;
const tracks = visibleSongs.map(songToTrack);
enqueue(tracks);
}}
>
<ListPlus size={15} />
{t('favorites.enqueueAll')}
</button>
{/* Filter Toggle Button */}
<button
className={`btn ${showFilters || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setShowFilters(v => !v)}
>
<SlidersHorizontal size={14} />
{t('common.filters')}
</button>
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear) && (
<button
className="btn btn-ghost"
onClick={() => {
setSelectedArtist(null);
setSelectedGenres([]);
setYearRange([minYear, currentYear]);
setSortKey('natural');
setSortClickCount(0);
}}
>
<X size={13} />
{t('common.clearAll')}
</button>
)}
</div>
{/* Filters Panel */}
{showFilters && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '0.75rem', background: 'var(--surface)', borderRadius: '8px', marginTop: '0.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
{/* Year Range Filter */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--muted)' }}>
<span>{t('common.yearRange')}:</span>
<span style={{ color: 'var(--accent)', fontWeight: 500 }}>{yearRange[0]} - {yearRange[1]}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min={minYear}
max={currentYear}
value={yearRange[0]}
onChange={e => {
const val = parseInt(e.target.value);
setYearRange(prev => [Math.min(val, prev[1] - 1), prev[1]]);
}}
style={{ flex: 1 }}
/>
<input
type="range"
min={minYear}
max={currentYear}
value={yearRange[1]}
onChange={e => {
const val = parseInt(e.target.value);
setYearRange(prev => [prev[0], Math.max(val, prev[0] + 1)]);
}}
style={{ flex: 1 }}
/>
</div>
</div>
</div>
)}
{selectedArtist && (
<button
onClick={() => setSelectedArtist(null)}
className="btn btn-ghost btn-sm"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
fontSize: '0.75rem',
alignSelf: 'flex-start',
}}
>
<X size={11} />
{t('favorites.clearArtistFilter')}
</button>
)}
</div>
);
}
@@ -0,0 +1,376 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
AudioLines, Check, ChevronDown, ChevronRight, ListPlus, Play, RotateCcw,
Square, X,
} from 'lucide-react';
import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { useSelectionStore } from '../../store/selectionStore';
import { useDragDrop } from '../../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/songToTrack';
import { AddToPlaylistSubmenu } from '../ContextMenu';
import StarRating from '../StarRating';
const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration']);
interface Props {
visibleSongs: SubsonicSong[];
selectedIds: Set<string>;
selectedCount: number;
inSelectMode: boolean;
toggleSelect: (id: string, idx: number, shift: boolean) => void;
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
allColumns: readonly ColDef[];
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
colVisible: Set<string>;
toggleColumn: (key: string) => void;
resetColumns: () => void;
pickerOpen: boolean;
setPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
pickerRef: React.RefObject<HTMLDivElement | null>;
tracklistRef: React.RefObject<HTMLDivElement | null>;
startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void;
handleSortClick: (key: string) => void;
getSortIndicator: (key: string) => React.ReactNode;
ratings: Record<string, number>;
handleRate: (songId: string, rating: number) => void;
removeSong: (id: string) => void;
hasFilters: boolean;
}
export default function FavoritesSongsTracklist({
visibleSongs, selectedIds, selectedCount, inSelectMode, toggleSelect,
showPlPicker, setShowPlPicker,
allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
startResize, handleSortClick, getSortIndicator,
ratings, handleRate, removeSong, hasFilters,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const psyDrag = useDragDrop();
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
return (
<div className="tracklist" data-preview-loc="favorites" 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>
)}
{/* Column visibility picker */}
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
<div className="tracklist-col-picker">
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{allColumns.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div>
)}
</div>
</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') {
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;
const canSort = SORTABLE_COLUMNS.has('title');
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'flex-start',
paddingLeft: 12,
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleSortClick('title')}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{canSort && getSortIndicator('title')}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'remove') return <div key="remove" />;
const isCentered = key === 'duration' || key === 'rating';
const canSort = SORTABLE_COLUMNS.has(key);
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => canSort && handleSortClick(key)}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
{canSort && getSortIndicator(key)}
</div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
);
})}
</div>
</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 track-row-with-actions${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 if (orbitActive) {
queueHint();
} else {
playTrack(track, visibleSongs.map(songToTrack));
}
}}
onDoubleClick={orbitActive ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey || inSelectMode) return;
addTrackToOrbit(song.id);
} : undefined}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
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);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
<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"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{i + 1}</span>
)}
</div>
);
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
{song.albumId ? (
<span
className="track-artist track-artist-link"
onClick={(e) => {
e.stopPropagation();
navigate(`/album/${song.albumId}`);
}}
>
{song.album}
</span>
) : (
<span className="track-artist">{song.album}</span>
)}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">
{song.suffix?.toUpperCase()}
{song.suffix && song.bitRate && ' · '}
{song.bitRate && `${song.bitRate} kbps`}
</span>
)}
</div>
);
case 'rating': return (
<StarRating
key="rating"
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => handleRate(song.id, r)}
/>
);
case 'duration': return (
<div key="duration" className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
);
case 'remove': return (
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
<X size={14} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
})}
{/* Empty state when filters return no results */}
{visibleSongs.length === 0 && hasFilters && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--muted)' }}>
{t('favorites.noFilterResults')}
</div>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { SubsonicSong } from '../api/subsonicTypes';
import { useSelectionStore } from '../store/selectionStore';
export interface FavoritesSelectionResult {
toggleSelect: (id: string, idx: number, shift: boolean) => void;
}
export function useFavoritesSelection(
songs: SubsonicSong[],
inSelectMode: boolean,
tracklistRef: React.RefObject<HTMLDivElement | null>,
): FavoritesSelectionResult {
const lastSelectedIdxRef = useRef<number | null>(null);
// 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, tracklistRef]);
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]);
return { toggleSelect };
}
+47 -471
View File
@@ -5,8 +5,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { TopFavoriteArtistsRow } from '../components/favorites/TopFavoriteArtists'; import { TopFavoriteArtistsRow } from '../components/favorites/TopFavoriteArtists';
import { RadioStationRow } from '../components/favorites/RadioFavorites'; import { RadioStationRow } from '../components/favorites/RadioFavorites';
import FavoritesSongsSectionHeader from '../components/favorites/FavoritesSongsSectionHeader';
import FavoritesSongsTracklist from '../components/favorites/FavoritesSongsTracklist';
import { useFavoritesData } from '../hooks/useFavoritesData'; import { useFavoritesData } from '../hooks/useFavoritesData';
import { useFavoritesSongFiltering } from '../hooks/useFavoritesSongFiltering'; import { useFavoritesSongFiltering } from '../hooks/useFavoritesSongFiltering';
import { useFavoritesSelection } from '../hooks/useFavoritesSelection';
import AlbumRow from '../components/AlbumRow'; import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow'; import ArtistRow from '../components/ArtistRow';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
@@ -75,7 +78,6 @@ export default function Favorites() {
const selectedCount = useSelectionStore(s => s.selectedIds.size); const selectedCount = useSelectionStore(s => s.selectedIds.size);
const selectedIds = useSelectionStore(s => s.selectedIds); const selectedIds = useSelectionStore(s => s.selectedIds);
const inSelectMode = selectedCount > 0; 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);
@@ -109,46 +111,7 @@ export default function Favorites() {
selectedArtist, selectedGenres, yearRange, ratings, selectedArtist, selectedGenres, yearRange, ratings,
}); });
const openContextMenu = usePlayerStore(s => s.openContextMenu); const { toggleSelect } = useFavoritesSelection(songs, inSelectMode, tracklistRef);
const navigate = useNavigate();
// 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]);
if (loading) { if (loading) {
@@ -204,438 +167,51 @@ export default function Favorites() {
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && ( {(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
<section className="album-row-section"> <section className="album-row-section">
{/* ── Section Header with Stats & Filters ───────────────────────── */} <FavoritesSongsSectionHeader
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}> visibleSongs={visibleSongs}
{/* Title Row with showing X of Y indicator */} songs={songs}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}> selectedArtist={selectedArtist}
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2> setSelectedArtist={setSelectedArtist}
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && ( selectedGenres={selectedGenres}
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontStyle: 'italic' }}> setSelectedGenres={setSelectedGenres}
{selectedArtist yearRange={yearRange}
? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length, artist: selectedArtist }) setYearRange={setYearRange}
: t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })} showFilters={showFilters}
</span> setShowFilters={setShowFilters}
)} setSortKey={setSortKey}
</div> setSortClickCount={setSortClickCount}
playTrack={playTrack}
{/* Action Buttons */} enqueue={enqueue}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}> starredOverrides={starredOverrides}
<button minYear={MIN_YEAR}
className="btn btn-primary" currentYear={CURRENT_YEAR}
disabled={visibleSongs.length === 0}
onClick={() => {
if (visibleSongs.length === 0) return;
const tracks = visibleSongs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
<Play size={15} />
{t('favorites.playAll')}
</button>
<button
className="btn btn-surface"
disabled={visibleSongs.length === 0}
onClick={() => {
if (visibleSongs.length === 0) return;
const tracks = visibleSongs.map(songToTrack);
enqueue(tracks);
}}
>
<ListPlus size={15} />
{t('favorites.enqueueAll')}
</button>
{/* Filter Toggle Button */}
<button
className={`btn ${showFilters || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setShowFilters(v => !v)}
>
<SlidersHorizontal size={14} />
{t('common.filters')}
</button>
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
<button
className="btn btn-ghost"
onClick={() => {
setSelectedArtist(null);
setSelectedGenres([]);
setYearRange([MIN_YEAR, CURRENT_YEAR]);
setSortKey('natural');
setSortClickCount(0);
}}
>
<X size={13} />
{t('common.clearAll')}
</button>
)}
</div>
{/* Filters Panel */}
{showFilters && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '0.75rem', background: 'var(--surface)', borderRadius: '8px', marginTop: '0.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
{/* Year Range Filter */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--muted)' }}>
<span>{t('common.yearRange')}:</span>
<span style={{ color: 'var(--accent)', fontWeight: 500 }}>{yearRange[0]} - {yearRange[1]}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min={MIN_YEAR}
max={CURRENT_YEAR}
value={yearRange[0]}
onChange={e => {
const val = parseInt(e.target.value);
setYearRange(prev => [Math.min(val, prev[1] - 1), prev[1]]);
}}
style={{ flex: 1 }}
/> />
<input <FavoritesSongsTracklist
type="range" visibleSongs={visibleSongs}
min={MIN_YEAR} selectedIds={selectedIds}
max={CURRENT_YEAR} selectedCount={selectedCount}
value={yearRange[1]} inSelectMode={inSelectMode}
onChange={e => { toggleSelect={toggleSelect}
const val = parseInt(e.target.value); showPlPicker={showPlPicker}
setYearRange(prev => [prev[0], Math.max(val, prev[0] + 1)]); setShowPlPicker={setShowPlPicker}
}} allColumns={FAV_COLUMNS}
style={{ flex: 1 }} visibleCols={visibleCols}
gridStyle={gridStyle}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
pickerRef={pickerRef}
tracklistRef={tracklistRef}
startResize={startResize}
handleSortClick={handleSortClick}
getSortIndicator={getSortIndicator}
ratings={ratings}
handleRate={handleRate}
removeSong={removeSong}
hasFilters={!!(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR)}
/> />
</div>
</div>
</div>
)}
{selectedArtist && (
<button
onClick={() => setSelectedArtist(null)}
className="btn btn-ghost btn-sm"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.3rem',
fontSize: '0.75rem',
alignSelf: 'flex-start',
}}
>
<X size={11} />
{t('favorites.clearArtistFilter')}
</button>
)}
</div>
<div className="tracklist" data-preview-loc="favorites" 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>
)}
{/* Column visibility picker */}
<div className="tracklist-col-picker-wrapper" ref={pickerRef}>
<div className="tracklist-col-picker">
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{FAV_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
<div className="tracklist-col-picker-divider" />
<button className="tracklist-col-picker-reset" onClick={resetColumns}>
<RotateCcw size={13} />
{t('albumDetail.resetColumns')}
</button>
</div>
)}
</div>
</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') {
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;
const canSort = SORTABLE_COLUMNS.has('title');
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'flex-start',
paddingLeft: 12,
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleSortClick('title')}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{canSort && getSortIndicator('title')}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'remove') return <div key="remove" />;
const isCentered = key === 'duration' || key === 'rating';
const canSort = SORTABLE_COLUMNS.has(key);
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => canSort && handleSortClick(key)}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
{canSort && getSortIndicator(key)}
</div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
);
})}
</div>
</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 track-row-with-actions${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 if (orbitActive) {
queueHint();
} else {
playTrack(track, visibleSongs.map(songToTrack));
}
}}
onDoubleClick={orbitActive ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey || inSelectMode) return;
addTrackToOrbit(song.id);
} : undefined}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
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);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
<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"><AudioLines className="eq-bars" size={14} /></span>
) : (
<span className="track-num-number">{i + 1}</span>
)}
</div>
);
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
{song.albumId ? (
<span
className="track-artist track-artist-link"
onClick={(e) => {
e.stopPropagation();
navigate(`/album/${song.albumId}`);
}}
>
{song.album}
</span>
) : (
<span className="track-artist">{song.album}</span>
)}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">
{song.suffix?.toUpperCase()}
{song.suffix && song.bitRate && ' · '}
{song.bitRate && `${song.bitRate} kbps`}
</span>
)}
</div>
);
case 'rating': return (
<StarRating
key="rating"
value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => handleRate(song.id, r)}
/>
);
case 'duration': return (
<div key="duration" className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
);
case 'remove': return (
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
<X size={14} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
})}
{/* Empty state when filters return no results */}
{visibleSongs.length === 0 && (selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--muted)' }}>
{t('favorites.noFilterResults')}
</div>
)}
</div>
</section> </section>
)} )}
</> </>