mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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,116 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
|
||||
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 ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
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,117 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
|
||||
export interface TopFavoriteArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
coverArtId: 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 coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
|
||||
|
||||
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 ? (
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user