refactor(favorites): co-locate favorites feature into features/favorites

This commit is contained in:
Psychotoxical
2026-06-29 22:59:21 +02:00
parent ea3e3f1adf
commit dbffe59e58
13 changed files with 91 additions and 78 deletions
@@ -0,0 +1,154 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square, X } from 'lucide-react';
import type { ColDef } from '@/utils/useTracklistColumns';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/i18n';
import { formatTrackTime } from '@/utils/format/formatDuration';
import StarRating from '@/components/StarRating';
import { OpenArtistRefInline } from '@/components/OpenArtistRefInline';
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
export interface FavoriteSongRowCallbacks {
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
dblOrbit: (songId: string, e: React.MouseEvent) => void;
context: (song: SubsonicSong, e: React.MouseEvent) => void;
mouseDownRow: (song: SubsonicSong, e: React.MouseEvent) => void;
toggleSelect: (songId: string, index: number, shift: boolean) => void;
play: (index: number) => void;
startPreview: (song: SubsonicSong) => void;
rate: (songId: string, rating: number) => void;
remove: (songId: string) => void;
navArtist: (artistId: string, serverId?: string) => void;
navAlbum: (albumId: string, serverId?: string) => void;
}
interface Props {
song: SubsonicSong;
index: number;
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
showBitrate: boolean;
isActive: boolean;
showEq: boolean;
isSelected: boolean;
inSelectMode: boolean;
ratingValue: number;
isPreviewing: boolean;
previewStarted: boolean;
orbitActive: boolean;
cb: FavoriteSongRowCallbacks;
}
function FavoriteSongRow({
song, index: i, visibleCols, gridStyle, showBitrate,
isActive, showEq, isSelected, inSelectMode,
ratingValue, isPreviewing, previewStarted, orbitActive, cb,
}: Props) {
const { t } = useTranslation();
return (
<div
className={`track-row track-row-va track-row-with-actions${isActive ? ' active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
role="row"
onClick={e => cb.activate(song, i, e)}
onDoubleClick={orbitActive ? e => cb.dblOrbit(song.id, e) : undefined}
onContextMenu={e => cb.context(song, e)}
onMouseDown={e => cb.mouseDownRow(song, e)}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${isActive ? ' track-num-active' : ''}`}>
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); cb.toggleSelect(song.id, i, e.shiftKey); }} />
{showEq ? (
<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(); cb.play(i); }}
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${isPreviewing ? ' is-previewing' : ''}${isPreviewing && previewStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); cb.startPreview(song); }}
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={isPreviewing ? 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>
{isPreviewing
? <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">
<OpenArtistRefInline
refs={resolveTrackArtistRefs(song)}
fallbackName={song.artist}
onGoArtist={id => cb.navArtist(id, song.serverId)}
as="none"
linkTag="span"
linkClassName="track-artist track-artist-link"
separatorClassName="track-artist-sep"
/>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{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 || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'rating': return <StarRating key="rating" value={ratingValue} onChange={r => cb.rate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatTrackTime(song.duration)}</div>;
case 'playCount': return (
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
);
case 'lastPlayed': return (
<div key="lastPlayed" className="track-genre">{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}</div>
);
case 'bpm': return (
<div key="bpm" className="track-duration">{song.bpm && song.bpm > 0 ? song.bpm : '—'}</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(); cb.remove(song.id); }} aria-label={t('favorites.removeSong')}>
<X size={14} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
}
export default React.memo(FavoriteSongRow);
@@ -0,0 +1,74 @@
import { HardDrive } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import {
useFavoritesOfflineStatus,
type FavoritesOfflineSemaphore,
} from '@/features/favorites/hooks/useFavoritesOfflineStatus';
import {
disableFavoritesOfflineSync,
scheduleFavoritesOfflineSync,
} from '@/utils/offline/favoritesOfflineSync';
function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string {
switch (semaphore) {
case 'red':
return 'favorites.offlineSemaphoreError';
case 'yellow':
return 'favorites.offlineSemaphoreSyncing';
case 'green':
return 'favorites.offlineSemaphoreSynced';
}
}
export default function FavoritesOfflineHeader() {
const { t } = useTranslation();
const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled);
const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus();
const semaphoreLabel = semaphore
? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount })
: undefined;
return (
<div className="favorites-offline-control">
{enabled && semaphore && (
<span
className={`favorites-offline-led favorites-offline-led--${semaphore}`}
role="status"
aria-live="polite"
aria-label={semaphoreLabel}
data-tooltip={semaphoreLabel}
data-tooltip-pos="bottom"
/>
)}
<div
className="favorites-offline-toggle"
data-tooltip={t('favorites.offlineTooltip')}
data-tooltip-pos="bottom"
>
<HardDrive
size={16}
className={`favorites-offline-disk-icon${enabled ? ' favorites-offline-disk-icon--on' : ''}`}
aria-hidden
/>
<label className="toggle-switch" aria-label={t('favorites.offlineTooltip')}>
<input
type="checkbox"
checked={enabled}
onChange={async e => {
const next = e.target.checked;
if (!next) {
await disableFavoritesOfflineSync();
} else {
setEnabled(true);
scheduleFavoritesOfflineSync();
}
}}
/>
<span className="toggle-track" />
</label>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
import React, { useMemo, useRef } 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 { useSelectionStore } from '@/store/selectionStore';
import { songToTrack } from '@/utils/playback/songToTrack';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import GenreFilterBar from '@/components/GenreFilterBar';
interface Props {
visibleSongs: SubsonicSong[];
songs: SubsonicSong[];
selectedArtist: string | null;
selectedArtistName: 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;
inSelectMode: boolean;
selectedCount: number;
selectedIds: ReadonlySet<string>;
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function FavoritesSongsSectionHeader({
visibleSongs, songs, selectedArtist, selectedArtistName, setSelectedArtist,
selectedGenres, setSelectedGenres, yearRange, setYearRange,
showFilters, setShowFilters, setSortKey, setSortClickCount,
playTrack, enqueue, starredOverrides, minYear, currentYear,
inSelectMode, selectedCount, selectedIds, showPlPicker, setShowPlPicker,
}: Props) {
const { t } = useTranslation();
const targetSongs = useMemo(() => {
if (!inSelectMode) return visibleSongs;
return visibleSongs.filter(s => selectedIds.has(s.id));
}, [inSelectMode, visibleSongs, selectedIds]);
// Snapshot selection when the picker opens so add-to-playlist still sees every
// checked row if a document mousedown races ahead of the playlist click.
const pickerSongIdsRef = useRef<string[]>([]);
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: selectedArtistName ?? selectedArtist })
: t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })}
</span>
)}
</div>
{/* Action Buttons */}
<div className="favorites-songs-toolbar compact-action-bar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary"
disabled={targetSongs.length === 0}
aria-label={inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
data-tooltip={inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
onClick={() => {
if (targetSongs.length === 0) return;
const tracks = targetSongs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
<Play size={15} />
<span className="compact-btn-label">{inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}</span>
</button>
<button
className="btn btn-surface"
disabled={targetSongs.length === 0}
aria-label={inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
data-tooltip={inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
onClick={() => {
if (targetSongs.length === 0) return;
const tracks = targetSongs.map(songToTrack);
enqueue(tracks);
}}
>
<ListPlus size={15} />
<span className="compact-btn-label">{inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}</span>
</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)}
aria-label={t('common.filters')}
data-tooltip={t('common.filters')}
>
<SlidersHorizontal size={14} />
<span className="compact-btn-label">{t('common.filters')}</span>
</button>
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear) && (
<button
className="btn btn-ghost"
aria-label={t('common.clearAll')}
data-tooltip={t('common.clearAll')}
onClick={() => {
setSelectedArtist(null);
setSelectedGenres([]);
setYearRange([minYear, currentYear]);
setSortKey('natural');
setSortClickCount(0);
}}
>
<X size={13} />
<span className="compact-btn-label">{t('common.clearAll')}</span>
</button>
)}
{/* Bulk action chips — inline at row end so a selection does not
push the column header / rows downward (matches Album toolbar). */}
{inSelectMode && (
<div className="bulk-action-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginLeft: 'auto' }}>
<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(prev => {
if (!prev) pickerSongIdsRef.current = [...selectedIds];
return !prev;
});
}}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
songIds={pickerSongIdsRef.current}
resolveSongIds={() => pickerSongIdsRef.current}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
</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,318 @@
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FavoriteSongRow, { type FavoriteSongRowCallbacks } from '@/features/favorites/components/FavoriteSongRow';
import { TracklistColumnPicker } from '@/components/albumTrackList/TracklistColumnPicker';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { ColDef } from '@/utils/useTracklistColumns';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { usePlayerStore } from '@/store/playerStore';
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
import { useSelectionStore } from '@/store/selectionStore';
import { useThemeStore } from '@/store/themeStore';
import { useDragDrop } from '@/contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '@/hooks/useOrbitSongRowBehavior';
import { songToTrack } from '@/utils/playback/songToTrack';
import { appendServerQuery } from '@/utils/navigation/detailServerScope';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useElementClientHeightById } from '@/hooks/useResizeClientHeight';
import { SORTABLE_COLUMNS } from '@/features/favorites/hooks/useFavoritesSongFiltering';
interface Props {
visibleSongs: SubsonicSong[];
selectedIds: Set<string>;
selectedCount: number;
inSelectMode: boolean;
toggleSelect: (id: string, idx: number, shift: boolean) => void;
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,
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 showBitrate = useThemeStore(s => s.showBitrate);
const psyDrag = useDragDrop();
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
const visibleTracks = useMemo(() => visibleSongs.map(songToTrack), [visibleSongs]);
const latestVals = {
visibleSongs, visibleTracks, selectedIds, inSelectMode, orbitActive,
toggleSelect, handleRate, removeSong, playTrack, openContextMenu,
navigate, queueHint, addTrackToOrbit, psyDrag,
};
const latest = useRef(latestVals);
latest.current = latestVals;
const cb = useMemo<FavoriteSongRowCallbacks>(() => ({
activate: (song, index, e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
const L = latest.current;
if (e.ctrlKey || e.metaKey) L.toggleSelect(song.id, index, false);
else if (L.inSelectMode) L.toggleSelect(song.id, index, e.shiftKey);
else if (L.orbitActive) L.queueHint();
else L.playTrack(L.visibleTracks[index], L.visibleTracks);
},
dblOrbit: (songId, e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
const L = latest.current;
if (e.ctrlKey || e.metaKey || L.inSelectMode) return;
L.addTrackToOrbit(songId);
},
context: (song, e) => {
e.preventDefault();
latest.current.openContextMenu(e.clientX, e.clientY, songToTrack(song), 'favorite-song');
},
mouseDownRow: (song, e) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, a, input')) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const track = songToTrack(song);
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 L = latest.current;
const { selectedIds: selIds } = useSelectionStore.getState();
if (selIds.has(song.id) && selIds.size > 1) {
const bulkTracks = L.visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack);
L.psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else {
L.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);
},
toggleSelect: (songId, index, shift) => latest.current.toggleSelect(songId, index, shift),
play: (index) => {
const L = latest.current;
if (L.orbitActive) { L.queueHint(); return; }
L.playTrack(L.visibleTracks[index], L.visibleTracks);
},
startPreview: (song) => usePreviewStore.getState().startPreview(
previewInputFromSong(song),
'favorites',
),
rate: (songId, r) => latest.current.handleRate(songId, r),
remove: (songId) => latest.current.removeSong(songId),
navArtist: (artistId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`);
},
navAlbum: (albumId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`);
},
}), []);
const listWrapRef = useRef<HTMLDivElement | null>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const viewportH = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
// Bulk bar show/hide shifts listWrapRef top — remeasure on that edge only.
const bulkBarVisible = selectedIds.size > 0;
useLayoutEffect(() => {
const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
// scrollMargin must track height changes in sections above the list (filters, top artists).
// Intentionally coupled to the Favorites page shell class — keep in sync with that layout.
const root = tracklistRef.current?.closest('.content-body') as HTMLElement | null;
if (!sc) return;
const measure = () => {
const wrap = listWrapRef.current;
if (!wrap) return;
const m = wrap.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop;
setScrollMargin(prev => (Math.abs(prev - m) > 0.5 ? m : prev));
};
const ro = new ResizeObserver(measure);
ro.observe(sc);
if (root) ro.observe(root);
measure();
return () => ro.disconnect();
}, [tracklistRef, bulkBarVisible, pickerOpen, visibleSongs.length]);
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: visibleSongs.length,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => 48,
overscan: Math.max(8, Math.ceil(viewportH / 48)),
scrollMargin,
getItemKey: i => `${visibleSongs[i].id}:${i}`,
});
const virtualItems = rowVirtualizer.getVirtualItems();
return (
<>
<TracklistColumnPicker
allColumns={allColumns}
pickerRef={pickerRef}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
t={t}
/>
<div className="tracklist" data-preview-loc="favorites" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}>
<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>
<div ref={listWrapRef} style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const song = visibleSongs[vi.index];
const i = vi.index;
return (
<div
key={vi.key}
data-index={i}
ref={rowVirtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start - scrollMargin}px)` }}
>
<FavoriteSongRow
song={song}
index={i}
visibleCols={visibleCols}
gridStyle={gridStyle}
showBitrate={showBitrate}
isActive={currentTrack?.id === song.id}
showEq={currentTrack?.id === song.id && isPlaying}
isSelected={selectedIds.has(song.id)}
inSelectMode={inSelectMode}
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isPreviewing={previewingId === song.id}
previewStarted={previewingId === song.id && previewAudioStarted}
orbitActive={orbitActive}
cb={cb}
/>
</div>
);
})}
</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>
</>
);
}
@@ -0,0 +1,119 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
import type { InternetRadioStation } from '@/api/subsonicTypes';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { coverArtIdFromRadio } from '@/cover/ids';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
interface RadioStationRowProps {
title: string;
stations: InternetRadioStation[];
currentRadio: InternetRadioStation | null;
isPlaying: boolean;
onPlay: (s: InternetRadioStation) => void;
onUnfavorite: (id: string) => void;
}
export function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: dir === 'left' ? -scrollRef.current.clientWidth * 0.75 : scrollRef.current.clientWidth * 0.75, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn${!showLeft ? ' disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn${!showRight ? ' disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{stations.map(s => (
<RadioFavCard
key={s.id}
station={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
onPlay={() => onPlay(s)}
onUnfavorite={() => onUnfavorite(s.id)}
/>
))}
</div>
</div>
</section>
);
}
interface RadioFavCardProps {
station: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
onPlay: () => void;
onUnfavorite: () => void;
}
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
const { t } = useTranslation();
return (
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
<div className="album-card-cover">
{s.coverArt ? (
<CoverArtImage
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name}
className="album-card-cover-img"
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<Cast size={48} strokeWidth={1.2} />
</div>
)}
{isActive && isPlaying && (
<div className="radio-live-overlay">
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
<button
className="radio-favorite-btn active"
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
onClick={onUnfavorite}
data-tooltip={t('radio.unfavorite')}
>
<Heart size={12} fill="currentColor" />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import { coverServerScopeForServerId } from '@/cover/serverScope';
export interface TopFavoriteArtist {
id: string;
name: string;
count: number;
coverArtId: string;
/** Present when favorites are merged across servers. */
serverId?: string;
/** Raw artist id for song filtering (without server prefix). */
artistId?: string;
}
interface TopFavoriteArtistsRowProps {
title: string;
artists: TopFavoriteArtist[];
selectedKey: string | null;
onToggle: (key: string) => void;
}
export function TopFavoriteArtistsRow({ title, artists, selectedKey, onToggle }: TopFavoriteArtistsRowProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
useEffect(() => {
handleScroll();
window.addEventListener('resize', handleScroll);
return () => window.removeEventListener('resize', handleScroll);
}, [artists]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<TopFavoriteArtistCard
key={a.id}
artist={a}
isSelected={selectedKey === a.id}
onClick={() => onToggle(a.id)}
songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })}
/>
))}
</div>
</div>
</section>
);
}
interface TopFavoriteArtistCardProps {
artist: TopFavoriteArtist;
isSelected: boolean;
onClick: () => void;
songCountLabel: string;
}
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId;
const artistEntityId = artist.artistId ?? artist.coverArtId;
return (
<div
className={`artist-card${isSelected ? ' artist-card-selected' : ''}`}
onClick={onClick}
style={isSelected ? { outline: '2px solid var(--accent)', outlineOffset: '-2px', borderRadius: 12 } : undefined}
>
<div className="artist-card-avatar">
{coverId ? (
<ArtistCoverArtImage
artistId={artistEntityId}
coverArt={artist.coverArtId}
serverScope={coverServerScopeForServerId(artist.serverId)}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={32} color="var(--text-muted)" />
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name">{artist.name}</span>
<span className="artist-card-meta">{songCountLabel}</span>
</div>
</div>
);
}
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from 'react';
import { getInternetRadioStations } from '@/api/subsonicRadio';
import { getStarred } from '@/api/subsonicStarRating';
import type {
InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong,
} from '@/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import type { TopFavoriteArtist } from '@/features/favorites/components/TopFavoriteArtists';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
import { isActiveServerReachable } from '@/utils/network/activeServerReachability';
import { useOfflineBrowseContext } from '@/hooks/useOfflineBrowseContext';
import { useOfflineBrowseReloadToken } from '@/hooks/useOfflineBrowseReloadToken';
import {
loadStarredFromAllLibraryIndexes,
loadStarredFromAllServersOnline,
} from '@/utils/offline/offlineStarredLoad';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
artists: SubsonicArtist[];
songs: SubsonicSong[];
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
radioStations: InternetRadioStation[];
setRadioStations: React.Dispatch<React.SetStateAction<InternetRadioStation[]>>;
loading: boolean;
topFavoriteArtists: TopFavoriteArtist[];
unfavoriteStation: (id: string) => void;
}
function topArtistKey(song: SubsonicSong): string {
const artistKey = song.artistId || song.artist;
if (!artistKey) return '';
return song.serverId ? `${song.serverId}:${artistKey}` : artistKey;
}
export function useFavoritesData(): FavoritesDataResult {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const servers = useAuthStore(s => s.servers);
const { status: connStatus } = useConnectionStatus();
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
useEffect(() => {
let cancelled = false;
const applyStarred = (starred: {
albums: SubsonicAlbum[];
artists: SubsonicArtist[];
songs: SubsonicSong[];
}) => {
if (cancelled) return;
setAlbums(starred.albums);
setArtists(starred.artists);
setSongs(starred.songs);
};
const loadRadioFavorites = async () => {
if (!isActiveServerReachable()) return;
try {
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size === 0) return;
const all = await getInternetRadioStations();
if (!cancelled) {
setRadioStations(all.filter(s => favIds.has(s.id)));
}
} catch { /* ignore */ }
};
const loadAll = async () => {
setLoading(true);
if (favoritesOfflineEnabled) {
try {
applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive));
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
if (connStatus === 'connected' && isActiveServerReachable()) {
try {
applyStarred(await loadStarredFromAllServersOnline());
} catch { /* keep library snapshot */ }
}
} else {
if (connStatus === 'connected' && isActiveServerReachable()) {
const [starredResult] = await Promise.allSettled([getStarred()]);
if (starredResult.status === 'fulfilled') {
applyStarred(starredResult.value);
}
}
if (!cancelled) setLoading(false);
}
void loadRadioFavorites();
};
void loadAll();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]);
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
const counts = new Map<string, TopFavoriteArtist>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = topArtistKey(s);
if (!key) continue;
const existing = counts.get(key);
if (existing) {
existing.count += 1;
} else {
counts.set(key, {
id: key,
name: s.artist || key,
count: 1,
coverArtId: s.artistId || '',
serverId: s.serverId,
artistId: s.artistId || s.artist,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 12);
}, [songs, starredOverrides]);
function unfavoriteStation(id: string) {
setRadioStations(prev => prev.filter(s => s.id !== id));
try {
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
next.delete(id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
} catch { /* ignore */ }
}
return {
albums, artists, songs, setSongs, radioStations, setRadioStations,
loading, topFavoriteArtists, unfavoriteStation,
};
}
@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { useAuthStore } from '@/store/authStore';
import { useFavoritesOfflineSyncStore } from '@/store/favoritesOfflineSyncStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineJobStore } from '@/store/offlineJobStore';
import { FAVORITES_OFFLINE_JOB_ID } from '@/utils/offline/favoritesOfflineConstants';
import { entryBelongsToServer } from '@/utils/offline/offlineLibraryHelpers';
export type FavoritesOfflineUiStatus =
| 'disabled'
| 'syncing'
| 'complete'
| 'partial'
| 'error'
| 'idle';
export type FavoritesOfflineSemaphore = 'red' | 'yellow' | 'green';
export interface FavoritesOfflineStatusResult {
enabled: boolean;
status: FavoritesOfflineUiStatus;
semaphore: FavoritesOfflineSemaphore | null;
savedCount: number;
targetCount: number;
jobDone: number;
jobTotal: number;
}
export function useFavoritesOfflineStatus(): FavoritesOfflineStatusResult {
const enabled = useAuthStore(s => s.favoritesOfflineEnabled);
const serverId = useAuthStore(s => s.activeServerId);
const entries = useLocalPlaybackStore(s => s.entries);
const running = useFavoritesOfflineSyncStore(s => s.running);
const lastError = useFavoritesOfflineSyncStore(s => s.lastError);
const targetTrackIds = useFavoritesOfflineSyncStore(s => s.targetTrackIds);
const jobs = useOfflineJobStore(s => s.jobs);
return useMemo(() => {
if (!enabled) {
return {
enabled: false,
status: 'disabled' as const,
semaphore: null,
savedCount: 0,
targetCount: 0,
jobDone: 0,
jobTotal: 0,
};
}
const favJobs = jobs.filter(j => j.albumId === FAVORITES_OFFLINE_JOB_ID);
const jobDone = favJobs.filter(j => j.status === 'done').length;
const jobTotal = favJobs.length;
const hasActiveJobs = favJobs.some(j => j.status === 'downloading' || j.status === 'queued');
const hasJobErrors = favJobs.some(j => j.status === 'error');
const savedCount = serverId
? Object.values(entries).filter(
e => e.tier === 'favorite-auto' && entryBelongsToServer(e, serverId),
).length
: 0;
const targetCount = targetTrackIds.length;
let status: FavoritesOfflineUiStatus = 'idle';
if (running || hasActiveJobs) {
status = 'syncing';
} else if (lastError || hasJobErrors) {
status = 'error';
} else if (targetCount > 0 && savedCount >= targetCount) {
status = 'complete';
} else if (savedCount > 0 && targetCount > 0 && savedCount < targetCount) {
status = 'partial';
} else if (savedCount > 0) {
status = 'complete';
}
let semaphore: FavoritesOfflineSemaphore = 'green';
if (lastError || hasJobErrors) {
semaphore = 'red';
} else if (running || hasActiveJobs || (targetCount > 0 && savedCount < targetCount)) {
semaphore = 'yellow';
}
return { enabled, status, semaphore, savedCount, targetCount, jobDone, jobTotal };
}, [enabled, serverId, entries, running, lastError, targetTrackIds, jobs]);
}
@@ -0,0 +1,56 @@
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(
visibleSongs: 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;
}, [visibleSongs]);
// Clear selection on click outside tracklist
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!tracklistRef.current || tracklistRef.current.contains(target)) return;
// Toolbar (play/enqueue, filters, bulk actions) sits outside the tracklist
// DOM but belongs to the selection — don't clear before its click runs.
if (target.closest('.favorites-songs-toolbar, .bulk-pl-picker-wrap, .context-submenu')) return;
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);
for (let j = from; j <= to; j++) {
const sid = visibleSongs[j]?.id;
if (sid) next.add(sid);
}
} else {
if (next.has(id)) { next.delete(id); }
else { next.add(id); lastSelectedIdxRef.current = idx; }
}
return next;
});
}, [visibleSongs]);
return { toggleSelect };
}
@@ -0,0 +1,151 @@
import React, { useMemo } from 'react';
import { ArrowDown, ArrowUp } from 'lucide-react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { usePlayerStore } from '@/store/playerStore';
const CURRENT_YEAR = new Date().getFullYear();
const MIN_YEAR = 1950;
// Columns that support 3-state sorting (asc → desc → reset).
// Single source of truth — the tracklist header imports this to decide which
// headers show the sortable affordance, so the cursor and the click behaviour
// can never drift apart. Every key here is handled in the comparator below.
export const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
export type SortDir = 'asc' | 'desc';
export interface FavoritesSongFilteringDeps {
songs: SubsonicSong[];
sortKey: string;
setSortKey: React.Dispatch<React.SetStateAction<string>>;
sortDir: SortDir;
setSortDir: React.Dispatch<React.SetStateAction<SortDir>>;
sortClickCount: number;
setSortClickCount: React.Dispatch<React.SetStateAction<number>>;
selectedArtist: string | null;
selectedGenres: string[];
yearRange: [number, number];
ratings: Record<string, number>;
}
export interface FavoritesSongFilteringResult {
filteredSongs: SubsonicSong[];
visibleSongs: SubsonicSong[];
handleSortClick: (key: string) => void;
getSortIndicator: (key: string) => React.ReactNode;
}
export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): FavoritesSongFilteringResult {
const {
songs, sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount,
selectedArtist, selectedGenres, yearRange, ratings,
} = deps;
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const handleSortClick = (key: string) => {
if (!SORTABLE_COLUMNS.has(key)) return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
// Reset to natural order (favorite addition order)
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
// Toggle direction
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
// Start new sort on this column
setSortKey(key);
setSortDir('asc');
setSortClickCount(1);
}
};
const getSortIndicator = (key: string) => {
if (sortKey !== key) return null;
if (sortClickCount === 0) return null;
return sortDir === 'asc' ? <ArrowUp size={12} style={{ marginLeft: 4, opacity: 0.7 }} /> : <ArrowDown size={12} style={{ marginLeft: 4, opacity: 0.7 }} />;
};
// ── Filter logic ─────────────────────────────────────────────────────────
const filteredSongs = useMemo(() => {
return songs.filter(s => {
// Remove unfavorited
if (starredOverrides[s.id] === false) return false;
// Artist filter (composite key when favorites span servers)
if (selectedArtist) {
const compositeKey = s.serverId
? `${s.serverId}:${s.artistId || s.artist}`
: (s.artistId || s.artist);
const artistMatch = selectedArtist === compositeKey
|| s.artistId === selectedArtist
|| s.artist === selectedArtist
|| s.albumArtist === selectedArtist;
if (!artistMatch) return false;
}
// Genre filter
if (selectedGenres.length > 0) {
const songGenre = s.genre || '';
const hasMatchingGenre = selectedGenres.some(g =>
songGenre.toLowerCase().includes(g.toLowerCase())
);
if (!hasMatchingGenre) return false;
}
// Year range filter — only applied when range is non-default; songs without year are excluded
if (yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) {
if (s.year === undefined || s.year < yearRange[0] || s.year > yearRange[1]) return false;
}
return true;
});
}, [songs, starredOverrides, selectedArtist, selectedGenres, yearRange]);
// ── Sort logic ───────────────────────────────────────────────────────────
const visibleSongs = useMemo(() => {
if (sortKey === 'natural' || sortClickCount === 0) {
return filteredSongs;
}
const sorted = [...filteredSongs];
const multiplier = sortDir === 'asc' ? 1 : -1;
return sorted.sort((a, b) => {
switch (sortKey) {
case 'title':
return multiplier * (a.title || '').localeCompare(b.title || '');
case 'artist':
return multiplier * ((a.artist || '').localeCompare(b.artist || ''));
case 'album':
return multiplier * ((a.album || '').localeCompare(b.album || ''));
case 'rating': {
const ratingA = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
const ratingB = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
return multiplier * (ratingA - ratingB);
}
case 'duration':
return multiplier * ((a.duration || 0) - (b.duration || 0));
case 'playCount':
return multiplier * ((a.playCount || 0) - (b.playCount || 0));
case 'lastPlayed': {
const ta = a.played ? Date.parse(a.played) || 0 : 0;
const tb = b.played ? Date.parse(b.played) || 0 : 0;
return multiplier * (ta - tb);
}
case 'bpm':
return multiplier * ((a.bpm || 0) - (b.bpm || 0));
default:
return 0;
}
});
}, [filteredSongs, sortKey, sortDir, sortClickCount, ratings, userRatingOverrides]);
return { filteredSongs, visibleSongs, handleSortClick, getSortIndicator };
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Favorites feature — the Favorites page (starred songs/artists + favourite
* radio stations) and its data/selection/filtering hooks. The `Favorites` page
* is lazy-loaded by the router via its deep path, so it is not re-exported here.
*
* Note: the offline integration layer (`utils/offline/favorites*`,
* `store/favoritesOfflineSyncStore`) stays in the offline layer — the offline
* core imports it, so it cannot live under this feature without inverting the
* core→feature dependency.
*/
export { useFavoritesData } from './hooks/useFavoritesData';
export { useFavoritesSelection } from './hooks/useFavoritesSelection';
export { useFavoritesOfflineStatus } from './hooks/useFavoritesOfflineStatus';
+224
View File
@@ -0,0 +1,224 @@
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
import React, { useEffect, useMemo, useState } from 'react';
import { useTracklistColumns, type ColDef } from '@/utils/useTracklistColumns';
import { TopFavoriteArtistsRow } from '@/features/favorites/components/TopFavoriteArtists';
import { RadioStationRow } from '@/features/favorites/components/RadioFavorites';
import FavoritesSongsSectionHeader from '@/features/favorites/components/FavoritesSongsSectionHeader';
import FavoritesSongsTracklist from '@/features/favorites/components/FavoritesSongsTracklist';
import { useFavoritesData } from '@/features/favorites/hooks/useFavoritesData';
import { useFavoritesSongFiltering } from '@/features/favorites/hooks/useFavoritesSongFiltering';
import { useFavoritesSelection } from '@/features/favorites/hooks/useFavoritesSelection';
import { useBulkPlPickerOutsideClick } from '@/hooks/useBulkPlPickerOutsideClick';
import AlbumRow from '@/components/AlbumRow';
import ArtistRow from '@/components/ArtistRow';
import { usePlayerStore } from '@/store/playerStore';
import { useTranslation } from 'react-i18next';
import { useSelectionStore } from '@/store/selectionStore';
import FavoritesOfflineHeader from '@/features/favorites/components/FavoritesOfflineHeader';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false },
{ key: 'playCount', i18nKey: 'trackPlayCount', minWidth: 60, defaultWidth: 80, required: false },
{ key: 'lastPlayed', i18nKey: 'trackLastPlayed', minWidth: 90, defaultWidth: 130, required: false },
{ key: 'bpm', i18nKey: 'trackBpm', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
const CURRENT_YEAR = new Date().getFullYear();
const MIN_YEAR = 1950;
export default function Favorites() {
const { t } = useTranslation();
const {
albums, artists, songs, setSongs, radioStations,
loading, topFavoriteArtists, unfavoriteStation,
} = useFavoritesData();
// ── Sorting (3-state: asc → desc → reset) ────────────────────────────────
const [sortKey, setSortKey] = useState<string>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
// ── Artist filtering ─────────────────────────────────────────────────────
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
// ── Genre filtering ──────────────────────────────────────────────────────
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
// ── Year range filtering ─────────────────────────────────────────────────
const [yearRange, setYearRange] = useState<[number, number]>([MIN_YEAR, CURRENT_YEAR]);
const [showFilters, setShowFilters] = useState(false);
// ── Column resize/visibility (must be before early return) ───────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
const [ratings, setRatings] = useState<Record<string, number>>({});
const [showPlPicker, setShowPlPicker] = useState(false);
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const selectedIds = useSelectionStore(s => s.selectedIds);
const inSelectMode = selectedCount > 0;
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const playRadio = usePlayerStore(s => s.playRadio);
const stop = usePlayerStore(s => s.stop);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
};
function removeSong(id: string) {
// F4: optimistic un-star + retried server sync via the central helper.
const song = songs.find(s => s.id === id);
queueSongStar(id, false, song?.serverId);
setSongs(prev => prev.filter(s => s.id !== id));
}
const { visibleSongs, handleSortClick, getSortIndicator } = useFavoritesSongFiltering({
songs, sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount,
selectedArtist, selectedGenres, yearRange, ratings,
});
const selectedArtistName = useMemo(
() => selectedArtist ? topFavoriteArtists.find(a => a.id === selectedArtist)?.name ?? null : null,
[selectedArtist, topFavoriteArtists],
);
const { toggleSelect } = useFavoritesSelection(visibleSongs, inSelectMode, tracklistRef);
useBulkPlPickerOutsideClick(showPlPicker, setShowPlPicker);
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!inSelectMode) setShowPlPicker(false);
}, [inSelectMode]);
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
);
}
// Check if user has any favorites (using original unfiltered lists)
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0 || radioStations.length > 0;
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div className="playlists-header" style={{ marginBottom: '-1.5rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('favorites.title')}</h1>
<FavoritesOfflineHeader />
</div>
{!hasAnyFavorites ? (
<div className="empty-state">{t('favorites.empty')}</div>
) : (
<>
{artists.length > 0 && (
<ArtistRow title={t('favorites.artists')} artists={artists} />
)}
{albums.length > 0 && (
<AlbumRow title={t('favorites.albums')} albums={albums} />
)}
{radioStations.length > 0 && (
<RadioStationRow
title={t('favorites.stations')}
stations={radioStations}
currentRadio={currentRadio}
isPlaying={isPlaying}
onPlay={s => {
if (currentRadio?.id === s.id && isPlaying) stop();
else playRadio(s);
}}
onUnfavorite={unfavoriteStation}
/>
)}
{topFavoriteArtists.length >= 2 && (
<TopFavoriteArtistsRow
title={t('favorites.topArtists')}
artists={topFavoriteArtists}
selectedKey={selectedArtist}
onToggle={key => setSelectedArtist(prev => prev === key ? null : key)}
/>
)}
{(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
<section className="album-row-section">
<FavoritesSongsSectionHeader
visibleSongs={visibleSongs}
songs={songs}
selectedArtist={selectedArtist}
selectedArtistName={selectedArtistName}
setSelectedArtist={setSelectedArtist}
selectedGenres={selectedGenres}
setSelectedGenres={setSelectedGenres}
yearRange={yearRange}
setYearRange={setYearRange}
showFilters={showFilters}
setShowFilters={setShowFilters}
setSortKey={setSortKey}
setSortClickCount={setSortClickCount}
playTrack={playTrack}
enqueue={enqueue}
starredOverrides={starredOverrides}
minYear={MIN_YEAR}
currentYear={CURRENT_YEAR}
inSelectMode={inSelectMode}
selectedCount={selectedCount}
selectedIds={selectedIds}
showPlPicker={showPlPicker}
setShowPlPicker={setShowPlPicker}
/>
<FavoritesSongsTracklist
visibleSongs={visibleSongs}
selectedIds={selectedIds}
selectedCount={selectedCount}
inSelectMode={inSelectMode}
toggleSelect={toggleSelect}
allColumns={FAV_COLUMNS}
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)}
/>
</section>
)}
</>
)}
</div>
);
}