538 lines
21 KiB
TypeScript
538 lines
21 KiB
TypeScript
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useState, useEffect } from 'react';
|
|
import { Play, Pause, Volume2, Radio, Search, Loader2, ChevronDown, Link2, SkipBack, SkipForward, Zap } from 'lucide-react';
|
|
import { useSettings } from '@/contexts/SettingsContext';
|
|
import { useMusic, Station } from '@/contexts/MusicContext';
|
|
|
|
const CATEGORIES = [
|
|
{ value: '', label: 'All Genres' },
|
|
{ value: 'pop', label: 'Pop' },
|
|
{ value: 'rock', label: 'Rock' },
|
|
{ value: 'jazz', label: 'Jazz' },
|
|
{ value: 'classical', label: 'Classical' },
|
|
{ value: 'electronic', label: 'Electronic' },
|
|
{ value: 'techno', label: 'Techno' },
|
|
{ value: 'house', label: 'House' },
|
|
{ value: 'trance', label: 'Trance' },
|
|
{ value: 'drum and bass', label: 'Drum & Bass' },
|
|
{ value: 'dubstep', label: 'Dubstep' },
|
|
{ value: 'hiphop', label: 'Hip Hop' },
|
|
{ value: 'country', label: 'Country' },
|
|
{ value: 'metal', label: 'Metal' },
|
|
{ value: 'ambient', label: 'Ambient' },
|
|
{ value: 'lofi', label: 'Lo-Fi' },
|
|
{ value: 'chillout', label: 'Chillout' },
|
|
{ value: 'synthwave', label: 'Synthwave' },
|
|
{ value: 'news', label: 'News' },
|
|
{ value: 'talk', label: 'Talk' },
|
|
];
|
|
|
|
// Preset electronic music streams
|
|
const PRESET_STREAMS = [
|
|
{ name: 'SomaFM - Groove Salad', url: 'https://ice2.somafm.com/groovesalad-128-mp3', genre: 'Ambient/Downtempo' },
|
|
{ name: 'SomaFM - DEF CON Radio', url: 'https://ice2.somafm.com/defcon-128-mp3', genre: 'Electronic/Hacker' },
|
|
{ name: 'SomaFM - Space Station', url: 'https://ice2.somafm.com/spacestation-128-mp3', genre: 'Space/Ambient' },
|
|
{ name: 'SomaFM - Drone Zone', url: 'https://ice2.somafm.com/dronezone-128-mp3', genre: 'Dark Ambient' },
|
|
{ name: 'SomaFM - Beat Blender', url: 'https://ice2.somafm.com/beatblender-128-mp3', genre: 'Deep House' },
|
|
{ name: 'SomaFM - cliqhop idm', url: 'https://ice2.somafm.com/cliqhop-128-mp3', genre: 'IDM/Glitch' },
|
|
{ name: 'Nightwave Plaza', url: 'https://radio.plaza.one/mp3', genre: 'Vaporwave' },
|
|
];
|
|
|
|
interface CategoryDropdownProps {
|
|
selectedCategory: string;
|
|
onSelect: (value: string) => void;
|
|
playSound: (sound: string) => void;
|
|
}
|
|
|
|
const CategoryDropdown = ({ selectedCategory, onSelect, playSound }: CategoryDropdownProps) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useState<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdownRef[0] && !dropdownRef[0].contains(e.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [dropdownRef]);
|
|
|
|
const selectedLabel = CATEGORIES.find(c => c.value === selectedCategory)?.label || 'All Genres';
|
|
|
|
return (
|
|
<div ref={(el) => { dropdownRef[1](el); }} className="relative">
|
|
<button
|
|
onClick={() => {
|
|
playSound('click');
|
|
setIsOpen(!isOpen);
|
|
}}
|
|
className="w-full flex items-center justify-between px-4 py-2 bg-background border border-primary/30 hover:border-primary font-pixel text-sm text-primary transition-colors"
|
|
>
|
|
<span>{selectedLabel}</span>
|
|
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -5 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-primary/50 box-glow max-h-60 overflow-y-auto"
|
|
>
|
|
{CATEGORIES.map((cat) => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => {
|
|
playSound('click');
|
|
onSelect(cat.value);
|
|
setIsOpen(false);
|
|
}}
|
|
className={`w-full px-4 py-2 text-left font-pixel text-sm transition-colors hover:bg-primary/20 ${
|
|
selectedCategory === cat.value ? 'bg-primary/30 text-primary' : 'text-primary/80'
|
|
}`}
|
|
>
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// URL validation helper
|
|
const isValidStreamUrl = (url: string): { valid: boolean; error?: string } => {
|
|
if (!url.trim()) {
|
|
return { valid: false, error: 'Please enter a URL' };
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(url.trim());
|
|
|
|
// Only allow http/https protocols
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return { valid: false, error: 'URL must use http or https protocol' };
|
|
}
|
|
|
|
// Basic format check
|
|
if (!parsed.hostname || parsed.hostname.length < 3) {
|
|
return { valid: false, error: 'Invalid hostname' };
|
|
}
|
|
|
|
return { valid: true };
|
|
} catch {
|
|
return { valid: false, error: 'Invalid URL format' };
|
|
}
|
|
};
|
|
|
|
const Music = () => {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState('');
|
|
const [customUrl, setCustomUrl] = useState('');
|
|
const [customUrlError, setCustomUrlError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'browse' | 'presets' | 'custom'>('browse');
|
|
const [filteredStations, setFilteredStations] = useState<Station[]>([]);
|
|
const { playSound } = useSettings();
|
|
|
|
const {
|
|
isPlaying,
|
|
isBuffering,
|
|
volume,
|
|
stations,
|
|
selectedStation,
|
|
hasFetched,
|
|
setVolume,
|
|
playStation,
|
|
togglePlay,
|
|
playNext,
|
|
playPrevious,
|
|
fetchStations,
|
|
} = useMusic();
|
|
|
|
// Fetch stations on mount
|
|
useEffect(() => {
|
|
fetchStations();
|
|
}, [fetchStations]);
|
|
|
|
// Filter stations based on search and category
|
|
useEffect(() => {
|
|
const filtered = stations.filter(station => {
|
|
const matchesSearch = station.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
station.tags.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
station.country.toLowerCase().includes(searchQuery.toLowerCase());
|
|
|
|
const matchesCategory = !selectedCategory ||
|
|
station.tags.toLowerCase().includes(selectedCategory.toLowerCase());
|
|
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
setFilteredStations(filtered);
|
|
}, [searchQuery, selectedCategory, stations]);
|
|
|
|
const handlePlayStation = (station: Station, index: number) => {
|
|
playSound('click');
|
|
playStation(station, index);
|
|
};
|
|
|
|
// Clear error when URL changes
|
|
useEffect(() => {
|
|
if (customUrlError) setCustomUrlError(null);
|
|
}, [customUrl]);
|
|
|
|
const playCustomUrl = () => {
|
|
const validation = isValidStreamUrl(customUrl);
|
|
|
|
if (!validation.valid) {
|
|
setCustomUrlError(validation.error || 'Invalid URL');
|
|
playSound('error');
|
|
return;
|
|
}
|
|
|
|
setCustomUrlError(null);
|
|
playSound('click');
|
|
|
|
const customStation: Station = {
|
|
stationuuid: 'custom-' + Date.now(),
|
|
name: 'Custom Stream',
|
|
url: customUrl.trim(),
|
|
favicon: '',
|
|
country: 'Custom',
|
|
tags: 'custom',
|
|
bitrate: 0,
|
|
};
|
|
|
|
playStation(customStation, -1);
|
|
};
|
|
|
|
const playPreset = (preset: typeof PRESET_STREAMS[0]) => {
|
|
playSound('click');
|
|
|
|
const presetStation: Station = {
|
|
stationuuid: 'preset-' + preset.name,
|
|
name: preset.name,
|
|
url: preset.url,
|
|
favicon: '',
|
|
country: preset.genre,
|
|
tags: preset.genre.toLowerCase(),
|
|
bitrate: 128,
|
|
};
|
|
|
|
playStation(presetStation, -1);
|
|
};
|
|
|
|
const handleTogglePlay = () => {
|
|
playSound('click');
|
|
togglePlay();
|
|
};
|
|
|
|
const handlePlayNext = () => {
|
|
playSound('click');
|
|
playNext();
|
|
};
|
|
|
|
const handlePlayPrevious = () => {
|
|
playSound('click');
|
|
playPrevious();
|
|
};
|
|
|
|
const isLoading = !hasFetched;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.5 }}
|
|
className="space-y-6"
|
|
>
|
|
<h1 className="font-minecraft text-3xl md:text-4xl text-primary text-glow-strong">
|
|
Radio Player
|
|
</h1>
|
|
|
|
<p className="font-pixel text-foreground/80">
|
|
Stream radio stations from around the world or add your own stream URL.
|
|
</p>
|
|
|
|
{/* Now Playing */}
|
|
{selectedStation && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="border border-primary p-4 bg-primary/5 box-glow space-y-4"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 border border-primary/50 flex items-center justify-center bg-background">
|
|
{selectedStation.favicon ? (
|
|
<img
|
|
src={selectedStation.favicon}
|
|
alt={selectedStation.name}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<Radio className="w-6 h-6 text-primary" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="font-minecraft text-lg text-primary text-glow truncate">
|
|
{selectedStation.name}
|
|
</h2>
|
|
<p className="font-pixel text-xs text-foreground/60 truncate">
|
|
{selectedStation.country} {selectedStation.bitrate > 0 && `• ${selectedStation.bitrate}kbps`}
|
|
</p>
|
|
{isBuffering && (
|
|
<p className="font-pixel text-xs text-primary/60">Buffering...</p>
|
|
)}
|
|
</div>
|
|
{/* Playback Controls */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handlePlayPrevious}
|
|
className="p-2 border border-primary/50 text-primary hover:bg-primary hover:text-background transition-all duration-300"
|
|
title="Previous station"
|
|
>
|
|
<SkipBack size={16} />
|
|
</button>
|
|
<button
|
|
onClick={handleTogglePlay}
|
|
className="p-3 border border-primary text-primary hover:bg-primary hover:text-background transition-all duration-300"
|
|
disabled={isBuffering}
|
|
>
|
|
{isBuffering ? (
|
|
<Loader2 size={20} className="animate-spin" />
|
|
) : isPlaying ? (
|
|
<Pause size={20} />
|
|
) : (
|
|
<Play size={20} />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={handlePlayNext}
|
|
className="p-2 border border-primary/50 text-primary hover:bg-primary hover:text-background transition-all duration-300"
|
|
title="Next station"
|
|
>
|
|
<SkipForward size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Volume2 size={16} className="text-primary" />
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={volume}
|
|
onChange={(e) => setVolume(Number(e.target.value))}
|
|
className="flex-1 h-1.5 bg-primary/30 appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_5px_hsl(var(--glow-color)/0.8)]"
|
|
/>
|
|
<span className="font-pixel text-xs text-primary min-w-[40px]">
|
|
{volume}%
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-2 border-b border-primary/30">
|
|
{(['browse', 'presets', 'custom'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => {
|
|
playSound('click');
|
|
setActiveTab(tab);
|
|
}}
|
|
className={`px-4 py-2 font-pixel text-sm transition-all ${
|
|
activeTab === tab
|
|
? 'text-primary border-b-2 border-primary text-glow'
|
|
: 'text-foreground/60 hover:text-primary'
|
|
}`}
|
|
>
|
|
{tab === 'browse' && 'Browse'}
|
|
{tab === 'presets' && 'Electronic'}
|
|
{tab === 'custom' && 'Custom URL'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Browse Tab */}
|
|
{activeTab === 'browse' && (
|
|
<>
|
|
{/* Filters */}
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="sm:w-48">
|
|
<CategoryDropdown
|
|
selectedCategory={selectedCategory}
|
|
onSelect={setSelectedCategory}
|
|
playSound={playSound}
|
|
/>
|
|
</div>
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/50" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search stations..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 bg-background border border-primary/30 focus:border-primary font-pixel text-sm text-foreground placeholder:text-foreground/40 outline-none transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Station List */}
|
|
<div className="border border-primary/30 max-h-[250px] overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center p-8">
|
|
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
|
<span className="font-pixel text-sm text-primary ml-2">Loading stations...</span>
|
|
</div>
|
|
) : filteredStations.length === 0 ? (
|
|
<div className="p-4 text-center">
|
|
<p className="font-pixel text-sm text-foreground/60">No stations found</p>
|
|
</div>
|
|
) : (
|
|
filteredStations.map((station, index) => (
|
|
<motion.button
|
|
key={station.stationuuid}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: index * 0.02 }}
|
|
onClick={() => handlePlayStation(station, index)}
|
|
className={`w-full flex items-center gap-3 p-3 border-b border-primary/10 hover:bg-primary/10 transition-all duration-200 text-left ${
|
|
selectedStation?.stationuuid === station.stationuuid ? 'bg-primary/20' : ''
|
|
}`}
|
|
>
|
|
<div className="w-8 h-8 border border-primary/30 flex items-center justify-center flex-shrink-0 bg-background">
|
|
{station.favicon ? (
|
|
<img
|
|
src={station.favicon}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
) : (
|
|
<Radio className="w-4 h-4 text-primary/50" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-pixel text-sm text-primary truncate">{station.name}</p>
|
|
<p className="font-pixel text-xs text-foreground/50 truncate">
|
|
{station.country} {station.tags && `• ${station.tags.split(',')[0]}`}
|
|
</p>
|
|
</div>
|
|
{selectedStation?.stationuuid === station.stationuuid && isPlaying && (
|
|
<div className="flex gap-0.5">
|
|
<span className="w-1 h-3 bg-primary animate-pulse" style={{ animationDelay: '0ms' }} />
|
|
<span className="w-1 h-3 bg-primary animate-pulse" style={{ animationDelay: '150ms' }} />
|
|
<span className="w-1 h-3 bg-primary animate-pulse" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
)}
|
|
</motion.button>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<p className="font-pixel text-xs text-foreground/50 text-center">
|
|
{filteredStations.length} stations available
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{/* Electronic Presets Tab */}
|
|
{activeTab === 'presets' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 text-primary mb-4">
|
|
<Zap size={16} />
|
|
<span className="font-pixel text-sm">Curated Electronic Streams</span>
|
|
</div>
|
|
<div className="border border-primary/30 max-h-[300px] overflow-y-auto">
|
|
{PRESET_STREAMS.map((preset, index) => (
|
|
<motion.button
|
|
key={preset.name}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: index * 0.05 }}
|
|
onClick={() => playPreset(preset)}
|
|
className={`w-full flex items-center gap-3 p-3 border-b border-primary/10 hover:bg-primary/10 transition-all duration-200 text-left ${
|
|
selectedStation?.name === preset.name ? 'bg-primary/20' : ''
|
|
}`}
|
|
>
|
|
<div className="w-8 h-8 border border-primary/30 flex items-center justify-center flex-shrink-0 bg-background">
|
|
<Zap className="w-4 h-4 text-primary/70" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-pixel text-sm text-primary truncate">{preset.name}</p>
|
|
<p className="font-pixel text-xs text-foreground/50 truncate">{preset.genre}</p>
|
|
</div>
|
|
{selectedStation?.name === preset.name && isPlaying && (
|
|
<div className="flex gap-0.5">
|
|
<span className="w-1 h-3 bg-primary animate-pulse" style={{ animationDelay: '0ms' }} />
|
|
<span className="w-1 h-3 bg-primary animate-pulse" style={{ animationDelay: '150ms' }} />
|
|
<span className="w-1 h-3 bg-primary animate-pulse" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
)}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
<p className="font-pixel text-xs text-foreground/50 text-center">
|
|
High-quality electronic music streams from SomaFM and others
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom URL Tab */}
|
|
{activeTab === 'custom' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 text-primary mb-4">
|
|
<Link2 size={16} />
|
|
<span className="font-pixel text-sm">Play Custom Stream URL</span>
|
|
</div>
|
|
<p className="font-pixel text-xs text-foreground/60">
|
|
Enter a direct URL to an audio stream (MP3, AAC, OGG, etc.)
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="url"
|
|
placeholder="https://stream.example.com/radio.mp3"
|
|
value={customUrl}
|
|
onChange={(e) => setCustomUrl(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && playCustomUrl()}
|
|
className={`flex-1 px-4 py-2 bg-background border font-pixel text-sm text-foreground placeholder:text-foreground/40 outline-none transition-colors ${
|
|
customUrlError ? 'border-destructive focus:border-destructive' : 'border-primary/30 focus:border-primary'
|
|
}`}
|
|
/>
|
|
<button
|
|
onClick={playCustomUrl}
|
|
disabled={!customUrl.trim()}
|
|
className="px-4 py-2 border border-primary text-primary hover:bg-primary hover:text-background transition-all duration-300 font-pixel text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Play
|
|
</button>
|
|
</div>
|
|
{customUrlError && (
|
|
<p className="font-pixel text-xs text-destructive">{customUrlError}</p>
|
|
)}
|
|
<div className="border border-primary/20 p-3 bg-primary/5">
|
|
<p className="font-pixel text-xs text-primary mb-2">Tips:</p>
|
|
<ul className="font-pixel text-xs text-foreground/60 space-y-1 list-disc list-inside">
|
|
<li>Use direct stream URLs (not webpage URLs)</li>
|
|
<li>Supported formats: MP3, AAC, OGG, FLAC</li>
|
|
<li>Look for .m3u or .pls files on radio sites</li>
|
|
<li>SomaFM, Radio.co, and Icecast streams work well</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default Music;
|