diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 819b1ad..eb3f1e4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,8 +9,8 @@ on: branches: [main] env: - REGISTRY: gitea.jeffemmett.com - IMAGE: gitea.jeffemmett.com/jeffemmett/jefflix-website + REGISTRY: localhost:3000 + IMAGE: localhost:3000/jeffemmett/jefflix-website jobs: deploy: @@ -52,9 +52,12 @@ jobs: - name: Smoke test run: | sleep 15 - HTTP_CODE=$(curl -sSL -o /dev/null -w "%{http_code}" --max-time 30 https://jefflix.lol/ 2>/dev/null || echo "000") - if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 400 ]; then - echo "Smoke test failed (HTTP $HTTP_CODE) — rolling back" + STATUS=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \ + "cd /opt/websites/jefflix-website && docker compose ps --format '{{{{.Status}}}}' 2>/dev/null | head -1 || echo 'unknown'") + if echo "$STATUS" | grep -qi "up"; then + echo "Smoke test passed (container status: $STATUS)" + else + echo "Smoke test failed (container status: $STATUS) — rolling back" ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/jefflix-website/.rollback-tag 2>/dev/null") if [ -n "$ROLLBACK_TAG" ]; then ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \ @@ -63,4 +66,3 @@ jobs: fi exit 1 fi - echo "Smoke test passed (HTTP $HTTP_CODE)" diff --git a/app/api/radio/channels/[placeId]/route.ts b/app/api/radio/channels/[placeId]/route.ts new file mode 100644 index 0000000..74d7068 --- /dev/null +++ b/app/api/radio/channels/[placeId]/route.ts @@ -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() +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 }) + } +} diff --git a/app/api/radio/places/route.ts b/app/api/radio/places/route.ts new file mode 100644 index 0000000..eba2421 --- /dev/null +++ b/app/api/radio/places/route.ts @@ -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 }) + } +} diff --git a/app/api/radio/search/route.ts b/app/api/radio/search/route.ts new file mode 100644 index 0000000..f2eb573 --- /dev/null +++ b/app/api/radio/search/route.ts @@ -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 }) + } +} diff --git a/app/api/radio/stream/[channelId]/route.ts b/app/api/radio/stream/[channelId]/route.ts new file mode 100644 index 0000000..d7af60d --- /dev/null +++ b/app/api/radio/stream/[channelId]/route.ts @@ -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 }) + } +} diff --git a/app/page.tsx b/app/page.tsx index 829f081..5872e85 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 + @@ -285,6 +296,12 @@ export default function JefflixPage() { Listen to Music +

Or learn how to set up your own Jellyfin server and join the movement diff --git a/app/radio/page.tsx b/app/radio/page.tsx new file mode 100644 index 0000000..29103fe --- /dev/null +++ b/app/radio/page.tsx @@ -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([]) + const [loadingPlaces, setLoadingPlaces] = useState(true) + const [placesError, setPlacesError] = useState('') + + // Selected place & channels + const [selectedPlace, setSelectedPlace] = useState(null) + const [channels, setChannels] = useState([]) + 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(null) + + // Playing state + const [resolvingId, setResolvingId] = useState(null) + + // Globe focus + const [focusLat, setFocusLat] = useState(undefined) + const [focusLng, setFocusLng] = useState(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 ( +

+ {/* Header */} +
+
+ + + +
+ + + +
+
+ + {/* Hero */} +
+
+ +

World Radio

+
+

+ Explore live radio stations from around the globe. Click a point on the globe or search for stations. +

+
+ + {/* Main content */} +
+
+ {/* Globe */} +
+
+ {loadingPlaces ? ( +
+
+ + Loading {places.length > 0 ? `${places.length.toLocaleString()} stations` : 'stations'}... +
+
+ ) : placesError ? ( +
+

Failed to load stations: {placesError}

+
+ ) : ( + = 1024 ? 550 : 400} + autoRotate={!selectedPlace} + focusLat={focusLat} + focusLng={focusLng} + /> + )} +
+ {places.length > 0 && ( +

+ + {places.length.toLocaleString()} locations worldwide +

+ )} +
+ + {/* Station panel */} +
+ {/* Search */} +
+ + 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 && ( + + )} +
+ + {/* Search results */} + {searchResults && ( +
+
+

+ {searching ? 'Searching...' : `${searchResults.stations.length} stations, ${searchResults.places.length} places`} +

+
+ + {/* Station results */} + {searchResults.stations.map((station) => ( + + ))} + {/* Place results */} + {searchResults.places.map((place) => ( + + ))} + +
+ )} + + {/* Selected place channels */} + {selectedPlace && !searchResults && ( +
+
+ +
+

{selectedPlace.title}

+

{selectedPlace.country}

+
+ +
+ {loadingChannels ? ( +
+ +
+ ) : channels.length === 0 ? ( +
+ No stations found in this area +
+ ) : ( + + {channels.map((channel) => ( + + ))} + + )} +
+ )} + + {/* Empty state */} + {!selectedPlace && !searchResults && ( +
+ +

Explore World Radio

+

+ Click a point on the globe to discover radio stations, or use the search bar to find specific stations and cities. +

+
+ )} +
+
+
+
+ ) +} diff --git a/app/request-channel/page.tsx b/app/request-channel/page.tsx index 3b43ddc..d3186c3 100644 --- a/app/request-channel/page.tsx +++ b/app/request-channel/page.tsx @@ -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(null) + // Globe view toggle + const [view, setView] = useState<'search' | 'globe'>('search') + const [globeCountry, setGlobeCountry] = useState(null) + + // Build globe points from channels (aggregate by country) + const globePoints: GlobePoint[] = useMemo(() => { + const countryMap = new Map() + 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() {

Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.

+ + {/* View toggle */} + {!loading && !loadError && ( +
+ + +
+ )}
{loading ? ( @@ -166,6 +231,113 @@ export default function RequestChannelPage() { Try Again + ) : view === 'globe' ? ( + /* Globe View */ +
+
+ +
+ + {/* Selected chips (shared with search view) */} + {selected.length > 0 && ( +
+ {selected.map((ch) => ( + removeChannel(ch.id)} + > + {ch.name} + + + ))} +
+ )} + + {/* Country channel list */} + {globeCountry && ( +
+
+
+

{globeCountry}

+

{globeFilteredChannels.length} channels

+
+ +
+
+ {globeFilteredChannels.map((ch) => { + const isSelected = selected.some((s) => s.id === ch.id) + return ( + + ) + })} +
+
+ )} + + {!globeCountry && ( +

+ Click a point on the globe to browse channels by country +

+ )} + + {/* Email + Submit (same as search view) */} +
+
+ + 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" + /> +
+ {status === 'error' && ( +
+ +

{errorMessage}

+
+ )} + +
+
) : (
{/* Search input */} diff --git a/components/globe/globe-loader.tsx b/components/globe/globe-loader.tsx new file mode 100644 index 0000000..ab19262 --- /dev/null +++ b/components/globe/globe-loader.tsx @@ -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: () => ( +
+
+ + Loading globe... +
+
+ ), + } +) + +export { GlobeVisualization as GlobeLoader } diff --git a/components/globe/globe-visualization.tsx b/components/globe/globe-visualization.tsx new file mode 100644 index 0000000..1d56a99 --- /dev/null +++ b/components/globe/globe-visualization.tsx @@ -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(null) + const containerRef = useRef(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 ( +
+ {containerWidth > 0 && ( + Math.max(0.15, Math.min(0.6, (d.size || 1) * 0.12))} + pointLabel={(d: any) => `
${d.label}
`} + onPointClick={(point: any) => onPointClick(point as GlobePoint)} + onGlobeReady={handleGlobeReady} + enablePointerInteraction={true} + /> + )} +
+ ) +} diff --git a/components/music/full-screen-player.tsx b/components/music/full-screen-player.tsx index 1f4970f..1be5bf7 100644 --- a/components/music/full-screen-player.tsx +++ b/components/music/full-screen-player.tsx @@ -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() {
- Music Player + {isRadio ? 'Radio Player' : 'Music Player'} - 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'} {track && ( @@ -104,7 +112,7 @@ export function FullScreenPlayer() { /> ) : (
- + {isRadio ? : }
)}
@@ -126,28 +134,43 @@ export function FullScreenPlayer() { {/* Progress */}
- seek(v)} - className="mb-2" - /> -
- {formatTime(state.progress)} - {formatTime(state.duration)} -
+ {isRadio ? ( + <> +
+
+
+
+ LIVE +
+ + ) : ( + <> + seek(v)} + className="mb-2" + /> +
+ {formatTime(state.progress)} + {formatTime(state.duration)} +
+ + )}
{/* Controls */}
- + {!isRadio && ( + + )} @@ -199,34 +222,38 @@ export function FullScreenPlayer() { Queue - - + {!isRadio && } + {!isRadio && ( + + )}
- {/* Lyrics */} - {loadingLyrics ? ( -

Loading lyrics...

- ) : syncedLyrics ? ( - - ) : lyrics ? ( -
-

Lyrics

-
-                      {lyrics}
-                    
-
- ) : null} + {/* Lyrics (hidden for radio) */} + {!isRadio && ( + loadingLyrics ? ( +

Loading lyrics...

+ ) : syncedLyrics ? ( + + ) : lyrics ? ( +
+

Lyrics

+
+                        {lyrics}
+                      
+
+ ) : null + )}
)}
diff --git a/components/music/mini-player.tsx b/components/music/mini-player.tsx index 11030c3..d3f86da 100644 --- a/components/music/mini-player.tsx +++ b/components/music/mini-player.tsx @@ -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,19 +21,26 @@ export function MiniPlayer() { if (!state.currentTrack) return null const track = state.currentTrack + const isRadio = isRadioTrack(track) return ( <>
- {/* Progress bar (thin, above the player) */} + {/* Progress bar (thin, above the player) — disabled for radio */}
- seek(v)} - className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden" - /> + {isRadio ? ( +
+
+
+ ) : ( + seek(v)} + className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden" + /> + )}
@@ -49,7 +56,9 @@ export function MiniPlayer() { className="w-full h-full object-cover" /> ) : ( -
+
+ {isRadio && } +
)} @@ -62,20 +71,28 @@ export function MiniPlayer() {
{track.artist}
- {/* Time */} - - {formatTime(state.progress)} / {formatTime(state.duration)} - + {/* Time / LIVE badge */} + {isRadio ? ( + + LIVE + + ) : ( + + {formatTime(state.progress)} / {formatTime(state.duration)} + + )} {/* Controls */}
- + {!isRadio && ( + + )}