feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -1,88 +0,0 @@
|
||||
import { asc, count, gt } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogItems, db, Soundtracks } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
DEFAULT_SOUNDTRACK_BASE_URL,
|
||||
parseTrackSamples,
|
||||
} from "@/lib/services/soundtracks";
|
||||
import { SoundsClient } from "./sounds-client";
|
||||
|
||||
export default async function SoundsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||
|
||||
const [rows, usage, baseUrlSetting] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: Soundtracks.id,
|
||||
code: Soundtracks.code,
|
||||
name: Soundtracks.name,
|
||||
author: Soundtracks.author,
|
||||
length: Soundtracks.length,
|
||||
track: Soundtracks.track,
|
||||
})
|
||||
.from(Soundtracks)
|
||||
.orderBy(asc(Soundtracks.id)),
|
||||
db
|
||||
.select({
|
||||
songId: CatalogItems.songId,
|
||||
total: count(),
|
||||
})
|
||||
.from(CatalogItems)
|
||||
.where(gt(CatalogItems.songId, 0))
|
||||
.groupBy(CatalogItems.songId),
|
||||
siteSettings.get("soundtrack_base_url", DEFAULT_SOUNDTRACK_BASE_URL),
|
||||
]);
|
||||
|
||||
const usageMap: Record<number, number> = {};
|
||||
for (const row of usage) {
|
||||
if (row.songId != null) usageMap[row.songId] = Number(row.total);
|
||||
}
|
||||
|
||||
const soundtracks = rows.map((r) => {
|
||||
const samples = parseTrackSamples(r.track);
|
||||
return {
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
author: r.author,
|
||||
length: r.length,
|
||||
track: r.track,
|
||||
samples,
|
||||
kind: (samples.length > 0 ? "composition" : "single") as
|
||||
| "composition"
|
||||
| "single",
|
||||
usedBy: usageMap[r.id] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Normalize: ensure trailing slash so ${base}${code}.mp3 yields a valid URL.
|
||||
const configuredBaseUrl = baseUrlSetting ?? "/sounds/";
|
||||
const baseUrl = configuredBaseUrl.endsWith("/")
|
||||
? configuredBaseUrl
|
||||
: `${configuredBaseUrl}/`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-muted-foreground">
|
||||
Files are served from <code>{baseUrl}</code> (configurable in{" "}
|
||||
<a href="/admin/settings" className="underline">
|
||||
Settings
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<SoundsClient
|
||||
soundtracks={soundtracks}
|
||||
canEdit={canEdit}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,682 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Layers,
|
||||
Loader2,
|
||||
Music,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteSoundtrack, updateSoundtrack } from "@/actions/soundtracks";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
||||
import { parseTraxChannels } from "@/lib/trax-format";
|
||||
import { TraxPlayer } from "./trax-player";
|
||||
|
||||
interface SoundtrackRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
author: string;
|
||||
length: number;
|
||||
track: string;
|
||||
samples: number[];
|
||||
kind: "single" | "composition";
|
||||
usedBy: number;
|
||||
}
|
||||
|
||||
function formatLength(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return "—";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatSamples(samples: number[]): string {
|
||||
if (samples.length === 0) return "—";
|
||||
if (samples.length <= 20) return samples.join(", ");
|
||||
return `${samples.slice(0, 20).join(", ")}, …(+${samples.length - 20})`;
|
||||
}
|
||||
|
||||
export function SoundsClient({
|
||||
soundtracks,
|
||||
canEdit,
|
||||
baseUrl,
|
||||
}: {
|
||||
soundtracks: SoundtrackRow[];
|
||||
canEdit: boolean;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<
|
||||
"all" | "single" | "composition"
|
||||
>("all");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SoundtrackRow | null>(null);
|
||||
const [deleting, setDeleting] = useState<SoundtrackRow | null>(null);
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
let single = 0;
|
||||
let composition = 0;
|
||||
for (const s of soundtracks) {
|
||||
if (s.kind === "composition") composition++;
|
||||
else single++;
|
||||
}
|
||||
return { single, composition };
|
||||
}, [soundtracks]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return soundtracks.filter((s) => {
|
||||
if (kindFilter !== "all" && s.kind !== kindFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.author.toLowerCase().includes(q) ||
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
String(s.id).includes(q)
|
||||
);
|
||||
});
|
||||
}, [soundtracks, query, kindFilter]);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) return;
|
||||
const id = deleting.id;
|
||||
run(() => deleteSoundtrack({ id }), {
|
||||
successMessage: `Song disk #${id} deleted.`,
|
||||
errorMessage: `Unable to delete song disk #${id}.`,
|
||||
onSuccess: () => {
|
||||
invalidateSongPickerCache();
|
||||
setDeleting(null);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[220px] max-w-sm">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name, author, code or id…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={kindFilter}
|
||||
onValueChange={(v) => setKindFilter(v as typeof kindFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All ({soundtracks.length})</SelectItem>
|
||||
<SelectItem value="single">
|
||||
Single MP3s ({counts.single})
|
||||
</SelectItem>
|
||||
<SelectItem value="composition">
|
||||
Composition ({counts.composition})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filtered.length} / {soundtracks.length}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{canEdit && (
|
||||
<Button onClick={() => setUploadOpen(true)} disabled={isPending}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Upload MP3
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">ID</TableHead>
|
||||
<TableHead className="w-24">Type</TableHead>
|
||||
<TableHead className="w-40">Code</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead className="w-20">Length</TableHead>
|
||||
<TableHead className="w-20">Used</TableHead>
|
||||
<TableHead className="w-[300px]">Preview / Samples</TableHead>
|
||||
{canEdit && (
|
||||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={canEdit ? 9 : 8}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
{soundtracks.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Music className="h-8 w-8 opacity-50" />
|
||||
<p>No song disks uploaded yet.</p>
|
||||
{canEdit && (
|
||||
<p className="text-xs">
|
||||
Click “Upload MP3” to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>No results for the active filters.</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-mono text-xs">{s.id}</TableCell>
|
||||
<TableCell>
|
||||
{s.kind === "composition" ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Layers className="h-3 w-3" />
|
||||
Trax
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Music className="h-3 w-3" />
|
||||
MP3
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs break-all">
|
||||
{s.code}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{s.author || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{formatLength(s.length)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.usedBy > 0 ? (
|
||||
<Badge variant="secondary">{s.usedBy}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">0</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.kind === "composition" ? (
|
||||
<div className="space-y-1 max-w-[300px]">
|
||||
<TraxPlayer
|
||||
channels={parseTraxChannels(s.track)}
|
||||
baseUrl={baseUrl}
|
||||
expectedLength={s.length}
|
||||
/>
|
||||
<div
|
||||
className="text-[10px] text-muted-foreground font-mono truncate"
|
||||
title={`Sample usati (${s.samples.length}): ${formatSamples(s.samples)}`}
|
||||
>
|
||||
{s.samples.length} sample: {formatSamples(s.samples)}
|
||||
</div>
|
||||
</div>
|
||||
) : playingId === s.id ? (
|
||||
// biome-ignore lint/a11y/useMediaCaption: hotel soundtrack preview, no caption track available
|
||||
<audio
|
||||
controls
|
||||
autoPlay
|
||||
src={`${baseUrl}${encodeURIComponent(s.code)}.mp3`}
|
||||
className="h-8 max-w-[280px]"
|
||||
onEnded={() => setPlayingId(null)}
|
||||
onError={() =>
|
||||
toast.error(
|
||||
`Failed to play "${s.code}.mp3" from ${baseUrl}.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPlayingId(s.id)}
|
||||
>
|
||||
▶ Play
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
{canEdit && (
|
||||
<TableCell className="text-right">
|
||||
<div className="inline-flex gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditing(s)}
|
||||
disabled={isPending}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleting(s)}
|
||||
disabled={isPending}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<UploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
onUploaded={() => {
|
||||
invalidateSongPickerCache();
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEdit && editing && (
|
||||
<EditDialog
|
||||
soundtrack={editing}
|
||||
onOpenChange={(open) => !open && setEditing(null)}
|
||||
onSaved={() => {
|
||||
invalidateSongPickerCache();
|
||||
setEditing(null);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEdit && deleting && (
|
||||
<Dialog open onOpenChange={(open) => !open && setDeleting(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete “{deleting.name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleting.usedBy > 0 ? (
|
||||
<>
|
||||
This song disk is currently used by{" "}
|
||||
<strong>{deleting.usedBy}</strong> catalog item
|
||||
{deleting.usedBy === 1 ? "" : "s"}. Deleting it will reset
|
||||
those catalog items to “no song disk”.
|
||||
{deleting.kind === "single" && (
|
||||
<> The MP3 file will be removed from disk.</>
|
||||
)}{" "}
|
||||
This action cannot be undone.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{deleting.kind === "single"
|
||||
? "The MP3 file will be removed from disk and the row from the database."
|
||||
: "The row will be removed from the database. No file is deleted (a composition has no dedicated MP3)."}{" "}
|
||||
This action cannot be undone.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleting(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Upload dialog ────────────────────────────────────────────────────
|
||||
|
||||
function UploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUploaded,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setAuthor("");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (!file) {
|
||||
toast.error("Select an MP3 file.");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
toast.error("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("name", name.trim());
|
||||
fd.append("author", author.trim());
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/sounds/upload", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
id?: number;
|
||||
};
|
||||
if (!res.ok || !json.ok) {
|
||||
toast.error(json.error || "Upload failed.");
|
||||
return;
|
||||
}
|
||||
toast.success(`Song disk #${json.id} uploaded.`);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
onUploaded();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Network error during upload.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!uploading) onOpenChange(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Song Disk</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload an MP3 file. Max size 10 MB. The code will be generated
|
||||
automatically as <code>song_{id}</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sound-file">MP3 file</Label>
|
||||
<Input
|
||||
id="sound-file"
|
||||
type="file"
|
||||
accept=".mp3,audio/mpeg"
|
||||
ref={fileRef}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sound-name">Name</Label>
|
||||
<Input
|
||||
id="sound-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Track title"
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sound-author">Author</Label>
|
||||
<Input
|
||||
id="sound-author"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="Author (optional)"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={uploading}>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit dialog ──────────────────────────────────────────────────────
|
||||
|
||||
function EditDialog({
|
||||
soundtrack,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
soundtrack: SoundtrackRow;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(soundtrack.name);
|
||||
const [author, setAuthor] = useState(soundtrack.author);
|
||||
const [track, setTrack] = useState(soundtrack.track ?? "");
|
||||
const [length, setLength] = useState(String(soundtrack.length ?? 0));
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
const isComposition = soundtrack.kind === "composition";
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
toast.error("Name is required.");
|
||||
return;
|
||||
}
|
||||
const lenNum = Number.parseInt(length, 10);
|
||||
if (!Number.isFinite(lenNum) || lenNum < 0) {
|
||||
toast.error("Length must be a number ≥ 0.");
|
||||
return;
|
||||
}
|
||||
run(
|
||||
() =>
|
||||
updateSoundtrack({
|
||||
id: soundtrack.id,
|
||||
name: name.trim(),
|
||||
author: author.trim(),
|
||||
track: isComposition ? track : (soundtrack.track ?? ""),
|
||||
length: lenNum,
|
||||
}),
|
||||
{
|
||||
successMessage: "Song disk updated.",
|
||||
errorMessage: "Unable to update the song disk.",
|
||||
onSuccess: onSaved,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onOpenChange(false)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit {isComposition ? "Trax Composition" : "Song Disk"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-mono text-xs">{soundtrack.code}</span>
|
||||
{isComposition ? (
|
||||
<>
|
||||
{" "}
|
||||
— composition of {soundtrack.samples.length} samples. Edit the{" "}
|
||||
<code>track</code> field only if you know what you are doing.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
— the MP3 file cannot be replaced: delete and re-upload if you
|
||||
need to change the audio.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-name">Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-author">Author</Label>
|
||||
<Input
|
||||
id="edit-author"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-length">Length (seconds)</Label>
|
||||
<Input
|
||||
id="edit-length"
|
||||
type="number"
|
||||
min={0}
|
||||
value={length}
|
||||
onChange={(e) => setLength(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isComposition
|
||||
? "Total composition length. Leave 0 if unknown."
|
||||
: "Set automatically from the upload. Edit only if incorrect."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isComposition && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-track">Track (Trax composition)</Label>
|
||||
<Textarea
|
||||
id="edit-track"
|
||||
value={track}
|
||||
onChange={(e) => setTrack(e.target.value)}
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
placeholder="1:371,8;374,4;378,4;0,4;…"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: <code>channel:sample_id,length;sample_id,length;…</code>{" "}
|
||||
— <code>0</code> = silence. Detected sample IDs:{" "}
|
||||
<span className="font-mono">
|
||||
{soundtrack.samples.join(", ") || "—"}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Plays an Arcturus Trax composition by scheduling each sample on the
|
||||
* Web Audio API timeline.
|
||||
*
|
||||
* Format recap (see `@/lib/services/soundtracks`):
|
||||
* channelN:sample_id,ticks;sample_id,ticks;…:channelM:…
|
||||
* Each tick = 2 seconds. Channels play in parallel.
|
||||
*
|
||||
* We fetch each unique `sound_machine_sample_{id}.mp3`, decode it once,
|
||||
* then schedule AudioBufferSourceNode instances at `tickOffset * 2`
|
||||
* seconds relative to the master start time. Samples that end before
|
||||
* their tick slot simply fall silent until the next event — the slot
|
||||
* acts as an upper bound, not a forced loop.
|
||||
*/
|
||||
|
||||
import { Loader2, Play, Square } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TRAX_TICK_SECONDS, type TraxChannel } from "@/lib/trax-format";
|
||||
|
||||
const TICK_SECONDS = TRAX_TICK_SECONDS;
|
||||
|
||||
// Module-level registry so only one TraxPlayer plays at a time.
|
||||
// When a new player starts, the previous one's stop() is invoked.
|
||||
// `activeOwner` identifies the player via a stable object ref so each
|
||||
// instance can tell "am I the current one?" without closing over callbacks.
|
||||
let activeOwner: object | null = null;
|
||||
let activeStop: (() => void) | null = null;
|
||||
|
||||
interface TraxPlayerProps {
|
||||
channels: TraxChannel[];
|
||||
/** URL prefix to prepend to `sound_machine_sample_{id}.mp3`. */
|
||||
baseUrl: string;
|
||||
/** Expected total length in seconds (used for the progress bar). */
|
||||
expectedLength: number;
|
||||
}
|
||||
|
||||
type Status = "idle" | "loading" | "ready" | "playing" | "error";
|
||||
|
||||
export function TraxPlayer({
|
||||
channels,
|
||||
baseUrl,
|
||||
expectedLength,
|
||||
}: TraxPlayerProps) {
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [loaded, setLoaded] = useState(0);
|
||||
const [totalSamples, setTotalSamples] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const ctxRef = useRef<AudioContext | null>(null);
|
||||
const buffersRef = useRef<Map<number, AudioBuffer>>(new Map());
|
||||
const sourcesRef = useRef<AudioBufferSourceNode[]>([]);
|
||||
const startTimeRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// Unique sample IDs referenced by the track (skip silence).
|
||||
const uniqueIds = Array.from(
|
||||
new Set(channels.flatMap((c) => c.events.map((e) => e.sampleId))),
|
||||
).filter((id) => id > 0);
|
||||
|
||||
// Stable self-marker used to identify this instance in the module registry.
|
||||
const selfRef = useRef<object>({});
|
||||
|
||||
const stop = useCallback(() => {
|
||||
for (const src of sourcesRef.current) {
|
||||
try {
|
||||
src.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
sourcesRef.current = [];
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStatus((prev) => (prev === "playing" ? "ready" : prev));
|
||||
setProgress(0);
|
||||
if (activeOwner === selfRef.current) {
|
||||
activeOwner = null;
|
||||
activeStop = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop();
|
||||
ctxRef.current?.close();
|
||||
ctxRef.current = null;
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
async function loadBuffers(): Promise<void> {
|
||||
setStatus("loading");
|
||||
setTotalSamples(uniqueIds.length);
|
||||
setLoaded(0);
|
||||
|
||||
const AudioCtor = window.AudioContext;
|
||||
if (!AudioCtor) {
|
||||
toast.error("Web Audio API is not supported by this browser.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
const ctx = ctxRef.current ?? new AudioCtor();
|
||||
ctxRef.current = ctx;
|
||||
|
||||
const buffers = buffersRef.current;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
||||
await Promise.all(
|
||||
uniqueIds.map(async (id) => {
|
||||
if (buffers.has(id)) {
|
||||
done++;
|
||||
setLoaded(done);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = `${baseUrl}sound_machine_sample_${id}.mp3`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const arr = await res.arrayBuffer();
|
||||
const buf = await ctx.decodeAudioData(arr);
|
||||
buffers.set(id, buf);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn(`[trax] failed to load sample ${id}:`, err);
|
||||
} finally {
|
||||
done++;
|
||||
setLoaded(done);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (failed > 0) {
|
||||
toast.warning(
|
||||
`${failed}/${uniqueIds.length} samples failed to load — playback may have gaps.`,
|
||||
);
|
||||
}
|
||||
if (failed === uniqueIds.length) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("ready");
|
||||
}
|
||||
|
||||
async function play() {
|
||||
// Stop any other TraxPlayer currently playing.
|
||||
if (activeOwner && activeOwner !== selfRef.current) {
|
||||
activeStop?.();
|
||||
}
|
||||
|
||||
if (status !== "ready") {
|
||||
await loadBuffers();
|
||||
}
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
if (ctx.state === "suspended") await ctx.resume();
|
||||
|
||||
stop(); // just in case
|
||||
const buffers = buffersRef.current;
|
||||
const start = ctx.currentTime + 0.05; // tiny head-start
|
||||
startTimeRef.current = start;
|
||||
const scheduled: AudioBufferSourceNode[] = [];
|
||||
|
||||
for (const ch of channels) {
|
||||
let t = 0; // ticks from channel start
|
||||
for (const ev of ch.events) {
|
||||
if (ev.sampleId > 0) {
|
||||
const buf = buffers.get(ev.sampleId);
|
||||
if (buf) {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
const when = start + t * TICK_SECONDS;
|
||||
const maxDur = ev.ticks * TICK_SECONDS;
|
||||
src.start(when, 0, Math.min(buf.duration, maxDur));
|
||||
scheduled.push(src);
|
||||
}
|
||||
}
|
||||
t += ev.ticks;
|
||||
}
|
||||
}
|
||||
sourcesRef.current = scheduled;
|
||||
setStatus("playing");
|
||||
activeOwner = selfRef.current;
|
||||
activeStop = stop;
|
||||
|
||||
// Progress tick.
|
||||
const totalSec = Math.max(
|
||||
expectedLength,
|
||||
...channels.map(
|
||||
(c) => c.events.reduce((s, e) => s + e.ticks, 0) * TICK_SECONDS,
|
||||
),
|
||||
);
|
||||
const tick = () => {
|
||||
const elapsed = ctxRef.current
|
||||
? ctxRef.current.currentTime - startTimeRef.current
|
||||
: 0;
|
||||
const pct = totalSec > 0 ? Math.min(100, (elapsed / totalSec) * 100) : 0;
|
||||
setProgress(pct);
|
||||
if (elapsed >= totalSec) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
const isBusy = status === "loading";
|
||||
const isPlaying = status === "playing";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isPlaying ? (
|
||||
<Button size="sm" variant="outline" onClick={stop}>
|
||||
<Square className="h-3 w-3 mr-1 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={play}
|
||||
disabled={isBusy || uniqueIds.length === 0}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{loaded}/{totalSamples}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-3 w-3 mr-1 fill-current" />
|
||||
Play Trax
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isPlaying && (
|
||||
<div className="flex-1 h-2 rounded bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-[width] duration-100"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user