URL: https://lovable.dev/projects/d16edf1f-4126-499a-89e7-db99f81ad1c2
A comprehensive Afrobeats music platform featuring a global audio player, artist discovery, dance tutorials, event listings, and community features.
- Framework: React 18 with TypeScript
- Build Tool: Vite
- Styling: Tailwind CSS with shadcn/ui components
- Routing: React Router DOM v6
- State Management: React Context API
- Audio: YouTube IFrame API
- Drag & Drop: react-beautiful-dnd
- Icons: Lucide React
- Animations: Framer Motion
The Global Audio Player is a persistent, YouTube-powered music player that provides seamless audio playback across all pages of the application. It is implemented as a React Context provider that wraps the entire application.
src/components/
├── GlobalAudioPlayer.tsx # Main player component & context provider
├── QueueDrawer.tsx # Queue and history management drawer
├── MarkdownPreviewDialog.tsx # Export preview dialog
└── VibeOfTheDay.tsx # Contains VIBE_VIDEOS array for random playback
The player uses React Context to provide global state and controls accessible from any component in the application.
// Context type definition
interface GlobalAudioPlayerContextType {
currentSong: Song | null;
queue: Song[];
isPlaying: boolean;
playNow: (song: Song) => void;
addToQueue: (song: Song) => void;
removeFromQueue: (songId: string) => void;
togglePlay: () => void;
nextSong: () => void;
previousSong: () => void;
setVolume: (value: number) => void;
toggleRepeat: () => void;
reorderQueue: (from: number, to: number) => void;
duration: number;
currentTime: number;
isDragging: boolean;
}interface Song {
id: string; // Unique identifier for the song
youtube: string; // YouTube video URL or video ID
title?: string; // Optional display title
artist?: string; // Optional artist name
}The player persists the following state to localStorage:
| Key | Purpose |
|---|---|
afrobeats_current_song |
Currently playing song object |
afrobeats_queue |
Array of songs in the queue |
afrobeats_volume |
Volume level (0-100) |
afrobeats_repeat |
Repeat mode boolean |
afrobeats_played_songs |
Array of recently played song IDs |
afrobeats_video_visible |
Video visibility state boolean |
| State | Type | Default | Description |
|---|---|---|---|
player |
any | null | YouTube player instance |
currentSong |
Song | null | null | Currently playing song |
queue |
Song[] | [] | Queue of upcoming songs |
isPlaying |
boolean | false | Playback state |
volume |
number | 100 | Volume level (0-100) |
repeat |
boolean | false | Repeat mode |
youtubeApiLoaded |
boolean | false | YouTube API load state |
expandedView |
boolean | true | Player view mode |
videoTitle |
string | "Loading..." | Current video title from YouTube |
channelTitle |
string | "Loading..." | Channel name from YouTube |
previousVideoData |
Song | null | null | Previous song for error recovery |
isMobile |
boolean | (detected) | Mobile device detection |
videoVisible |
boolean | false | Video player visibility |
duration |
number | 0 | Song duration in seconds |
currentTime |
number | 0 | Current playback position |
isDragging |
boolean | false | Seek slider drag state |
isLoading |
boolean | false | Loading state |
loadingTitle |
string | "Loading..." | Title shown during load |
queueVisible |
boolean | false | Queue drawer visibility |
showPlayedSongs |
boolean | false | Filter played songs in queue |
playedSongs |
Set<string> | new Set() | Set of played song IDs |
isInitialLoad |
boolean | true | Initial load flag |
thumbnailUrl |
string | "" | YouTube thumbnail URL |
showVolumeSlider |
boolean | false | Volume popup visibility (mobile) |
Immediately plays a song, replacing the current track.
Behavior:
- Sets loading state to true
- Stores previous song for error recovery
- Updates
currentSongstate - Sets
isPlayingto true - Extracts video ID from YouTube URL
- Calls
player.loadVideoById(videoId) - Updates thumbnail URL
Adds a song to the end of the playback queue.
const addToQueue = useCallback((song: Song) => {
setQueue(prev => [...prev, song]);
}, []);Removes a specific song from the queue by its ID.
const removeFromQueue = useCallback((songId: string) => {
setQueue(prev => prev.filter(song => song.id !== songId));
}, []);Toggles between play and pause states.
Behavior:
- If playing → calls
player.pauseVideo() - If paused → calls
player.playVideo() - Updates
isPlayingstate
Advances to the next song.
Behavior:
- If queue has songs → plays first song in queue, removes from queue
- If queue is empty → calls
findUnplayedSong()to get a random vibe video
Seeks to the beginning of the current song.
const previousSong = useCallback(() => {
if (player) {
player.seekTo(0);
}
}, [player]);Sets the playback volume.
const updateVolume = useCallback((value: number) => {
if (player) {
player.setVolume(value);
setVolume(value);
}
}, [player]);Toggles repeat mode on/off.
Reorders the queue via drag and drop.
const reorderQueue = useCallback((from: number, to: number) => {
setQueue(prev => {
const newQueue = [...prev];
const [removed] = newQueue.splice(from, 1);
newQueue.splice(to, 0, removed);
return newQueue;
});
}, []);Toggles YouTube video player visibility.
Toggles between expanded and collapsed player views.
Shows/hides the queue drawer.
Extracts video ID from various YouTube URL formats.
Supported formats:
https://www.youtube.com/watch?v=VIDEO_IDhttps://youtu.be/VIDEO_ID- Direct video ID string
const getVideoId = useCallback((youtube: string): string => {
if (youtube.includes('v=')) {
return youtube.split('v=')[1].split('&')[0];
} else if (youtube.includes('youtu.be/')) {
return youtube.split('youtu.be/')[1].split('?')[0];
}
return youtube;
}, []);Formats seconds into MM:SS display format.
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};Returns a random video from VIBE_VIDEOS array, optionally excluding a specific ID.
const getRandomVibeVideo = useCallback((excludeId?: string) => {
const availableVideos = VIBE_VIDEOS.filter(id => id !== excludeId);
const randomIndex = Math.floor(Math.random() * availableVideos.length);
return availableVideos[randomIndex];
}, []);Intelligently finds the next song to play.
Algorithm:
- First, searches queue for unplayed songs
- If all queue songs played, gets random vibe video
- Tries up to 5 times to avoid recently played videos
- Returns a Song object
The player dynamically loads the YouTube IFrame API:
useEffect(() => {
if (!window.YT && !document.getElementById('youtube-iframe-api')) {
const tag = document.createElement('script');
tag.id = 'youtube-iframe-api';
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
window.onYouTubeIframeAPIReady = () => {
setYoutubeApiLoaded(true);
};
} else if (window.YT) {
setYoutubeApiLoaded(true);
}
}, []);const newPlayer = new window.YT.Player('youtube-player', {
height: '240',
width: '426',
playerVars: {
playsinline: 1,
controls: 1
},
events: {
onStateChange: handleStateChange,
onError: handleError,
onReady: handleReady
}
});onStateChange:
ENDED→ Plays next song or repeats currentPLAYING→ Updates metadata, sets durationPAUSED→ Updates isPlaying stateBUFFERING→ Sets loading state
onError:
- Logs error to console
- Shows toast notification
- Adds failed song to end of queue
- Plays next song
- Falls back to previous video if available
onReady:
- Sets initial volume
- Loads saved current song if available
The player integrates with the browser's Media Session API for background playback control:
useEffect(() => {
if ('mediaSession' in navigator && currentSong) {
navigator.mediaSession.metadata = new MediaMetadata({
title: videoTitle || 'Unknown Title',
artist: channelTitle || 'Unknown Artist',
album: 'Afrobeats Player',
artwork: [{
src: thumbnailUrl || '/AfrobeatsDAOMeta.png',
sizes: '128x128',
type: 'image/png'
}]
});
navigator.mediaSession.setActionHandler('play', togglePlay);
navigator.mediaSession.setActionHandler('pause', togglePlay);
navigator.mediaSession.setActionHandler('previoustrack', previousSong);
navigator.mediaSession.setActionHandler('nexttrack', nextSong);
}
}, [currentSong, videoTitle, channelTitle, thumbnailUrl]);The player is fixed to the bottom of the viewport:
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 150;| Element | Color |
|---|---|
| Background | bg-black/95 (95% opacity black) |
| Border | border-white/10 (10% opacity white) |
| Text | text-white |
| Accent | #FFD600 (Golden yellow) |
| Secondary text | text-gray-400 |
┌─────────────────────────────────────────────────────────────────────┐
│ [Thumbnail] Title [<<][▶][>>][🔁] [Queue][Video][🔊]━━━ │
│ Channel │
├─────────────────────────────────────────────────────────────────────┤
│ 0:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:45 │
└─────────────────────────────────────────────────────────────────────┘
Column 1 (Left): Song info with thumbnail, title, channel Column 2 (Center): Playback controls (Previous, Play/Pause, Next, Repeat) Column 3 (Right): Queue toggle, Video toggle, Volume controls
┌─────────────────────────────────────────────┐
│ [Thumbnail] Title [Queue]│
│ Channel │
├─────────────────────────────────────────────┤
│ 0:00 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:45 │
├─────────────────────────────────────────────┤
│ [<<] [▶] [>>] [🔁] [🔊] [📹] │
└─────────────────────────────────────────────┘
Row 1: Song info + Queue button Row 2: Time slider with current/total time Row 3: All controls centered
| Icon | Component | Action |
|---|---|---|
<SkipBack /> |
Previous | Seeks to start of song |
<Play /> / <Pause /> |
Play/Pause | Toggle playback |
<SkipForward /> |
Next | Play next song |
<Repeat /> / <Repeat1 /> |
Repeat | Toggle repeat mode |
<List /> / <ListCollapse /> |
Queue | Toggle queue drawer |
<Video /> / <VideoOff /> |
Video | Toggle video visibility |
<Volume2 /> / <VolumeX /> |
Volume | Mute/unmute or show slider |
Active controls use accent color: text-[#FFD600]
Horizontal slider inline with controls:
<div className="w-24">
<Slider
value={[volume]}
min={0}
max={100}
step={1}
onValueChange={([value]) => updateVolume(value)}
/>
</div>Vertical popup slider on hover/tap:
<div className="relative">
<Button
onMouseEnter={() => setShowVolumeSlider(true)}
onMouseLeave={() => setShowVolumeSlider(false)}
onClick={() => setVolume(volume === 0 ? 100 : 0)}
>
{volume === 0 ? <VolumeX /> : <Volume2 />}
</Button>
{showVolumeSlider && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2">
<Slider orientation="vertical" className="h-16" ... />
</div>
)}
</div>- Displays current position and total duration
- Drag to seek (sets
isDraggingstate) - Updates on release via
onValueCommit
<Slider
value={[currentTime]}
min={0}
max={duration}
step={1}
onValueChange={([value]) => {
setCurrentTime(value);
setIsDragging(true);
}}
onValueCommit={([value]) => {
handleTimeChange(value);
setIsDragging(false);
}}
/>useEffect(() => {
if (!player || !isPlaying || isDragging) return;
const interval = setInterval(() => {
if (player.getCurrentTime) {
setCurrentTime(player.getCurrentTime());
}
}, 1000);
return () => clearInterval(interval);
}, [player, isPlaying, isDragging]);The YouTube video is rendered in a positioned container:
<div
ref={playerContainerRef}
className={`fixed z-[200] bg-black/95 border border-white/10 rounded-lg overflow-hidden shadow-xl ${
isMobile
? 'bottom-[100px] right-4 left-4'
: 'bottom-[80px] right-4'
}`}
style={{
display: expandedView ? 'block' : 'none',
visibility: videoVisible ? 'visible' : 'hidden',
...(expandedView && !videoVisible ? { left: '-9999px' } : {})
}}
>
<div id="youtube-player"></div>
</div>Desktop positioning: Bottom right, above player bar Mobile positioning: Full width with margins, above player bar
When no song is playing:
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<Music2 className="h-8 w-8 text-[#FFD600]" />
<span className="text-sm">Afrobeats Player</span>
</div>
<Button
onClick={() => {
const defaultVideo = getRandomVibeVideo();
playNow({
id: `default-vibe-${defaultVideo}`,
youtube: defaultVideo,
title: "Random Vibe"
});
}}
className="bg-[#FFD600] text-black hover:bg-[#FFD600]/90"
>
<Play className="mr-2 h-4 w-4" />
Play Something
</Button>
</div>Location: src/components/QueueDrawer.tsx
interface QueueDrawerProps {
queue: Song[];
isVisible: boolean;
playNow: (song: Song) => void;
reorderQueue: (from: number, to: number) => void;
playedSongs: Set<string>;
showPlayedSongs: boolean;
setShowPlayedSongs: (show: boolean) => void;
}- Tabs: Queue / History switching
- Drag & Drop: Reorder queue items
- Minimizable: Collapse to small header
- Export: Download queue/history as markdown
┌─────────────────────────────────────┐
│ [Queue] [History] [—] │
├─────────────────────────────────────┤
│ │
│ [≡] [Thumb] Title [Played]│
│ Artist │
│ │
│ [≡] [Thumb] Title │
│ Artist │
│ │
│ [≡] [Thumb] Title │
│ Artist │
│ │
├─────────────────────────────────────┤
│ [📥 Export Queue] │
└─────────────────────────────────────┘
position: fixed;
right: 4 (1rem);
bottom: 80px;
width: 350px;
z-index: 40;Uses react-beautiful-dnd:
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="queue-drawer-droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{filteredQueue.map((song, index) => (
<Draggable
key={`queue-drawer-item-${song.id}`}
draggableId={`queue-drawer-item-${song.id}`}
index={index}
>
{(provided) => (
<div ref={provided.innerRef} {...provided.draggableProps}>
<div {...provided.dragHandleProps}>
<MoveVertical />
</div>
{/* Song content */}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext><div className="flex items-center gap-3 p-2 rounded-md hover:bg-accent/10 group">
{/* Drag handle */}
<div {...provided.dragHandleProps}>
<MoveVertical className="h-4 w-4" />
</div>
{/* Thumbnail with play overlay */}
<div className="relative w-16 h-12 rounded-md overflow-hidden">
<img src={getVideoThumbnail(videoId)} />
<Button
className="absolute inset-0 opacity-0 group-hover:opacity-100 bg-black/50"
onClick={() => playNow(song)}
>
<Play />
</Button>
</div>
{/* Song info */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate text-black">
{song.title || "Title of video"}
</h4>
<p className="text-xs text-muted-foreground truncate">
{song.artist || "Unknown Artist"}
</p>
</div>
{/* Played badge */}
{playedSongs.has(song.id) && (
<Badge variant="outline">Played</Badge>
)}
</div>Displays recently played songs (without drag & drop):
{playedSongsList.map((song, index) => (
<div key={`history-item-${song.id}`} className="flex items-center gap-3 p-2">
{/* Same structure as queue item, without drag handle */}
</div>
))}Generates markdown content for queue or history:
const generateMarkdownContent = (tab: TabType) => {
let content = "# Afrobeats Music History\n\n";
if (tab === "queue") {
content += "## Current Queue\n\n";
filteredQueue.forEach((song, index) => {
content += `${index + 1}. **${song.title}** - ${song.artist}\n`;
content += ` - [Watch Video](https://www.youtube.com/watch?v=${videoId})\n\n`;
});
}
// ... similar for history
content += `Exported on ${new Date().toLocaleString()}\n`;
return content;
};Download function:
const handleDownload = () => {
const blob = new Blob([markdownContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = "afrobeats-queue.md";
a.click();
URL.revokeObjectURL(url);
};Queue empty:
[ListMusic icon]
Your queue is empty
Add songs from playlists
History empty:
[ListMusic icon]
No play history yet
Songs will appear here after playing
The player tracks recently played songs to avoid repetition:
const RECENTLY_PLAYED_LIMIT = 10;
useEffect(() => {
if (currentSong?.id) {
setPlayedSongs(prev => {
const newSet = new Set([...prev, currentSong.id]);
// Limit size to prevent unbounded growth
if (newSet.size > RECENTLY_PLAYED_LIMIT * 2) {
const array = Array.from(newSet);
const newArray = array.slice(-RECENTLY_PLAYED_LIMIT);
return new Set(newArray);
}
return newSet;
});
}
}, [currentSong]);onError: (event: any) => {
console.error("YouTube player error:", event);
setIsLoading(false);
if (currentSong) {
// Show error toast
toast({
title: "Error playing song",
description: "This song couldn't be played. Adding to end of queue and moving to next."
});
// Add failed song to end of queue for retry
setQueue(prevQueue => [...prevQueue, currentSong]);
// Play next song
nextSong();
} else if (previousVideoData) {
// Revert to previous video
setCurrentSong(previousVideoData);
event.target.loadVideoById(previousVideoData.youtube);
} else {
setVideoTitle("Error loading video");
setChannelTitle("Unknown");
}
}import { useGlobalAudioPlayer } from '@/components/GlobalAudioPlayer';
const MyComponent = () => {
const { playNow, addToQueue, isPlaying, togglePlay } = useGlobalAudioPlayer();
const handlePlay = () => {
playNow({
id: 'song-123',
youtube: 'dQw4w9WgXcQ',
title: 'Song Title',
artist: 'Artist Name'
});
};
return <button onClick={handlePlay}>Play</button>;
};import { GlobalAudioPlayerProvider } from '@/components/GlobalAudioPlayer';
function App() {
return (
<GlobalAudioPlayerProvider>
<Routes>
{/* ... routes */}
</Routes>
</GlobalAudioPlayerProvider>
);
}YouTube thumbnails are fetched using:
const getVideoThumbnail = (videoId: string) => {
return `https://img.youtube.com/vi/${videoId}/default.jpg`;
};Fallback on error:
<img
src={getVideoThumbnail(videoId)}
onError={(e) => {
e.currentTarget.src = "/AfrobeatsDAOMeta.png";
}}
/>{
"react-beautiful-dnd": "^13.1.1", // Drag & drop
"lucide-react": "^0.462.0", // Icons
"@radix-ui/react-slider": "...", // Slider component (via shadcn)
"@radix-ui/react-tabs": "...", // Tabs component (via shadcn)
"@radix-ui/react-scroll-area": "...", // Scroll area (via shadcn)
"@radix-ui/react-avatar": "..." // Avatar component (via shadcn)
}declare global {
interface Window {
onYouTubeIframeAPIReady: () => void;
YT: any;
}
}Simply visit the Lovable Project and start prompting.
# Clone the repository
git clone <YOUR_GIT_URL>
# Navigate to the project directory
cd <YOUR_PROJECT_NAME>
# Install dependencies
npm i
# Start the development server
npm run devOpen Lovable and click on Share → Publish.
Navigate to Project > Settings > Domains and click Connect Domain.
Read more: Setting up a custom domain