jefflix-website/app/radio/page.tsx

404 lines
17 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useMusicPlayer, type RadioTrack } from '@/components/music/music-provider'
import { GlobeLoader, type GlobePoint } from '@/components/globe/globe-loader'
import { JefflixLogo } from '@/components/jefflix-logo'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
ArrowLeft,
Search,
Radio,
Loader2,
Play,
MapPin,
Globe2,
X,
} from 'lucide-react'
import Link from 'next/link'
import type { RadioPlace, RadioChannel } from '@/lib/radio'
import { getPlaces, getChannels, resolveStreamUrl, searchRadio } from '@/lib/radio'
export default function RadioPage() {
const { state, playTrack } = useMusicPlayer()
// Globe data
const [places, setPlaces] = useState<RadioPlace[]>([])
const [loadingPlaces, setLoadingPlaces] = useState(true)
const [placesError, setPlacesError] = useState('')
// Selected place & channels
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null)
const [channels, setChannels] = useState<RadioChannel[]>([])
const [loadingChannels, setLoadingChannels] = useState(false)
// Search
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [searchResults, setSearchResults] = useState<{
stations: Array<{ id: string; title: string; placeId: string; placeTitle: string; country: string }>
places: Array<{ id: string; title: string; country: string }>
} | null>(null)
const [searching, setSearching] = useState(false)
const debounceRef = useRef<NodeJS.Timeout>(null)
// Playing state
const [resolvingId, setResolvingId] = useState<string | null>(null)
// Globe focus
const [focusLat, setFocusLat] = useState<number | undefined>(undefined)
const [focusLng, setFocusLng] = useState<number | undefined>(undefined)
// Load places on mount
useEffect(() => {
getPlaces()
.then((data) => {
setPlaces(data)
setLoadingPlaces(false)
})
.catch((err) => {
setPlacesError(err.message)
setLoadingPlaces(false)
})
}, [])
// Debounce search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
// Execute search
useEffect(() => {
if (debouncedQuery.length < 2) {
setSearchResults(null)
return
}
setSearching(true)
searchRadio(debouncedQuery)
.then(setSearchResults)
.catch(() => setSearchResults(null))
.finally(() => setSearching(false))
}, [debouncedQuery])
// Convert places to globe points
const globePoints: GlobePoint[] = places.map((p) => ({
lat: p.lat,
lng: p.lng,
id: p.id,
label: `${p.title}, ${p.country}`,
color: '#f43f5e', // rose-500
size: p.size,
}))
// Handle globe point click
const handlePointClick = useCallback((point: GlobePoint) => {
const place = places.find((p) => p.id === point.id)
if (!place) return
setSelectedPlace(place)
setFocusLat(place.lat)
setFocusLng(place.lng)
setLoadingChannels(true)
getChannels(place.id)
.then(setChannels)
.catch(() => setChannels([]))
.finally(() => setLoadingChannels(false))
}, [places])
// Handle search place click
const handleSearchPlaceClick = useCallback((placeId: string, placeTitle: string, country: string) => {
// Find in places array for geo data, or just load channels
const place = places.find((p) => p.id === placeId)
if (place) {
setSelectedPlace(place)
setFocusLat(place.lat)
setFocusLng(place.lng)
} else {
setSelectedPlace({ id: placeId, title: placeTitle, country, lat: 0, lng: 0, size: 1 })
}
setQuery('')
setSearchResults(null)
setLoadingChannels(true)
getChannels(placeId)
.then(setChannels)
.catch(() => setChannels([]))
.finally(() => setLoadingChannels(false))
}, [places])
// Play a station
const handlePlayStation = useCallback(async (channel: RadioChannel) => {
setResolvingId(channel.id)
try {
const streamUrl = await resolveStreamUrl(channel.id)
const radioTrack: RadioTrack = {
type: 'radio',
id: `radio:${channel.id}`,
title: channel.title,
artist: `${channel.placeTitle}, ${channel.country}`,
album: '',
albumId: '',
duration: 0,
coverArt: '',
streamUrl,
}
playTrack(radioTrack)
} catch (err) {
console.error('Failed to play station:', err)
} finally {
setResolvingId(null)
}
}, [playTrack])
// Play search station directly
const handlePlaySearchStation = useCallback(async (station: { id: string; title: string; placeTitle: string; country: string }) => {
setResolvingId(station.id)
try {
const streamUrl = await resolveStreamUrl(station.id)
const radioTrack: RadioTrack = {
type: 'radio',
id: `radio:${station.id}`,
title: station.title,
artist: `${station.placeTitle}, ${station.country}`,
album: '',
albumId: '',
duration: 0,
coverArt: '',
streamUrl,
}
playTrack(radioTrack)
} catch (err) {
console.error('Failed to play station:', err)
} finally {
setResolvingId(null)
}
}, [playTrack])
const isPlaying = (channelId: string) =>
state.currentTrack?.id === `radio:${channelId}` && state.isPlaying
return (
<div className={`min-h-screen bg-background ${state.currentTrack ? 'pb-20' : ''}`}>
{/* Header */}
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-40">
<div className="container mx-auto px-4 py-3 flex items-center gap-4">
<Link href="/" className="flex-shrink-0">
<JefflixLogo />
</Link>
<div className="flex-1" />
<Link href="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1" />
Home
</Button>
</Link>
</div>
</div>
{/* Hero */}
<div className="container mx-auto px-4 py-6 text-center">
<div className="flex items-center justify-center gap-3 mb-2">
<Radio className="h-8 w-8 text-rose-500" />
<h1 className="text-3xl font-bold">World Radio</h1>
</div>
<p className="text-muted-foreground max-w-lg mx-auto">
Explore live radio stations from around the globe. Click a point on the globe or search for stations.
</p>
</div>
{/* Main content */}
<div className="container mx-auto px-4 pb-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Globe */}
<div className="lg:col-span-3 relative">
<div className="rounded-xl border border-border bg-card overflow-hidden">
{loadingPlaces ? (
<div className="flex items-center justify-center h-[400px] lg:h-[550px]">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="text-sm">Loading {places.length > 0 ? `${places.length.toLocaleString()} stations` : 'stations'}...</span>
</div>
</div>
) : placesError ? (
<div className="flex items-center justify-center h-[400px] lg:h-[550px] text-destructive">
<p>Failed to load stations: {placesError}</p>
</div>
) : (
<GlobeLoader
points={globePoints}
onPointClick={handlePointClick}
height={typeof window !== 'undefined' && window.innerWidth >= 1024 ? 550 : 400}
autoRotate={!selectedPlace}
focusLat={focusLat}
focusLng={focusLng}
/>
)}
</div>
{places.length > 0 && (
<p className="text-xs text-muted-foreground text-center mt-2">
<Globe2 className="h-3 w-3 inline mr-1" />
{places.length.toLocaleString()} locations worldwide
</p>
)}
</div>
{/* Station panel */}
<div className="lg:col-span-2">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search stations, cities, countries..."
className="w-full pl-10 pr-10 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-rose-500/50"
/>
{query && (
<button
onClick={() => { setQuery(''); setSearchResults(null) }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Search results */}
{searchResults && (
<div className="rounded-lg border border-border bg-card mb-4">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium text-muted-foreground">
{searching ? 'Searching...' : `${searchResults.stations.length} stations, ${searchResults.places.length} places`}
</h3>
</div>
<ScrollArea className="max-h-[300px]">
{/* Station results */}
{searchResults.stations.map((station) => (
<button
key={station.id}
onClick={() => handlePlaySearchStation(station)}
disabled={resolvingId === station.id}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-rose-500/10 flex items-center justify-center">
{resolvingId === station.id ? (
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
) : isPlaying(station.id) ? (
<div className="flex items-center gap-0.5">
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
<div className="w-0.5 h-4 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
<div className="w-0.5 h-2 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
</div>
) : (
<Play className="h-3.5 w-3.5 text-rose-500 ml-0.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{station.title}</div>
<div className="text-xs text-muted-foreground truncate">
{station.placeTitle}, {station.country}
</div>
</div>
<Radio className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
</button>
))}
{/* Place results */}
{searchResults.places.map((place) => (
<button
key={place.id}
onClick={() => handleSearchPlaceClick(place.id, place.title, place.country)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center">
<MapPin className="h-3.5 w-3.5 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{place.title}</div>
<div className="text-xs text-muted-foreground">{place.country}</div>
</div>
<Badge variant="secondary" className="text-[10px]">Place</Badge>
</button>
))}
</ScrollArea>
</div>
)}
{/* Selected place channels */}
{selectedPlace && !searchResults && (
<div className="rounded-lg border border-border bg-card">
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
<MapPin className="h-4 w-4 text-rose-500" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate">{selectedPlace.title}</h3>
<p className="text-xs text-muted-foreground">{selectedPlace.country}</p>
</div>
<button
onClick={() => { setSelectedPlace(null); setChannels([]) }}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{loadingChannels ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : channels.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No stations found in this area
</div>
) : (
<ScrollArea className="max-h-[400px]">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => handlePlayStation(channel)}
disabled={resolvingId === channel.id}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
>
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-rose-500/10 flex items-center justify-center">
{resolvingId === channel.id ? (
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
) : isPlaying(channel.id) ? (
<div className="flex items-center gap-0.5">
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
<div className="w-0.5 h-5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
<div className="w-0.5 h-2.5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
</div>
) : (
<Play className="h-5 w-5 text-rose-500 ml-0.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{channel.title}</div>
<div className="text-xs text-muted-foreground truncate">
{channel.placeTitle}, {channel.country}
</div>
</div>
</button>
))}
</ScrollArea>
)}
</div>
)}
{/* Empty state */}
{!selectedPlace && !searchResults && (
<div className="rounded-lg border border-border bg-card px-6 py-12 text-center">
<Globe2 className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
<h3 className="font-medium mb-1">Explore World Radio</h3>
<p className="text-sm text-muted-foreground">
Click a point on the globe to discover radio stations, or use the search bar to find specific stations and cities.
</p>
</div>
)}
</div>
</div>
</div>
</div>
)
}