refactor(favorites): G.82 — extract Top Artists row + Radio favorites row + data hook + song-filtering hook (cluster) (#649)

Four-cut cluster opening the Favorites refactor. 1018 → 643 LOC
(−375).

TopFavoriteArtists — TopFavoriteArtistsRow (the horizontal-scroll
section with chevron nav buttons and resize-driven scroll-state)
plus the private TopFavoriteArtistCard with the cached avatar
image and selected-outline styling. Exports the
TopFavoriteArtist row-data shape.

RadioFavorites — RadioStationRow (same horizontal-scroll pattern
as the artists row) plus the private RadioFavCard with cover or
Cast-icon fallback, live-radio badge overlay when active, and an
unfavorite heart button.

useFavoritesData — owns the four data states (albums, artists,
songs, radioStations) + loading + the load-on-mount effect (calls
getStarred + reads radio favorites from localStorage + fetches
matching stations). Computes topFavoriteArtists memo (counts
favorited songs by artist, top 12). Exports unfavoriteStation
(removes from state + persists to localStorage).

useFavoritesSongFiltering — owns the filtering pipeline (drops
unfavorited, applies artist / genre / year-range filters) and
the three-state sort (asc → desc → reset). Returns
filteredSongs / visibleSongs plus handleSortClick /
getSortIndicator. Hook file uses .tsx because getSortIndicator
returns ArrowUp / ArrowDown JSX.

Favorites drops the inline definitions plus the now-unused direct
imports (getInternetRadioStations, getStarred, buildCoverArtUrl /
coverArtCacheKey, useAuthStore, Users / ArrowUp / ArrowDown
icons). Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 16:32:54 +02:00
committed by GitHub
parent 7482030a6b
commit a4b1b29dd6
5 changed files with 477 additions and 387 deletions
+95
View File
@@ -0,0 +1,95 @@
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 '../components/favorites/TopFavoriteArtists';
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;
}
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 starredOverrides = usePlayerStore(s => s.starredOverrides);
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<string>(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]);
// ── Top Favorite Artists aggregated from favorited songs ─────────────
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
const counts = new Map<string, TopFavoriteArtist>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = s.artistId || s.artist;
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 || '',
});
}
}
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,
};
}
+135
View File
@@ -0,0 +1,135 @@
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)
const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration']);
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
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]);
return { filteredSongs, visibleSongs, handleSortClick, getSortIndicator };
}