Files
lidify/frontend/app/radio/page.tsx
2025-12-25 18:58:06 -06:00

438 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Radio, Play, Loader2, Shuffle, ChevronLeft } from "lucide-react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAudioControls } from "@/lib/audio-controls-context";
import { Track } from "@/lib/audio-state-context";
import { toast } from "sonner";
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
const STATIC_STATIONS: RadioStation[] = [
{
id: "all",
name: "Shuffle All",
description: "Your entire library",
color: "from-brand/40 to-amber-600/30",
filter: { type: "all" },
minTracks: 10,
},
{
id: "workout",
name: "Workout",
description: "High energy tracks",
color: "from-red-500/30 to-orange-600/30",
filter: { type: "workout" },
minTracks: 15,
},
{
id: "discovery",
name: "Discovery",
description: "Lesser-played gems",
color: "from-emerald-500/30 to-teal-600/30",
filter: { type: "discovery" },
minTracks: 20,
},
{
id: "favorites",
name: "Favorites",
description: "Most played",
color: "from-rose-500/30 to-pink-600/30",
filter: { type: "favorites" },
minTracks: 10,
},
];
interface DecadeCount {
decade: number;
count: number;
}
// Decade color mapping - covers from 1700s (classical) to 2020s
const DECADE_COLORS: Record<number, string> = {
1700: "from-amber-800/30 to-yellow-900/30",
1710: "from-amber-700/30 to-yellow-800/30",
1720: "from-amber-700/30 to-yellow-800/30",
1730: "from-amber-700/30 to-yellow-800/30",
1740: "from-amber-700/30 to-yellow-800/30",
1750: "from-amber-600/30 to-yellow-700/30",
1760: "from-amber-600/30 to-yellow-700/30",
1770: "from-amber-600/30 to-yellow-700/30",
1780: "from-amber-600/30 to-yellow-700/30",
1790: "from-amber-600/30 to-yellow-700/30",
1800: "from-slate-600/30 to-gray-700/30",
1810: "from-slate-600/30 to-gray-700/30",
1820: "from-slate-500/30 to-gray-600/30",
1830: "from-slate-500/30 to-gray-600/30",
1840: "from-slate-500/30 to-gray-600/30",
1850: "from-slate-400/30 to-gray-500/30",
1860: "from-slate-400/30 to-gray-500/30",
1870: "from-slate-400/30 to-gray-500/30",
1880: "from-slate-400/30 to-gray-500/30",
1890: "from-slate-400/30 to-gray-500/30",
1900: "from-sepia-400/30 to-amber-500/30",
1910: "from-amber-400/30 to-yellow-500/30",
1920: "from-yellow-500/30 to-amber-600/30",
1930: "from-orange-400/30 to-amber-500/30",
1940: "from-red-400/30 to-orange-500/30",
1950: "from-pink-400/30 to-red-500/30",
1960: "from-amber-500/30 to-orange-600/30",
1970: "from-orange-500/30 to-red-600/30",
1980: "from-fuchsia-500/30 to-purple-600/30",
1990: "from-purple-500/30 to-violet-600/30",
2000: "from-blue-500/30 to-cyan-600/30",
2010: "from-teal-500/30 to-emerald-600/30",
2020: "from-orange-500/30 to-amber-600/30",
};
const getDecadeColor = (decade: number): string => {
return DECADE_COLORS[decade] || "from-gray-500/30 to-slate-600/30";
};
const getDecadeName = (decade: number): string => {
if (decade < 1900) return `${decade}s`;
if (decade < 2000) return `${decade.toString().slice(2)}s`;
return `${decade}s`;
};
const getDecadeDescription = (decade: number, count: number): string => {
return `${decade}-${decade + 9}${count} tracks`;
};
// Genre color mapping
const GENRE_COLORS: Record<string, string> = {
rock: "from-red-500/30 to-orange-600/30",
pop: "from-pink-500/30 to-rose-600/30",
"hip hop": "from-purple-500/30 to-indigo-600/30",
"hip-hop": "from-purple-500/30 to-indigo-600/30",
rap: "from-purple-500/30 to-indigo-600/30",
electronic: "from-cyan-500/30 to-blue-600/30",
jazz: "from-amber-500/30 to-yellow-600/30",
classical: "from-slate-400/30 to-gray-500/30",
metal: "from-zinc-600/30 to-neutral-700/30",
country: "from-orange-400/30 to-amber-500/30",
folk: "from-green-500/30 to-emerald-600/30",
indie: "from-violet-500/30 to-purple-600/30",
alternative: "from-indigo-500/30 to-blue-600/30",
"r&b": "from-fuchsia-500/30 to-pink-600/30",
soul: "from-amber-600/30 to-orange-700/30",
blues: "from-blue-600/30 to-indigo-700/30",
punk: "from-lime-500/30 to-green-600/30",
reggae: "from-green-400/30 to-yellow-500/30",
default: "from-gray-500/30 to-slate-600/30",
};
const getGenreColor = (genre: string): string => {
const lower = genre.toLowerCase();
return GENRE_COLORS[lower] || GENRE_COLORS.default;
};
// Radio Station Card Component
function RadioStationCard({
station,
onPlay,
isLoading
}: {
station: RadioStation;
onPlay: () => void;
isLoading: boolean;
}) {
return (
<button
onClick={onPlay}
disabled={isLoading}
className={`
relative group w-full
aspect-[4/3] 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
`}
>
{/* Content */}
<div className="absolute inset-0 p-3 flex flex-col justify-between">
<div className="flex items-center gap-1.5">
<Radio className="w-4 h-4 text-white/60" />
<span className="text-[10px] text-white/60 font-medium uppercase tracking-wider">
Radio
</span>
</div>
<div>
<h3 className="text-sm font-bold text-white truncate leading-tight">
{station.name}
</h3>
<p className="text-xs text-white/50 truncate">
{station.description}
</p>
</div>
</div>
{/* Play overlay on hover */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{isLoading ? (
<Loader2 className="w-8 h-8 text-white animate-spin" />
) : (
<div className="w-12 h-12 rounded-full bg-brand flex items-center justify-center shadow-lg">
<Play className="w-5 h-5 text-black ml-0.5" fill="currentColor" />
</div>
)}
</div>
</button>
);
}
// Section Header Component
function SectionHeader({ title, description }: { title: string; description?: string }) {
return (
<div className="mb-4">
<h2 className="text-xl font-bold text-white">{title}</h2>
{description && <p className="text-sm text-white/50 mt-1">{description}</p>}
</div>
);
}
export default function RadioPage() {
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);
// Fetch available genres and decades from library
useEffect(() => {
const fetchData = async () => {
try {
const [genresRes, decadesRes] = await Promise.all([
api.get<{ genres: GenreCount[] }>("/library/genres"),
api.get<{ decades: DecadeCount[] }>("/library/decades"),
]);
// Filter to genres with enough tracks (at least 15)
const validGenres = (genresRes.genres || []).filter((g) => g.count >= 15);
setGenres(validGenres);
// Decades already filtered by backend (15+ tracks)
setDecades(decadesRes.decades || []);
} 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;
}
// Shuffle the tracks
const shuffled = [...response.tracks].sort(() => Math.random() - 0.5);
// Start playing
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);
}
};
// Create genre stations from library
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,
}));
// Create decade stations from library (dynamically based on what's available)
const decadeStations: RadioStation[] = decades.map((d) => ({
id: `decade-${d.decade}`,
name: getDecadeName(d.decade),
description: getDecadeDescription(d.decade, d.count),
color: getDecadeColor(d.decade),
filter: { type: "decade" as const, value: d.decade.toString() },
minTracks: 15,
}));
return (
<div className="min-h-screen relative">
{/* Hero gradient */}
<div
className="absolute top-0 left-0 right-0 pointer-events-none"
style={{
background: "linear-gradient(to bottom, rgba(236, 178, 0, 0.15) 0%, rgba(139, 92, 246, 0.08) 40%, transparent 100%)",
height: "35vh"
}}
/>
<div
className="absolute top-0 left-0 right-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at top, rgba(236, 178, 0, 0.1) 0%, transparent 70%)",
height: "25vh"
}}
/>
{/* Content */}
<div className="relative px-4 md:px-8 py-6">
{/* Back link */}
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-white/60 hover:text-white transition-colors mb-6"
>
<ChevronLeft className="w-4 h-4" />
Back to Home
</Link>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-brand to-amber-600 flex items-center justify-center">
<Radio className="w-6 h-6 text-black" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Radio Stations</h1>
<p className="text-white/60">Continuous shuffle from your library</p>
</div>
</div>
</div>
{/* Quick Start Section */}
<section className="mb-10">
<SectionHeader
title="Quick Start"
description="Jump into your music instantly"
/>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{STATIC_STATIONS.map((station) => (
<RadioStationCard
key={station.id}
station={station}
onPlay={() => startRadio(station)}
isLoading={loadingStation === station.id}
/>
))}
</div>
</section>
{/* Genres Section */}
{(isLoading || genreStations.length > 0) && (
<section className="mb-10">
<SectionHeader
title="By Genre"
description="Shuffle tracks from specific genres"
/>
{isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-[4/3] rounded-lg bg-white/5 animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{genreStations.map((station) => (
<RadioStationCard
key={station.id}
station={station}
onPlay={() => startRadio(station)}
isLoading={loadingStation === station.id}
/>
))}
</div>
)}
</section>
)}
{/* Decades Section - Only show if there are decade stations */}
{(isLoading || decadeStations.length > 0) && (
<section className="mb-10">
<SectionHeader
title="By Decade"
description="Travel through time with your music"
/>
{isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-[4/3] rounded-lg bg-white/5 animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{decadeStations.map((station) => (
<RadioStationCard
key={station.id}
station={station}
onPlay={() => startRadio(station)}
isLoading={loadingStation === station.id}
/>
))}
</div>
)}
</section>
)}
{/* Info */}
<div className="mt-12 p-4 rounded-lg bg-white/5 border border-white/10">
<h3 className="text-sm font-semibold text-white mb-2">About Radio Stations</h3>
<p className="text-sm text-white/60">
Radio stations are generated from your personal music library. As you add more music,
new genre and decade stations will automatically appear. Each station requires a minimum
number of tracks to ensure a good listening experience.
</p>
</div>
</div>
</div>
);
}