154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
|
|
import { Link, useRouter } from '@tanstack/react-router';
|
|
import type { Track } from '../types';
|
|
import { usePlaybackStore } from '../store/usePlaybackStore';
|
|
import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
|
import { startVibeSession } from '../services/vibeSession';
|
|
import { Artwork } from './Artwork';
|
|
import { ArtistLinks } from './ArtistLinks';
|
|
|
|
export function formatDuration(seconds?: number | null): string {
|
|
if (!seconds || seconds < 0 || !Number.isFinite(seconds)) return '0:00';
|
|
const total = Math.floor(seconds);
|
|
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
type TrackRowVariant = 'default' | 'compact';
|
|
|
|
interface TrackRowProps {
|
|
track: Track;
|
|
queue: Track[];
|
|
index: number;
|
|
showActions?: boolean;
|
|
/** compact — smaller artwork, no duration, for queue panels / VibeTimeline */
|
|
variant?: TrackRowVariant;
|
|
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
|
|
showVibe?: boolean;
|
|
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
|
|
onSelect?: (track: Track) => void;
|
|
/** Display-only rows keep their surrounding playback controller authoritative. */
|
|
playable?: boolean;
|
|
}
|
|
|
|
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect, playable = true }: TrackRowProps) {
|
|
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
|
|
const dislikeTrack = useDislikeTrack();
|
|
const router = useRouter();
|
|
const isCurrent = currentTrack?.id === track.id;
|
|
const compact = variant === 'compact';
|
|
|
|
const handlePlay = () => {
|
|
if (!playable) return;
|
|
if (onSelect) {
|
|
onSelect(track);
|
|
return;
|
|
}
|
|
if (isCurrent) { isPlaying ? pause() : play(); return; }
|
|
// Queue the whole list and start at this track, so Previous can walk back
|
|
// into the tracks before it.
|
|
setQueue(queue);
|
|
playTrack(track);
|
|
};
|
|
|
|
const playLabel = !playable
|
|
? `${track.title || 'Track'} is queued by Vibe`
|
|
: isCurrent && isPlaying
|
|
? `Pause ${track.title || 'track'}`
|
|
: `Play ${track.title || 'track'}`;
|
|
|
|
const handleDislike = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
dislikeTrack(track.id);
|
|
};
|
|
|
|
const handleVibe = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
// Start and hydrate the V2 plan before showing the Vibe page.
|
|
startVibeSession(track).then(() => {
|
|
router.navigate({ to: '/vibe' });
|
|
}).catch(() => {
|
|
// Session failed — still navigate so the user can try manually.
|
|
router.navigate({ to: '/vibe' });
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`group flex w-full items-center gap-3 rounded-lg border transition-colors ${
|
|
compact ? 'p-2' : 'p-2.5'
|
|
} ${
|
|
isCurrent
|
|
? 'border-accent/60 bg-accent/10'
|
|
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
|
|
}`}
|
|
>
|
|
{/* Artwork + play overlay */}
|
|
<button
|
|
type="button"
|
|
onClick={handlePlay}
|
|
disabled={!playable}
|
|
aria-label={playLabel}
|
|
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
|
|
compact ? 'h-9 w-9' : 'h-10 w-10'
|
|
}`}>
|
|
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
|
|
{isCurrent && isPlaying ? (
|
|
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
|
|
) : (
|
|
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-70 transition-opacity group-hover:opacity-100" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Title + artist */}
|
|
<div className="min-w-0 flex-1">
|
|
<button
|
|
type="button"
|
|
onClick={handlePlay}
|
|
disabled={!playable}
|
|
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
|
|
aria-label={playLabel}
|
|
>
|
|
{track.title || 'Untitled'}
|
|
</button>
|
|
<ArtistLinks
|
|
artists={track.artists}
|
|
fallback={track.artist}
|
|
stopPropagation
|
|
className={`truncate block text-muted ${compact ? 'text-xs' : 'text-xs'}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions (vibe → album link → dislike) */}
|
|
{showActions && !compact && (
|
|
<div className="track-row-actions flex flex-none items-center gap-1">
|
|
{showVibe && (
|
|
<button type="button" onClick={handleVibe} aria-label="Start a Vibe from this track" title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
|
|
<Sparkles size={16} />
|
|
</button>
|
|
)}
|
|
{track.album_id && (
|
|
<Link
|
|
to="/albums/$albumId"
|
|
params={{ albumId: track.album_id }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Go to album"
|
|
title="Go to album"
|
|
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
|
|
>
|
|
<Disc3 size={16} />
|
|
</Link>
|
|
)}
|
|
<button type="button" onClick={handleDislike} aria-label={`Dislike ${track.title || 'track'}`} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
|
|
<ThumbsDown size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Duration (hidden in compact) */}
|
|
{!compact && (
|
|
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|