432 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|