Vibe coding with AI course · Module 11: Spotify Clone
Playlists and Library Management
In this lesson4
Spotify is all about music organization - playlists, liked songs, albums in library.
Playlist Structure
interface Playlist {
id: string;
name: string;
description: string;
coverImage: string;
owner: {
id: string;
username: string;
avatar: string;
};
tracks: Track[];
followers: number;
isPublic: boolean;
isCollaborative: boolean; // multiple people can add tracks
totalDuration: number;
createdAt: Date;
updatedAt: Date;
}
interface Library {
likedTracks: Track[];
savedAlbums: Album[];
followedArtists: Artist[];
playlists: Playlist[];
recentlyPlayed: Track[];
}Playlist Page
'use client';
import { useState } from 'react';
interface PlaylistPageProps {
playlist: Playlist;
onPlay: (trackIndex?: number) => void;
onLikePlaylist: () => void;
onAddTrack: () => void;
onRemoveTrack: (trackId: string) => void;
}
export default function PlaylistPage({
playlist,
onPlay,
onLikePlaylist,
onAddTrack,
onRemoveTrack
}: PlaylistPageProps) {
const [sortBy, setSortBy] = useState<'added' | 'title' | 'artist' | 'album' | 'duration'>('added');
const totalDuration = playlist.tracks.reduce((acc, track) => acc + track.duration, 0);
const totalHours = Math.floor(totalDuration / 3600);
const totalMinutes = Math.floor((totalDuration % 3600) / 60);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="min-h-screen bg-gradient-to-b from-blue-900 to-black text-white">
{/* Header */}
<div className="bg-gradient-to-b from-blue-900 to-transparent p-8">
<div className="flex items-end gap-6">
{/* Cover */}
<img
src={playlist.coverImage}
alt={playlist.name}
className="w-60 h-60 rounded shadow-2xl"
/>
{/* Info */}
<div className="flex-1">
<p className="text-sm font-semibold uppercase">Playlist</p>
<h1 className="text-7xl font-bold my-4">{playlist.name}</h1>
<p className="text-gray-300 mb-4">{playlist.description}</p>
<div className="flex items-center gap-2 text-sm">
<img
src={playlist.owner.avatar}
alt={playlist.owner.username}
className="w-6 h-6 rounded-full"
/>
<span className="font-semibold">{playlist.owner.username}</span>
<span>•</span>
<span>{playlist.followers.toLocaleString()} followers</span>
<span>•</span>
<span>{playlist.tracks.length} songs</span>
{totalHours > 0 && (
<>
<span>•</span>
<span>{totalHours}h {totalMinutes}min</span>
</>
)}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="px-8 py-6 flex items-center gap-4">
<button
onClick={() => onPlay()}
className="w-14 h-14 bg-green-500 rounded-full flex items-center justify-center hover:scale-105 transition"
>
<span className="text-black text-2xl ml-1">▶</span>
</button>
<button
onClick={onLikePlaylist}
className="text-sm font-semibold text-gray-400 hover:text-white"
>
Like
</button>
<button className="text-3xl text-gray-400 hover:text-white">
⋯
</button>
</div>
{/* Track List */}
<div className="px-8 pb-8">
{/* Headers */}
<div className="grid grid-cols-[16px_6fr_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-gray-700 text-sm text-gray-400 mb-2">
<div>#</div>
<div>Title</div>
<div>Album</div>
<div>Date Added</div>
<div className="text-right">⏱</div>
</div>
{/* Tracks */}
<div>
{playlist.tracks.map((track, index) => (
<div
key={track.id}
onDoubleClick={() => onPlay(index)}
className="grid grid-cols-[16px_6fr_4fr_3fr_minmax(120px,1fr)] gap-4 px-4 py-2 rounded hover:bg-white hover:bg-white/10 group"
>
<div className="flex items-center text-gray-400">
<span className="group-hover:hidden">{index + 1}</span>
<button className="hidden group-hover:block">▶</button>
</div>
<div className="flex items-center gap-3">
<img
src={track.album.coverArt}
alt={track.title}
className="w-10 h-10 rounded"
/>
<div>
<div className="font-semibold">{track.title}</div>
<div className="text-sm text-gray-400">
{track.artist.name}
</div>
</div>
</div>
<div className="flex items-center text-sm text-gray-400">
{track.album.title}
</div>
<div className="flex items-center text-sm text-gray-400">
{new Date().toLocaleDateString()}
</div>
<div className="flex items-center justify-end gap-4">
<button className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white">
{track.isLiked ? 'Liked' : 'Like'}
</button>
<span className="text-sm text-gray-400">
{formatDuration(track.duration)}
</span>
<button
onClick={() => onRemoveTrack(track.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
>
⋯
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}Create Playlist Modal
'use client';
import { useState } from 'react';
interface CreatePlaylistModalProps {
onClose: () => void;
onCreate: (name: string, description: string, isPublic: boolean) => void;
}
export default function CreatePlaylistModal({
onClose,
onCreate
}: CreatePlaylistModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(true);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onCreate(name, description, isPublic);
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/75 z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 rounded-lg max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-white mb-6">Create Playlist</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-semibold text-white mb-2">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My playlist #1"
className="w-full bg-gray-800 text-white px-4 py-3 rounded outline-none focus:ring-2 focus:ring-green-500"
autoFocus
/>
</div>
<div className="mb-4">
<label className="block text-sm font-semibold text-white mb-2">
Description (Optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add an optional description"
className="w-full bg-gray-800 text-white px-4 py-3 rounded outline-none resize-none focus:ring-2 focus:ring-green-500"
rows={3}
/>
</div>
<div className="mb-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="w-5 h-5"
/>
<span className="text-white">Make playlist public</span>
</label>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 border border-gray-700 rounded-full text-white font-semibold hover:border-white"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 py-3 bg-green-500 rounded-full text-black font-semibold hover:bg-green-400 disabled:bg-gray-700 disabled:text-gray-500"
>
Create
</button>
</div>
</form>
</div>
</div>
);
}Summary
Playlist structure - tracks, followers, duration Playlist page - header with cover art, track list Track grid - #, title, album, date, duration Hover actions - play, like, remove track Double-click to play - quick playback Create playlist - modal with name, description, privacy Public/private - visibility control Playlist stats - track count, total duration, followers
In the next exercise we'll add search and recommendations!
See you soon!
Code for this lesson: App.tsx
'use client';
import { useState } from 'react';
interface Track {
id: string;
title: string;
artist: string;
coverArt: string;
duration: number;
}
const mockQueue: Track[] = [
{
id: '1',
title: "The Pirate's Anthem",
artist: 'Captain Redbeard',
coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
duration: 240
},
{
id: '2',
title: 'Sailing the Seven Seas',
artist: 'Crew Council',
coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
duration: 195
},
{
id: '3',
title: 'Treasure Hunt',
artist: 'Blackbeard Band',
coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
duration: 210
}
];
export default function Queue() {
const [queue, setQueue] = useState(mockQueue);
const [currentIndex, setCurrentIndex] = useState(0);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleRemove = (index: number) => {
setQueue(queue.filter((_, i) => i !== index));
};
return (
<div className="min-h-screen bg-black text-white flex">
<div className="flex-1 bg-gray-950"></div>
<div className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold">Queue</h3>
<button
onClick={() => setQueue([])}
className="text-xs text-gray-400 hover:text-white"
>
Clear
</button>
</div>
<p className="text-xs text-gray-400">{queue.length} tracks</p>
</div>
{/* Now Playing */}
{queue[currentIndex] && (
<div className="p-4 bg-gray-800 border-b border-gray-700">
<p className="text-xs text-gray-400 mb-2">Now Playing</p>
<div className="flex items-center gap-3">
<img
src={queue[currentIndex].coverArt}
alt={queue[currentIndex].title}
className="w-12 h-12 rounded"
/>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{queue[currentIndex].title}
</div>
<div className="text-xs text-gray-400 truncate">
{queue[currentIndex].artist}
</div>
</div>
</div>
</div>
)}
{/* Next Up */}
<div className="flex-1 overflow-y-auto p-4">
<p className="text-xs text-gray-400 mb-3">Next Up</p>
<div className="space-y-2">
{queue.slice(currentIndex + 1).map((track, idx) => {
const actualIndex = currentIndex + 1 + idx;
return (
<div
key={track.id}
onClick={() => setCurrentIndex(actualIndex)}
className="flex items-center gap-3 p-2 hover:bg-gray-800 rounded cursor-pointer group"
>
<img
src={track.coverArt}
alt={track.title}
className="w-10 h-10 rounded"
/>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{track.title}</div>
<div className="text-xs text-gray-400 truncate">
{track.artist}
</div>
</div>
<span className="text-xs text-gray-400">
{formatDuration(track.duration)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleRemove(actualIndex);
}}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-white"
>
×
</button>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does a Playlist contain?
2. What does the isCollaborative field in Playlist enable?
These are 2 of 7 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Click in order
Click the elements to build the create-playlist call:
- Click in order
Click the elements to build the playlist name state:
- Horizontal ordering
Arrange the track list columns in a playlist:
- Horizontal ordering
Arrange the elements of a track row in the track list from left to right:
- Code editor
Playlist display component
- Click in order
Click the elements to sum the tracks' duration:
- Vertical ordering
Order the Library structure fields as they appear in the code:
- Vertical ordering
Order the PlaylistPage sections from top to bottom:
- Horizontal ordering
Arrange the tokens to read the playlist owner's name on one line:
- Vertical ordering
Order the steps of creating a playlist in the modal:
- Code editor
Playlist creation interface