Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
181 lines
8.3 KiB
TypeScript
181 lines
8.3 KiB
TypeScript
import React from "react";
|
|
import { Play, Pause, Volume2, Music } from "lucide-react";
|
|
import { cn } from "@/utils/cn";
|
|
import Image from "next/image";
|
|
import { api } from "@/lib/api";
|
|
import type { Track, Artist } from "../types";
|
|
|
|
interface PopularTracksProps {
|
|
tracks: Track[];
|
|
artist: Artist;
|
|
currentTrackId: string | undefined;
|
|
colors: any;
|
|
onPlayTrack: (track: Track) => void;
|
|
previewTrack: string | null;
|
|
previewPlaying: boolean;
|
|
onPreview: (track: Track, e: React.MouseEvent) => void;
|
|
}
|
|
|
|
export const PopularTracks: React.FC<PopularTracksProps> = ({
|
|
tracks,
|
|
artist,
|
|
currentTrackId,
|
|
colors,
|
|
onPlayTrack,
|
|
previewTrack,
|
|
previewPlaying,
|
|
onPreview,
|
|
}) => {
|
|
const formatDuration = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
const formatNumber = (num: number) => {
|
|
if (num >= 1000000) {
|
|
return `${(num / 1000000).toFixed(1)}M`;
|
|
} else if (num >= 1000) {
|
|
return `${(num / 1000).toFixed(1)}K`;
|
|
}
|
|
return num.toString();
|
|
};
|
|
|
|
return (
|
|
<section>
|
|
<h2 className="text-xl font-bold mb-4">Popular</h2>
|
|
<div data-tv-section="tracks">
|
|
{tracks.slice(0, 5).map((track, index) => {
|
|
const isPlaying = currentTrackId === track.id;
|
|
const isPreviewPlaying =
|
|
previewTrack === track.id && previewPlaying;
|
|
const isUnowned =
|
|
!track.album?.id ||
|
|
!track.album?.title ||
|
|
track.album.title === "Unknown Album";
|
|
const coverUrl = track.album?.coverArt
|
|
? api.getCoverArtUrl(track.album.coverArt, 80)
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
key={track.id}
|
|
data-track-row
|
|
data-tv-card
|
|
data-tv-card-index={index}
|
|
tabIndex={0}
|
|
className={cn(
|
|
"grid grid-cols-[40px_1fr_auto] md:grid-cols-[40px_minmax(200px,4fr)_minmax(80px,1fr)_80px] gap-4 py-2 rounded-md hover:bg-white/5 transition-colors group cursor-pointer",
|
|
isPlaying && "bg-white/10"
|
|
)}
|
|
onClick={(e) => {
|
|
if (isUnowned) {
|
|
onPreview(track, e);
|
|
} else {
|
|
onPlayTrack(track);
|
|
}
|
|
}}
|
|
>
|
|
{/* Track Number / Play Icon */}
|
|
<div className="flex items-center justify-center">
|
|
<span
|
|
className={cn(
|
|
"text-sm group-hover:hidden",
|
|
isPlaying
|
|
? "text-[#ecb200]"
|
|
: "text-gray-400"
|
|
)}
|
|
>
|
|
{isPlaying ? (
|
|
<Music className="w-4 h-4 text-[#ecb200] animate-pulse" />
|
|
) : (
|
|
index + 1
|
|
)}
|
|
</span>
|
|
<Play className="w-4 h-4 text-white hidden group-hover:block" />
|
|
</div>
|
|
|
|
{/* Title + Album Art */}
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-10 h-10 bg-[#282828] rounded shrink-0 overflow-hidden">
|
|
{coverUrl ? (
|
|
<Image
|
|
src={coverUrl}
|
|
alt={track.title}
|
|
width={40}
|
|
height={40}
|
|
className="object-cover"
|
|
unoptimized
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Music className="w-5 h-5 text-gray-600" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div
|
|
className={cn(
|
|
"text-sm font-medium truncate flex items-center gap-2",
|
|
isPlaying
|
|
? "text-[#ecb200]"
|
|
: "text-white"
|
|
)}
|
|
>
|
|
<span className="truncate">
|
|
{track.displayTitle ?? track.title}
|
|
</span>
|
|
{isUnowned && (
|
|
<span className="shrink-0 text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded font-medium">
|
|
PREVIEW
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-400 truncate">
|
|
{artist.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Play Count (hidden on mobile) */}
|
|
<div className="hidden md:flex items-center text-sm text-gray-400">
|
|
{track.playCount !== undefined &&
|
|
track.playCount > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Play className="w-3 h-3" />
|
|
{formatNumber(track.playCount)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Duration + Preview */}
|
|
<div className="flex items-center justify-end gap-2">
|
|
{isUnowned && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onPreview(track, e);
|
|
}}
|
|
className="p-1.5 rounded-full opacity-0 group-hover:opacity-100 hover:bg-white/10 text-gray-400 hover:text-white transition-all"
|
|
>
|
|
{isPreviewPlaying ? (
|
|
<Pause className="w-4 h-4" />
|
|
) : (
|
|
<Volume2 className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
{track.duration && (
|
|
<span className="text-sm text-gray-400 w-10 text-right">
|
|
{formatDuration(track.duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|