Files
lidify/frontend/app/browse/playlists/[id]/page.tsx
2025-12-25 18:58:06 -06:00

491 lines
21 KiB
TypeScript

"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>
);
}