Vibe coding with AI course · Module 11: Spotify Clone
Search, Discovery, and Recommendations
In this lesson4
Spotify's power lies in discovery - powerful search, personalized playlists (Discover Weekly, Release Radar), and recommendations.
Search System
interface SearchResults {
tracks: Track[];
albums: Album[];
artists: Artist[];
playlists: Playlist[];
}
interface SearchFilters {
type?: 'track' | 'album' | 'artist' | 'playlist';
genre?: string[];
year?: { min?: number; max?: number };
explicit?: boolean;
}
// Search API
async function search(
query: string,
filters?: SearchFilters
): Promise<SearchResults> {
// Implementation...
}Search Page
'use client';
import { useState, useEffect } from 'react';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'all' | 'tracks' | 'albums' | 'artists' | 'playlists'>('all');
const [results, setResults] = useState<SearchResults | null>(null);
useEffect(() => {
if (query.length > 0) {
// Debounced search
const timeout = setTimeout(() => {
performSearch(query);
}, 300);
return () => clearTimeout(timeout);
}
}, [query]);
const performSearch = async (searchQuery: string) => {
// Mock search results
setResults({
tracks: mockTracks.filter(t =>
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.artist.name.toLowerCase().includes(searchQuery.toLowerCase())
),
albums: mockAlbums.filter(a =>
a.title.toLowerCase().includes(searchQuery.toLowerCase())
),
artists: mockArtists.filter(a =>
a.name.toLowerCase().includes(searchQuery.toLowerCase())
),
playlists: mockPlaylists.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
)
});
};
return (
<div className="min-h-screen bg-black text-white p-8">
{/* Search Input */}
<div className="mb-6">
<div className="relative max-w-md">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="What do you want to listen to?"
className="w-full bg-gray-800 text-white px-12 py-3 rounded-full outline-none focus:ring-2 focus:ring-white"
autoFocus
/>
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-xl">
⌕
</span>
</div>
</div>
{!query ? (
// Browse All / Recent Searches
<div>
<h2 className="text-2xl font-bold mb-4">Browse All</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{['Sea Shanties', 'Battle Anthems', 'Treasure Hunting', 'Calm Seas', 'Epic Adventures', 'Ship Repairs'].map(genre => (
<button
key={genre}
className="aspect-square bg-gradient-to-br from-purple-600 to-pink-600 rounded-lg p-4 font-bold text-lg hover:scale-105 transition"
>
{genre}
</button>
))}
</div>
</div>
) : results && (
<>
{/* Tabs */}
<div className="flex gap-4 mb-6 border-b border-gray-800">
{[
{ key: 'all', label: 'All' },
{ key: 'tracks', label: 'Songs' },
{ key: 'albums', label: 'Albums' },
{ key: 'artists', label: 'Artists' },
{ key: 'playlists', label: 'Playlists' }
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
className={`pb-2 px-4 font-semibold ${
activeTab === tab.key
? 'border-b-2 border-green-500'
: 'text-gray-400'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Results */}
<div className="space-y-6">
{(activeTab === 'all' || activeTab === 'tracks') && results.tracks.length > 0 && (
<div>
<h3 className="text-xl font-bold mb-4">Songs</h3>
<div className="space-y-2">
{results.tracks.slice(0, activeTab === 'all' ? 4 : undefined).map(track => (
<div
key={track.id}
className="flex items-center gap-4 p-2 rounded hover:bg-gray-800 cursor-pointer"
>
<img
src={track.album.coverArt}
alt={track.title}
className="w-12 h-12 rounded"
/>
<div className="flex-1">
<div className="font-semibold">{track.title}</div>
<div className="text-sm text-gray-400">
{track.artist.name}
</div>
</div>
<span className="text-sm text-gray-400">
{Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')}
</span>
</div>
))}
</div>
</div>
)}
{(activeTab === 'all' || activeTab === 'artists') && results.artists.length > 0 && (
<div>
<h3 className="text-xl font-bold mb-4">Artists</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{results.artists.slice(0, activeTab === 'all' ? 4 : undefined).map(artist => (
<div
key={artist.id}
className="bg-gray-900 p-4 rounded hover:bg-gray-800 cursor-pointer"
>
<img
src={artist.avatar}
alt={artist.name}
className="w-full aspect-square rounded-full mb-4"
/>
<div className="font-semibold">{artist.name}</div>
<div className="text-sm text-gray-400">Artist</div>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
);
}Recommendations System
interface RecommendationEngine {
// Based on listening history
discoverWeekly: (userId: string) => Playlist;
// New releases from followed artists
releaseRadar: (userId: string) => Playlist;
// Based on specific seed
radioStation: (seed: Track | Artist | Genre) => Track[];
// Similar to a track
similarTracks: (trackId: string, limit?: number) => Track[];
}
// Discover Weekly - personalized playlist based on listening history
function generateDiscoverWeekly(
listeningHistory: Track[],
likedTracks: Track[],
followedArtists: Artist[]
): Playlist {
// Algorithm:
// 1. Analyze user's taste (genres, artists, audio features)
// 2. Find similar tracks user hasn't heard
// 3. Weight by popularity and recency
// 4. Limit to 30 tracks
return {
id: 'discover-weekly',
name: 'Discover Weekly',
description: 'Your weekly mixtape of fresh music',
coverImage: '/discover-weekly.jpg',
owner: { id: 'spotify', username: 'Spotify', avatar: '/spotify.png' },
tracks: [...], // 30 recommended tracks
followers: 0,
isPublic: false,
isCollaborative: false,
totalDuration: 0,
createdAt: new Date(),
updatedAt: new Date()
};
}Summary
Search system - tracks, albums, artists, playlists Debounced search - 300ms delay for performance Search tabs - filter results by type Browse categories - genre cards Recent searches - history tracking Discover Weekly - personalized recommendations Release Radar - new music from followed artists Radio station - endless playback based on seed Similar tracks - recommendation algorithm
You now have a complete Spotify clone with player, playlists, 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;
album: string;
coverArt: string;
duration: number;
isLiked: boolean;
}
const mockTracks: Track[] = [
{
id: '1',
title: "The Pirate's Anthem",
artist: 'Captain Redbeard',
album: 'Sea Adventures',
coverArt: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
duration: 240,
isLiked: true
},
{
id: '2',
title: 'Sailing the Seven Seas',
artist: 'Crew Council',
album: 'Ocean Voyage',
coverArt: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=200&h=200&fit=crop',
duration: 195,
isLiked: false
},
{
id: '3',
title: 'Treasure Hunt',
artist: 'Blackbeard Band',
album: 'Gold & Glory',
coverArt: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=200&h=200&fit=crop',
duration: 210,
isLiked: true
}
];
const mockPlaylist = {
name: 'Pirate Favorites',
description: 'Best sea shanties and adventure songs',
coverImage: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400&h=400&fit=crop',
owner: {
username: 'Captain Redbeard',
avatar: 'https://i.pravatar.cc/150?img=12'
},
followers: 1247,
tracks: mockTracks
};
export default function PlaylistPage() {
const [tracks, setTracks] = useState(mockTracks);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const totalDuration = tracks.reduce((acc, t) => acc + t.duration, 0);
const totalMinutes = Math.floor(totalDuration / 60);
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">
<img
src={mockPlaylist.coverImage}
alt={mockPlaylist.name}
className="w-60 h-60 rounded shadow-2xl"
/>
<div className="flex-1">
<p className="text-sm font-semibold uppercase">Playlist</p>
<h1 className="text-5xl font-bold my-4">{mockPlaylist.name}</h1>
<p className="text-gray-300 mb-4">{mockPlaylist.description}</p>
<div className="flex items-center gap-2 text-sm">
<img
src={mockPlaylist.owner.avatar}
alt={mockPlaylist.owner.username}
className="w-6 h-6 rounded-full"
/>
<span className="font-semibold">{mockPlaylist.owner.username}</span>
<span>•</span>
<span>{mockPlaylist.followers.toLocaleString()} followers</span>
<span>•</span>
<span>{tracks.length} songs, {totalMinutes} min</span>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="px-8 py-6 flex items-center gap-4">
<button 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 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">
<div className="grid grid-cols-[16px_6fr_4fr_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 className="text-right">⏱</div>
</div>
<div>
{tracks.map((track, index) => (
<div
key={track.id}
className="grid grid-cols-[16px_6fr_4fr_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.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}</div>
</div>
</div>
<div className="flex items-center text-sm text-gray-400">
{track.album}
</div>
<div className="flex items-center justify-end gap-4">
<button className="opacity-0 group-hover:opacity-100">
{track.isLiked ? 'Liked' : 'Like'}
</button>
<span className="text-sm text-gray-400">
{formatDuration(track.duration)}
</span>
<button 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. Why is debouncing used in search?
2. What result types does SearchResults return?
These are 2 of 6 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 search call:
- Click in order
Click the elements to build the delayed search:
- Horizontal ordering
Arrange the SearchPage tabs from left to right:
- Code editor
Search interface
- Vertical ordering
Order the steps of debounced search in useEffect:
- Vertical ordering
Order the steps of generating Discover Weekly:
- Click in order
Click the elements to clear the previous timer in cleanup:
- Horizontal ordering
Arrange the tokens to filter tracks by title on one line:
- Vertical ordering
Order the steps of how a Radio Station works (based on a seed):
- Code editor
Music discovery interface