mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(favorites): co-locate favorites feature into features/favorites
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 effect’s 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user