feat: add support for multi-disc albums
This commit is contained in:
@@ -61,9 +61,13 @@ export async function POST(request: Request) {
|
|||||||
// Calculate total size
|
// Calculate total size
|
||||||
const totalSize = tracks.reduce((acc, track) => acc + (track.size || 0), 0);
|
const totalSize = tracks.reduce((acc, track) => acc + (track.size || 0), 0);
|
||||||
|
|
||||||
// Sort tracks by track number if available, else by filename
|
// Sort tracks by disc number first, then track number, else by filename
|
||||||
tracks.sort((a, b) => {
|
tracks.sort((a, b) => {
|
||||||
if ('error' in a || 'error' in b) return 0; // Keep order if error
|
if ('error' in a || 'error' in b) return 0; // Keep order if error
|
||||||
|
const discA = a.common?.disk?.no ?? 1;
|
||||||
|
const discB = b.common?.disk?.no ?? 1;
|
||||||
|
if (discA !== discB) return discA - discB;
|
||||||
|
|
||||||
const trackA = a.common?.track?.no ?? 0;
|
const trackA = a.common?.track?.no ?? 0;
|
||||||
const trackB = b.common?.track?.no ?? 0;
|
const trackB = b.common?.track?.no ?? 0;
|
||||||
if (trackA && trackB) return trackA - trackB;
|
if (trackA && trackB) return trackA - trackB;
|
||||||
@@ -95,6 +99,11 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const discNumbers = new Set(
|
||||||
|
tracks.map(t => ('error' in t ? 1 : (t.common?.disk?.no || 1)))
|
||||||
|
);
|
||||||
|
const isMultiDisc = discNumbers.size > 1 || Array.from(discNumbers).some(d => d > 1);
|
||||||
|
|
||||||
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
||||||
mixed = true;
|
mixed = true;
|
||||||
qualityDetails = `NOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
qualityDetails = `NOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
||||||
@@ -102,7 +111,13 @@ export async function POST(request: Request) {
|
|||||||
// Generate detailed message for mixed content
|
// Generate detailed message for mixed content
|
||||||
const mixedDetails = tracks.map(t => {
|
const mixedDetails = tracks.map(t => {
|
||||||
if ('error' in t) return `${t.filename}: Error`;
|
if ('error' in t) return `${t.filename}: Error`;
|
||||||
return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
const discNo = t.common?.disk?.no || 1;
|
||||||
|
const trackNo = t.common?.track?.no;
|
||||||
|
let trackStr = trackNo !== undefined && trackNo !== null ? String(trackNo).padStart(2, '0') : '?';
|
||||||
|
if (isMultiDisc) {
|
||||||
|
trackStr = `${discNo}-${trackStr}`;
|
||||||
|
}
|
||||||
|
return `Track ${trackStr}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
qualityDetails += '\n' + mixedDetails;
|
qualityDetails += '\n' + mixedDetails;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export async function POST(request: Request) {
|
|||||||
// Sort tracks
|
// Sort tracks
|
||||||
tracks.sort((a, b) => {
|
tracks.sort((a, b) => {
|
||||||
if ('error' in a || 'error' in b) return 0;
|
if ('error' in a || 'error' in b) return 0;
|
||||||
|
const discA = a.common?.disk?.no ?? 1;
|
||||||
|
const discB = b.common?.disk?.no ?? 1;
|
||||||
|
if (discA !== discB) return discA - discB;
|
||||||
|
|
||||||
const trackA = a.common?.track?.no ?? 0;
|
const trackA = a.common?.track?.no ?? 0;
|
||||||
const trackB = b.common?.track?.no ?? 0;
|
const trackB = b.common?.track?.no ?? 0;
|
||||||
if (trackA && trackB) return trackA - trackB;
|
if (trackA && trackB) return trackA - trackB;
|
||||||
@@ -58,13 +62,24 @@ export async function POST(request: Request) {
|
|||||||
if (!sr || sr < 48000 || !bd || bd < 24) isHiRes = false;
|
if (!sr || sr < 48000 || !bd || bd < 24) isHiRes = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const discNumbers = new Set(
|
||||||
|
tracks.map(t => ('error' in t ? 1 : (t.common?.disk?.no || 1)))
|
||||||
|
);
|
||||||
|
const isMultiDisc = discNumbers.size > 1 || Array.from(discNumbers).some(d => d > 1);
|
||||||
|
|
||||||
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
||||||
mixed = true;
|
mixed = true;
|
||||||
qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
||||||
|
|
||||||
const mixedDetails = tracks.map(t => {
|
const mixedDetails = tracks.map(t => {
|
||||||
if ('error' in t) return `${t.filename}: Error`;
|
if ('error' in t) return `${t.filename}: Error`;
|
||||||
return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
const discNo = t.common?.disk?.no || 1;
|
||||||
|
const trackNo = t.common?.track?.no;
|
||||||
|
let trackStr = trackNo !== undefined && trackNo !== null ? String(trackNo).padStart(2, '0') : '?';
|
||||||
|
if (isMultiDisc) {
|
||||||
|
trackStr = `${discNo}-${trackStr}`;
|
||||||
|
}
|
||||||
|
return `Track ${trackStr}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
qualityDetails += '\n' + mixedDetails;
|
qualityDetails += '\n' + mixedDetails;
|
||||||
@@ -155,10 +170,32 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tracklist
|
// Tracklist
|
||||||
const tracklist = tracks.map(t => {
|
|
||||||
if ('error' in t) return `${t.filename} (Error)`;
|
let currentDisc: number | null = null;
|
||||||
return `${t.common?.track?.no || '00'}. ${t.common?.title || t.filename}`;
|
const tracklistLines: string[] = [];
|
||||||
}).join('\n');
|
|
||||||
|
tracks.forEach(t => {
|
||||||
|
if ('error' in t) {
|
||||||
|
tracklistLines.push(`${t.filename} (Error)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discNo = t.common?.disk?.no || 1;
|
||||||
|
const trackNo = t.common?.track?.no
|
||||||
|
? String(t.common.track.no).padStart(2, '0')
|
||||||
|
: '00';
|
||||||
|
|
||||||
|
if (isMultiDisc && discNo !== currentDisc) {
|
||||||
|
if (currentDisc !== null) tracklistLines.push('');
|
||||||
|
tracklistLines.push(`[b]Disc ${discNo}[/b]`);
|
||||||
|
currentDisc = discNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackLabel = isMultiDisc ? `${discNo}-${trackNo}` : trackNo;
|
||||||
|
tracklistLines.push(`${trackLabel}. ${t.common?.title || t.filename}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tracklist = tracklistLines.join('\n');
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
let description = "Sourced from TIDAL";
|
let description = "Sourced from TIDAL";
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ export async function POST(request: Request) {
|
|||||||
if ('error' in track) return;
|
if ('error' in track) return;
|
||||||
|
|
||||||
const trackNo = track.common?.track?.no;
|
const trackNo = track.common?.track?.no;
|
||||||
|
const discNo = track.common?.disk?.no;
|
||||||
if (trackNo === undefined || trackNo === null) return;
|
if (trackNo === undefined || trackNo === null) return;
|
||||||
|
|
||||||
const outputFilename = `${trackNo}.png`;
|
const discPrefix = (discNo && discNo > 1) || tracks.some(t => !('error' in t) && (t.common?.disk?.no ?? 1) > 1)
|
||||||
|
? `${discNo ?? 1}-`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const outputFilename = `${discPrefix}${trackNo}.png`;
|
||||||
const outputPath = path.join(albumSpecDir, outputFilename);
|
const outputPath = path.join(albumSpecDir, outputFilename);
|
||||||
const relativePath = path.join(albumId, outputFilename);
|
const relativePath = path.join(albumId, outputFilename);
|
||||||
const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`;
|
const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`;
|
||||||
@@ -43,7 +48,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
// Generate
|
// Generate
|
||||||
const title = `${trackNo} - ${track.common?.title || track.filename}`;
|
const title = `${discPrefix}${trackNo} - ${track.common?.title || track.filename}`;
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
track.path,
|
track.path,
|
||||||
|
|||||||
+203
-97
@@ -975,12 +975,64 @@ export function AlbumView({
|
|||||||
) : null;
|
) : null;
|
||||||
}, [album.coverPath, album.name]);
|
}, [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(() => {
|
const spectrogramsMemo = useMemo(() => {
|
||||||
if (!data?.tracks) return null;
|
if (!data?.tracks) return null;
|
||||||
return data.tracks.map((track: AnalysisTrack) => {
|
return data.tracks.map((track: AnalysisTrack) => {
|
||||||
const spec = spectrograms[track.filename];
|
const spec = spectrograms[track.filename];
|
||||||
if (!spec?.exists || !spec.url) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={track.filename}
|
key={track.filename}
|
||||||
@@ -988,8 +1040,8 @@ export function AlbumView({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="font-medium text-white/90 flex items-center gap-3">
|
<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">
|
<span className="h-6 rounded bg-white/10 text-xs flex items-center justify-center font-mono text-white/50 px-2">
|
||||||
{(!("error" in track) && track.common?.track?.no) || ""}
|
{trackNumStr}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{(!("error" in track) && track.common?.title) || track.filename}
|
{(!("error" in track) && track.common?.title) || track.filename}
|
||||||
@@ -1017,7 +1069,7 @@ export function AlbumView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [data?.tracks, spectrograms]);
|
}, [data?.tracks, spectrograms, isMultiDisc]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -1050,12 +1102,35 @@ export function AlbumView({
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const { metadata, tracks, quality } = data;
|
const { metadata, tracks, quality } = data;
|
||||||
const tracklistText = tracks
|
let currentDiscText: number | null = null;
|
||||||
.map((t) => {
|
const tracklistTextLines: string[] = [];
|
||||||
if ("error" in t) return `${t.filename} (Error)`;
|
|
||||||
return `${t.common?.track?.no || "00"} - ${t.common?.title || t.filename}`;
|
tracks.forEach((t) => {
|
||||||
})
|
if ("error" in t) {
|
||||||
.join("\n");
|
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
|
// Dynamic offset deduction for Tracklist Max Height
|
||||||
const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true";
|
const isLogEnabled = process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true";
|
||||||
@@ -1420,97 +1495,128 @@ export function AlbumView({
|
|||||||
style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }}
|
style={{ maxHeight: `calc(100vh - ${tracklistOffsetRem}rem)` }}
|
||||||
>
|
>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<div className="p-2 space-y-0.5">
|
<div className="p-2 space-y-3">
|
||||||
{tracks.map((track: AnalysisTrack, i: number) => {
|
{discGroups.map((group) => (
|
||||||
const isError = "error" in track;
|
<div key={group.discNo} className="space-y-0.5">
|
||||||
return (
|
{isMultiDisc && (
|
||||||
<motion.div
|
<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">
|
||||||
key={i}
|
<Disc className="w-3.5 h-3.5 shrink-0 text-primary" />
|
||||||
initial={{ opacity: 0, x: -10 }}
|
<span>Disc {group.discNo}</span>
|
||||||
animate={{ opacity: 1, x: 0 }}
|
<span className="text-[10px] font-normal text-white/40 lowercase">
|
||||||
transition={{ delay: i * 0.02 }}
|
({group.items.length} {group.items.length === 1 ? "track" : "tracks"})
|
||||||
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")}
|
|
||||||
</span>
|
</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>
|
||||||
<div className="relative z-10 flex items-center gap-4 shrink-0">
|
)}
|
||||||
{!isError && (
|
{group.items.map(({ track, originalIndex: i }) => {
|
||||||
<div className="flex items-center gap-2 text-xs text-white/40 font-mono">
|
const isError = "error" in track;
|
||||||
<span>
|
let discNo = group.discNo;
|
||||||
{track.format?.bitsPerSample}bit/
|
let trackNo = 0;
|
||||||
{track.format?.sampleRate
|
|
||||||
? track.format.sampleRate / 1000
|
if (!isError) {
|
||||||
: "?"}
|
discNo = track.common?.disk?.no || group.discNo;
|
||||||
kHz
|
trackNo = track.common?.track?.no || 0;
|
||||||
</span>
|
if (!track.common?.disk?.no) {
|
||||||
{process.env.NEXT_PUBLIC_SHOW_TRACK_BITRATE ===
|
const match = track.filename.match(/^(\d+)-(\d+)\b/);
|
||||||
"true" && (
|
if (match) discNo = parseInt(match[1], 10);
|
||||||
<span>
|
}
|
||||||
{Math.round(
|
}
|
||||||
(track.format?.bitrate || 0) / 1000,
|
|
||||||
)}
|
const trackNumDisplay = isError
|
||||||
kbps
|
? "!!"
|
||||||
</span>
|
: isMultiDisc
|
||||||
)}
|
? `${discNo}-${(trackNo > 0 ? trackNo : i + 1)
|
||||||
</div>
|
.toString()
|
||||||
)}
|
.padStart(2, "0")}`
|
||||||
{/* Spectrogram Button */}
|
: (trackNo > 0 ? trackNo : i + 1)
|
||||||
<Button
|
.toString()
|
||||||
variant="ghost"
|
.padStart(2, "0");
|
||||||
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"}`}
|
return (
|
||||||
onClick={(e) => {
|
<motion.div
|
||||||
e.stopPropagation();
|
key={i}
|
||||||
const spec = spectrograms[track.filename];
|
initial={{ opacity: 0, x: -10 }}
|
||||||
if (spec?.exists && spec.url) {
|
animate={{ opacity: 1, x: 0 }}
|
||||||
setPreviewUrl(spec.url);
|
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) =>
|
||||||
disabled={!spectrograms[track.filename]?.exists}
|
handleTrackMouseDown(e, track.filename)
|
||||||
title="View Spectrogram"
|
}
|
||||||
|
onMouseMove={(e) =>
|
||||||
|
handleTrackMouseMove(e, track.filename)
|
||||||
|
}
|
||||||
|
onMouseUp={(e) => handleTrackMouseUp(e, track.filename)}
|
||||||
|
onMouseLeave={handleTrackMouseLeave}
|
||||||
>
|
>
|
||||||
<Activity className="w-3 h-3" />
|
{/* Progress Bar */}
|
||||||
</Button>
|
{playingTrack === track.filename && (
|
||||||
</div>
|
<div
|
||||||
</motion.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+23
-2
@@ -1,4 +1,5 @@
|
|||||||
import { parseFile } from 'music-metadata';
|
import { parseFile } from 'music-metadata';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
export interface AudioMetadata {
|
export interface AudioMetadata {
|
||||||
format: {
|
format: {
|
||||||
@@ -39,6 +40,26 @@ export async function getFlacMetadata(filePath: string): Promise<AudioMetadata>
|
|||||||
//console.log(metadata.native.vorbis);
|
//console.log(metadata.native.vorbis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filename = path.basename(filePath);
|
||||||
|
let diskNo = metadata.common.disk?.no;
|
||||||
|
let trackNo = metadata.common.track?.no;
|
||||||
|
|
||||||
|
// Extract disc and track number from multi-disc filename prefix e.g. "1-03 - Title"
|
||||||
|
const multiDiscMatch = filename.match(/^(\d+)-(\d+)\b/);
|
||||||
|
if (multiDiscMatch) {
|
||||||
|
if (!diskNo) diskNo = parseInt(multiDiscMatch[1], 10);
|
||||||
|
if (!trackNo) trackNo = parseInt(multiDiscMatch[2], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackObj = {
|
||||||
|
no: trackNo ?? metadata.common.track?.no ?? null,
|
||||||
|
of: metadata.common.track?.of ?? null
|
||||||
|
};
|
||||||
|
const diskObj = {
|
||||||
|
no: diskNo ?? metadata.common.disk?.no ?? null,
|
||||||
|
of: metadata.common.disk?.of ?? null
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
format: {
|
format: {
|
||||||
sampleRate: metadata.format.sampleRate,
|
sampleRate: metadata.format.sampleRate,
|
||||||
@@ -50,8 +71,8 @@ export async function getFlacMetadata(filePath: string): Promise<AudioMetadata>
|
|||||||
title: metadata.common.title,
|
title: metadata.common.title,
|
||||||
artist: metadata.common.artist,
|
artist: metadata.common.artist,
|
||||||
album: metadata.common.album,
|
album: metadata.common.album,
|
||||||
track: metadata.common.track,
|
track: trackObj,
|
||||||
disk: metadata.common.disk,
|
disk: diskObj,
|
||||||
year: metadata.common.year,
|
year: metadata.common.year,
|
||||||
date: metadata.common.date,
|
date: metadata.common.date,
|
||||||
copyright: metadata.common.copyright,
|
copyright: metadata.common.copyright,
|
||||||
|
|||||||
Reference in New Issue
Block a user