feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
1 parent c9e35cf602
commit 2b8f73a91d
457 files changed
+589 -55936

No files matched your search

-88
View File
@@ -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>
);
}
-682
View File
@@ -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 &ldquo;Upload MP3&rdquo; 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 &ldquo;{deleting.name}&rdquo;?</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 &ldquo;no song disk&rdquo;.
{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_&#123;id&#125;</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>
);
}
-253
View File
@@ -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>
);
}