import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import CachedImage from '../components/CachedImage'; import { getStarred, getInternetRadioStations, setRating, SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation, buildCoverArtUrl, coverArtCacheKey, } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import StarRating from '../components/StarRating'; import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { unstar } from '../api/subsonic'; import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; import { useSelectionStore } from '../store/selectionStore'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import GenreFilterBar from '../components/GenreFilterBar'; 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: '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: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; const CURRENT_YEAR = new Date().getFullYear(); const MIN_YEAR = 1950; // Columns that support 3-state sorting (asc → desc → reset) const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration']); export default function Favorites() { const { t } = useTranslation(); const [albums, setAlbums] = useState([]); const [artists, setArtists] = useState([]); const [songs, setSongs] = useState([]); const [radioStations, setRadioStations] = useState([]); const [loading, setLoading] = useState(true); // ── Sorting (3-state: asc → desc → reset) ──────────────────────────────── const [sortKey, setSortKey] = useState('natural'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [sortClickCount, setSortClickCount] = useState(0); // ── Artist filtering ───────────────────────────────────────────────────── const [selectedArtist, setSelectedArtist] = useState(null); // ── Genre filtering ────────────────────────────────────────────────────── const [selectedGenres, setSelectedGenres] = useState([]); // ── 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>({}); const [showPlPicker, setShowPlPicker] = useState(false); const selectedCount = useSelectionStore(s => s.selectedIds.size); const selectedIds = useSelectionStore(s => s.selectedIds); const inSelectMode = selectedCount > 0; const lastSelectedIdxRef = useRef(null); const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); const playRadio = usePlayerStore(s => s.playRadio); const stop = usePlayerStore(s => s.stop); const currentTrack = usePlayerStore(s => s.currentTrack); const currentRadio = usePlayerStore(s => s.currentRadio); const isPlaying = usePlayerStore(s => s.isPlaying); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const psyDrag = useDragDrop(); const handleRate = (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); usePlayerStore.getState().setUserRatingOverride(songId, rating); setRating(songId, rating).catch(() => {}); }; function removeSong(id: string) { unstar(id, 'song').catch(() => {}); setStarredOverride(id, false); setSongs(prev => prev.filter(s => s.id !== id)); } // ── Sorting logic ───────────────────────────────────────────────────────── 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' ? : ; }; function unfavoriteStation(id: string) { setRadioStations(prev => prev.filter(s => s.id !== id)); try { const next = new Set(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')); next.delete(id); localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next])); } catch { /* ignore */ } } const openContextMenu = usePlayerStore(s => s.openContextMenu); const navigate = useNavigate(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); // Clear selection when song list changes useEffect(() => { useSelectionStore.getState().clearAll(); lastSelectedIdxRef.current = null; }, [songs]); // Clear selection on click outside tracklist useEffect(() => { if (!inSelectMode) return; const handler = (e: MouseEvent) => { if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) { useSelectionStore.getState().clearAll(); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [inSelectMode]); const toggleSelect = useCallback((id: string, idx: number, shift: boolean) => { useSelectionStore.getState().setSelectedIds(prev => { const next = new Set(prev); if (shift && lastSelectedIdxRef.current !== null) { const from = Math.min(lastSelectedIdxRef.current, idx); const to = Math.max(lastSelectedIdxRef.current, idx); // we need visibleSongs here — read from latest closure via ref trick // Instead, just toggle range based on idx into songs array for (let j = from; j <= to; j++) { const sid = songs[j]?.id; if (sid) next.add(sid); } } else { if (next.has(id)) { next.delete(id); } else { next.add(id); lastSelectedIdxRef.current = idx; } } return next; }); }, [songs]); useEffect(() => { const loadAll = async () => { const [starredResult] = await Promise.allSettled([ getStarred(), ]); if (starredResult.status === 'fulfilled') { setAlbums(starredResult.value.albums); setArtists(starredResult.value.artists); setSongs(starredResult.value.songs); } // Radio favorites: read IDs from localStorage, fetch all stations, filter try { const favIds = new Set(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')); if (favIds.size > 0) { const all = await getInternetRadioStations(); setRadioStations(all.filter(s => favIds.has(s.id))); } } catch { /* ignore */ } setLoading(false); }; loadAll(); }, [musicLibraryFilterVersion]); // ── Filter & sort logic ────────────────────────────────────────────────── const filteredSongs = useMemo(() => { return songs.filter(s => { // Remove unfavorited if (starredOverrides[s.id] === false) return false; // Artist filter if (selectedArtist) { const artistMatch = 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)); default: return 0; } }); }, [filteredSongs, sortKey, sortDir, sortClickCount, ratings, userRatingOverrides]); if (loading) { return (
); } // 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 (

{t('favorites.title')}

{!hasAnyFavorites ? (
{t('favorites.empty')}
) : ( <> {artists.length > 0 && ( )} {albums.length > 0 && ( )} {radioStations.length > 0 && ( { if (currentRadio?.id === s.id && isPlaying) stop(); else playRadio(s); }} onUnfavorite={unfavoriteStation} /> )} {(visibleSongs.length > 0 || selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
{/* ── Section Header with Stats & Filters ───────────────────────── */}
{/* Title Row with showing X of Y indicator */}

{t('favorites.songs')}

{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && ( {selectedArtist ? t('favorites.showingFiltered', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length, artist: selectedArtist }) : t('favorites.showingCount', { filtered: visibleSongs.length, total: songs.filter(s => starredOverrides[s.id] !== false).length })} )}
{/* Action Buttons */}
{/* Filter Toggle Button */} {(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && ( )}
{/* Filters Panel */} {showFilters && (
{/* Year Range Filter */}
{t('common.yearRange')}: {yearRange[0]} - {yearRange[1]}
{ const val = parseInt(e.target.value); setYearRange(prev => [Math.min(val, prev[1] - 1), prev[1]]); }} style={{ flex: 1 }} /> { const val = parseInt(e.target.value); setYearRange(prev => [prev[0], Math.max(val, prev[0] + 1)]); }} style={{ flex: 1 }} />
)} {selectedArtist && ( )}
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); }}> {/* ── Bulk action bar ── */} {inSelectMode && (
{t('common.bulkSelected', { count: selectedCount })}
{showPlPicker && ( { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} dropDown /> )}
)} {/* Column visibility picker */}
{pickerOpen && (
{t('albumDetail.columns')}
{FAV_COLUMNS.filter(c => !c.required).map(c => { const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key; const isOn = colVisible.has(c.key); return ( ); })}
)}
{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 (
{ e.stopPropagation(); if (allSelected) { useSelectionStore.getState().clearAll(); } else { useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id))); } }} /> #
); } if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; const canSort = SORTABLE_COLUMNS.has('title'); return (
handleSortClick('title')} > {label} {canSort && getSortIndicator('title')}
{hasNextCol &&
startResize(e, colIndex + 1, -1)} />}
); } if (key === 'remove') return
; const isCentered = key === 'duration' || key === 'rating'; const canSort = SORTABLE_COLUMNS.has(key); return (
canSort && handleSortClick(key)} > {label} {canSort && getSortIndicator(key)}
{!isLastCol &&
startResize(e, colIndex, 1)} />}
); })}
{visibleSongs.map((song, i) => { const track = songToTrack(song); const isSelected = selectedIds.has(song.id); return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (e.ctrlKey || e.metaKey) { toggleSelect(song.id, i, false); } else if (inSelectMode) { toggleSelect(song.id, i, e.shiftKey); } else { playTrack(track, visibleSongs.map(songToTrack)); } }} onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }} role="row" onMouseDown={e => { if (e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); const { selectedIds: selIds } = useSelectionStore.getState(); if (selIds.has(song.id) && selIds.size > 1) { const bulkTracks = visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack); psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); } else { psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); } } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} > {visibleCols.map(colDef => { switch (colDef.key) { case 'num': return (
{ e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}> { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} {i + 1}
); case 'title': return
{song.title}
; case 'artist': return (
song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}
); case 'album': return (
{song.albumId ? ( { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} > {song.album} ) : ( {song.album} )}
); case 'format': return (
{(song.suffix || song.bitRate) && ( {song.suffix?.toUpperCase()} {song.suffix && song.bitRate && ' · '} {song.bitRate && `${song.bitRate} kbps`} )}
); case 'rating': return ( handleRate(song.id, r)} /> ); case 'duration': return (
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
); case 'remove': return (
); default: return null; } })}
); })} {/* Empty state when filters return no results */} {visibleSongs.length === 0 && (selectedArtist || selectedGenres.length > 0 || yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) && (
{t('favorites.noFilterResults')}
)}
)} )}
); } // ── Radio Station Row ───────────────────────────────────────────────────────── interface RadioStationRowProps { title: string; stations: InternetRadioStation[]; currentRadio: InternetRadioStation | null; isPlaying: boolean; onPlay: (s: InternetRadioStation) => void; onUnfavorite: (id: string) => void; } function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) { const scrollRef = useRef(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 (

{title}

{stations.map(s => ( onPlay(s)} onUnfavorite={() => onUnfavorite(s.id)} /> ))}
); } // ── Radio Favorite Card ─────────────────────────────────────────────────────── 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 (
{s.coverArt ? ( ) : (
)} {isActive && isPlaying && (
{t('radio.live')}
)}
{s.name}
); }