mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
7482030a6b
commit
a4b1b29dd6
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user