Files
lidify/frontend/features/home/components/LibraryRadioStations.tsx
2025-12-25 18:58:06 -06:00

499 lines
18 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import {
Radio,
Play,
Loader2,
Shuffle,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { api } from "@/lib/api";
import { useAudioControls } from "@/lib/audio-controls-context";
import { Track } from "@/lib/audio-state-context";
import { toast } from "sonner";
import { cn } from "@/utils/cn";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
interface RadioStation {
id: string;
name: string;
description: string;
color: string;
filter: {
type:
| "genre"
| "decade"
| "discovery"
| "favorites"
| "all"
| "workout";
value?: string;
};
minTracks?: number;
}
interface GenreCount {
genre: string;
count: number;
}
// Static radio stations (always shown if tracks exist)
const STATIC_STATIONS: RadioStation[] = [
{
id: "all",
name: "Shuffle All",
description: "Your entire library",
color: "from-[#ecb200]/60 to-amber-600/40",
filter: { type: "all" },
minTracks: 10,
},
{
id: "workout",
name: "Workout",
description: "High energy tracks",
color: "from-red-500/50 to-orange-600/40",
filter: { type: "workout" },
minTracks: 15,
},
{
id: "discovery",
name: "Discovery",
description: "Lesser-played gems",
color: "from-emerald-500/50 to-teal-600/40",
filter: { type: "discovery" },
minTracks: 20,
},
{
id: "favorites",
name: "Favorites",
description: "Most played",
color: "from-rose-500/50 to-pink-600/40",
filter: { type: "favorites" },
minTracks: 10,
},
];
interface DecadeCount {
decade: number;
count: number;
}
// Decade color mapping
const DECADE_COLORS: Record<number, string> = {
1700: "from-amber-800/50 to-yellow-900/40",
1800: "from-slate-500/50 to-gray-600/40",
1900: "from-amber-400/50 to-yellow-500/40",
1920: "from-yellow-500/50 to-amber-600/40",
1940: "from-red-400/50 to-orange-500/40",
1950: "from-pink-400/50 to-red-500/40",
1960: "from-amber-500/50 to-orange-600/40",
1970: "from-orange-500/50 to-red-600/40",
1980: "from-fuchsia-500/50 to-purple-600/40",
1990: "from-purple-500/50 to-violet-600/40",
2000: "from-blue-500/50 to-cyan-600/40",
2010: "from-teal-500/50 to-emerald-600/40",
2020: "from-orange-500/50 to-amber-600/40",
};
const getDecadeColor = (decade: number): string => {
const knownDecades = Object.keys(DECADE_COLORS)
.map(Number)
.sort((a, b) => b - a);
for (const known of knownDecades) {
if (decade >= known) {
return DECADE_COLORS[known];
}
}
return "from-gray-500/50 to-slate-600/40";
};
const getDecadeName = (decade: number): string => {
if (decade < 1900) return `${decade}s`;
if (decade < 2000) return `${decade.toString().slice(2)}s`;
return `${decade}s`;
};
// Genre color mapping
const GENRE_COLORS: Record<string, string> = {
rock: "from-red-500/50 to-orange-600/40",
pop: "from-pink-500/50 to-rose-600/40",
"hip hop": "from-purple-500/50 to-indigo-600/40",
"hip-hop": "from-purple-500/50 to-indigo-600/40",
rap: "from-purple-500/50 to-indigo-600/40",
electronic: "from-cyan-500/50 to-blue-600/40",
jazz: "from-amber-500/50 to-yellow-600/40",
classical: "from-slate-400/50 to-gray-500/40",
metal: "from-zinc-600/50 to-neutral-700/40",
country: "from-orange-400/50 to-amber-500/40",
folk: "from-green-500/50 to-emerald-600/40",
indie: "from-violet-500/50 to-purple-600/40",
alternative: "from-indigo-500/50 to-blue-600/40",
"r&b": "from-fuchsia-500/50 to-pink-600/40",
soul: "from-amber-600/50 to-orange-700/40",
blues: "from-blue-600/50 to-indigo-700/40",
punk: "from-lime-500/50 to-green-600/40",
reggae: "from-green-400/50 to-yellow-500/40",
default: "from-gray-500/50 to-slate-600/40",
};
const getGenreColor = (genre: string): string => {
const lower = genre.toLowerCase();
return GENRE_COLORS[lower] || GENRE_COLORS.default;
};
export function LibraryRadioStations() {
const { playTracks } = useAudioControls();
const [loadingStation, setLoadingStation] = useState<string | null>(null);
const [genres, setGenres] = useState<GenreCount[]>([]);
const [decades, setDecades] = useState<DecadeCount[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [genresRes, decadesRes] = await Promise.all([
api.get<{ genres: GenreCount[] }>("/library/genres"),
api.get<{ decades: DecadeCount[] }>("/library/decades"),
]);
const validGenres = (genresRes.genres || [])
.filter((g) => g.count >= 15)
.slice(0, 6);
setGenres(validGenres);
setDecades((decadesRes.decades || []).slice(0, 4));
} catch (error) {
console.error("Failed to fetch radio data:", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const startRadio = async (station: RadioStation) => {
setLoadingStation(station.id);
try {
const params = new URLSearchParams();
params.set("type", station.filter.type);
if (station.filter.value) {
params.set("value", station.filter.value);
}
params.set("limit", "100");
const response = await api.get<{ tracks: Track[] }>(
`/library/radio?${params.toString()}`
);
if (!response.tracks || response.tracks.length === 0) {
toast.error(`No tracks found for ${station.name}`);
return;
}
if (response.tracks.length < (station.minTracks || 10)) {
toast.error(`Not enough tracks for ${station.name} radio`, {
description: `Found ${
response.tracks.length
}, need at least ${station.minTracks || 10}`,
});
return;
}
const shuffled = [...response.tracks].sort(
() => Math.random() - 0.5
);
playTracks(shuffled, 0);
toast.success(`${station.name} Radio`, {
description: `Shuffling ${shuffled.length} tracks`,
icon: <Shuffle className="w-4 h-4" />,
});
} catch (error) {
console.error("Failed to start radio:", error);
toast.error("Failed to start radio station");
} finally {
setLoadingStation(null);
}
};
const allStations = useMemo(() => {
const genreStations: RadioStation[] = genres.map((g) => ({
id: `genre-${g.genre}`,
name: g.genre,
description: `${g.count} tracks`,
color: getGenreColor(g.genre),
filter: { type: "genre" as const, value: g.genre },
minTracks: 15,
}));
const decadeStations: RadioStation[] = decades.map((d) => ({
id: `decade-${d.decade}`,
name: getDecadeName(d.decade),
description: `${d.count} tracks`,
color: getDecadeColor(d.decade),
filter: { type: "decade" as const, value: d.decade.toString() },
minTracks: 15,
}));
return [...STATIC_STATIONS, ...genreStations, ...decadeStations];
}, [genres, decades]);
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isMobileOrTablet = isMobile || isTablet;
// Group stations into pages of 6 (2x3 grid) for mobile only
const stationPages = useMemo(() => {
const pages: RadioStation[][] = [];
for (let i = 0; i < allStations.length; i += 6) {
pages.push(allStations.slice(i, i + 6));
}
return pages;
}, [allStations]);
const checkScroll = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
// Update current page for mobile
if (isMobileOrTablet) {
const pageWidth = el.clientWidth;
const newPage = Math.round(el.scrollLeft / pageWidth);
setCurrentPage(newPage);
}
};
useEffect(() => {
checkScroll();
const el = scrollRef.current;
if (el) {
el.addEventListener("scroll", checkScroll);
window.addEventListener("resize", checkScroll);
}
return () => {
if (el) el.removeEventListener("scroll", checkScroll);
window.removeEventListener("resize", checkScroll);
};
}, [stationPages, isMobileOrTablet]);
const scroll = (direction: "left" | "right") => {
const el = scrollRef.current;
if (!el) return;
const scrollAmount = isMobileOrTablet
? el.clientWidth
: el.clientWidth * 0.8;
el.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
});
};
// Desktop: Compact horizontal card
const renderDesktopCard = (station: RadioStation) => (
<button
key={station.id}
onClick={() => startRadio(station)}
disabled={loadingStation !== null}
className={cn(
"flex-shrink-0 snap-start",
"w-[160px] h-[72px] rounded-lg overflow-hidden",
`bg-gradient-to-br ${station.color}`,
"border border-white/10 hover:border-white/20",
"transition-all duration-200",
"hover:scale-[1.02] active:scale-[0.98]",
"disabled:opacity-50 disabled:cursor-not-allowed",
"group relative"
)}
>
<div className="absolute inset-0 p-3 flex flex-col justify-between">
<div className="flex items-center gap-1">
<Radio className="w-3 h-3 text-white/70" />
<span className="text-[8px] text-white/70 font-semibold uppercase tracking-wider">
Radio
</span>
</div>
<div>
<h3 className="text-sm font-bold text-white truncate leading-tight">
{station.name}
</h3>
<p className="text-[10px] text-white/60 truncate">
{station.description}
</p>
</div>
</div>
{loadingStation === station.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-white animate-spin" />
</div>
)}
{/* Play button on hover */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-[#f5c518] flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition-transform">
<Play
className="w-4 h-4 text-black ml-0.5"
fill="currentColor"
/>
</div>
</div>
</button>
);
// Mobile: Card for 2x3 grid
const renderMobileCard = (station: RadioStation) => (
<button
key={station.id}
onClick={() => startRadio(station)}
disabled={loadingStation !== null}
className={cn(
"relative group w-full",
"aspect-[5/3] rounded-lg overflow-hidden",
`bg-gradient-to-br ${station.color}`,
"border border-white/10",
"transition-all duration-200",
"active:scale-[0.98]",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div className="absolute inset-0 p-2 flex flex-col justify-between">
<div className="flex items-center gap-1">
<Radio className="w-2.5 h-2.5 text-white/70" />
<span className="text-[7px] text-white/70 font-semibold uppercase tracking-wider">
Radio
</span>
</div>
<div>
<h3 className="text-[11px] font-bold text-white truncate leading-tight">
{station.name}
</h3>
<p className="text-[9px] text-white/60 truncate">
{station.description}
</p>
</div>
</div>
{loadingStation === station.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 className="w-4 h-4 text-white animate-spin" />
</div>
)}
</button>
);
// Desktop layout: single row horizontal carousel
if (!isMobileOrTablet) {
return (
<div className="relative group/carousel">
{canScrollLeft && (
<button
onClick={() => scroll("left")}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-black/80 flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-opacity hover:bg-black hover:scale-105 border border-white/10 shadow-lg -translate-x-1/2"
aria-label="Scroll left"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
)}
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-hide scroll-smooth snap-x snap-mandatory gap-3 px-1"
>
{allStations.map((station) => renderDesktopCard(station))}
{isLoading &&
Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex-shrink-0 w-[160px] h-[72px] rounded-lg bg-white/5 animate-pulse"
/>
))}
</div>
{canScrollRight && (
<button
onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-black/80 flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-opacity hover:bg-black hover:scale-105 border border-white/10 shadow-lg translate-x-1/2"
aria-label="Scroll right"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
)}
</div>
);
}
// Mobile/Tablet layout: 2x3 grid pages
return (
<div className="relative">
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-hide scroll-smooth snap-x snap-mandatory gap-3"
>
{stationPages.map((page, pageIndex) => (
<div
key={pageIndex}
className="flex-shrink-0 snap-start w-full grid grid-cols-3 grid-rows-2 gap-2"
>
{page.map((station) => renderMobileCard(station))}
{page.length < 6 &&
Array.from({ length: 6 - page.length }).map(
(_, i) => (
<div
key={`empty-${i}`}
className="aspect-[5/3]"
/>
)
)}
</div>
))}
{isLoading && (
<div className="flex-shrink-0 snap-start w-full grid grid-cols-3 grid-rows-2 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="aspect-[5/3] rounded-lg bg-white/5 animate-pulse"
/>
))}
</div>
)}
</div>
{stationPages.length > 1 && (
<div className="flex justify-center gap-1.5 mt-3">
{stationPages.map((_, index) => (
<button
key={index}
onClick={() => {
const el = scrollRef.current;
if (el) {
el.scrollTo({
left: index * el.clientWidth,
behavior: "smooth",
});
}
}}
className={cn(
"w-1.5 h-1.5 rounded-full transition-colors",
index === currentPage
? "bg-white"
: "bg-white/30 hover:bg-white/50"
)}
aria-label={`Go to page ${index + 1}`}
/>
))}
</div>
)}
</div>
);
}