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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user