"use client"; import { useMemo } from "react"; import type { SanityImageSource } from "@sanity/image-url"; import { IoPlay, IoPause } from "react-icons/io5"; import { usePlayer, type PlayerTrack } from "@/components/player/PlayerContext"; import { parseDuration } from "@/lib/duration"; export type TrackData = { workId?: string; workTitle?: string; composerName?: string; arrangerName?: string; movement?: string; displayTitle?: string; duration?: string; artist?: string; previewMp3Url?: string; }; type Props = { tracks: TrackData[]; albumCover?: SanityImageSource; albumArtist?: string; releaseSlug?: string; }; /* ── Grouping types ── */ type MovementEntry = { movement?: string; duration?: string; flatIndex: number; }; type WorkGroup = { workId?: string; workTitle?: string; arrangerName?: string; entries: MovementEntry[]; }; type ComposerBlock = { type: "composer"; composerName: string; works: WorkGroup[]; }; type DisplayTitleBlock = { type: "displayTitle"; title: string; duration?: string; flatIndex: number; }; type TracklistBlock = ComposerBlock | DisplayTitleBlock; /* ── Grouping logic ── */ function groupTracks(tracks: TrackData[]): TracklistBlock[] { const blocks: TracklistBlock[] = []; let currentComposer: ComposerBlock | null = null; let currentWork: WorkGroup | null = null; function flushWork() { if (currentWork && currentComposer) { currentComposer.works.push(currentWork); currentWork = null; } } function flushComposer() { flushWork(); if (currentComposer) { blocks.push(currentComposer); currentComposer = null; } } for (let i = 0; i < tracks.length; i++) { const track = tracks[i]; // Display title track (no work reference) if (!track.workId && track.displayTitle) { flushComposer(); blocks.push({ type: "displayTitle", title: track.displayTitle, duration: track.duration, flatIndex: i, }); continue; } // Work-based track const composerName = track.composerName || ""; const arrangerName = track.arrangerName || ""; const prevArranger = currentWork?.arrangerName || currentComposer?.works.at(-1)?.arrangerName || ""; if ( !currentComposer || currentComposer.composerName !== composerName || arrangerName !== prevArranger ) { flushComposer(); currentComposer = { type: "composer", composerName, works: [], }; } if (!currentWork || currentWork.workId !== track.workId) { flushWork(); currentWork = { workId: track.workId, workTitle: track.workTitle, arrangerName: track.arrangerName, entries: [], }; } currentWork.entries.push({ movement: track.movement, duration: track.duration, flatIndex: i, }); } flushComposer(); return blocks; } /* ── Duration helpers ── */ function formatDuration(totalSeconds: number): string { if (totalSeconds <= 0) return ""; const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; const pad = (n: number) => n.toString().padStart(2, "0"); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } /* ── Build player-friendly flat track list ── */ export function buildPlayerTracks( tracks: TrackData[], albumCover?: SanityImageSource, albumArtist?: string, releaseSlug?: string, ): PlayerTrack[] { return tracks .filter((t) => t.previewMp3Url) .map((t) => { let title: string; let movement: string | undefined; if (t.displayTitle) { title = t.displayTitle; } else if (t.workTitle && t.movement) { title = t.workTitle; movement = t.movement; } else { title = t.workTitle || "(Untitled)"; } let subtitle: string; if (t.composerName) { subtitle = t.arrangerName ? `${t.composerName} (arr. ${t.arrangerName})` : t.composerName; } else { subtitle = t.artist || albumArtist || ""; } return { previewMp3Url: t.previewMp3Url!, title, movement, subtitle, albumCover, trackDuration: parseDuration(t.duration) || undefined, releaseSlug, }; }); } /* ── Build a map from flat track index → player playlist index ── */ function buildIndexMap(tracks: TrackData[]): Map { const map = new Map(); let playerIdx = 0; for (let i = 0; i < tracks.length; i++) { if (tracks[i].previewMp3Url) { map.set(i, playerIdx); playerIdx++; } } return map; } /* ── Component ── */ function Duration({ value }: { value?: string }) { if (!value) return ; return ( {value} ); } function TrackRow({ children, duration, indent, playable, isCurrent, isCurrentPlaying, onPlay, onToggle, }: { children: React.ReactNode; duration?: string; indent?: boolean; playable?: boolean; isCurrent?: boolean; isCurrentPlaying?: boolean; onPlay?: () => void; onToggle?: () => void; }) { const handleClick = () => { if (isCurrent) { onToggle?.(); } else if (playable) { onPlay?.(); } }; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); } } : undefined } className={[ "group mb-1 flex items-start justify-between gap-4 leading-tight", indent ? "pl-4" : "", playable ? "cursor-pointer" : "", isCurrent ? "text-trptkblue dark:text-white" : "", ].join(" ")} > {playable && ( {isCurrent && isCurrentPlaying ? : } )} {children}
); } export function Tracklist({ tracks, albumCover, albumArtist, releaseSlug }: Props) { const { loadPlaylist, currentIndex, playlist, isPlaying, togglePlayback } = usePlayer(); const validTracks = tracks.filter(Boolean); const blocks = useMemo(() => groupTracks(validTracks), [validTracks]); const totalSeconds = validTracks.reduce((sum, t) => sum + parseDuration(t.duration), 0); const totalTime = formatDuration(totalSeconds); const playerTracks = useMemo( () => buildPlayerTracks(validTracks, albumCover, albumArtist, releaseSlug), [validTracks, albumCover, albumArtist, releaseSlug], ); const indexMap = useMemo(() => buildIndexMap(validTracks), [validTracks]); // Check if a flat track index is the currently loaded track (playing or paused) const isCurrentTrack = (flatIndex: number) => { if (currentIndex === null) return false; const playerIdx = indexMap.get(flatIndex); if (playerIdx === undefined) return false; return ( playerIdx === currentIndex && playlist[currentIndex]?.previewMp3Url === playerTracks[playerIdx]?.previewMp3Url ); }; const playTrack = (flatIndex: number) => { const playerIdx = indexMap.get(flatIndex); if (playerIdx !== undefined) { loadPlaylist(playerTracks, playerIdx); } }; if (validTracks.length === 0) { return

No tracks available.

; } return (
{blocks.map((block, i) => { const prevBlock = i > 0 ? blocks[i - 1] : null; const isConsecutiveDisplayTitle = block.type === "displayTitle" && prevBlock?.type === "displayTitle"; const blockMargin = i === 0 ? "" : isConsecutiveDisplayTitle ? "" : "mt-6"; if (block.type === "displayTitle") { const playable = indexMap.has(block.flatIndex); return (
playTrack(block.flatIndex)} onToggle={togglePlayback} > {block.title}
); } return (
{block.composerName && (

{block.composerName} {block.works[0]?.arrangerName && ( (arr. {block.works[0].arrangerName}) )}

)}
{block.works.map((work, wi) => { const showArranger = work.arrangerName && work.arrangerName !== block.works[0]?.arrangerName; // Single entry for this work if (work.entries.length === 1) { const entry = work.entries[0]; const playable = indexMap.has(entry.flatIndex); return (
{showArranger && (

arr. {work.arrangerName}

)} playTrack(entry.flatIndex)} onToggle={togglePlayback} > {work.workTitle || "(Untitled)"} {entry.movement && : {entry.movement}}
); } // Multiple movements for this work return (
0 ? "mt-3" : ""}> {showArranger && (

arr. {work.arrangerName}

)}

{work.workTitle || "(Untitled)"}

{work.entries.map((entry, ei) => { const playable = indexMap.has(entry.flatIndex); return ( playTrack(entry.flatIndex)} onToggle={togglePlayback} > {entry.movement || `${ei + 1}.`} ); })}
); })}
); })} {totalTime && (
Total playing time {totalTime}
)}
); }