Files
personal_website/src/pages/Music.tsx
T

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;