Files
lidify/frontend/features/album/components/TrackList.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

288 lines
10 KiB
TypeScript

import React, { memo, useCallback } from "react";
import { Card } from "@/components/ui/Card";
import { Play, Pause, Volume2, ListPlus, Plus } from "lucide-react";
import { cn } from "@/utils/cn";
import type { Track, Album, AlbumSource } from "../types";
interface TrackListProps {
tracks: Track[];
album: Album;
source: AlbumSource;
currentTrackId: string | undefined;
colors: any;
onPlayTrack: (track: Track, index: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (trackId: string) => void;
previewTrack: string | null;
previewPlaying: boolean;
onPreview: (track: Track, e: React.MouseEvent) => void;
}
interface TrackRowProps {
track: Track;
index: number;
album: Album;
isOwned: boolean;
isPlaying: boolean;
isPreviewPlaying: boolean;
colors: any;
onPlayTrack: (track: Track, index: number) => void;
onAddToQueue: (track: Track) => void;
onAddToPlaylist: (trackId: string) => void;
onPreview: (track: Track, e: React.MouseEvent) => void;
}
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();
};
const TrackRow = memo(
function TrackRow({
track,
index,
album,
isOwned,
isPlaying,
isPreviewPlaying,
colors,
onPlayTrack,
onAddToQueue,
onAddToPlaylist,
onPreview,
}: TrackRowProps) {
const isPreviewOnly = !isOwned;
const handleAddToQueue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddToQueue(track);
},
[track, onAddToQueue]
);
const handleAddToPlaylist = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddToPlaylist(track.id);
},
[track.id, onAddToPlaylist]
);
const handlePreview = useCallback(
(e: React.MouseEvent) => {
onPreview(track, e);
},
[track, onPreview]
);
const handlePlayTrack = useCallback(() => {
onPlayTrack(track, index);
}, [track, index, onPlayTrack]);
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// For unowned tracks, play preview instead of local file
if (isPreviewOnly) {
onPreview(track, e);
} else {
onPlayTrack(track, index);
}
},
[isPreviewOnly, track, index, onPlayTrack, onPreview]
);
return (
<div
data-track-row
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className={cn(
"group relative flex items-center gap-3 md:gap-4 px-3 md:px-4 py-3 hover:bg-[#141414] transition-colors cursor-pointer",
isPlaying && "bg-[#1a1a1a] border-l-2",
isPreviewOnly && "opacity-70 hover:opacity-90"
)}
style={
isPlaying
? { borderLeftColor: colors?.vibrant || "#a855f7" }
: undefined
}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (isPreviewOnly) {
onPreview(track, e as unknown as React.MouseEvent);
} else {
handlePlayTrack();
}
}
}}
>
<div className="w-6 md:w-8 flex-shrink-0 text-center">
<span
className={cn(
"group-hover:hidden text-sm",
isPlaying
? "text-purple-400 font-bold"
: "text-gray-500"
)}
>
{index + 1}
</span>
<Play
className="hidden group-hover:inline-block w-4 h-4 text-white"
fill="currentColor"
/>
</div>
<div className="flex-1 min-w-0">
<div
className={cn(
"font-medium truncate text-sm md:text-base flex items-center gap-2",
isPlaying ? "text-purple-400" : "text-white"
)}
>
<span className="truncate">
{track.displayTitle ?? track.title}
</span>
{isPreviewOnly && (
<span className="shrink-0 text-[10px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30 font-medium">
PREVIEW
</span>
)}
</div>
{track.artist?.name &&
track.artist.name !== album.artist?.name && (
<div className="text-xs md:text-sm text-gray-400 truncate">
{track.artist.name}
</div>
)}
</div>
{isOwned &&
track.playCount !== undefined &&
track.playCount > 0 && (
<div className="hidden lg:flex items-center gap-1.5 text-xs text-gray-400 bg-[#1a1a1a] px-2 py-1 rounded-full">
<Play className="w-3 h-3" />
<span>{formatNumber(track.playCount)}</span>
</div>
)}
{isOwned && (
<>
<button
onClick={handleAddToQueue}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-2 hover:bg-[#2a2a2a] rounded-full transition-all text-gray-400 hover:text-white"
aria-label="Add to queue"
title="Add to queue"
>
<ListPlus className="w-4 h-4" />
</button>
<button
onClick={handleAddToPlaylist}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-2 hover:bg-[#2a2a2a] rounded-full transition-all text-gray-400 hover:text-white"
aria-label="Add to playlist"
title="Add to playlist"
>
<Plus className="w-4 h-4" />
</button>
</>
)}
{isPreviewOnly && (
<button
onClick={handlePreview}
className="p-2 rounded-full bg-[#1a1a1a] hover:bg-[#2a2a2a] transition-colors text-white"
aria-label={
isPreviewPlaying ? "Pause preview" : "Play preview"
}
>
{isPreviewPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
)}
{track.duration && (
<div className="text-xs md:text-sm text-gray-400 w-10 md:w-12 text-right tabular-nums">
{formatDuration(track.duration)}
</div>
)}
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.track.id === nextProps.track.id &&
prevProps.isPlaying === nextProps.isPlaying &&
prevProps.isPreviewPlaying === nextProps.isPreviewPlaying &&
prevProps.index === nextProps.index &&
prevProps.isOwned === nextProps.isOwned
);
}
);
export const TrackList = memo(function TrackList({
tracks,
album,
source,
currentTrackId,
colors,
onPlayTrack,
onAddToQueue,
onAddToPlaylist,
previewTrack,
previewPlaying,
onPreview,
}: TrackListProps) {
const isOwned = source === "library";
return (
<section>
<Card>
<div
data-tv-section="tracks"
className="divide-y divide-[#1c1c1c]"
>
{tracks.map((track, index) => {
const isPlaying = currentTrackId === track.id;
const isPreviewPlaying =
previewTrack === track.id && previewPlaying;
return (
<TrackRow
key={track.id}
track={track}
index={index}
album={album}
isOwned={isOwned}
isPlaying={isPlaying}
isPreviewPlaying={isPreviewPlaying}
colors={colors}
onPlayTrack={onPlayTrack}
onAddToQueue={onAddToQueue}
onAddToPlaylist={onAddToPlaylist}
onPreview={onPreview}
/>
);
})}
</div>
</Card>
</section>
);
});