Files
lidify/frontend/features/album/components/TrackList.tsx
T
2025-12-25 18:58:06 -06:00

255 lines
7.4 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.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>
);
});