404 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|