Files
lidify/frontend/features/artist/components/PopularTracks.tsx
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
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
2026-01-06 20:07:33 -06:00

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