Changes
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Settings, Key, Eye, EyeOff, Check, X, AlertCircle } from 'lucide-react';
|
||||
import { useMusicApiKeys } from '@/hooks/useMusicApiKeys';
|
||||
|
||||
interface ApiKeySettingsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playSound: (sound: string) => void;
|
||||
}
|
||||
|
||||
export const ApiKeySettings = ({ isOpen, onClose, playSound }: ApiKeySettingsProps) => {
|
||||
const { apiKeys, saveApiKeys, hasYoutubeKey, hasSoundcloudKey } = useMusicApiKeys();
|
||||
const [youtubeKey, setYoutubeKey] = useState(apiKeys.youtubeApiKey);
|
||||
const [soundcloudId, setSoundcloudId] = useState(apiKeys.soundcloudClientId);
|
||||
const [showYoutubeKey, setShowYoutubeKey] = useState(false);
|
||||
const [showSoundcloudKey, setShowSoundcloudKey] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
playSound('click');
|
||||
saveApiKeys({
|
||||
youtubeApiKey: youtubeKey.trim(),
|
||||
soundcloudClientId: soundcloudId.trim(),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
playSound('click');
|
||||
// Reset to current saved values
|
||||
setYoutubeKey(apiKeys.youtubeApiKey);
|
||||
setSoundcloudId(apiKeys.soundcloudClientId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-md border border-primary bg-background box-glow p-6 space-y-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-minecraft text-xl text-primary text-glow">API Keys</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 text-foreground/60 hover:text-primary transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 bg-primary/10 border border-primary/30">
|
||||
<AlertCircle className="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
||||
<p className="font-pixel text-xs text-foreground/80">
|
||||
API keys are stored locally in your browser. They are never sent to our servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* YouTube API Key */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-pixel text-sm text-primary flex items-center gap-2">
|
||||
<Key size={14} />
|
||||
YouTube Data API Key
|
||||
</label>
|
||||
{hasYoutubeKey && (
|
||||
<span className="flex items-center gap-1 text-primary/80 font-pixel text-xs">
|
||||
<Check size={12} /> Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showYoutubeKey ? 'text' : 'password'}
|
||||
value={youtubeKey}
|
||||
onChange={(e) => setYoutubeKey(e.target.value)}
|
||||
placeholder="AIzaSy..."
|
||||
className="w-full px-3 py-2 pr-10 bg-background border border-primary/30 focus:border-primary font-pixel text-sm text-foreground placeholder:text-foreground/40 outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowYoutubeKey(!showYoutubeKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-foreground/50 hover:text-primary"
|
||||
>
|
||||
{showYoutubeKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-pixel text-xs text-foreground/50">
|
||||
Get from <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Google Cloud Console</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SoundCloud Client ID */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-pixel text-sm text-primary flex items-center gap-2">
|
||||
<Key size={14} />
|
||||
SoundCloud Client ID
|
||||
</label>
|
||||
{hasSoundcloudKey && (
|
||||
<span className="flex items-center gap-1 text-primary/80 font-pixel text-xs">
|
||||
<Check size={12} /> Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSoundcloudKey ? 'text' : 'password'}
|
||||
value={soundcloudId}
|
||||
onChange={(e) => setSoundcloudId(e.target.value)}
|
||||
placeholder="Your Client ID..."
|
||||
className="w-full px-3 py-2 pr-10 bg-background border border-primary/30 focus:border-primary font-pixel text-sm text-foreground placeholder:text-foreground/40 outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSoundcloudKey(!showSoundcloudKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-foreground/50 hover:text-primary"
|
||||
>
|
||||
{showSoundcloudKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-pixel text-xs text-foreground/50">
|
||||
Get from <a href="https://soundcloud.com/you/apps" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">SoundCloud Developers</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-1 py-2 border border-primary/30 text-primary/80 font-pixel text-sm hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 py-2 border border-primary bg-primary text-background font-pixel text-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Save Keys
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Play, Pause, Loader2, Heart, Clock, Volume2, Music2 } from 'lucide-react';
|
||||
import { useMusicApiKeys } from '@/hooks/useMusicApiKeys';
|
||||
import type { SCWidget } from '@/types/media-apis.d';
|
||||
|
||||
interface SoundCloudTrack {
|
||||
id: number;
|
||||
title: string;
|
||||
user: { username: string };
|
||||
artwork_url: string | null;
|
||||
duration: number;
|
||||
permalink_url: string;
|
||||
stream_url?: string;
|
||||
}
|
||||
|
||||
interface SoundCloudPlayerProps {
|
||||
playSound: (sound: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
// SoundCloud mix/set search queries
|
||||
const SOUNDCLOUD_GENRES = [
|
||||
{ label: 'House', query: 'house mix set' },
|
||||
{ label: 'Techno', query: 'techno mix set' },
|
||||
{ label: 'Drum & Bass', query: 'drum and bass mix' },
|
||||
{ label: 'Trance', query: 'trance mix set' },
|
||||
{ label: 'Dubstep', query: 'dubstep mix' },
|
||||
{ label: 'Deep House', query: 'deep house mix' },
|
||||
{ label: 'Ambient', query: 'ambient mix' },
|
||||
{ label: 'Lo-Fi', query: 'lofi mix beats' },
|
||||
];
|
||||
|
||||
const FAVORITES_KEY = 'soundcloud-favorites';
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const SoundCloudPlayer = ({ playSound, onOpenSettings }: SoundCloudPlayerProps) => {
|
||||
const { apiKeys, hasSoundcloudKey } = useMusicApiKeys();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tracks, setTracks] = useState<SoundCloudTrack[]>([]);
|
||||
const [favorites, setFavorites] = useState<SoundCloudTrack[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentTrack, setCurrentTrack] = useState<SoundCloudTrack | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(80);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
const widgetRef = useRef<SCWidget | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Load favorites from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
setFavorites(JSON.parse(stored));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse favorites');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load SoundCloud Widget API
|
||||
useEffect(() => {
|
||||
if (!document.getElementById('sc-widget-script')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'sc-widget-script';
|
||||
script.src = 'https://w.soundcloud.com/player/api.js';
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchTracks = useCallback(async (query: string) => {
|
||||
if (!hasSoundcloudKey || !query.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Search for mixes/sets (longer duration tracks)
|
||||
const response = await fetch(
|
||||
`https://api.soundcloud.com/tracks?q=${encodeURIComponent(query)}&client_id=${apiKeys.soundcloudClientId}&limit=20&duration[from]=600000`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search SoundCloud');
|
||||
}
|
||||
|
||||
const data: SoundCloudTrack[] = await response.json();
|
||||
setTracks(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to search');
|
||||
console.error('SoundCloud search error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [hasSoundcloudKey, apiKeys.soundcloudClientId]);
|
||||
|
||||
const handleSearch = () => {
|
||||
playSound('click');
|
||||
setSelectedGenre(null);
|
||||
setShowFavorites(false);
|
||||
searchTracks(searchQuery);
|
||||
};
|
||||
|
||||
const handleGenreClick = (genre: typeof SOUNDCLOUD_GENRES[0]) => {
|
||||
playSound('click');
|
||||
setSelectedGenre(genre.label);
|
||||
setShowFavorites(false);
|
||||
setSearchQuery(genre.query);
|
||||
searchTracks(genre.query);
|
||||
};
|
||||
|
||||
const playTrack = (track: SoundCloudTrack) => {
|
||||
playSound('click');
|
||||
setCurrentTrack(track);
|
||||
setIsPlaying(true);
|
||||
|
||||
// Use widget to play
|
||||
if (iframeRef.current && window.SC) {
|
||||
const widget = window.SC.Widget(iframeRef.current);
|
||||
widgetRef.current = widget;
|
||||
|
||||
// Load and play the track
|
||||
widget.load(track.permalink_url, {
|
||||
auto_play: true,
|
||||
buying: false,
|
||||
sharing: false,
|
||||
download: false,
|
||||
show_artwork: false,
|
||||
show_playcount: false,
|
||||
show_user: false,
|
||||
callback: () => {
|
||||
widget.setVolume(volume);
|
||||
widget.bind(window.SC.Widget.Events.PLAY, () => setIsPlaying(true));
|
||||
widget.bind(window.SC.Widget.Events.PAUSE, () => setIsPlaying(false));
|
||||
widget.bind(window.SC.Widget.Events.FINISH, () => {
|
||||
// Play next track if available
|
||||
const displayTracks = showFavorites ? favorites : tracks;
|
||||
const currentIndex = displayTracks.findIndex(t => t.id === track.id);
|
||||
if (currentIndex < displayTracks.length - 1) {
|
||||
playTrack(displayTracks[currentIndex + 1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
playSound('click');
|
||||
if (!widgetRef.current) return;
|
||||
|
||||
widgetRef.current.toggle();
|
||||
};
|
||||
|
||||
const handleVolumeChange = (newVolume: number) => {
|
||||
setVolume(newVolume);
|
||||
if (widgetRef.current) {
|
||||
widgetRef.current.setVolume(newVolume);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = (track: SoundCloudTrack) => {
|
||||
playSound('click');
|
||||
const isFavorite = favorites.some(f => f.id === track.id);
|
||||
let updated: SoundCloudTrack[];
|
||||
|
||||
if (isFavorite) {
|
||||
updated = favorites.filter(f => f.id !== track.id);
|
||||
} else {
|
||||
updated = [track, ...favorites];
|
||||
}
|
||||
|
||||
setFavorites(updated);
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const displayTracks = showFavorites ? favorites : tracks;
|
||||
|
||||
if (!hasSoundcloudKey) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4 border border-primary/30 bg-primary/5">
|
||||
<Music2 className="w-12 h-12 text-primary/50" />
|
||||
<p className="font-pixel text-sm text-foreground/60 text-center">
|
||||
SoundCloud Client ID required to search and play mixes
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
playSound('click');
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="px-4 py-2 border border-primary text-primary font-pixel text-sm hover:bg-primary hover:text-background transition-colors"
|
||||
>
|
||||
Add Client ID
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Hidden SoundCloud widget iframe */}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
id="sc-widget"
|
||||
src="https://w.soundcloud.com/player/?url=https://soundcloud.com/example"
|
||||
width="0"
|
||||
height="0"
|
||||
allow="autoplay"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-2">
|
||||
<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 SoundCloud mixes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
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>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!searchQuery.trim() || isLoading}
|
||||
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"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Genre pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
playSound('click');
|
||||
setShowFavorites(!showFavorites);
|
||||
setSelectedGenre(null);
|
||||
}}
|
||||
className={`px-3 py-1 border font-pixel text-xs transition-all flex items-center gap-1 ${
|
||||
showFavorites
|
||||
? 'border-primary bg-primary text-background'
|
||||
: 'border-primary/30 text-primary/70 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<Heart size={12} /> Favorites
|
||||
</button>
|
||||
{SOUNDCLOUD_GENRES.map((genre) => (
|
||||
<button
|
||||
key={genre.label}
|
||||
onClick={() => handleGenreClick(genre)}
|
||||
className={`px-3 py-1 border font-pixel text-xs transition-all ${
|
||||
selectedGenre === genre.label
|
||||
? 'border-primary bg-primary text-background'
|
||||
: 'border-primary/30 text-primary/70 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{genre.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Now Playing */}
|
||||
{currentTrack && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border border-primary p-3 bg-primary/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{currentTrack.artwork_url ? (
|
||||
<img
|
||||
src={currentTrack.artwork_url.replace('-large', '-t200x200')}
|
||||
alt={currentTrack.title}
|
||||
className="w-12 h-12 object-cover border border-primary/30"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-primary/20 flex items-center justify-center border border-primary/30">
|
||||
<Music2 className="w-6 h-6 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-pixel text-sm text-primary truncate">{currentTrack.title}</p>
|
||||
<p className="font-pixel text-xs text-foreground/50 truncate">{currentTrack.user.username}</p>
|
||||
<p className="font-pixel text-[10px] text-primary/50 flex items-center gap-1">
|
||||
<Clock size={10} /> {formatDuration(currentTrack.duration)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={togglePlay} className="p-2 border border-primary text-primary hover:bg-primary hover:text-background">
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleFavorite(currentTrack)}
|
||||
className={`p-1 ${favorites.some(f => f.id === currentTrack.id) ? 'text-red-500' : 'text-primary/50 hover:text-red-500'}`}
|
||||
>
|
||||
<Heart size={16} fill={favorites.some(f => f.id === currentTrack.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Volume2 size={14} className="text-primary/70" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={(e) => handleVolumeChange(Number(e.target.value))}
|
||||
className="flex-1 h-1 bg-primary/30 appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
<span className="font-pixel text-xs text-primary/70 w-8">{volume}%</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 border border-destructive/50 bg-destructive/10">
|
||||
<p className="font-pixel text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<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">Searching...</span>
|
||||
</div>
|
||||
) : displayTracks.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Music2 className="w-8 h-8 text-primary/30 mx-auto mb-2" />
|
||||
<p className="font-pixel text-sm text-foreground/60">
|
||||
{showFavorites ? 'No favorites yet' : 'Search for mixes or select a genre'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
displayTracks.map((track, index) => (
|
||||
<motion.button
|
||||
key={track.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
onClick={() => playTrack(track)}
|
||||
className={`w-full flex items-center gap-3 p-2 border-b border-primary/10 hover:bg-primary/10 transition-all text-left ${
|
||||
currentTrack?.id === track.id ? 'bg-primary/20' : ''
|
||||
}`}
|
||||
>
|
||||
{track.artwork_url ? (
|
||||
<img
|
||||
src={track.artwork_url.replace('-large', '-t67x67')}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover border border-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary/10 flex items-center justify-center border border-primary/20">
|
||||
<Music2 className="w-5 h-5 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-pixel text-xs text-primary truncate">{track.title}</p>
|
||||
<p className="font-pixel text-[10px] text-foreground/50 truncate">
|
||||
{track.user.username} • {formatDuration(track.duration)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(track);
|
||||
}}
|
||||
className={`p-1 ${favorites.some(f => f.id === track.id) ? 'text-red-500' : 'text-primary/30 hover:text-red-500'}`}
|
||||
>
|
||||
<Heart size={14} fill={favorites.some(f => f.id === track.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</motion.button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="font-pixel text-xs text-foreground/50 text-center">
|
||||
{showFavorites ? `${favorites.length} favorites` : `${tracks.length} mixes`} • Mixes & sets (10+ min)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Play, Pause, Loader2, Youtube, Heart, SkipForward, SkipBack, Volume2 } from 'lucide-react';
|
||||
import { useMusicApiKeys } from '@/hooks/useMusicApiKeys';
|
||||
import type { YTPlayer, YTStateChangeEvent, YTPlayerEvent } from '@/types/media-apis.d';
|
||||
interface YouTubeVideo {
|
||||
id: string;
|
||||
title: string;
|
||||
channelTitle: string;
|
||||
thumbnail: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
interface YouTubePlayerProps {
|
||||
playSound: (sound: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
// YouTube genres for discovery
|
||||
const YOUTUBE_GENRES = [
|
||||
{ label: 'Electronic', query: 'electronic music mix 2024' },
|
||||
{ label: 'Lo-Fi', query: 'lofi hip hop beats to study' },
|
||||
{ label: 'Synthwave', query: 'synthwave retrowave mix' },
|
||||
{ label: 'Ambient', query: 'ambient relaxing music' },
|
||||
{ label: 'Jazz', query: 'jazz music playlist' },
|
||||
{ label: 'Classical', query: 'classical music piano' },
|
||||
{ label: 'Hip Hop', query: 'hip hop beats instrumental' },
|
||||
{ label: 'Chill', query: 'chill vibes music mix' },
|
||||
];
|
||||
|
||||
const FAVORITES_KEY = 'youtube-favorites';
|
||||
|
||||
export const YouTubePlayer = ({ playSound, onOpenSettings }: YouTubePlayerProps) => {
|
||||
const { apiKeys, hasYoutubeKey } = useMusicApiKeys();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [videos, setVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [favorites, setFavorites] = useState<YouTubeVideo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentVideo, setCurrentVideo] = useState<YouTubeVideo | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(80);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load favorites from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
setFavorites(JSON.parse(stored));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse favorites');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load YouTube IFrame API
|
||||
useEffect(() => {
|
||||
if (!window.YT) {
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0];
|
||||
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchVideos = useCallback(async (query: string) => {
|
||||
if (!hasYoutubeKey || !query.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/search?part=snippet&maxResults=15&type=video&videoCategoryId=10&q=${encodeURIComponent(query)}&key=${apiKeys.youtubeApiKey}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error?.message || 'Failed to search YouTube');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const results: YouTubeVideo[] = data.items.map((item: any) => ({
|
||||
id: item.id.videoId,
|
||||
title: item.snippet.title,
|
||||
channelTitle: item.snippet.channelTitle,
|
||||
thumbnail: item.snippet.thumbnails.medium?.url || item.snippet.thumbnails.default?.url,
|
||||
}));
|
||||
|
||||
setVideos(results);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to search');
|
||||
console.error('YouTube search error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [hasYoutubeKey, apiKeys.youtubeApiKey]);
|
||||
|
||||
const handleSearch = () => {
|
||||
playSound('click');
|
||||
setSelectedGenre(null);
|
||||
setShowFavorites(false);
|
||||
searchVideos(searchQuery);
|
||||
};
|
||||
|
||||
const handleGenreClick = (genre: typeof YOUTUBE_GENRES[0]) => {
|
||||
playSound('click');
|
||||
setSelectedGenre(genre.label);
|
||||
setShowFavorites(false);
|
||||
setSearchQuery(genre.query);
|
||||
searchVideos(genre.query);
|
||||
};
|
||||
|
||||
const playVideo = (video: YouTubeVideo) => {
|
||||
playSound('click');
|
||||
setCurrentVideo(video);
|
||||
setIsPlaying(true);
|
||||
|
||||
// Save to recent for recommendations
|
||||
const recentKey = 'youtube-recent';
|
||||
const recent = JSON.parse(localStorage.getItem(recentKey) || '[]');
|
||||
const updated = [video, ...recent.filter((v: YouTubeVideo) => v.id !== video.id)].slice(0, 20);
|
||||
localStorage.setItem(recentKey, JSON.stringify(updated));
|
||||
|
||||
if (playerRef.current) {
|
||||
playerRef.current.loadVideoById(video.id);
|
||||
} else if (playerContainerRef.current && window.YT?.Player) {
|
||||
playerRef.current = new window.YT.Player(playerContainerRef.current, {
|
||||
height: '0',
|
||||
width: '0',
|
||||
videoId: video.id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
controls: 0,
|
||||
},
|
||||
events: {
|
||||
onStateChange: (event: YTStateChangeEvent) => {
|
||||
if (event.data === window.YT.PlayerState.PLAYING) {
|
||||
setIsPlaying(true);
|
||||
} else if (event.data === window.YT.PlayerState.PAUSED) {
|
||||
setIsPlaying(false);
|
||||
} else if (event.data === window.YT.PlayerState.ENDED) {
|
||||
playNext();
|
||||
}
|
||||
},
|
||||
onReady: (event: YTPlayerEvent) => {
|
||||
event.target.setVolume(volume);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
playSound('click');
|
||||
if (!playerRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
playerRef.current.pauseVideo();
|
||||
} else {
|
||||
playerRef.current.playVideo();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const playNext = () => {
|
||||
if (!currentVideo || videos.length === 0) return;
|
||||
const currentIndex = videos.findIndex(v => v.id === currentVideo.id);
|
||||
const nextIndex = (currentIndex + 1) % videos.length;
|
||||
playVideo(videos[nextIndex]);
|
||||
};
|
||||
|
||||
const playPrevious = () => {
|
||||
if (!currentVideo || videos.length === 0) return;
|
||||
const currentIndex = videos.findIndex(v => v.id === currentVideo.id);
|
||||
const prevIndex = currentIndex <= 0 ? videos.length - 1 : currentIndex - 1;
|
||||
playVideo(videos[prevIndex]);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (newVolume: number) => {
|
||||
setVolume(newVolume);
|
||||
if (playerRef.current) {
|
||||
playerRef.current.setVolume(newVolume);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = (video: YouTubeVideo) => {
|
||||
playSound('click');
|
||||
const isFavorite = favorites.some(f => f.id === video.id);
|
||||
let updated: YouTubeVideo[];
|
||||
|
||||
if (isFavorite) {
|
||||
updated = favorites.filter(f => f.id !== video.id);
|
||||
} else {
|
||||
updated = [video, ...favorites];
|
||||
}
|
||||
|
||||
setFavorites(updated);
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const displayVideos = showFavorites ? favorites : videos;
|
||||
|
||||
if (!hasYoutubeKey) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4 border border-primary/30 bg-primary/5">
|
||||
<Youtube className="w-12 h-12 text-primary/50" />
|
||||
<p className="font-pixel text-sm text-foreground/60 text-center">
|
||||
YouTube API key required to search and play music
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
playSound('click');
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="px-4 py-2 border border-primary text-primary font-pixel text-sm hover:bg-primary hover:text-background transition-colors"
|
||||
>
|
||||
Add API Key
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Hidden player container */}
|
||||
<div ref={playerContainerRef} className="hidden" />
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-2">
|
||||
<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 YouTube music..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
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>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!searchQuery.trim() || isLoading}
|
||||
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"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Genre pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
playSound('click');
|
||||
setShowFavorites(!showFavorites);
|
||||
setSelectedGenre(null);
|
||||
}}
|
||||
className={`px-3 py-1 border font-pixel text-xs transition-all flex items-center gap-1 ${
|
||||
showFavorites
|
||||
? 'border-primary bg-primary text-background'
|
||||
: 'border-primary/30 text-primary/70 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<Heart size={12} /> Favorites
|
||||
</button>
|
||||
{YOUTUBE_GENRES.map((genre) => (
|
||||
<button
|
||||
key={genre.label}
|
||||
onClick={() => handleGenreClick(genre)}
|
||||
className={`px-3 py-1 border font-pixel text-xs transition-all ${
|
||||
selectedGenre === genre.label
|
||||
? 'border-primary bg-primary text-background'
|
||||
: 'border-primary/30 text-primary/70 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{genre.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Now Playing */}
|
||||
{currentVideo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border border-primary p-3 bg-primary/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={currentVideo.thumbnail}
|
||||
alt={currentVideo.title}
|
||||
className="w-16 h-12 object-cover border border-primary/30"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-pixel text-sm text-primary truncate">{currentVideo.title}</p>
|
||||
<p className="font-pixel text-xs text-foreground/50 truncate">{currentVideo.channelTitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={playPrevious} className="p-1 text-primary hover:text-primary/80">
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button onClick={togglePlay} className="p-2 border border-primary text-primary hover:bg-primary hover:text-background">
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<button onClick={playNext} className="p-1 text-primary hover:text-primary/80">
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleFavorite(currentVideo)}
|
||||
className={`p-1 ${favorites.some(f => f.id === currentVideo.id) ? 'text-red-500' : 'text-primary/50 hover:text-red-500'}`}
|
||||
>
|
||||
<Heart size={16} fill={favorites.some(f => f.id === currentVideo.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Volume2 size={14} className="text-primary/70" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={(e) => handleVolumeChange(Number(e.target.value))}
|
||||
className="flex-1 h-1 bg-primary/30 appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
<span className="font-pixel text-xs text-primary/70 w-8">{volume}%</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 border border-destructive/50 bg-destructive/10">
|
||||
<p className="font-pixel text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<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">Searching...</span>
|
||||
</div>
|
||||
) : displayVideos.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Youtube className="w-8 h-8 text-primary/30 mx-auto mb-2" />
|
||||
<p className="font-pixel text-sm text-foreground/60">
|
||||
{showFavorites ? 'No favorites yet' : 'Search for music or select a genre'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
displayVideos.map((video, index) => (
|
||||
<motion.button
|
||||
key={video.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
onClick={() => playVideo(video)}
|
||||
className={`w-full flex items-center gap-3 p-2 border-b border-primary/10 hover:bg-primary/10 transition-all text-left ${
|
||||
currentVideo?.id === video.id ? 'bg-primary/20' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt=""
|
||||
className="w-16 h-12 object-cover border border-primary/20"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-pixel text-xs text-primary truncate">{video.title}</p>
|
||||
<p className="font-pixel text-[10px] text-foreground/50 truncate">{video.channelTitle}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(video);
|
||||
}}
|
||||
className={`p-1 ${favorites.some(f => f.id === video.id) ? 'text-red-500' : 'text-primary/30 hover:text-red-500'}`}
|
||||
>
|
||||
<Heart size={14} fill={favorites.some(f => f.id === video.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</motion.button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="font-pixel text-xs text-foreground/50 text-center">
|
||||
{showFavorites ? `${favorites.length} favorites` : `${videos.length} results`} • Audio-only playback
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface MusicApiKeys {
|
||||
youtubeApiKey: string;
|
||||
soundcloudClientId: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'music-api-keys';
|
||||
|
||||
export const useMusicApiKeys = () => {
|
||||
const [apiKeys, setApiKeys] = useState<MusicApiKeys>({
|
||||
youtubeApiKey: '',
|
||||
soundcloudClientId: '',
|
||||
});
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
setApiKeys(JSON.parse(stored));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored API keys');
|
||||
}
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}, []);
|
||||
|
||||
const saveApiKeys = useCallback((keys: Partial<MusicApiKeys>) => {
|
||||
setApiKeys(prev => {
|
||||
const updated = { ...prev, ...keys };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearApiKeys = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setApiKeys({ youtubeApiKey: '', soundcloudClientId: '' });
|
||||
}, []);
|
||||
|
||||
const hasYoutubeKey = Boolean(apiKeys.youtubeApiKey);
|
||||
const hasSoundcloudKey = Boolean(apiKeys.soundcloudClientId);
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
saveApiKeys,
|
||||
clearApiKeys,
|
||||
hasYoutubeKey,
|
||||
hasSoundcloudKey,
|
||||
isLoaded,
|
||||
};
|
||||
};
|
||||
+64
-13
@@ -1,9 +1,12 @@
|
||||
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 { Play, Pause, Volume2, Radio, Search, Loader2, ChevronDown, Link2, SkipBack, SkipForward, Zap, Settings, Youtube, Music2 } from 'lucide-react';
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
import { useMusic, Station } from '@/contexts/MusicContext';
|
||||
|
||||
import { ApiKeySettings } from '@/components/music/ApiKeySettings';
|
||||
import { YouTubePlayer } from '@/components/music/YouTubePlayer';
|
||||
import { SoundCloudPlayer } from '@/components/music/SoundCloudPlayer';
|
||||
import { useMusicApiKeys } from '@/hooks/useMusicApiKeys';
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All Genres' },
|
||||
{ value: 'pop', label: 'Pop' },
|
||||
@@ -134,9 +137,11 @@ const Music = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [customUrlError, setCustomUrlError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'browse' | 'presets' | 'custom'>('browse');
|
||||
const [activeTab, setActiveTab] = useState<'browse' | 'presets' | 'custom' | 'youtube' | 'soundcloud'>('browse');
|
||||
const [filteredStations, setFilteredStations] = useState<Station[]>([]);
|
||||
const [showApiSettings, setShowApiSettings] = useState(false);
|
||||
const { playSound } = useSettings();
|
||||
const { hasYoutubeKey, hasSoundcloudKey } = useMusicApiKeys();
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
@@ -253,9 +258,34 @@ const Music = () => {
|
||||
</h1>
|
||||
|
||||
<p className="font-pixel text-foreground/80">
|
||||
Stream radio stations from around the world or add your own stream URL.
|
||||
Stream radio stations, YouTube music, or SoundCloud mixes.
|
||||
</p>
|
||||
|
||||
{/* API Settings Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
playSound('click');
|
||||
setShowApiSettings(true);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 border font-pixel text-xs transition-all ${
|
||||
hasYoutubeKey || hasSoundcloudKey
|
||||
? 'border-primary/30 text-primary/70 hover:border-primary hover:text-primary'
|
||||
: 'border-primary text-primary animate-pulse'
|
||||
}`}
|
||||
>
|
||||
<Settings size={14} />
|
||||
API Keys
|
||||
{hasYoutubeKey && hasSoundcloudKey && <span className="text-primary">●</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ApiKeySettings
|
||||
isOpen={showApiSettings}
|
||||
onClose={() => setShowApiSettings(false)}
|
||||
playSound={playSound}
|
||||
/>
|
||||
|
||||
{/* Now Playing */}
|
||||
{selectedStation && (
|
||||
<motion.div
|
||||
@@ -339,23 +369,28 @@ const Music = () => {
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-primary/30">
|
||||
{(['browse', 'presets', 'custom'] as const).map((tab) => (
|
||||
<div className="flex gap-1 border-b border-primary/30 overflow-x-auto pb-px">
|
||||
{([
|
||||
{ key: 'browse', label: 'Radio', icon: Radio },
|
||||
{ key: 'youtube', label: 'YouTube', icon: Youtube },
|
||||
{ key: 'soundcloud', label: 'SoundCloud', icon: Music2 },
|
||||
{ key: 'presets', label: 'Electronic', icon: Zap },
|
||||
{ key: 'custom', label: 'Custom', icon: Link2 },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
key={tab.key}
|
||||
onClick={() => {
|
||||
playSound('click');
|
||||
setActiveTab(tab);
|
||||
setActiveTab(tab.key);
|
||||
}}
|
||||
className={`px-4 py-2 font-pixel text-sm transition-all ${
|
||||
activeTab === tab
|
||||
className={`px-3 py-2 font-pixel text-xs transition-all flex items-center gap-1.5 whitespace-nowrap ${
|
||||
activeTab === tab.key
|
||||
? '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'}
|
||||
<tab.icon size={14} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -487,6 +522,22 @@ const Music = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YouTube Tab */}
|
||||
{activeTab === 'youtube' && (
|
||||
<YouTubePlayer
|
||||
playSound={playSound}
|
||||
onOpenSettings={() => setShowApiSettings(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SoundCloud Tab */}
|
||||
{activeTab === 'soundcloud' && (
|
||||
<SoundCloudPlayer
|
||||
playSound={playSound}
|
||||
onOpenSettings={() => setShowApiSettings(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom URL Tab */}
|
||||
{activeTab === 'custom' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
// YouTube IFrame API type definitions
|
||||
declare global {
|
||||
interface Window {
|
||||
YT: {
|
||||
Player: new (
|
||||
element: HTMLElement | string,
|
||||
options: YTPlayerOptions
|
||||
) => YTPlayer;
|
||||
PlayerState: {
|
||||
PLAYING: number;
|
||||
PAUSED: number;
|
||||
ENDED: number;
|
||||
BUFFERING: number;
|
||||
CUED: number;
|
||||
};
|
||||
};
|
||||
onYouTubeIframeAPIReady?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export interface YTPlayerOptions {
|
||||
height?: string | number;
|
||||
width?: string | number;
|
||||
videoId?: string;
|
||||
playerVars?: {
|
||||
autoplay?: 0 | 1;
|
||||
controls?: 0 | 1;
|
||||
modestbranding?: 0 | 1;
|
||||
rel?: 0 | 1;
|
||||
fs?: 0 | 1;
|
||||
playsinline?: 0 | 1;
|
||||
};
|
||||
events?: {
|
||||
onReady?: (event: YTPlayerEvent) => void;
|
||||
onStateChange?: (event: YTStateChangeEvent) => void;
|
||||
onError?: (event: YTErrorEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface YTPlayer {
|
||||
playVideo(): void;
|
||||
pauseVideo(): void;
|
||||
stopVideo(): void;
|
||||
loadVideoById(videoId: string, startSeconds?: number): void;
|
||||
cueVideoById(videoId: string, startSeconds?: number): void;
|
||||
setVolume(volume: number): void;
|
||||
getVolume(): number;
|
||||
mute(): void;
|
||||
unMute(): void;
|
||||
isMuted(): boolean;
|
||||
getCurrentTime(): number;
|
||||
getDuration(): number;
|
||||
getPlayerState(): number;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface YTPlayerEvent {
|
||||
target: YTPlayer;
|
||||
}
|
||||
|
||||
export interface YTStateChangeEvent {
|
||||
target: YTPlayer;
|
||||
data: number;
|
||||
}
|
||||
|
||||
export interface YTErrorEvent {
|
||||
target: YTPlayer;
|
||||
data: number;
|
||||
}
|
||||
|
||||
// SoundCloud Widget API type definitions
|
||||
export interface SCWidgetOptions {
|
||||
auto_play?: boolean;
|
||||
buying?: boolean;
|
||||
sharing?: boolean;
|
||||
download?: boolean;
|
||||
show_artwork?: boolean;
|
||||
show_playcount?: boolean;
|
||||
show_user?: boolean;
|
||||
callback?: () => void;
|
||||
}
|
||||
|
||||
export interface SCWidget {
|
||||
load(url: string, options?: SCWidgetOptions): void;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
toggle(): void;
|
||||
setVolume(volume: number): void;
|
||||
getVolume(callback: (volume: number) => void): void;
|
||||
bind(event: string, callback: () => void): void;
|
||||
unbind(event: string): void;
|
||||
getCurrentSound(callback: (sound: object) => void): void;
|
||||
getPosition(callback: (position: number) => void): void;
|
||||
seekTo(milliseconds: number): void;
|
||||
}
|
||||
|
||||
export interface SCWidgetConstructor {
|
||||
(iframe: HTMLIFrameElement | string): SCWidget;
|
||||
Events: {
|
||||
PLAY: string;
|
||||
PAUSE: string;
|
||||
FINISH: string;
|
||||
READY: string;
|
||||
PLAY_PROGRESS: string;
|
||||
LOAD_PROGRESS: string;
|
||||
SEEK: string;
|
||||
ERROR: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SC?: {
|
||||
Widget: SCWidgetConstructor;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user