499 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|