feat: add support for multi-disc albums
This commit is contained in:
+203
-97
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user