spotify-clone/frontend-vite/src/pages/Home.tsx

496 lines
26 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Play, ArrowUpDown, Clock, Music2, User } from "lucide-react";
import { Link } from 'react-router-dom';
import { usePlayer } from '../context/PlayerContext';
import { libraryService } from '../services/library';
import { Track, StaticPlaylist } from '../types';
import CoverImage from '../components/CoverImage';
import Skeleton from '../components/Skeleton';
type SortOption = 'recent' | 'alpha-asc' | 'alpha-desc' | 'artist';
export default function Home() {
const [timeOfDay, setTimeOfDay] = useState("Good evening");
const [browseData, setBrowseData] = useState<Record<string, StaticPlaylist[]>>({});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [showSortMenu, setShowSortMenu] = useState(false);
const { playTrack, playHistory } = usePlayer();
useEffect(() => {
const hour = new Date().getHours();
if (hour < 12) setTimeOfDay("Good morning");
else if (hour < 18) setTimeOfDay("Good afternoon");
else setTimeOfDay("Good evening");
// Cache First Strategy for "Super Fast" loading
const cached = localStorage.getItem('ytm_browse_cache_v4');
if (cached) {
setBrowseData(JSON.parse(cached));
setLoading(false);
}
setLoading(true);
libraryService.getBrowseContent()
.then(data => {
setBrowseData(data);
setLoading(false);
// Update Cache
localStorage.setItem('ytm_browse_cache_v4', JSON.stringify(data));
})
.catch(err => {
console.error("Error fetching browse:", err);
setLoading(false);
});
}, []);
const sortPlaylists = (playlists: StaticPlaylist[]) => {
const sorted = [...playlists];
switch (sortBy) {
case 'alpha-asc':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'alpha-desc':
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
case 'artist':
return sorted.sort((a, b) => (a.creator || '').localeCompare(b.creator || ''));
case 'recent':
default:
return sorted;
}
};
const firstCategory = Object.keys(browseData)[0];
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
const sortOptions = [
{ value: 'recent', label: 'Recently Added', icon: Clock },
{ value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown },
{ value: 'alpha-desc', label: 'Alphabetical (Z-A)', icon: ArrowUpDown },
{ value: 'artist', label: 'Artist Name', icon: User },
];
return (
<div className="h-full overflow-y-auto p-6 no-scrollbar pb-24">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{timeOfDay}</h1>
{/* Sort Dropdown */}
<div className="relative">
<button
onClick={() => setShowSortMenu(!showSortMenu)}
className="flex items-center gap-2 px-4 py-2 bg-spotify-card hover:bg-spotify-card-hover rounded-full text-sm font-medium transition"
>
<ArrowUpDown className="w-4 h-4" />
Sort
</button>
{showSortMenu && (
<div className="absolute right-0 mt-2 w-48 bg-[#282828] rounded-lg shadow-xl z-50 py-1 border border-[#383838]">
{sortOptions.map((option) => (
<button
key={option.value}
onClick={() => {
setSortBy(option.value as SortOption);
setShowSortMenu(false);
}}
className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-[#3a3a3a] transition ${sortBy === option.value ? 'text-[#1DB954]' : 'text-white'}`}
>
<option.icon className="w-4 h-4" />
{option.label}
{sortBy === option.value && (
<span className="ml-auto text-[#1DB954]"></span>
)}
</button>
))}
</div>
)}
</div>
</div>
{/* Hero Section */}
{loading ? (
<div className="mb-8 w-full h-80 bg-spotify-card rounded-xl flex items-center p-8 animate-pulse">
<Skeleton className="w-56 h-56 rounded-md shadow-2xl mr-8" />
<div className="flex-1 space-y-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-12 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
) : heroPlaylist && (
<Link to={`/playlist/${heroPlaylist.id}`}>
<div className="mb-8 w-full h-auto md:h-80 bg-gradient-to-r from-[#2a2a2a] to-[#181818] rounded-xl flex flex-col md:flex-row items-center p-6 md:p-8 hover:bg-[#2a2a2a] transition duration-300 group cursor-pointer shadow-2xl">
<div className="relative mb-4 md:mb-0 md:mr-8 flex-shrink-0">
<CoverImage
src={heroPlaylist.cover_url}
alt={heroPlaylist.title}
className="w-48 h-48 md:w-56 md:h-56 rounded-2xl shadow-2xl group-hover:scale-105 transition duration-500"
fallbackText="VB"
/>
</div>
<div className="flex flex-col text-center md:text-left">
<span className="text-xs font-bold tracking-wider uppercase mb-2">Featured Playlist</span>
<h2 className="text-3xl md:text-5xl font-black mb-4 leading-tight">{heroPlaylist.title}</h2>
<p className="text-[#a7a7a7] text-sm md:text-base line-clamp-2 md:line-clamp-3 max-w-2xl mb-6">
{heroPlaylist.description}
</p>
<div className="mt-auto inline-flex items-center gap-2 bg-[#1DB954] text-black px-8 py-3 rounded-full font-bold uppercase tracking-widest hover:scale-105 transition self-center md:self-start">
<Play className="fill-black" />
Play Now
</div>
</div>
</div>
</Link>
)}
{/* Recently Listened */}
<RecentlyListenedSection playHistory={playHistory} playTrack={playTrack} />
{/* Made For You */}
<MadeForYouSection />
{/* Artist Vietnam */}
<ArtistVietnamSection />
{/* Top Albums Section (NEW) */}
{loading ? (
<div className="mb-8">
<Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4">
{[1, 2, 3, 4, 5].map(j => (
<div key={j} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" />
</div>
))}
</div>
</div>
) : browseData["Top Albums"] && browseData["Top Albums"].length > 0 && (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">Top Albums</h2>
</div>
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-4">
{browseData["Top Albums"].slice(0, 15).map((album) => (
<Link to={`/album/${album.id}`} key={album.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-3 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-3">
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={album.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-10 md:h-10 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-5 md:h-5" />
</div>
</div>
</div>
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{album.title}</h3>
<p className="text-[10px] md:text-xs text-[#a7a7a7] line-clamp-1">{album.description}</p>
</div>
</Link>
))}
</div>
</div>
)}
{/* Browse Lists */}
{loading ? (
<div className="space-y-8">
{[1, 2].map(i => (
<div key={i}>
<Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4">
{[1, 2, 3, 4, 5].map(j => (
<div key={j} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
</div>
))}
</div>
) : Object.keys(browseData).length > 0 ? (
Object.entries(browseData)
.filter(([category]) => category !== "Top Albums") // Filter out albums since we showed them above
.map(([category, playlists]) => (
<div key={category} className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">{category}</h2>
<Link to={`/section?category=${encodeURIComponent(category)}`}>
<span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show all</span>
</Link>
</div>
{/* USER REQUEST: Bigger Grid, Smaller Text, Smaller Gap */}
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
{sortPlaylists(playlists).slice(0, 15).map((playlist) => (
<Link to={`/playlist/${playlist.id}`} key={playlist.id}>
<div className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{playlist.title}</h3>
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div>
</Link>
))}
</div>
</div>
))
) : (
<div className="text-center py-20">
<h2 className="text-xl font-bold mb-4">Ready to explore?</h2>
<p className="text-[#a7a7a7]">Browse content is loading or empty. Try searching for music.</p>
</div>
)}
</div>
);
}
// Recently Listened Section
function RecentlyListenedSection({ playHistory, playTrack }: { playHistory: Track[], playTrack: (track: Track, queue?: Track[]) => void }) {
if (playHistory.length === 0) return null;
return (
<div className="mb-8 animate-in">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Recently Listened</h2>
</div>
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
{playHistory.slice(0, 10).map((track, i) => (
<div
key={`${track.id}-${i}`}
onClick={() => playTrack(track, playHistory)}
className="flex-shrink-0 w-40 bg-spotify-card rounded-xl overflow-hidden hover:bg-spotify-card-hover transition duration-300 group cursor-pointer"
>
<div className="relative">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-40 h-40"
fallbackText={track.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-1 w-5 h-5" />
</div>
</div>
</div>
<div className="p-3">
<h3 className="font-medium text-sm truncate">{track.title}</h3>
<p className="text-xs text-[#a7a7a7] truncate">{track.artist}</p>
</div>
</div>
))}
</div>
</div>
);
}
// Made For You Section
function MadeForYouSection() {
const { playHistory, playTrack } = usePlayer();
const [recommendations, setRecommendations] = useState<Track[]>([]);
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (playHistory.length > 0) {
const seed = playHistory[0];
setSeedTrack(seed);
setLoading(true);
libraryService.getRecommendations(seed.artist)
.then(tracks => {
setRecommendations(tracks);
setLoading(false);
})
.catch(() => setLoading(false));
}
}, [playHistory.length > 0 ? playHistory[0]?.id : null]);
if (playHistory.length === 0) return null;
if (!loading && recommendations.length === 0) return null;
return (
<div className="mb-8 animate-in">
<div className="flex items-center gap-2 mb-2">
<Music2 className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Made For You</h2>
</div>
<p className="text-sm text-[#a7a7a7] mb-4">
{seedTrack ? <>Because you listened to <span className="text-white font-medium">{seedTrack.artist}</span></> : "Recommended for you"}
</p>
{loading ? (
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-2 md:gap-6">
{recommendations.slice(0, 10).map((track, i) => (
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-transparent md:bg-spotify-card p-0 md:p-4 rounded-xl hover:bg-spotify-card-hover transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-2 md:mb-4">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-full aspect-square rounded-xl shadow-lg"
fallbackText={track.title?.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-1 right-1 md:bottom-2 md:right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-8 h-8 md:w-12 md:h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-0.5 w-4 h-4 md:w-6 md:h-6" />
</div>
</div>
</div>
<h3 className="font-bold mb-0.5 truncate text-[11px] md:text-base">{track.title}</h3>
<p className="text-[10px] md:text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</div>
))}
</div>
)}
</div>
);
}
// Artist Vietnam Section
// Artist Vietnam Section (Optimized & Personalized)
function ArtistVietnamSection() {
const { playHistory } = usePlayer();
// Expanded pool of popular artists for discovery/fallback
const POPULAR_ARTISTS = [
"Sơn Tùng M-TP", "HIEUTHUHAI", "Đen Vâu", "Hoàng Dũng",
"Vũ.", "MONO", "Tlinh", "Erik", "Binz", "JustaTee",
"Rhymastic", "Low G", "MCK", "Min", "Amee", "Karik",
"Suboi", "Bích Phương", "Trúc Nhân", "Đức Phúc"
];
const [artists, setArtists] = useState<string[]>([]);
const [artistPhotos, setArtistPhotos] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
// 1. Determine Artist List (Personalized + Discovery)
const deriveArtists = () => {
const historyArtists = new Set<string>();
playHistory.forEach(track => {
if (track.artist) historyArtists.add(track.artist);
});
const recent = Array.from(historyArtists).slice(0, 5); // Take top 5 recent
// Fill rest with random popular artists that aren't in recent
const needed = 20 - recent.length;
const available = POPULAR_ARTISTS.filter(a => !historyArtists.has(a));
const shuffled = available.sort(() => 0.5 - Math.random()).slice(0, needed);
return [...recent, ...shuffled];
};
const targetArtists = deriveArtists();
setArtists(targetArtists);
// 2. Load Photos (Cache First Strategy)
const loadPhotos = async () => {
// v3: Progressive Loading + Smaller Thumbnails
const cacheKey = 'artist_photos_cache_v3';
const cached = JSON.parse(localStorage.getItem(cacheKey) || '{}');
// Initialize with cache immediately
setArtistPhotos(cached);
setLoading(false); // Show names immediately
// Identify missing photos
const missing = targetArtists.filter(name => !cached[name]);
if (missing.length > 0) {
// Fetch missing incrementally
for (const name of missing) {
try {
// Fetch one by one and update state immediately
// This prevents "batch waiting" feeling
libraryService.getArtistInfo(name).then(data => {
if (data.photo) {
setArtistPhotos(prev => {
const next: Record<string, string> = { ...prev, [name]: data.photo || "" };
localStorage.setItem(cacheKey, JSON.stringify(next));
return next;
});
}
});
} catch { /* ignore */ }
}
}
};
loadPhotos();
}, [playHistory.length]); // Re-run when history changes significantly
return (
<div className="mb-8 animate-in">
<div className="flex items-center gap-2 mb-4">
<User className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Suggested Artists</h2>
</div>
<p className="text-sm text-[#a7a7a7] mb-4">Based on your recent listening</p>
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
{artists.length === 0 && loading ? (
[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="flex-shrink-0 w-36 text-center space-y-3">
<Skeleton className="w-36 h-36 rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-auto" />
</div>
))
) : (
artists.map((name, i) => (
<Link to={`/artist/${encodeURIComponent(name)}`} key={i}>
<div className="flex-shrink-0 w-36 text-center group cursor-pointer">
<div className="relative mb-3">
<CoverImage
src={artistPhotos[name]}
alt={name}
className="w-36 h-36 rounded-xl shadow-lg group-hover:shadow-xl transition object-cover"
fallbackText={name.substring(0, 2).toUpperCase()}
/>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-xl flex items-center justify-center">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-1 w-5 h-5" />
</div>
</div>
</div>
<h3 className="font-bold text-sm truncate px-2">{name}</h3>
<p className="text-xs text-[#a7a7a7]">Artist</p>
</div>
</Link>
))
)}
</div>
</div>
);
}