diff --git a/src/components/favorites/RadioFavorites.tsx b/src/components/favorites/RadioFavorites.tsx new file mode 100644 index 00000000..ecc35ffe --- /dev/null +++ b/src/components/favorites/RadioFavorites.tsx @@ -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(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)} + /> + ))} +
+
+
+ ); +} + +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}
+
+ +
+
+
+ ); +} diff --git a/src/components/favorites/TopFavoriteArtists.tsx b/src/components/favorites/TopFavoriteArtists.tsx new file mode 100644 index 00000000..36277e3d --- /dev/null +++ b/src/components/favorites/TopFavoriteArtists.tsx @@ -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(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 ( +
+
+

{title}

+
+ + +
+
+ +
+
+ {artists.map(a => ( + onToggle(a.id)} + songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })} + /> + ))} +
+
+
+ ); +} + +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 ( +
+
+ {coverId ? ( + { + e.currentTarget.style.display = 'none'; + e.currentTarget.parentElement?.classList.add('fallback-visible'); + }} + /> + ) : ( + + )} +
+
+ {artist.name} + {songCountLabel} +
+
+ ); +} diff --git a/src/hooks/useFavoritesData.ts b/src/hooks/useFavoritesData.ts new file mode 100644 index 00000000..56bd1c41 --- /dev/null +++ b/src/hooks/useFavoritesData.ts @@ -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>; + radioStations: InternetRadioStation[]; + setRadioStations: React.Dispatch>; + loading: boolean; + topFavoriteArtists: TopFavoriteArtist[]; + unfavoriteStation: (id: string) => void; +} + +export function useFavoritesData(): FavoritesDataResult { + const [albums, setAlbums] = useState([]); + const [artists, setArtists] = useState([]); + const [songs, setSongs] = useState([]); + const [radioStations, setRadioStations] = useState([]); + 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(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(() => { + const counts = new Map(); + 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(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, + }; +} diff --git a/src/hooks/useFavoritesSongFiltering.tsx b/src/hooks/useFavoritesSongFiltering.tsx new file mode 100644 index 00000000..706cf3f0 --- /dev/null +++ b/src/hooks/useFavoritesSongFiltering.tsx @@ -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>; + sortDir: SortDir; + setSortDir: React.Dispatch>; + sortClickCount: number; + setSortClickCount: React.Dispatch>; + selectedArtist: string | null; + selectedGenres: string[]; + yearRange: [number, number]; + ratings: Record; +} + +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' ? : ; + }; + + // ── 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 }; +} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 643b3dd2..6c90cdf0 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -1,21 +1,22 @@ -import { getInternetRadioStations } from '../api/subsonicRadio'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; -import { getStarred, setRating, unstar } from '../api/subsonicStarRating'; +import { setRating, unstar } from '../api/subsonicStarRating'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; +import { TopFavoriteArtistsRow } from '../components/favorites/TopFavoriteArtists'; +import { RadioStationRow } from '../components/favorites/RadioFavorites'; +import { useFavoritesData } from '../hooks/useFavoritesData'; +import { useFavoritesSongFiltering } from '../hooks/useFavoritesSongFiltering'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import CachedImage from '../components/CachedImage'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import StarRating from '../components/StarRating'; -import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Square, Star, Users, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw, AudioLines } from 'lucide-react'; +import { Cast, ChevronDown, ChevronRight, Check, Heart, ListPlus, Play, Square, Star, X, SlidersHorizontal, RotateCcw, AudioLines } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; -import { useAuthStore } from '../store/authStore'; import { useSelectionStore } from '../store/selectionStore'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; @@ -41,11 +42,10 @@ const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duratio 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); + const { + albums, artists, songs, setSongs, radioStations, + loading, topFavoriteArtists, unfavoriteStation, + } = useFavoritesData(); // ── Sorting (3-state: asc → desc → reset) ──────────────────────────────── const [sortKey, setSortKey] = useState('natural'); @@ -104,48 +104,13 @@ export default function Favorites() { 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 { filteredSongs, visibleSongs, handleSortClick, getSortIndicator } = useFavoritesSongFiltering({ + songs, sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount, + selectedArtist, selectedGenres, yearRange, ratings, + }); const openContextMenu = usePlayerStore(s => s.openContextMenu); const navigate = useNavigate(); - const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); // Clear selection when song list changes useEffect(() => { @@ -185,116 +150,6 @@ export default function Favorites() { }); }, [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]); - - // ── Top Favorite Artists aggregated from favorited songs ───────────── - const topFavoriteArtists = useMemo(() => { - const counts = new Map(); - 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]); - - // ── 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 ( @@ -788,231 +643,3 @@ export default function Favorites() { ); } - -// ── Top Favorite Artists Row ────────────────────────────────────────────────── - -interface TopFavoriteArtist { - id: string; - name: string; - count: number; - coverArtId: string; -} - -interface TopFavoriteArtistsRowProps { - title: string; - artists: TopFavoriteArtist[]; - selectedKey: string | null; - onToggle: (key: string) => void; -} - -function TopFavoriteArtistsRow({ title, artists, selectedKey, onToggle }: TopFavoriteArtistsRowProps) { - const { t } = useTranslation(); - 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); - }; - - 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 ( -
-
-

{title}

-
- - -
-
- -
-
- {artists.map(a => ( - onToggle(a.id)} - songCountLabel={t('favorites.topArtistsSongCount', { count: a.count })} - /> - ))} -
-
-
- ); -} - -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 ( -
-
- {coverId ? ( - { - e.currentTarget.style.display = 'none'; - e.currentTarget.parentElement?.classList.add('fallback-visible'); - }} - /> - ) : ( - - )} -
-
- {artist.name} - {songCountLabel} -
-
- ); -} - -// ── 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}
-
- -
-
-
- ); -}