211 lines
12 KiB
TypeScript
211 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { libraryService } from '../services/library';
|
|
import { usePlayer } from '../context/PlayerContext';
|
|
import { Play, Shuffle, Heart, Clock, ListPlus, Download } from 'lucide-react';
|
|
import { Track } from '../types';
|
|
|
|
export default function Album() {
|
|
const { id } = useParams();
|
|
const { playTrack, toggleLike, likedTracks, setIsFullScreenOpen, currentTrack } = usePlayer();
|
|
const [tracks, setTracks] = useState<Track[]>([]);
|
|
const [albumInfo, setAlbumInfo] = useState<{ title: string, artist: string, cover?: string, year?: string } | null>(null);
|
|
const [moreByArtist, setMoreByArtist] = useState<Track[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
setLoading(true);
|
|
|
|
const fetchAlbum = async () => {
|
|
const queryId = decodeURIComponent(id);
|
|
try {
|
|
const album = await libraryService.getAlbum(queryId);
|
|
|
|
if (album) {
|
|
setTracks(album.tracks);
|
|
setAlbumInfo({
|
|
title: album.title,
|
|
artist: album.creator || "Unknown Artist",
|
|
cover: album.cover_url,
|
|
year: '2024'
|
|
});
|
|
|
|
// Fetch suggestions
|
|
try {
|
|
const artistQuery = album.creator || "Unknown Artist";
|
|
const suggestions = await libraryService.search(artistQuery);
|
|
const currentIds = new Set(album.tracks.map(t => t.id));
|
|
setMoreByArtist(suggestions.filter(t => !currentIds.has(t.id)).slice(0, 10));
|
|
} catch (e) { }
|
|
} else {
|
|
// Fallback to searching the query if ID not found anywhere
|
|
const cleanTitle = queryId.replace(/^search-|^album-/, '');
|
|
const results = await libraryService.search(queryId);
|
|
setTracks(results);
|
|
setAlbumInfo({
|
|
title: cleanTitle,
|
|
artist: results.length > 0 ? results[0].artist : "Unknown Artist",
|
|
cover: results.length > 0 ? results[0].cover_url : undefined,
|
|
year: '2024'
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
fetchAlbum();
|
|
}, [id]);
|
|
|
|
if (loading) return <div className="flex items-center justify-center h-full"><div className="animate-spin rounded-full h-12 w-12 border-t-2 border-white"></div></div>;
|
|
if (!albumInfo) return <div>Album not found</div>;
|
|
|
|
const totalDuration = tracks.reduce((acc, t) => acc + (t.duration || 0), 0);
|
|
const formattedDuration = `${Math.floor(totalDuration / 60)} minutes`;
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto bg-[#121212] no-scrollbar pb-32 relative">
|
|
{/* Banner Background */}
|
|
{albumInfo.cover && (
|
|
<div
|
|
className="absolute top-0 left-0 w-full h-[50vh] min-h-[400px] opacity-30 pointer-events-none"
|
|
style={{
|
|
backgroundImage: `url(${albumInfo.cover})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
maskImage: 'linear-gradient(to bottom, black 0%, transparent 100%)',
|
|
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, transparent 100%)'
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div className="relative z-10 flex flex-col md:flex-row gap-4 md:gap-8 p-4 md:p-12 items-center md:items-end pt-16 md:pt-16">
|
|
{/* Cover */}
|
|
<div
|
|
className="w-48 h-48 md:w-64 md:h-64 shadow-[0_20px_50px_rgba(0,0,0,0.5)] rounded-lg overflow-hidden shrink-0 mt-8 md:mt-0 cursor-pointer group/cover relative"
|
|
onClick={() => {
|
|
if (tracks.length > 0) {
|
|
playTrack(tracks[0], tracks);
|
|
setIsFullScreenOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
<img src={albumInfo.cover} alt={albumInfo.title} className="w-full h-full object-cover transition-transform duration-700 group-hover/cover:scale-110" />
|
|
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover/cover:opacity-100 transition flex items-center justify-center">
|
|
<Play fill="white" size={48} className="text-white drop-shadow-2xl" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex flex-col items-center md:items-start text-center md:text-left gap-2 md:gap-4 flex-1">
|
|
<span className="text-xs md:text-sm font-bold tracking-widest uppercase text-white/70">Album</span>
|
|
<h1 className="text-2xl md:text-6xl font-black text-white leading-tight line-clamp-3 text-ellipsis overflow-hidden">{albumInfo.title}</h1>
|
|
<div className="flex flex-wrap justify-center md:justify-start items-center gap-2 text-white/80 font-medium text-sm md:text-base">
|
|
<img src={albumInfo.cover} className="w-6 h-6 rounded-full" />
|
|
<span className="hover:underline cursor-pointer">{albumInfo.artist}</span>
|
|
<span>•</span>
|
|
<span>{albumInfo.year}</span>
|
|
<span>•</span>
|
|
<span>{tracks.length} songs, {formattedDuration}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="px-4 py-3 flex items-center justify-center gap-6 bg-black/20 backdrop-blur-sm sticky top-0 z-10 md:px-8">
|
|
<button
|
|
onClick={() => tracks.length > 0 && playTrack(tracks[0])} // Should play all
|
|
className="bg-white text-black px-8 py-2 rounded-full font-bold text-sm hover:scale-105 transition flex items-center gap-2 shadow-lg hover:shadow-xl hover:bg-neutral-200"
|
|
>
|
|
<Play fill="currentColor" size={18} />
|
|
Play
|
|
</button>
|
|
<div className="flex items-center gap-4">
|
|
<button className="p-2 text-neutral-400 hover:text-white transition border border-white/10 rounded-full hover:bg-white/10 hover:border-white">
|
|
<Shuffle size={20} />
|
|
</button>
|
|
<button className="p-2 text-neutral-400 hover:text-white transition border border-white/10 rounded-full hover:bg-white/10 hover:border-white">
|
|
<ListPlus size={20} />
|
|
</button>
|
|
<button className="p-2 text-neutral-400 hover:text-white transition border border-white/10 rounded-full hover:bg-white/10 hover:border-white">
|
|
<Download size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tracklist */}
|
|
<div className="p-4 md:p-8">
|
|
{/* Header Row */}
|
|
<div className="flex items-center text-sm text-neutral-400 border-b border-white/10 pb-2 mb-4 px-4 sticky top-20 bg-[#121212] z-10">
|
|
<span className="w-10 text-center">#</span>
|
|
<span className="flex-1">Title</span>
|
|
<span className="hidden md:block w-12 text-right"><Clock size={16} /></span>
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
{tracks.map((track, i) => (
|
|
<div
|
|
key={track.id}
|
|
className="group flex items-center p-3 rounded-md hover:bg-white/10 transition cursor-pointer"
|
|
onClick={() => playTrack(track, tracks)}
|
|
>
|
|
<span className="w-10 text-center text-neutral-500 font-medium group-hover:hidden">{i + 1}</span>
|
|
<Play size={16} className="w-10 hidden group-hover:block fill-white pl-2" />
|
|
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<div className="font-medium text-white truncate text-base">{track.title}</div>
|
|
<div className="text-sm text-neutral-400 truncate group-hover:text-white/70">{track.artist}</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleLike(track); }}
|
|
className={`mr-6 ${likedTracks.has(track.id) ? 'text-green-500 opacity-100' : 'text-neutral-400 opacity-0 group-hover:opacity-100'} hover:scale-110 transition`}
|
|
>
|
|
<Heart size={18} fill={likedTracks.has(track.id) ? "currentColor" : "none"} />
|
|
</button>
|
|
|
|
<span className="text-neutral-500 text-sm hidden md:block w-12 text-right font-mono">
|
|
{Math.floor((track.duration || 0) / 60)}:{((track.duration || 0) % 60).toString().padStart(2, '0')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Suggestions / More By Artist */}
|
|
{moreByArtist.length > 0 && (
|
|
<div className="p-4 md:p-8 mt-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-2xl font-bold hover:underline cursor-pointer">More by {albumInfo.artist}</h2>
|
|
<Link to={`/artist/${encodeURIComponent(albumInfo.artist)}`}>
|
|
<span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show discography</span>
|
|
</Link>
|
|
</div>
|
|
<div className="grid grid-cols-2 fold:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
|
|
{moreByArtist.map((track) => (
|
|
<div
|
|
className="bg-[#181818] p-3 md:p-4 rounded-xl hover:bg-[#282828] transition duration-300 group cursor-pointer relative flex flex-col"
|
|
key={track.id}
|
|
onClick={() => {
|
|
playTrack(track, moreByArtist);
|
|
}}
|
|
>
|
|
<div className="relative mb-3 md:mb-4">
|
|
<img src={track.cover_url} className="w-full aspect-square rounded-md shadow-lg object-cover" />
|
|
<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-10 h-10 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 text-sm md:text-base mb-1 truncate">{track.title}</h3>
|
|
<p className="text-xs md:text-sm text-[#a7a7a7] truncate">{track.artist}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|