Files
muzick/frontend/src/components/TrackRow.tsx
T

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