feat: add World Radio page with 3D globe + Radio Garden integration

- New /radio page with interactive Three.js globe showing 30K+ radio stations worldwide
- Radio Garden API proxy routes (places, channels, stream resolver, search) with caching
- Extended Track type to MusicTrack | RadioTrack discriminated union
- MusicProvider handles radio streams (skip IndexedDB/precache, direct stream URL)
- Mini-player and full-screen player show LIVE badge, hide seek/download/lyrics for radio
- Shared globe component reused on /request-channel page (Search/Globe tab toggle)
- Homepage gets rose "Listen to Radio" button with Waves icon
- react-globe.gl for efficient rendering of 40K+ geo-points

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 16:48:01 -04:00
parent b56f8c4030
commit c9cd56b452
17 changed files with 1802 additions and 74 deletions

View File

@ -0,0 +1,88 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://radio.garden/',
}
interface RGChannelItem {
page: {
url: string
title: string
place: { id: string; title: string }
country: { id: string; title: string }
website?: string
}
}
interface RGChannelsResponse {
data: {
title: string
content: Array<{ items: RGChannelItem[] }>
}
}
export interface SlimChannel {
id: string
title: string
placeTitle: string
country: string
website?: string
}
// Simple LRU-ish cache: map with max 500 entries
const channelCache = new Map<string, { data: SlimChannel[]; fetchedAt: number }>()
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
const MAX_CACHE = 500
function extractChannelId(url: string): string {
// url format: "/listen/kcsm/HQQcSxCf"
const parts = url.split('/')
return parts[parts.length - 1]
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ placeId: string }> }
) {
const { placeId } = await params
try {
const cached = channelCache.get(placeId)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
return NextResponse.json(cached.data)
}
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/page/${placeId}/channels`, {
headers: HEADERS,
signal: AbortSignal.timeout(10000),
})
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
const json: RGChannelsResponse = await res.json()
const channels: SlimChannel[] = (json.data.content?.[0]?.items || []).map((item) => ({
id: extractChannelId(item.page.url),
title: item.page.title,
placeTitle: item.page.place.title,
country: item.page.country.title,
website: item.page.website,
}))
// Evict oldest if over limit
if (channelCache.size >= MAX_CACHE) {
const oldest = channelCache.keys().next().value
if (oldest) channelCache.delete(oldest)
}
channelCache.set(placeId, { data: channels, fetchedAt: Date.now() })
return NextResponse.json(channels)
} catch (error) {
console.error(`Failed to fetch channels for ${placeId}:`, error)
const cached = channelCache.get(placeId)
if (cached) return NextResponse.json(cached.data)
return NextResponse.json({ error: 'Failed to load channels' }, { status: 502 })
}
}

View File

@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://radio.garden/',
}
interface RGPlace {
id: string
title: string
country: string
geo: [number, number] // [lng, lat]
size: number
}
interface RGPlacesResponse {
data: { list: RGPlace[] }
}
export interface SlimPlace {
id: string
title: string
country: string
lat: number
lng: number
size: number
}
let cache: { data: SlimPlace[]; fetchedAt: number } | null = null
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
export async function GET() {
try {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
return NextResponse.json(cache.data)
}
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/places`, {
headers: HEADERS,
signal: AbortSignal.timeout(10000),
})
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
const json: RGPlacesResponse = await res.json()
const places: SlimPlace[] = json.data.list.map((p) => ({
id: p.id,
title: p.title,
country: p.country,
lat: p.geo[1],
lng: p.geo[0],
size: p.size,
}))
cache = { data: places, fetchedAt: Date.now() }
return NextResponse.json(places)
} catch (error) {
console.error('Failed to fetch radio places:', error)
if (cache) return NextResponse.json(cache.data)
return NextResponse.json({ error: 'Failed to load radio stations' }, { status: 502 })
}
}

View File

@ -0,0 +1,92 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://radio.garden/',
}
interface RGSearchHit {
_source: {
code: string
type: 'channel' | 'place' | 'country'
page: {
url: string
title: string
place?: { id: string; title: string }
country?: { id: string; title: string }
subtitle?: string
}
}
_score: number
}
interface RGSearchResponse {
hits: { hits: RGSearchHit[] }
}
function extractId(url: string): string {
const parts = url.split('/')
return parts[parts.length - 1]
}
export interface SearchStation {
id: string
title: string
placeId: string
placeTitle: string
country: string
}
export interface SearchPlace {
id: string
title: string
country: string
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const q = searchParams.get('q')
if (!q || q.length < 2) {
return NextResponse.json({ stations: [], places: [] })
}
try {
const res = await fetch(`${RADIO_GARDEN_API}/search?q=${encodeURIComponent(q)}`, {
headers: HEADERS,
signal: AbortSignal.timeout(10000),
})
if (!res.ok) throw new Error(`Radio Garden search returned ${res.status}`)
const json: RGSearchResponse = await res.json()
const stations: SearchStation[] = []
const places: SearchPlace[] = []
for (const hit of json.hits.hits) {
const src = hit._source
if (src.type === 'channel') {
stations.push({
id: extractId(src.page.url),
title: src.page.title,
placeId: src.page.place?.id || '',
placeTitle: src.page.place?.title || '',
country: src.page.country?.title || src.code,
})
} else if (src.type === 'place') {
places.push({
id: extractId(src.page.url),
title: src.page.title,
country: src.page.country?.title || src.code,
})
}
}
return NextResponse.json({ stations, places })
} catch (error) {
console.error('Radio search error:', error)
return NextResponse.json({ error: 'Search failed' }, { status: 502 })
}
}

View File

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://radio.garden/',
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ channelId: string }> }
) {
const { channelId } = await params
try {
// Follow the 302 redirect to get the actual stream URL
const res = await fetch(
`${RADIO_GARDEN_API}/ara/content/listen/${channelId}/channel.mp3`,
{
headers: HEADERS,
redirect: 'manual', // Don't auto-follow — we want the Location header
signal: AbortSignal.timeout(10000),
}
)
if (res.status === 302) {
const streamUrl = res.headers.get('location')
if (streamUrl) {
return NextResponse.json({ url: streamUrl })
}
}
// Some stations return 301 or other redirects
if (res.status >= 300 && res.status < 400) {
const streamUrl = res.headers.get('location')
if (streamUrl) {
return NextResponse.json({ url: streamUrl })
}
}
throw new Error(`Unexpected status ${res.status}`)
} catch (error) {
console.error(`Failed to resolve stream for ${channelId}:`, error)
return NextResponse.json({ error: 'Failed to resolve stream URL' }, { status: 502 })
}
}

View File

@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio, ListPlus, Play, Upload } from "lucide-react"
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio, ListPlus, Play, Upload, Waves } from "lucide-react"
import { JefflixLogo } from "@/components/jefflix-logo"
export default function JefflixPage() {
@ -113,6 +113,17 @@ export default function JefflixPage() {
Listen to Music
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white"
variant="default"
>
<a href="/radio">
<Waves className="mr-2 h-5 w-5" />
Listen to Radio
</a>
</Button>
</div>
</div>
</div>
@ -285,6 +296,12 @@ export default function JefflixPage() {
Listen to Music
</a>
</Button>
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white">
<a href="/radio">
<Waves className="mr-2 h-5 w-5" />
Listen to Radio
</a>
</Button>
</div>
<p className="text-sm text-muted-foreground pt-4">
Or learn how to set up your own Jellyfin server and join the movement

403
app/radio/page.tsx Normal file
View File

@ -0,0 +1,403 @@
'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>
)
}

View File

@ -1,10 +1,12 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { JefflixLogo } from "@/components/jefflix-logo"
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2 } from "lucide-react"
import { GlobeLoader, type GlobePoint } from "@/components/globe/globe-loader"
import { countryToLatLng } from "@/lib/country-centroids"
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2, Globe2, List } from "lucide-react"
import Link from 'next/link'
interface Channel {
@ -26,6 +28,45 @@ export default function RequestChannelPage() {
const [errorMessage, setErrorMessage] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null)
// Globe view toggle
const [view, setView] = useState<'search' | 'globe'>('search')
const [globeCountry, setGlobeCountry] = useState<string | null>(null)
// Build globe points from channels (aggregate by country)
const globePoints: GlobePoint[] = useMemo(() => {
const countryMap = new Map<string, { count: number; coords: [number, number] }>()
for (const ch of channels) {
if (!ch.country) continue
const existing = countryMap.get(ch.country)
if (existing) {
existing.count++
} else {
const coords = countryToLatLng(ch.country)
if (coords) {
countryMap.set(ch.country, { count: 1, coords })
}
}
}
return Array.from(countryMap.entries()).map(([country, { count, coords }]) => ({
lat: coords[0],
lng: coords[1],
id: country,
label: `${country} (${count} channels)`,
color: '#06b6d4', // cyan-500
size: Math.max(1, Math.min(8, Math.log2(count + 1) * 2)),
}))
}, [channels])
// Channels filtered for globe-selected country
const globeFilteredChannels = useMemo(() => {
if (!globeCountry) return []
return channels.filter((ch) => ch.country === globeCountry)
}, [channels, globeCountry])
const handleGlobePointClick = useCallback((point: GlobePoint) => {
setGlobeCountry(point.id) // id = country name
}, [])
// Fetch channel list on mount
useEffect(() => {
fetch('/api/channels')
@ -151,6 +192,30 @@ export default function RequestChannelPage() {
<p className="text-muted-foreground">
Search the iptv-org catalog and select channels you&apos;d like us to add to the Live TV lineup.
</p>
{/* View toggle */}
{!loading && !loadError && (
<div className="flex items-center justify-center gap-2">
<Button
variant={view === 'search' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('search')}
className={view === 'search' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
>
<List className="h-4 w-4 mr-1.5" />
Search
</Button>
<Button
variant={view === 'globe' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('globe')}
className={view === 'globe' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
>
<Globe2 className="h-4 w-4 mr-1.5" />
Globe
</Button>
</div>
)}
</div>
{loading ? (
@ -166,6 +231,113 @@ export default function RequestChannelPage() {
Try Again
</Button>
</div>
) : view === 'globe' ? (
/* Globe View */
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<GlobeLoader
points={globePoints}
onPointClick={handleGlobePointClick}
height={450}
autoRotate={!globeCountry}
/>
</div>
{/* Selected chips (shared with search view) */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2">
{selected.map((ch) => (
<Badge
key={ch.id}
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
onClick={() => removeChannel(ch.id)}
>
{ch.name}
<X className="h-3.5 w-3.5 ml-1" />
</Badge>
))}
</div>
)}
{/* Country channel list */}
{globeCountry && (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div>
<h3 className="font-semibold">{globeCountry}</h3>
<p className="text-xs text-muted-foreground">{globeFilteredChannels.length} channels</p>
</div>
<button onClick={() => setGlobeCountry(null)} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[300px] overflow-y-auto divide-y divide-border">
{globeFilteredChannels.map((ch) => {
const isSelected = selected.some((s) => s.id === ch.id)
return (
<button
key={ch.id}
type="button"
onClick={() => toggleChannel(ch)}
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
}`}
>
<div className="min-w-0">
<div className="font-medium truncate">{ch.name}</div>
<div className="flex flex-wrap gap-1 mt-1">
{ch.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs">{cat}</Badge>
))}
</div>
</div>
{isSelected && <CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />}
</button>
)
})}
</div>
</div>
)}
{!globeCountry && (
<p className="text-center text-sm text-muted-foreground">
Click a point on the globe to browse channels by country
</p>
)}
{/* Email + Submit (same as search view) */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="globe-email" className="text-sm font-medium">Your Email</label>
<input
type="email"
id="globe-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="your@email.com"
/>
</div>
{status === 'error' && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
)}
<Button
type="submit"
disabled={status === 'submitting' || selected.length === 0}
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
>
{status === 'submitting'
? 'Submitting...'
: selected.length === 0
? 'Select channels to request'
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
</Button>
</form>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Search input */}

View File

@ -0,0 +1,23 @@
'use client'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
export type { GlobePoint } from './globe-visualization'
const GlobeVisualization = dynamic(
() => import('./globe-visualization').then((m) => m.GlobeVisualization),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full w-full min-h-[300px]">
<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 globe...</span>
</div>
</div>
),
}
)
export { GlobeVisualization as GlobeLoader }

View File

@ -0,0 +1,97 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import Globe from 'react-globe.gl'
export interface GlobePoint {
lat: number
lng: number
id: string
label: string
color: string
size?: number
}
interface GlobeVisualizationProps {
points: GlobePoint[]
onPointClick: (point: GlobePoint) => void
height?: number
autoRotate?: boolean
focusLat?: number
focusLng?: number
}
export function GlobeVisualization({
points,
onPointClick,
height = 500,
autoRotate = true,
focusLat,
focusLng,
}: GlobeVisualizationProps) {
const globeRef = useRef<any>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
// Responsive width
useEffect(() => {
if (!containerRef.current) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width)
}
})
observer.observe(containerRef.current)
setContainerWidth(containerRef.current.clientWidth)
return () => observer.disconnect()
}, [])
// Configure controls after globe is ready
const handleGlobeReady = useCallback(() => {
const globe = globeRef.current
if (!globe) return
const controls = globe.controls()
if (controls) {
controls.autoRotate = autoRotate
controls.autoRotateSpeed = 0.5
controls.enableDamping = true
controls.dampingFactor = 0.1
}
// Set initial view
globe.pointOfView({ lat: focusLat ?? 20, lng: focusLng ?? 0, altitude: 2.5 }, 0)
}, [autoRotate, focusLat, focusLng])
// Focus on specific coordinates when they change
useEffect(() => {
if (globeRef.current && focusLat != null && focusLng != null) {
globeRef.current.pointOfView({ lat: focusLat, lng: focusLng, altitude: 1.5 }, 1000)
}
}, [focusLat, focusLng])
return (
<div ref={containerRef} className="w-full" style={{ height }}>
{containerWidth > 0 && (
<Globe
ref={globeRef}
width={containerWidth}
height={height}
globeImageUrl="/textures/earth-blue-marble.jpg"
backgroundColor="rgba(0,0,0,0)"
showAtmosphere={true}
atmosphereColor="rgba(100,150,255,0.3)"
atmosphereAltitude={0.15}
pointsData={points}
pointLat="lat"
pointLng="lng"
pointColor="color"
pointAltitude={0.01}
pointRadius={(d: any) => Math.max(0.15, Math.min(0.6, (d.size || 1) * 0.12))}
pointLabel={(d: any) => `<div class="text-sm font-medium px-2 py-1 bg-background/90 border border-border rounded shadow-lg">${d.label}</div>`}
onPointClick={(point: any) => onPointClick(point as GlobePoint)}
onGlobeReady={handleGlobeReady}
enablePointerInteraction={true}
/>
)}
</div>
)
}

View File

@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
import { useMusicPlayer, isRadioTrack } from './music-provider'
import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { SyncedLyrics } from './synced-lyrics'
@ -23,6 +23,7 @@ import {
ChevronDown,
Speaker,
Shuffle,
Radio,
} from 'lucide-react'
function formatTime(secs: number) {
@ -43,9 +44,16 @@ export function FullScreenPlayer() {
const track = state.currentTrack
// Fetch lyrics when track changes
const isRadio = track ? isRadioTrack(track) : false
// Fetch lyrics when track changes (skip for radio)
useEffect(() => {
if (!track) return
if (!track || isRadioTrack(track)) {
setLyrics(null)
setSyncedLyrics(null)
setLoadingLyrics(false)
return
}
setLyrics(null)
setSyncedLyrics(null)
setLoadingLyrics(true)
@ -79,9 +87,9 @@ export function FullScreenPlayer() {
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
<Drawer.Title className="sr-only">Music Player</Drawer.Title>
<Drawer.Title className="sr-only">{isRadio ? 'Radio Player' : 'Music Player'}</Drawer.Title>
<Drawer.Description className="sr-only">
Full-screen music player with controls, lyrics, and playlist management
{isRadio ? 'Live radio player with station controls' : 'Full-screen music player with controls, lyrics, and playlist management'}
</Drawer.Description>
{track && (
@ -104,7 +112,7 @@ export function FullScreenPlayer() {
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Volume2 className="h-16 w-16" />
{isRadio ? <Radio className="h-16 w-16 text-rose-400" /> : <Volume2 className="h-16 w-16" />}
</div>
)}
</div>
@ -126,6 +134,17 @@ export function FullScreenPlayer() {
{/* Progress */}
<div className="w-full max-w-sm mb-4">
{isRadio ? (
<>
<div className="h-2 w-full bg-rose-500/20 rounded-full overflow-hidden mb-2">
<div className="h-full w-full bg-rose-500 animate-pulse" />
</div>
<div className="flex justify-center text-xs">
<span className="font-bold px-2 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse">LIVE</span>
</div>
</>
) : (
<>
<Slider
value={[state.progress]}
max={state.duration || 1}
@ -137,10 +156,13 @@ export function FullScreenPlayer() {
<span>{formatTime(state.progress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-6 mb-6">
{!isRadio && (
<button
onClick={toggleShuffle}
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
@ -148,6 +170,7 @@ export function FullScreenPlayer() {
>
<Shuffle className="h-5 w-5" />
</button>
)}
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipBack className="h-6 w-6" />
</button>
@ -199,19 +222,22 @@ export function FullScreenPlayer() {
<ListMusic className="h-4 w-4 mr-1.5" />
Queue
</Button>
<DownloadButton track={track} size="md" />
{!isRadio && <DownloadButton track={track} size="md" />}
{!isRadio && (
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
<ListPlus className="h-4 w-4 mr-1.5" />
Add to Playlist
</Button>
)}
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="h-4 w-4 mr-1.5" />
Share
</Button>
</div>
{/* Lyrics */}
{loadingLyrics ? (
{/* Lyrics (hidden for radio) */}
{!isRadio && (
loadingLyrics ? (
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
) : syncedLyrics ? (
<SyncedLyrics
@ -226,7 +252,8 @@ export function FullScreenPlayer() {
{lyrics}
</pre>
</div>
) : null}
) : null
)}
</div>
)}
</Drawer.Content>

View File

@ -1,11 +1,11 @@
'use client'
import { useState } from 'react'
import { useMusicPlayer } from './music-provider'
import { useMusicPlayer, isRadioTrack } from './music-provider'
import { FullScreenPlayer } from './full-screen-player'
import { QueueView } from './queue-view'
import { Slider } from '@/components/ui/slider'
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle } from 'lucide-react'
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle, Radio } from 'lucide-react'
function formatTime(secs: number) {
if (!secs || !isFinite(secs)) return '0:00'
@ -21,12 +21,18 @@ export function MiniPlayer() {
if (!state.currentTrack) return null
const track = state.currentTrack
const isRadio = isRadioTrack(track)
return (
<>
<div className="fixed bottom-0 inset-x-0 z-50 bg-card border-t border-border shadow-lg">
{/* Progress bar (thin, above the player) */}
{/* Progress bar (thin, above the player) — disabled for radio */}
<div className="px-2">
{isRadio ? (
<div className="h-1 w-full bg-rose-500/40 rounded-full overflow-hidden">
<div className="h-full w-full bg-rose-500 animate-pulse" />
</div>
) : (
<Slider
value={[state.progress]}
max={state.duration || 1}
@ -34,6 +40,7 @@ export function MiniPlayer() {
onValueChange={([v]) => seek(v)}
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
/>
)}
</div>
<div className="flex items-center gap-3 px-4 py-2 h-14">
@ -49,7 +56,9 @@ export function MiniPlayer() {
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted" />
<div className="w-full h-full flex items-center justify-center bg-muted">
{isRadio && <Radio className="h-5 w-5 text-rose-400" />}
</div>
)}
</button>
@ -62,13 +71,20 @@ export function MiniPlayer() {
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
</button>
{/* Time */}
{/* Time / LIVE badge */}
{isRadio ? (
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse hidden sm:block">
LIVE
</span>
) : (
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatTime(state.progress)} / {formatTime(state.duration)}
</span>
)}
{/* Controls */}
<div className="flex items-center gap-1">
{!isRadio && (
<button
onClick={toggleShuffle}
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
@ -76,6 +92,7 @@ export function MiniPlayer() {
>
<Shuffle className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={prevTrack}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"

View File

@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useRef, useEffect, useCal
import { getTrackBlob } from '@/lib/offline-db'
import { precacheUpcoming } from '@/lib/precache'
export interface Track {
interface TrackBase {
id: string
title: string
artist: string
@ -14,6 +14,21 @@ export interface Track {
coverArt: string
}
export interface MusicTrack extends TrackBase {
type?: 'music'
}
export interface RadioTrack extends TrackBase {
type: 'radio'
streamUrl: string
}
export type Track = MusicTrack | RadioTrack
export function isRadioTrack(track: Track): track is RadioTrack {
return track.type === 'radio'
}
interface PlayerState {
currentTrack: Track | null
queue: Track[]
@ -267,7 +282,15 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
blobUrlRef.current = null
}
// Try offline first, fall back to streaming
// Radio streams: use streamUrl directly, skip offline/IndexedDB
if (isRadioTrack(state.currentTrack)) {
audio.src = state.currentTrack.streamUrl
playWithRetry(audio)
requestAnimationFrame(() => { transitioningRef.current = false })
return
}
// Music: try offline first, fall back to streaming
getTrackBlob(trackId).then((blob) => {
// Guard: track may have changed while we awaited
if (state.currentTrack?.id !== trackId) return
@ -322,9 +345,10 @@ export function MusicProvider({ children }: { children: React.ReactNode }) {
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
}, [state.currentTrack])
// Pre-cache next 3 tracks in queue when current track changes
// Pre-cache next 3 tracks in queue when current track changes (skip for radio)
useEffect(() => {
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
if (state.currentTrack && isRadioTrack(state.currentTrack)) return
const ac = new AbortController()
const delay = setTimeout(() => {

268
lib/country-centroids.ts Normal file
View File

@ -0,0 +1,268 @@
/**
* ISO 3166-1 alpha-2 country codes mapped to [latitude, longitude] centroids.
* Coordinates represent approximate geographic centroids of each country.
*/
export const COUNTRY_CENTROIDS: Record<string, [number, number]> = {
AD: [42.5462, 1.6016], // Andorra
AE: [23.4241, 53.8478], // United Arab Emirates
AF: [33.9391, 67.7100], // Afghanistan
AG: [17.0608, -61.7964], // Antigua and Barbuda
AI: [18.2206, -63.0686], // Anguilla
AL: [41.1533, 20.1683], // Albania
AM: [40.0691, 45.0382], // Armenia
AO: [-11.2027, 17.8739], // Angola
AQ: [-90.0000, 0.0000], // Antarctica
AR: [-38.4161, -63.6167],// Argentina
AS: [-14.2710, -170.1322],// American Samoa
AT: [47.5162, 14.5501], // Austria
AU: [-25.2744, 133.7751],// Australia
AW: [12.5211, -69.9683], // Aruba
AX: [60.1785, 19.9156], // Aland Islands
AZ: [40.1431, 47.5769], // Azerbaijan
BA: [43.9159, 17.6791], // Bosnia and Herzegovina
BB: [13.1939, -59.5432], // Barbados
BD: [23.6850, 90.3563], // Bangladesh
BE: [50.5039, 4.4699], // Belgium
BF: [12.3641, -1.5275], // Burkina Faso
BG: [42.7339, 25.4858], // Bulgaria
BH: [26.0275, 50.5500], // Bahrain
BI: [-3.3731, 29.9189], // Burundi
BJ: [9.3077, 2.3158], // Benin
BL: [17.9000, -62.8333], // Saint Barthelemy
BM: [32.3213, -64.7572], // Bermuda
BN: [4.5353, 114.7277], // Brunei
BO: [-16.2902, -63.5887],// Bolivia
BQ: [12.1784, -68.2385], // Bonaire, Saint Eustatius and Saba
BR: [-14.2350, -51.9253],// Brazil
BS: [25.0343, -77.3963], // Bahamas
BT: [27.5142, 90.4336], // Bhutan
BV: [-54.4208, 3.3464], // Bouvet Island
BW: [-22.3285, 24.6849], // Botswana
BY: [53.7098, 27.9534], // Belarus
BZ: [17.1899, -88.4976], // Belize
CA: [56.1304, -106.3468],// Canada
CC: [-12.1642, 96.8710], // Cocos (Keeling) Islands
CD: [-4.0383, 21.7587], // Democratic Republic of the Congo
CF: [6.6111, 20.9394], // Central African Republic
CG: [-0.2280, 15.8277], // Republic of the Congo
CH: [46.8182, 8.2275], // Switzerland
CI: [7.5400, -5.5471], // Ivory Coast
CK: [-21.2368, -159.7777],// Cook Islands
CL: [-35.6751, -71.5430],// Chile
CM: [3.8480, 11.5021], // Cameroon
CN: [35.8617, 104.1954], // China
CO: [4.5709, -74.2973], // Colombia
CR: [9.7489, -83.7534], // Costa Rica
CU: [21.5218, -77.7812], // Cuba
CV: [16.5388, -23.0418], // Cape Verde
CW: [12.1696, -68.9900], // Curacao
CX: [-10.4475, 105.6904],// Christmas Island
CY: [35.1264, 33.4299], // Cyprus
CZ: [49.8175, 15.4730], // Czech Republic
DE: [51.1657, 10.4515], // Germany
DJ: [11.8251, 42.5903], // Djibouti
DK: [56.2639, 9.5018], // Denmark
DM: [15.4150, -61.3710], // Dominica
DO: [18.7357, -70.1627], // Dominican Republic
DZ: [28.0339, 1.6596], // Algeria
EC: [-1.8312, -78.1834], // Ecuador
EE: [58.5953, 25.0136], // Estonia
EG: [26.8206, 30.8025], // Egypt
EH: [24.2155, -12.8858], // Western Sahara
ER: [15.1794, 39.7823], // Eritrea
ES: [40.4637, -3.7492], // Spain
ET: [9.1450, 40.4897], // Ethiopia
FI: [61.9241, 25.7482], // Finland
FJ: [-16.5782, 179.4144],// Fiji
FK: [-51.7963, -59.5236],// Falkland Islands
FM: [7.4256, 150.5508], // Micronesia
FO: [61.8926, -6.9118], // Faroe Islands
FR: [46.2276, 2.2137], // France
GA: [-0.8037, 11.6094], // Gabon
GB: [55.3781, -3.4360], // United Kingdom
GD: [12.1165, -61.6790], // Grenada
GE: [42.3154, 43.3569], // Georgia
GF: [3.9339, -53.1258], // French Guiana
GG: [49.4657, -2.5853], // Guernsey
GH: [7.9465, -1.0232], // Ghana
GI: [36.1408, -5.3536], // Gibraltar
GL: [71.7069, -42.6043], // Greenland
GM: [13.4432, -15.3101], // Gambia
GN: [9.9456, -11.2874], // Guinea
GP: [16.9950, -62.0670], // Guadeloupe
GQ: [1.6508, 10.2679], // Equatorial Guinea
GR: [39.0742, 21.8243], // Greece
GS: [-54.4296, -36.5879],// South Georgia and the South Sandwich Islands
GT: [15.7835, -90.2308], // Guatemala
GU: [13.4443, 144.7937], // Guam
GW: [11.8037, -15.1804], // Guinea-Bissau
GY: [4.8604, -58.9302], // Guyana
HK: [22.3193, 114.1694], // Hong Kong
HM: [-53.0818, 73.5042], // Heard Island and McDonald Islands
HN: [15.1999, -86.2419], // Honduras
HR: [45.1000, 15.2000], // Croatia
HT: [18.9712, -72.2852], // Haiti
HU: [47.1625, 19.5033], // Hungary
ID: [-0.7893, 113.9213], // Indonesia
IE: [53.4129, -8.2439], // Ireland
IL: [31.0461, 34.8516], // Israel
IM: [54.2361, -4.5481], // Isle of Man
IN: [20.5937, 78.9629], // India
IO: [-6.3432, 71.8765], // British Indian Ocean Territory
IQ: [33.2232, 43.6793], // Iraq
IR: [32.4279, 53.6880], // Iran
IS: [64.9631, -19.0208], // Iceland
IT: [41.8719, 12.5674], // Italy
JE: [49.2144, -2.1312], // Jersey
JM: [18.1096, -77.2975], // Jamaica
JO: [30.5852, 36.2384], // Jordan
JP: [36.2048, 138.2529], // Japan
KE: [-0.0236, 37.9062], // Kenya
KG: [41.2044, 74.7661], // Kyrgyzstan
KH: [12.5657, 104.9910], // Cambodia
KI: [-3.3704, -168.7340],// Kiribati
KM: [-11.8750, 43.8722], // Comoros
KN: [17.3578, -62.7830], // Saint Kitts and Nevis
KP: [40.3399, 127.5101], // North Korea
KR: [35.9078, 127.7669], // South Korea
KW: [29.3117, 47.4818], // Kuwait
KY: [19.3133, -81.2546], // Cayman Islands
KZ: [48.0196, 66.9237], // Kazakhstan
LA: [19.8563, 102.4955], // Laos
LB: [33.8547, 35.8623], // Lebanon
LC: [13.9094, -60.9789], // Saint Lucia
LI: [47.1660, 9.5554], // Liechtenstein
LK: [7.8731, 80.7718], // Sri Lanka
LR: [6.4281, -9.4295], // Liberia
LS: [-29.6100, 28.2336], // Lesotho
LT: [55.1694, 23.8813], // Lithuania
LU: [49.8153, 6.1296], // Luxembourg
LV: [56.8796, 24.6032], // Latvia
LY: [26.3351, 17.2283], // Libya
MA: [31.7917, -7.0926], // Morocco
MC: [43.7384, 7.4246], // Monaco
MD: [47.4116, 28.3699], // Moldova
ME: [42.7087, 19.3744], // Montenegro
MF: [18.0708, -63.0501], // Saint Martin
MG: [-18.7669, 46.8691], // Madagascar
MH: [7.1315, 171.1845], // Marshall Islands
MK: [41.6086, 21.7453], // North Macedonia
ML: [17.5707, -3.9962], // Mali
MM: [21.9162, 95.9560], // Myanmar
MN: [46.8625, 103.8467], // Mongolia
MO: [22.1987, 113.5439], // Macao
MP: [17.3308, 145.3847], // Northern Mariana Islands
MQ: [14.6415, -61.0242], // Martinique
MR: [21.0079, -10.9408], // Mauritania
MS: [16.7425, -62.1874], // Montserrat
MT: [35.9375, 14.3754], // Malta
MU: [-20.3484, 57.5522], // Mauritius
MV: [3.2028, 73.2207], // Maldives
MW: [-13.2543, 34.3015], // Malawi
MX: [23.6345, -102.5528],// Mexico
MY: [4.2105, 101.9758], // Malaysia
MZ: [-18.6657, 35.5296], // Mozambique
NA: [-22.9576, 18.4904], // Namibia
NC: [-20.9043, 165.6180],// New Caledonia
NE: [17.6078, 8.0817], // Niger
NF: [-29.0408, 167.9547],// Norfolk Island
NG: [9.0820, 8.6753], // Nigeria
NI: [12.8654, -85.2072], // Nicaragua
NL: [52.1326, 5.2913], // Netherlands
NO: [60.4720, 8.4689], // Norway
NP: [28.3949, 84.1240], // Nepal
NR: [-0.5228, 166.9315], // Nauru
NU: [-19.0544, -169.8672],// Niue
NZ: [-40.9006, 174.8860],// New Zealand
OM: [21.4735, 55.9754], // Oman
PA: [8.5380, -80.7821], // Panama
PE: [-9.1900, -75.0152], // Peru
PF: [-17.6797, -149.4068],// French Polynesia
PG: [-6.3150, 143.9555], // Papua New Guinea
PH: [12.8797, 121.7740], // Philippines
PK: [30.3753, 69.3451], // Pakistan
PL: [51.9194, 19.1451], // Poland
PM: [46.8852, -56.3159], // Saint Pierre and Miquelon
PN: [-24.7036, -127.4392],// Pitcairn
PR: [18.2208, -66.5901], // Puerto Rico
PS: [31.9522, 35.2332], // Palestinian Territory
PT: [39.3999, -8.2245], // Portugal
PW: [7.5150, 134.5825], // Palau
PY: [-23.4425, -58.4438],// Paraguay
QA: [25.3548, 51.1839], // Qatar
RE: [-21.1151, 55.5364], // Reunion
RO: [45.9432, 24.9668], // Romania
RS: [44.0165, 21.0059], // Serbia
RU: [61.5240, 105.3188], // Russia
RW: [-1.9403, 29.8739], // Rwanda
SA: [23.8859, 45.0792], // Saudi Arabia
SB: [-9.6457, 160.1562], // Solomon Islands
SC: [-4.6796, 55.4920], // Seychelles
SD: [12.8628, 30.2176], // Sudan
SE: [60.1282, 18.6435], // Sweden
SG: [1.3521, 103.8198], // Singapore
SH: [-24.1435, -10.0307],// Saint Helena
SI: [46.1512, 14.9955], // Slovenia
SJ: [77.5536, 23.6703], // Svalbard and Jan Mayen
SK: [48.6690, 19.6990], // Slovakia
SL: [8.4606, -11.7799], // Sierra Leone
SM: [43.9424, 12.4578], // San Marino
SN: [14.4974, -14.4524], // Senegal
SO: [5.1521, 46.1996], // Somalia
SR: [3.9193, -56.0278], // Suriname
SS: [4.8594, 31.5713], // South Sudan
ST: [0.1864, 6.6131], // Sao Tome and Principe
SV: [13.7942, -88.8965], // El Salvador
SX: [18.0425, -63.0548], // Sint Maarten
SY: [34.8021, 38.9968], // Syria
SZ: [-26.5225, 31.4659], // Eswatini (Swaziland)
TC: [21.6940, -71.7979], // Turks and Caicos Islands
TD: [15.4542, 18.7322], // Chad
TF: [-49.2804, 69.3486], // French Southern Territories
TG: [8.6195, 0.8248], // Togo
TH: [15.8700, 100.9925], // Thailand
TJ: [38.8610, 71.2761], // Tajikistan
TK: [-9.2000, -171.8484],// Tokelau
TL: [-8.8742, 125.7275], // Timor-Leste
TM: [38.9697, 59.5563], // Turkmenistan
TN: [33.8869, 9.5375], // Tunisia
TO: [-21.1790, -175.1982],// Tonga
TR: [38.9637, 35.2433], // Turkey
TT: [10.6918, -61.2225], // Trinidad and Tobago
TV: [-7.1095, 177.6493], // Tuvalu
TW: [23.6978, 120.9605], // Taiwan
TZ: [-6.3690, 34.8888], // Tanzania
UA: [48.3794, 31.1656], // Ukraine
UG: [1.3733, 32.2903], // Uganda
UM: [19.2823, 166.6470], // United States Minor Outlying Islands
US: [37.0902, -95.7129], // United States
UY: [-32.5228, -55.7658],// Uruguay
UZ: [41.3775, 64.5853], // Uzbekistan
VA: [41.9029, 12.4534], // Vatican City
VC: [12.9843, -61.2872], // Saint Vincent and the Grenadines
VE: [6.4238, -66.5897], // Venezuela
VG: [18.4207, -64.6400], // British Virgin Islands
VI: [18.3358, -64.8963], // U.S. Virgin Islands
VN: [14.0583, 108.2772], // Vietnam
VU: [-15.3767, 166.9592],// Vanuatu
WF: [-13.7687, -177.1561],// Wallis and Futuna
WS: [-13.7590, -172.1046],// Samoa
XK: [42.6026, 20.9030], // Kosovo
YE: [15.5527, 48.5164], // Yemen
YT: [-12.8275, 45.1662], // Mayotte
ZA: [-30.5595, 22.9375], // South Africa
ZM: [-13.1339, 27.8493], // Zambia
ZW: [-19.0154, 29.1549], // Zimbabwe
};
/**
* Returns the [latitude, longitude] centroid for a given ISO 3166-1 alpha-2
* country code, or null if the code is not recognised.
*
* The lookup is case-insensitive.
*/
export function countryToLatLng(code: string): [number, number] | null {
if (!code) return null;
const normalised = code.trim().toUpperCase();
return COUNTRY_CENTROIDS[normalised] ?? null;
}

56
lib/radio.ts Normal file
View File

@ -0,0 +1,56 @@
export interface RadioPlace {
id: string
title: string
country: string
lat: number
lng: number
size: number
}
export interface RadioChannel {
id: string
title: string
placeTitle: string
country: string
website?: string
}
export interface RadioSearchResult {
stations: Array<{
id: string
title: string
placeId: string
placeTitle: string
country: string
}>
places: Array<{
id: string
title: string
country: string
}>
}
export async function getPlaces(): Promise<RadioPlace[]> {
const res = await fetch('/api/radio/places')
if (!res.ok) throw new Error('Failed to load radio places')
return res.json()
}
export async function getChannels(placeId: string): Promise<RadioChannel[]> {
const res = await fetch(`/api/radio/channels/${placeId}`)
if (!res.ok) throw new Error('Failed to load channels')
return res.json()
}
export async function resolveStreamUrl(channelId: string): Promise<string> {
const res = await fetch(`/api/radio/stream/${channelId}`)
if (!res.ok) throw new Error('Failed to resolve stream')
const data = await res.json()
return data.url
}
export async function searchRadio(q: string): Promise<RadioSearchResult> {
const res = await fetch(`/api/radio/search?q=${encodeURIComponent(q)}`)
if (!res.ok) throw new Error('Search failed')
return res.json()
}

View File

@ -51,6 +51,7 @@
"react": "19.2.0",
"react-day-picker": "9.8.0",
"react-dom": "19.2.0",
"react-globe.gl": "^2.37.1",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",

View File

@ -134,6 +134,9 @@ importers:
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
react-globe.gl:
specifier: ^2.37.1
version: 2.37.1(react@19.2.0)
react-hook-form:
specifier: ^7.60.0
version: 7.69.0(react@19.2.0)
@ -1180,6 +1183,18 @@ packages:
'@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
'@turf/boolean-point-in-polygon@7.3.4':
resolution: {integrity: sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==}
'@turf/helpers@7.3.4':
resolution: {integrity: sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==}
'@turf/invariant@7.3.4':
resolution: {integrity: sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==}
'@tweenjs/tween.js@25.0.0':
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@ -1207,6 +1222,9 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/node@22.19.3':
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
@ -1224,6 +1242,10 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
accessor-fn@1.5.3:
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
engines: {node: '>=12'}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@ -1274,6 +1296,10 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-delaunay@6.0.4:
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
@ -1282,18 +1308,37 @@ packages:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-geo-voronoi@2.1.0:
resolution: {integrity: sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-octree@1.1.0:
resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale-chromatic@3.1.0:
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
@ -1310,6 +1355,14 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-tricontour@1.1.0:
resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==}
engines: {node: '>=12'}
data-bind-mapper@1.0.3:
resolution: {integrity: sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==}
engines: {node: '>=12'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
@ -1319,6 +1372,9 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@ -1329,6 +1385,9 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
@ -1360,16 +1419,35 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
float-tooltip@1.7.5:
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
engines: {node: '>=12'}
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
frame-ticker@1.0.3:
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
globe.gl@2.45.3:
resolution: {integrity: sha512-oR7iZBjD60ltBcRlHFvlEzKGL8PMfk2Xl19vSfu1SN4SWXAs3NB3kfelanL+wj5XgG2rTQiNtPQncCR2O9oj+w==}
engines: {node: '>=12'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
h3-js@4.4.0:
resolution: {integrity: sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==}
engines: {node: '>=4', npm: '>=3', yarn: '>=1.3.0'}
index-array-by@1.4.2:
resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==}
engines: {node: '>=12'}
input-otp@1.4.1:
resolution: {integrity: sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==}
peerDependencies:
@ -1380,6 +1458,10 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
jerrypick@1.1.2:
resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==}
engines: {node: '>=12'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@ -1387,6 +1469,10 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
kapsule@1.16.3:
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
engines: {node: '>=12'}
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@ -1457,6 +1543,9 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -1518,6 +1607,13 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
polished@4.3.1:
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
engines: {node: '>=10'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -1529,6 +1625,9 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -1543,6 +1642,12 @@ packages:
peerDependencies:
react: ^19.2.0
react-globe.gl@2.37.1:
resolution: {integrity: sha512-/YvnaB+ID+WTBTx5Qa67fGkGhco2jPlKg4jbXNTNWSl22/it7GrfByZ4nksgUDy4176VWuHh4BFvQg+hziSk8Q==}
engines: {node: '>=12'}
peerDependencies:
react: '*'
react-hook-form@7.69.0:
resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==}
engines: {node: '>=18.0.0'}
@ -1555,6 +1660,12 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-kapsule@2.5.7:
resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.13.1'
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -1617,6 +1728,9 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -1629,6 +1743,9 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
simplesignal@2.1.7:
resolution: {integrity: sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==}
sonner@1.7.4:
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
peerDependencies:
@ -1667,9 +1784,45 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
three-conic-polygon-geometry@2.1.2:
resolution: {integrity: sha512-NaP3RWLJIyPGI+zyaZwd0Yj6rkoxm4FJHqAX1Enb4L64oNYLCn4bz1ESgOEYavgcUwCNYINu1AgEoUBJr1wZcA==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.72.0'
three-geojson-geometry@2.1.1:
resolution: {integrity: sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.72.0'
three-globe@2.45.2:
resolution: {integrity: sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.154'
three-render-objects@1.41.1:
resolution: {integrity: sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.179'
three-slippy-map-globe@1.0.6:
resolution: {integrity: sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.154'
three@0.183.2:
resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -2680,6 +2833,27 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.18
'@turf/boolean-point-in-polygon@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@turf/invariant': 7.3.4
'@types/geojson': 7946.0.16
point-in-polygon-hao: 1.2.4
tslib: 2.8.1
'@turf/helpers@7.3.4':
dependencies:
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/invariant@7.3.4':
dependencies:
'@turf/helpers': 7.3.4
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@tweenjs/tween.js@25.0.0': {}
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@ -2704,6 +2878,8 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/geojson@7946.0.16': {}
'@types/node@22.19.3':
dependencies:
undici-types: 6.21.0
@ -2724,6 +2900,8 @@ snapshots:
dependencies:
'@types/node': 22.19.3
accessor-fn@1.5.3: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@ -2777,16 +2955,38 @@ snapshots:
d3-color@3.1.0: {}
d3-delaunay@6.0.4:
dependencies:
delaunator: 5.1.0
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-geo-voronoi@2.1.0:
dependencies:
d3-array: 3.2.4
d3-delaunay: 6.0.4
d3-geo: 3.1.1
d3-tricontour: 1.1.0
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-octree@1.1.0: {}
d3-path@3.1.0: {}
d3-scale-chromatic@3.1.0:
dependencies:
d3-color: 3.1.0
d3-interpolate: 3.0.1
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
@ -2795,6 +2995,8 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
@ -2809,12 +3011,25 @@ snapshots:
d3-timer@3.0.1: {}
d3-tricontour@1.1.0:
dependencies:
d3-delaunay: 6.0.4
d3-scale: 4.0.2
data-bind-mapper@1.0.3:
dependencies:
accessor-fn: 1.5.3
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
decimal.js-light@2.5.1: {}
delaunator@5.1.0:
dependencies:
robust-predicates: 3.0.3
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@ -2824,6 +3039,8 @@ snapshots:
'@babel/runtime': 7.28.4
csstype: 3.2.3
earcut@3.0.2: {}
electron-to-chromium@1.5.267: {}
embla-carousel-react@8.5.1(react@19.2.0):
@ -2849,12 +3066,35 @@ snapshots:
fast-equals@5.4.0: {}
float-tooltip@1.7.5:
dependencies:
d3-selection: 3.0.0
kapsule: 1.16.3
preact: 10.29.1
fraction.js@5.3.4: {}
frame-ticker@1.0.3:
dependencies:
simplesignal: 2.1.7
get-nonce@1.0.1: {}
globe.gl@2.45.3:
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
kapsule: 1.16.3
three: 0.183.2
three-globe: 2.45.2(three@0.183.2)
three-render-objects: 1.41.1(three@0.183.2)
graceful-fs@4.2.11: {}
h3-js@4.4.0: {}
index-array-by@1.4.2: {}
input-otp@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
@ -2862,10 +3102,16 @@ snapshots:
internmap@2.0.3: {}
jerrypick@1.1.2: {}
jiti@2.6.1: {}
js-tokens@4.0.0: {}
kapsule@1.16.3:
dependencies:
lodash-es: 4.18.1
lightningcss-android-arm64@1.30.2:
optional: true
@ -2915,6 +3161,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
lodash-es@4.18.1: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
@ -2967,6 +3215,14 @@ snapshots:
picocolors@1.1.1: {}
point-in-polygon-hao@1.2.4:
dependencies:
robust-predicates: 3.0.3
polished@4.3.1:
dependencies:
'@babel/runtime': 7.28.4
postcss-value-parser@4.2.0: {}
postcss@8.4.31:
@ -2981,6 +3237,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.29.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@ -2999,6 +3257,13 @@ snapshots:
react: 19.2.0
scheduler: 0.27.0
react-globe.gl@2.37.1(react@19.2.0):
dependencies:
globe.gl: 2.45.3
prop-types: 15.8.1
react: 19.2.0
react-kapsule: 2.5.7(react@19.2.0)
react-hook-form@7.69.0(react@19.2.0):
dependencies:
react: 19.2.0
@ -3007,6 +3272,11 @@ snapshots:
react-is@18.3.1: {}
react-kapsule@2.5.7(react@19.2.0):
dependencies:
jerrypick: 1.1.2
react: 19.2.0
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.0):
dependencies:
react: 19.2.0
@ -3075,6 +3345,8 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
robust-predicates@3.0.3: {}
scheduler@0.27.0: {}
semver@7.7.3:
@ -3112,6 +3384,8 @@ snapshots:
'@img/sharp-win32-x64': 0.34.5
optional: true
simplesignal@2.1.7: {}
sonner@1.7.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
@ -3134,8 +3408,67 @@ snapshots:
tapable@2.3.0: {}
three-conic-polygon-geometry@2.1.2(three@0.183.2):
dependencies:
'@turf/boolean-point-in-polygon': 7.3.4
d3-array: 3.2.4
d3-geo: 3.1.1
d3-geo-voronoi: 2.1.0
d3-scale: 4.0.2
delaunator: 5.1.0
earcut: 3.0.2
three: 0.183.2
three-geojson-geometry@2.1.1(three@0.183.2):
dependencies:
d3-geo: 3.1.1
d3-interpolate: 3.0.1
earcut: 3.0.2
three: 0.183.2
three-globe@2.45.2(three@0.183.2):
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
d3-array: 3.2.4
d3-color: 3.1.0
d3-geo: 3.1.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-scale-chromatic: 3.1.0
data-bind-mapper: 1.0.3
frame-ticker: 1.0.3
h3-js: 4.4.0
index-array-by: 1.4.2
kapsule: 1.16.3
three: 0.183.2
three-conic-polygon-geometry: 2.1.2(three@0.183.2)
three-geojson-geometry: 2.1.1(three@0.183.2)
three-slippy-map-globe: 1.0.6(three@0.183.2)
tinycolor2: 1.6.0
three-render-objects@1.41.1(three@0.183.2):
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
float-tooltip: 1.7.5
kapsule: 1.16.3
polished: 4.3.1
three: 0.183.2
three-slippy-map-globe@1.0.6(three@0.183.2):
dependencies:
d3-geo: 3.1.1
d3-octree: 1.1.0
d3-scale: 4.0.2
three: 0.183.2
three@0.183.2: {}
tiny-invariant@1.3.3: {}
tinycolor2@1.6.0: {}
tslib@2.8.1: {}
tw-animate-css@1.3.3: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB