Integrate YouTube and SoundCloud

- Add client-side YouTube and SoundCloud music integrations
- Implement API key settings UI stored in localStorage
- Replace previous separate tabs with integrated in-page YouTube/SoundCloud modules
- Update Music page to include YouTube, SoundCloud, and custom URL playback options
- Introduce YouTubePlayer and SoundCloudPlayer components with basic search, playback, and favorites
- Add TypeScript typings for media APIs and related hooks

X-Lovable-Edit-ID: edt-252ad380-84c1-420a-8bd4-f9daeb5bc1ec
This commit is contained in:
gpt-engineer-app[bot]
2026-01-06 00:04:17 +00:00
6 changed files with 1197 additions and 13 deletions
+162
View File
@@ -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>
);
};
+401
View File
@@ -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>
);
};
+398
View File
@@ -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>
);
};
+53
View File
@@ -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
View File
@@ -1,9 +1,12 @@
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect } from 'react'; 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 { useSettings } from '@/contexts/SettingsContext';
import { useMusic, Station } from '@/contexts/MusicContext'; 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 = [ const CATEGORIES = [
{ value: '', label: 'All Genres' }, { value: '', label: 'All Genres' },
{ value: 'pop', label: 'Pop' }, { value: 'pop', label: 'Pop' },
@@ -134,9 +137,11 @@ const Music = () => {
const [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState('');
const [customUrl, setCustomUrl] = useState(''); const [customUrl, setCustomUrl] = useState('');
const [customUrlError, setCustomUrlError] = useState<string | null>(null); 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 [filteredStations, setFilteredStations] = useState<Station[]>([]);
const [showApiSettings, setShowApiSettings] = useState(false);
const { playSound } = useSettings(); const { playSound } = useSettings();
const { hasYoutubeKey, hasSoundcloudKey } = useMusicApiKeys();
const { const {
isPlaying, isPlaying,
@@ -253,9 +258,34 @@ const Music = () => {
</h1> </h1>
<p className="font-pixel text-foreground/80"> <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> </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 */} {/* Now Playing */}
{selectedStation && ( {selectedStation && (
<motion.div <motion.div
@@ -339,23 +369,28 @@ const Music = () => {
)} )}
{/* Tabs */} {/* Tabs */}
<div className="flex gap-2 border-b border-primary/30"> <div className="flex gap-1 border-b border-primary/30 overflow-x-auto pb-px">
{(['browse', 'presets', 'custom'] as const).map((tab) => ( {([
{ 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 <button
key={tab} key={tab.key}
onClick={() => { onClick={() => {
playSound('click'); playSound('click');
setActiveTab(tab); setActiveTab(tab.key);
}} }}
className={`px-4 py-2 font-pixel text-sm transition-all ${ className={`px-3 py-2 font-pixel text-xs transition-all flex items-center gap-1.5 whitespace-nowrap ${
activeTab === tab activeTab === tab.key
? 'text-primary border-b-2 border-primary text-glow' ? 'text-primary border-b-2 border-primary text-glow'
: 'text-foreground/60 hover:text-primary' : 'text-foreground/60 hover:text-primary'
}`} }`}
> >
{tab === 'browse' && 'Browse'} <tab.icon size={14} />
{tab === 'presets' && 'Electronic'} {tab.label}
{tab === 'custom' && 'Custom URL'}
</button> </button>
))} ))}
</div> </div>
@@ -487,6 +522,22 @@ const Music = () => {
</div> </div>
)} )}
{/* YouTube Tab */}
{activeTab === 'youtube' && (
<YouTubePlayer
playSound={playSound}
onOpenSettings={() => setShowApiSettings(true)}
/>
)}
{/* SoundCloud Tab */}
{activeTab === 'soundcloud' && (
<SoundCloudPlayer
playSound={playSound}
onOpenSettings={() => setShowApiSettings(true)}
/>
)}
{/* Custom URL Tab */} {/* Custom URL Tab */}
{activeTab === 'custom' && ( {activeTab === 'custom' && (
<div className="space-y-4"> <div className="space-y-4">
+119
View File
@@ -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 {};