trptk/components/release/Tracklist.tsx
2026-02-24 17:14:07 +01:00

432 lines
12 KiB
TypeScript

"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<number, number> {
const map = new Map<number, number>();
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 <span className="w-14 shrink-0" />;
return (
<span className="w-14 shrink-0 text-right text-lightsec tabular-nums dark:text-darksec">
{value}
</span>
);
}
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 (
<div
role={playable ? "button" : undefined}
tabIndex={playable ? 0 : undefined}
onClick={playable ? handleClick : undefined}
onKeyDown={
playable
? (e) => {
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(" ")}
>
<span className="relative min-w-0">
{playable && (
<span
className={[
"absolute top-0.5 right-full mr-1.5 transition-opacity duration-200 ease-in-out",
isCurrent ? "opacity-100" : "opacity-0 group-hover:opacity-50",
].join(" ")}
>
{isCurrent && isCurrentPlaying ? <IoPause size={14} /> : <IoPlay size={14} />}
</span>
)}
{children}
</span>
<Duration value={duration} />
</div>
);
}
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 <p className="text-lightsec dark:text-darksec">No tracks available.</p>;
}
return (
<div>
{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 (
<div key={i} className={blockMargin}>
<TrackRow
duration={block.duration}
playable={playable}
isCurrent={isCurrentTrack(block.flatIndex)}
isCurrentPlaying={isCurrentTrack(block.flatIndex) && isPlaying}
onPlay={() => playTrack(block.flatIndex)}
onToggle={togglePlayback}
>
{block.title}
</TrackRow>
</div>
);
}
return (
<div key={i} className={blockMargin}>
{block.composerName && (
<p className="text-lightsec dark:text-darksec">
{block.composerName}
{block.works[0]?.arrangerName && (
<span className="ml-2 opacity-50">(arr. {block.works[0].arrangerName})</span>
)}
</p>
)}
<div className="">
{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 (
<div key={wi}>
{showArranger && (
<p className="mt-2 mb-1 leading-tight text-lightsec opacity-50 dark:text-darksec">
arr. {work.arrangerName}
</p>
)}
<TrackRow
duration={entry.duration}
playable={playable}
isCurrent={isCurrentTrack(entry.flatIndex)}
isCurrentPlaying={isCurrentTrack(entry.flatIndex) && isPlaying}
onPlay={() => playTrack(entry.flatIndex)}
onToggle={togglePlayback}
>
<span className="font-silkasb">{work.workTitle || "(Untitled)"}</span>
{entry.movement && <span>: {entry.movement}</span>}
</TrackRow>
</div>
);
}
// Multiple movements for this work
return (
<div key={wi} className={wi > 0 ? "mt-3" : ""}>
{showArranger && (
<p className="mb-1 leading-tight text-lightsec opacity-50 dark:text-darksec">
arr. {work.arrangerName}
</p>
)}
<p className="mb-1 font-silkasb leading-tight">
{work.workTitle || "(Untitled)"}
</p>
{work.entries.map((entry, ei) => {
const playable = indexMap.has(entry.flatIndex);
return (
<TrackRow
key={ei}
duration={entry.duration}
indent
playable={playable}
isCurrent={isCurrentTrack(entry.flatIndex)}
isCurrentPlaying={isCurrentTrack(entry.flatIndex) && isPlaying}
onPlay={() => playTrack(entry.flatIndex)}
onToggle={togglePlayback}
>
{entry.movement || `${ei + 1}.`}
</TrackRow>
);
})}
</div>
);
})}
</div>
</div>
);
})}
{totalTime && (
<div className="mt-6 border-t border-lighttext/10 pt-6 dark:border-darktext/10">
<div className="flex items-baseline justify-between gap-4">
<span className="text-lightsec dark:text-darksec">Total playing time</span>
<span className="w-14 shrink-0 text-right text-lightsec tabular-nums dark:text-darksec">
{totalTime}
</span>
</div>
</div>
)}
</div>
);
}