Initial release v1.0.0
This commit is contained in:
490
frontend/app/browse/playlists/[id]/page.tsx
Normal file
490
frontend/app/browse/playlists/[id]/page.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Music2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/lib/toast-context";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
||||
|
||||
// Deezer icon component
|
||||
const DeezerIcon = ({ className }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor">
|
||||
<path d="M18.81 4.16v3.03H24V4.16h-5.19zM6.27 8.38v3.027h5.189V8.38h-5.19zm12.54 0v3.027H24V8.38h-5.19zM6.27 12.595v3.027h5.189v-3.027h-5.19zm6.27 0v3.027h5.19v-3.027h-5.19zm6.27 0v3.027H24v-3.027h-5.19zM0 16.81v3.029h5.19v-3.03H0zm6.27 0v3.029h5.189v-3.03h-5.19zm6.27 0v3.029h5.19v-3.03h-5.19zm6.27 0v3.029H24v-3.03h-5.19z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Types for Deezer playlist
|
||||
interface DeezerTrack {
|
||||
deezerId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
durationMs: number;
|
||||
previewUrl: string | null;
|
||||
coverUrl: string | null;
|
||||
}
|
||||
|
||||
interface DeezerPlaylistFull {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
creator: string;
|
||||
imageUrl: string | null;
|
||||
trackCount: number;
|
||||
tracks: DeezerTrack[];
|
||||
isPublic: boolean;
|
||||
source: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default function DeezerPlaylistDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const playlistId = params.id as string;
|
||||
|
||||
// State
|
||||
const [playlist, setPlaylist] = useState<DeezerPlaylistFull | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
// Preview playback state
|
||||
const [playingTrackId, setPlayingTrackId] = useState<string | null>(null);
|
||||
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false);
|
||||
const [previewVolume, setPreviewVolume] = useState(0.5);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Fetch playlist data
|
||||
useEffect(() => {
|
||||
async function fetchPlaylist() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<DeezerPlaylistFull>(
|
||||
`/browse/playlists/${playlistId}`
|
||||
);
|
||||
setPlaylist(data);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load playlist";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchPlaylist();
|
||||
}, [playlistId]);
|
||||
|
||||
// Cleanup audio on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle preview playback
|
||||
const handlePlayPreview = (track: DeezerTrack) => {
|
||||
if (!track.previewUrl) {
|
||||
toast.error("No preview available for this track");
|
||||
return;
|
||||
}
|
||||
|
||||
// If clicking the same track, toggle play/pause
|
||||
if (playingTrackId === track.deezerId) {
|
||||
if (isPreviewPlaying && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPreviewPlaying(false);
|
||||
} else if (audioRef.current) {
|
||||
audioRef.current.play();
|
||||
setIsPreviewPlaying(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current preview
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
|
||||
// Play new preview
|
||||
const audio = new Audio(track.previewUrl);
|
||||
audio.volume = isMuted ? 0 : previewVolume;
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.onended = () => {
|
||||
setPlayingTrackId(null);
|
||||
setIsPreviewPlaying(false);
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
toast.error("Failed to play preview");
|
||||
setPlayingTrackId(null);
|
||||
setIsPreviewPlaying(false);
|
||||
};
|
||||
|
||||
audio.play();
|
||||
setPlayingTrackId(track.deezerId);
|
||||
setIsPreviewPlaying(true);
|
||||
};
|
||||
|
||||
// Stop preview
|
||||
const stopPreview = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingTrackId(null);
|
||||
setIsPreviewPlaying(false);
|
||||
};
|
||||
|
||||
// Toggle mute
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = isMuted ? previewVolume : 0;
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
// Handle import/download
|
||||
const handleImport = () => {
|
||||
if (!playlist) return;
|
||||
// Navigate to import page with the Deezer URL
|
||||
router.push(
|
||||
`/import/spotify?url=${encodeURIComponent(playlist.url)}`
|
||||
);
|
||||
};
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Calculate total duration
|
||||
const totalDuration = playlist?.tracks.reduce(
|
||||
(sum, track) => sum + track.durationMs,
|
||||
0
|
||||
) || 0;
|
||||
|
||||
const formatTotalDuration = (ms: number) => {
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
const mins = Math.floor((ms % 3600000) / 60000);
|
||||
if (hours > 0) {
|
||||
return `about ${hours} hr ${mins} min`;
|
||||
}
|
||||
return `${mins} min`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<GradientSpinner size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !playlist) {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b from-[#AD47FF]/15 via-purple-900/10 to-transparent"
|
||||
style={{ height: "35vh" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative px-4 md:px-8 py-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Back
|
||||
</button>
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mb-4">
|
||||
<Music2 className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
Playlist not found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-6 max-w-sm">
|
||||
{error || "This playlist may be private or no longer available."}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/browse/playlists")}
|
||||
className="px-6 py-2.5 rounded-full bg-white text-black text-sm font-medium hover:scale-105 transition-transform"
|
||||
>
|
||||
Browse playlists
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<div className="relative bg-gradient-to-b from-[#AD47FF]/20 via-[#1a1a1a] to-transparent pt-16 pb-10 px-4 md:px-8">
|
||||
<div className="flex items-end gap-6">
|
||||
{/* Cover Art */}
|
||||
<div className="w-[140px] h-[140px] md:w-[192px] md:h-[192px] bg-[#282828] rounded shadow-2xl shrink-0 overflow-hidden">
|
||||
{playlist.imageUrl ? (
|
||||
<img
|
||||
src={playlist.imageUrl}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-[#AD47FF]/30 to-[#AD47FF]/10">
|
||||
<Music2 className="w-16 h-16 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playlist Info */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DeezerIcon className="w-4 h-4 text-[#AD47FF]" />
|
||||
<p className="text-xs font-medium text-white/90">
|
||||
Deezer Playlist
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold text-white leading-tight line-clamp-2 mb-2">
|
||||
{playlist.title}
|
||||
</h1>
|
||||
{playlist.description && (
|
||||
<p className="text-sm text-gray-400 line-clamp-2 mb-2">
|
||||
{playlist.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm text-white/70">
|
||||
<span className="font-medium text-white">
|
||||
{playlist.creator}
|
||||
</span>
|
||||
<span className="mx-1">•</span>
|
||||
<span>{playlist.trackCount} songs</span>
|
||||
{totalDuration > 0 && (
|
||||
<>
|
||||
<span>, {formatTotalDuration(totalDuration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="bg-gradient-to-b from-[#1a1a1a]/60 to-transparent px-4 md:px-8 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Download/Import Button */}
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="h-12 px-6 rounded-full bg-[#ecb200] hover:bg-[#d4a000] hover:scale-105 flex items-center justify-center gap-2 shadow-lg transition-all disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="w-5 h-5 text-black animate-spin" />
|
||||
) : (
|
||||
<Download className="w-5 h-5 text-black" />
|
||||
)}
|
||||
<span className="text-black font-medium">
|
||||
{isImporting ? "Importing..." : "Download & Create Playlist"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Volume Control (when playing preview) */}
|
||||
{playingTrackId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 rounded-full hover:bg-white/10 text-white/60 hover:text-white transition-all"
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-5 h-5" />
|
||||
) : (
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={stopPreview}
|
||||
className="px-3 py-1.5 rounded-full bg-white/10 text-sm text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Stop Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Open in Deezer */}
|
||||
<a
|
||||
href={playlist.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 hover:bg-white/20 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Open in Deezer</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track Listing */}
|
||||
<div className="px-4 md:px-8 pb-32">
|
||||
{playlist.tracks.length > 0 ? (
|
||||
<div className="w-full">
|
||||
{/* Table Header */}
|
||||
<div className="hidden md:grid grid-cols-[40px_minmax(200px,4fr)_minmax(100px,1fr)_80px] gap-4 px-4 py-2 text-xs text-gray-400 uppercase tracking-wider border-b border-white/10 mb-2">
|
||||
<span className="text-center">#</span>
|
||||
<span>Title</span>
|
||||
<span>Album</span>
|
||||
<span className="text-right">Duration</span>
|
||||
</div>
|
||||
|
||||
{/* Track Rows */}
|
||||
<div>
|
||||
{playlist.tracks.map((track, index) => {
|
||||
const isCurrentlyPlaying =
|
||||
playingTrackId === track.deezerId;
|
||||
const hasPreview = !!track.previewUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={track.deezerId}
|
||||
onClick={() =>
|
||||
hasPreview && handlePlayPreview(track)
|
||||
}
|
||||
className={cn(
|
||||
"grid grid-cols-[40px_1fr_auto] md:grid-cols-[40px_minmax(200px,4fr)_minmax(100px,1fr)_80px] gap-4 px-4 py-2 rounded-md transition-colors group",
|
||||
hasPreview
|
||||
? "hover:bg-white/5 cursor-pointer"
|
||||
: "opacity-60 cursor-not-allowed",
|
||||
isCurrentlyPlaying && "bg-white/10"
|
||||
)}
|
||||
>
|
||||
{/* Track Number / Play Icon */}
|
||||
<div className="flex items-center justify-center">
|
||||
{hasPreview ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm group-hover:hidden",
|
||||
isCurrentlyPlaying
|
||||
? "text-[#AD47FF]"
|
||||
: "text-gray-400"
|
||||
)}
|
||||
>
|
||||
{isCurrentlyPlaying &&
|
||||
isPreviewPlaying ? (
|
||||
<Pause className="w-4 h-4 text-[#AD47FF]" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<Play className="w-4 h-4 text-white hidden group-hover:block" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + Artist */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 bg-[#282828] rounded shrink-0 overflow-hidden">
|
||||
{track.coverUrl ? (
|
||||
<img
|
||||
src={track.coverUrl}
|
||||
alt={track.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isCurrentlyPlaying
|
||||
? "text-[#AD47FF]"
|
||||
: "text-white"
|
||||
)}
|
||||
>
|
||||
{track.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{track.artist}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Album (hidden on mobile) */}
|
||||
<p className="hidden md:flex items-center text-sm text-gray-400 truncate">
|
||||
{track.album}
|
||||
</p>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatDuration(track.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-20 h-20 bg-[#282828] rounded-full flex items-center justify-center mb-4">
|
||||
<Music2 className="w-10 h-10 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-1">
|
||||
No tracks found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
This playlist appears to be empty
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview indicator */}
|
||||
{playingTrackId && (
|
||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-[#AD47FF] rounded-full text-black text-sm font-medium shadow-lg flex items-center gap-2 z-50">
|
||||
<div className="w-2 h-2 rounded-full bg-black animate-pulse" />
|
||||
Playing 30s preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user