feat: add support for multi-disc albums

This commit is contained in:
2026-07-25 21:54:25 +01:00
parent 5185d4dc0b
commit c6080b975a
5 changed files with 292 additions and 108 deletions
+203 -97
View File
@@ -975,12 +975,64 @@ export function AlbumView({
) : null;
}, [album.coverPath, album.name]);
const discGroups = useMemo(() => {
if (!data?.tracks) return [];
const map = new Map<number, { track: AnalysisTrack; originalIndex: number }[]>();
data.tracks.forEach((track: AnalysisTrack, i: number) => {
let discNo = 1;
if (!("error" in track)) {
if (track.common?.disk?.no) {
discNo = track.common.disk.no;
} else {
const match = track.filename.match(/^(\d+)-(\d+)\b/);
if (match) discNo = parseInt(match[1], 10);
}
}
if (!map.has(discNo)) {
map.set(discNo, []);
}
map.get(discNo)!.push({ track, originalIndex: i });
});
const sortedDiscs = Array.from(map.keys()).sort((a, b) => a - b);
return sortedDiscs.map((discNo) => ({
discNo,
items: map.get(discNo)!,
}));
}, [data?.tracks]);
const isMultiDisc = useMemo(() => {
return (
discGroups.length > 1 ||
(discGroups.length === 1 && discGroups[0].discNo > 1)
);
}, [discGroups]);
const spectrogramsMemo = useMemo(() => {
if (!data?.tracks) return null;
return data.tracks.map((track: AnalysisTrack) => {
const spec = spectrograms[track.filename];
if (!spec?.exists || !spec.url) return null;
let discNo = 1;
let trackNo = 0;
if (!("error" in track)) {
discNo = track.common?.disk?.no || 1;
trackNo = track.common?.track?.no || 0;
if (!track.common?.disk?.no) {
const match = track.filename.match(/^(\d+)-(\d+)\b/);
if (match) {
discNo = parseInt(match[1], 10);
if (!track.common?.track?.no) trackNo = parseInt(match[2], 10);
}
}
}
const trackNumStr = isMultiDisc
? `${discNo}-${trackNo.toString().padStart(2, "0")}`
: (trackNo || "").toString().padStart(2, "0");
return (
<div
key={track.filename}
@@ -988,8 +1040,8 @@ export function AlbumView({
>
<div className="flex items-center justify-between">
<div className="font-medium text-white/90 flex items-center gap-3">
<span className="w-6 h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50">
{(!("error" in track) && track.common?.track?.no) || ""}
<span className="h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50 px-2">
{trackNumStr}
</span>
<span>
{(!("error" in track) && track.common?.title) || track.filename}
@@ -1017,7 +1069,7 @@ export function AlbumView({
</div>
);
});
}, [data?.tracks, spectrograms]);
}, [data?.tracks, spectrograms, isMultiDisc]);
if (loading) {
return (
@@ -1050,12 +1102,35 @@ export function AlbumView({
if (!data) return null;
const { metadata, tracks, quality } = data;
const tracklistText = tracks
.map((t) => {
if ("error" in t) return `${t.filename} (Error)`;
return `${t.common?.track?.no || "00"} - ${t.common?.title || t.filename}`;
})
.join("\n");
let currentDiscText: number | null = null;
const tracklistTextLines: string[] = [];
tracks.forEach((t) => {
if ("error" in t) {
tracklistTextLines.push(`${t.filename} (Error)`);
return;
}
let discNo = t.common?.disk?.no || 1;
let trackNo = (t.common?.track?.no || 0).toString().padStart(2, "0");
if (!t.common?.disk?.no) {
const match = t.filename.match(/^(\d+)-(\d+)\b/);
if (match) {
discNo = parseInt(match[1], 10);
if (!t.common?.track?.no) trackNo = match[2].padStart(2, "0");
}
}
if (isMultiDisc && discNo !== currentDiscText) {
if (currentDiscText !== null) tracklistTextLines.push("");
tracklistTextLines.push(`Disc ${discNo}`);
currentDiscText = discNo;
}
const trackNumStr = isMultiDisc ? `${discNo}-${trackNo}` : trackNo;
tracklistTextLines.push(`${trackNumStr} - ${t.common?.title || t.filename}`);
});
const tracklistText = tracklistTextLines.join("\n");
// Dynamic offset deduction for Tracklist Max Height
const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true";
@@ -1420,97 +1495,128 @@ export function AlbumView({
style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }}
>
<div className="h-full">
<div className="p-2 space-y-0.5">
{tracks.map((track: AnalysisTrack, i: number) => {
const isError = "error" in track;
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.02 }}
className={`group relative flex items-center justify-between p-2 rounded-lg transition-colors text-sm overflow-hidden select-none cursor-pointer ${playingTrack === track.filename ? "bg-white/10" : "hover:bg-white/5"}`}
onMouseDown={(e) =>
handleTrackMouseDown(e, track.filename)
}
onMouseMove={(e) =>
handleTrackMouseMove(e, track.filename)
}
onMouseUp={(e) => handleTrackMouseUp(e, track.filename)}
onMouseLeave={handleTrackMouseLeave}
>
{/* Progress Bar */}
{playingTrack === track.filename && (
<div
className="absolute inset-y-0 left-0 bg-white/10 z-0 pointer-events-none transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
)}
<div className="relative z-10 flex items-center gap-3 min-w-0 pointer-events-none">
<span className="text-white/30 font-mono w-6 text-right shrink-0">
{isError
? "!!"
: (track.common?.track?.no || i + 1)
.toString()
.padStart(2, "0")}
<div className="p-2 space-y-3">
{discGroups.map((group) => (
<div key={group.discNo} className="space-y-0.5">
{isMultiDisc && (
<div className="flex items-center gap-2 pt-2 pb-1.5 px-2 text-xs font-semibold uppercase tracking-wider text-primary/80 border-b border-white/5 mb-1 select-none">
<Disc className="w-3.5 h-3.5 shrink-0 text-primary" />
<span>Disc {group.discNo}</span>
<span className="text-[10px] font-normal text-white/40 lowercase">
({group.items.length} {group.items.length === 1 ? "track" : "tracks"})
</span>
<div className="flex flex-col min-w-0 pr-2 pointer-events-auto">
<ScrollableTrackName
text={
isError
? track.filename
: track.common?.title || track.filename
}
isError={isError}
/>
<span className="text-[10px] text-white/30 truncate font-mono">
{track.filename}
</span>
</div>
</div>
<div className="relative z-10 flex items-center gap-4 shrink-0">
{!isError && (
<div className="flex items-center gap-2 text-xs text-white/40 font-mono">
<span>
{track.format?.bitsPerSample}bit/
{track.format?.sampleRate
? track.format.sampleRate / 1000
: "?"}
kHz
</span>
{process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE ===
"true" && (
<span>
{Math.round(
(track.format?.bitrate || 0) / 1000,
)}
kbps
</span>
)}
</div>
)}
{/* Spectrogram Button */}
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 cursor-pointer relative z-20 ${spectrograms[track.filename]?.exists ? "text-primary hover:text-primary hover:bg-primary/10" : "text-white/20 hover:text-white hover:bg-white/10"}`}
onClick={(e) => {
e.stopPropagation();
const spec = spectrograms[track.filename];
if (spec?.exists && spec.url) {
setPreviewUrl(spec.url);
}
}}
disabled={!spectrograms[track.filename]?.exists}
title="View Spectrogram"
)}
{group.items.map(({ track, originalIndex: i }) => {
const isError = "error" in track;
let discNo = group.discNo;
let trackNo = 0;
if (!isError) {
discNo = track.common?.disk?.no || group.discNo;
trackNo = track.common?.track?.no || 0;
if (!track.common?.disk?.no) {
const match = track.filename.match(/^(\d+)-(\d+)\b/);
if (match) discNo = parseInt(match[1], 10);
}
}
const trackNumDisplay = isError
? "!!"
: isMultiDisc
? `${discNo}-${(trackNo > 0 ? trackNo : i + 1)
.toString()
.padStart(2, "0")}`
: (trackNo > 0 ? trackNo : i + 1)
.toString()
.padStart(2, "0");
return (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.02 }}
className={`group relative flex items-center justify-between p-2 rounded-lg transition-colors text-sm overflow-hidden select-none cursor-pointer ${playingTrack === track.filename ? "bg-white/10" : "hover:bg-white/5"}`}
onMouseDown={(e) =>
handleTrackMouseDown(e, track.filename)
}
onMouseMove={(e) =>
handleTrackMouseMove(e, track.filename)
}
onMouseUp={(e) => handleTrackMouseUp(e, track.filename)}
onMouseLeave={handleTrackMouseLeave}
>
<Activity className="w-3 h-3" />
</Button>
</div>
</motion.div>
);
})}
{/* Progress Bar */}
{playingTrack === track.filename && (
<div
className="absolute inset-y-0 left-0 bg-white/10 z-0 pointer-events-none transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
)}
<div className="relative z-10 flex items-center gap-3 min-w-0 pointer-events-none">
<span className={`text-white/30 font-mono text-right shrink-0 ${isMultiDisc ? "w-10" : "w-6"}`}>
{trackNumDisplay}
</span>
<div className="flex flex-col min-w-0 pr-2 pointer-events-auto">
<ScrollableTrackName
text={
isError
? track.filename
: track.common?.title || track.filename
}
isError={isError}
/>
<span className="text-[10px] text-white/30 truncate font-mono">
{track.filename}
</span>
</div>
</div>
<div className="relative z-10 flex items-center gap-4 shrink-0">
{!isError && (
<div className="flex items-center gap-2 text-xs text-white/40 font-mono">
<span>
{track.format?.bitsPerSample}bit/
{track.format?.sampleRate
? track.format.sampleRate / 1000
: "?"}
kHz
</span>
{process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE ===
"true" && (
<span>
{Math.round(
(track.format?.bitrate || 0) / 1000,
)}
kbps
</span>
)}
</div>
)}
{/* Spectrogram Button */}
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 cursor-pointer relative z-20 ${spectrograms[track.filename]?.exists ? "text-primary hover:text-primary hover:bg-primary/10" : "text-white/20 hover:text-white hover:bg-white/10"}`}
onClick={(e) => {
e.stopPropagation();
const spec = spectrograms[track.filename];
if (spec?.exists && spec.url) {
setPreviewUrl(spec.url);
}
}}
disabled={!spectrograms[track.filename]?.exists}
title="View Spectrogram"
>
<Activity className="w-3 h-3" />
</Button>
</div>
</motion.div>
);
})}
</div>
))}
</div>
</div>
</div>