style: format code with prettier
Local Build and Deploy / deploy (push) Successful in 49s

This commit is contained in:
openhands committed 2026-07-12 21:07:34 +02:00
1 parent e85e4d74ea
commit 2e4ed76121
378 files changed
+22404 -20686

No files matched your search

+24 -24
View File
@@ -1,22 +1,22 @@
import { redirect } from 'next/navigation'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { siteSettings } from '@/lib/services/site-settings'
import { DEFAULT_SOUNDTRACK_BASE_URL, parseTrackSamples } from '@/lib/services/soundtracks'
import { SoundsClient } from './sounds-client'
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
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()
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect('/admin')
redirect("/admin");
}
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
const [rows, usage, baseUrlSetting] = await Promise.all([
prisma.soundtracks.findMany({
orderBy: { id: 'asc' },
orderBy: { id: "asc" },
select: {
id: true,
code: true,
@@ -27,20 +27,20 @@ export default async function SoundsPage() {
},
}),
prisma.catalogItems.groupBy({
by: ['songId'],
by: ["songId"],
where: { songId: { gt: 0 } },
_count: { _all: true },
}),
siteSettings.get('soundtrack_base_url', DEFAULT_SOUNDTRACK_BASE_URL),
])
siteSettings.get("soundtrack_base_url", DEFAULT_SOUNDTRACK_BASE_URL),
]);
const usageMap: Record<number, number> = {}
const usageMap: Record<number, number> = {};
for (const row of usage) {
if (row.songId != null) usageMap[row.songId] = row._count._all
if (row.songId != null) usageMap[row.songId] = row._count._all;
}
const soundtracks = rows.map((r) => {
const samples = parseTrackSamples(r.track)
const samples = parseTrackSamples(r.track);
return {
id: r.id,
code: r.code,
@@ -49,22 +49,22 @@ export default async function SoundsPage() {
length: r.length,
track: r.track,
samples,
kind: (samples.length > 0 ? 'composition' : 'single') as 'composition' | 'single',
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}/`
const configuredBaseUrl = baseUrlSetting ?? "/sounds/";
const baseUrl = configuredBaseUrl.endsWith("/") ? configuredBaseUrl : `${configuredBaseUrl}/`;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Song Disks</h1>
<p className="text-muted-foreground">
Carica e gestisci i file MP3 dei song disk riprodotti dai jukebox in-game. I file sono
serviti da <code>{baseUrl}</code> (configurabile in{' '}
Carica e gestisci i file MP3 dei song disk riprodotti dai jukebox in-game. I file sono serviti da{" "}
<code>{baseUrl}</code> (configurabile in{" "}
<a href="/admin/settings/cms" className="underline">
Impostazioni &gt; CMS
</a>
@@ -73,5 +73,5 @@ export default async function SoundsPage() {
</div>
<SoundsClient soundtracks={soundtracks} canEdit={canEdit} baseUrl={baseUrl} />
</div>
)
);
}
+151 -184
View File
@@ -1,12 +1,12 @@
'use client'
"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 { 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,
@@ -14,53 +14,40 @@ import {
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 { invalidateSongPickerCache } from '@/lib/client-cache/song-picker-cache'
import { parseTraxChannels } from '@/lib/trax-format'
import { TraxPlayer } from './trax-player'
} 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 { 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
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')}`
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})`
if (samples.length === 0) return "—";
if (samples.length <= 20) return samples.join(", ");
return `${samples.slice(0, 20).join(", ")}, …(+${samples.length - 20})`;
}
export function SoundsClient({
@@ -68,56 +55,56 @@ export function SoundsClient({
canEdit,
baseUrl,
}: {
soundtracks: SoundtrackRow[]
canEdit: boolean
baseUrl: string
soundtracks: SoundtrackRow[];
canEdit: boolean;
baseUrl: string;
}) {
const router = useRouter()
const { run, isPending } = useServerAction()
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 [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
let single = 0;
let composition = 0;
for (const s of soundtracks) {
if (s.kind === 'composition') composition++
else single++
if (s.kind === "composition") composition++;
else single++;
}
return { single, composition }
}, [soundtracks])
return { single, composition };
}, [soundtracks]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
const q = query.trim().toLowerCase();
return soundtracks.filter((s) => {
if (kindFilter !== 'all' && s.kind !== kindFilter) return false
if (!q) return true
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])
);
});
}, [soundtracks, query, kindFilter]);
function handleDelete() {
if (!deleting) return
const id = deleting.id
if (!deleting) return;
const id = deleting.id;
run(() => deleteSoundtrack({ id }), {
successMessage: `Song disk #${id} eliminato.`,
errorMessage: `Impossibile eliminare il song disk #${id}.`,
onSuccess: () => {
invalidateSongPickerCache()
setDeleting(null)
router.refresh()
invalidateSongPickerCache();
setDeleting(null);
router.refresh();
},
})
});
}
return (
@@ -172,17 +159,12 @@ export function SoundsClient({
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={canEdit ? 9 : 8}
className="text-center text-muted-foreground py-8"
>
<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>Nessun song disk caricato.</p>
{canEdit && (
<p className="text-xs">Clicca &ldquo;Upload MP3&rdquo; per iniziare.</p>
)}
{canEdit && <p className="text-xs">Clicca &ldquo;Upload MP3&rdquo; per iniziare.</p>}
</div>
) : (
<>Nessun risultato per i filtri attivi.</>
@@ -194,7 +176,7 @@ export function SoundsClient({
<TableRow key={s.id}>
<TableCell className="font-mono text-xs">{s.id}</TableCell>
<TableCell>
{s.kind === 'composition' ? (
{s.kind === "composition" ? (
<Badge variant="outline" className="gap-1">
<Layers className="h-3 w-3" />
Trax
@@ -208,7 +190,7 @@ export function SoundsClient({
</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="text-muted-foreground">{s.author || "—"}</TableCell>
<TableCell className="font-mono text-xs">{formatLength(s.length)}</TableCell>
<TableCell>
{s.usedBy > 0 ? (
@@ -218,7 +200,7 @@ export function SoundsClient({
)}
</TableCell>
<TableCell>
{s.kind === 'composition' ? (
{s.kind === "composition" ? (
<div className="space-y-1 max-w-[300px]">
<TraxPlayer
channels={parseTraxChannels(s.track)}
@@ -240,9 +222,7 @@ export function SoundsClient({
src={`${baseUrl}${encodeURIComponent(s.code)}.mp3`}
className="h-8 max-w-[280px]"
onEnded={() => setPlayingId(null)}
onError={() =>
toast.error(`Impossibile riprodurre "${s.code}.mp3" da ${baseUrl}.`)
}
onError={() => toast.error(`Impossibile riprodurre "${s.code}.mp3" da ${baseUrl}.`)}
/>
) : (
<Button size="sm" variant="ghost" onClick={() => setPlayingId(s.id)}>
@@ -288,8 +268,8 @@ export function SoundsClient({
open={uploadOpen}
onOpenChange={setUploadOpen}
onUploaded={() => {
invalidateSongPickerCache()
router.refresh()
invalidateSongPickerCache();
router.refresh();
}}
/>
)}
@@ -299,9 +279,9 @@ export function SoundsClient({
soundtrack={editing}
onOpenChange={(open) => !open && setEditing(null)}
onSaved={() => {
invalidateSongPickerCache()
setEditing(null)
router.refresh()
invalidateSongPickerCache();
setEditing(null);
router.refresh();
}}
/>
)}
@@ -314,17 +294,17 @@ export function SoundsClient({
<DialogDescription>
{deleting.usedBy > 0 ? (
<>
Questo song disk è attualmente usato da <strong>{deleting.usedBy}</strong>{' '}
catalog item{deleting.usedBy === 1 ? '' : 's'}. Eliminandolo, quei catalog item
verranno riportati a &ldquo;nessun song disk&rdquo;.
{deleting.kind === 'single' && <> Il file MP3 sarà rimosso dal disco.</>}{' '}
L&apos;azione è irreversibile.
Questo song disk è attualmente usato da <strong>{deleting.usedBy}</strong> catalog item
{deleting.usedBy === 1 ? "" : "s"}. Eliminandolo, quei catalog item verranno riportati a
&ldquo;nessun song disk&rdquo;.
{deleting.kind === "single" && <> Il file MP3 sarà rimosso dal disco.</>} L&apos;azione è
irreversibile.
</>
) : (
<>
{deleting.kind === 'single'
? 'Il file MP3 sarà rimosso dal disco e la riga dal database.'
: 'La riga sarà rimossa dal database. Nessun file viene eliminato (una composition non ha MP3 dedicato).'}{' '}
{deleting.kind === "single"
? "Il file MP3 sarà rimosso dal disco e la riga dal database."
: "La riga sarà rimossa dal database. Nessun file viene eliminato (una composition non ha MP3 dedicato)."}{" "}
L&apos;azione è irreversibile.
</>
)}
@@ -343,7 +323,7 @@ export function SoundsClient({
</Dialog>
)}
</div>
)
);
}
// ── Upload dialog ────────────────────────────────────────────────────
@@ -353,55 +333,55 @@ function UploadDialog({
onOpenChange,
onUploaded,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onUploaded: () => void
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)
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 = ''
setName("");
setAuthor("");
if (fileRef.current) fileRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const file = fileRef.current?.files?.[0]
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) {
toast.error('Seleziona un file MP3.')
return
toast.error("Seleziona un file MP3.");
return;
}
if (!name.trim()) {
toast.error('Il nome è obbligatorio.')
return
toast.error("Il nome è obbligatorio.");
return;
}
setUploading(true)
const fd = new FormData()
fd.append('file', file)
fd.append('name', name.trim())
fd.append('author', author.trim())
setUploading(true);
const fd = new FormData();
fd.append("file", file);
fd.append("name", name.trim());
fd.append("author", author.trim());
try {
const res = await fetch('/api/admin/sounds/upload', { method: 'POST', body: fd })
const json = (await res.json()) as { ok?: boolean; error?: string; id?: number }
const res = await fetch("/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 fallito.')
return
toast.error(json.error || "Upload fallito.");
return;
}
toast.success(`Song disk #${json.id} caricato.`)
reset()
onOpenChange(false)
onUploaded()
toast.success(`Song disk #${json.id} caricato.`);
reset();
onOpenChange(false);
onUploaded();
} catch (err) {
console.error(err)
toast.error("Errore di rete durante l'upload.")
console.error(err);
toast.error("Errore di rete durante l'upload.");
} finally {
setUploading(false)
setUploading(false);
}
}
@@ -409,15 +389,15 @@ function UploadDialog({
<Dialog
open={open}
onOpenChange={(o) => {
if (!uploading) onOpenChange(o)
if (!uploading) onOpenChange(o);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Song Disk</DialogTitle>
<DialogDescription>
Carica un file MP3. Dimensione massima 10 MB. Il codice sarà generato automaticamente
come <code>song_&#123;id&#125;</code>.
Carica un file MP3. Dimensione massima 10 MB. Il codice sarà generato automaticamente come{" "}
<code>song_&#123;id&#125;</code>.
</DialogDescription>
</DialogHeader>
@@ -449,12 +429,7 @@ function UploadDialog({
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={uploading}
>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={uploading}>
Annulla
</Button>
<Button type="submit" disabled={uploading}>
@@ -469,7 +444,7 @@ function UploadDialog({
</form>
</DialogContent>
</Dialog>
)
);
}
// ── Edit dialog ──────────────────────────────────────────────────────
@@ -479,28 +454,28 @@ function EditDialog({
onOpenChange,
onSaved,
}: {
soundtrack: SoundtrackRow
onOpenChange: (open: boolean) => void
onSaved: () => void
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 [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'
const isComposition = soundtrack.kind === "composition";
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
e.preventDefault();
if (!name.trim()) {
toast.error('Il nome è obbligatorio.')
return
toast.error("Il nome è obbligatorio.");
return;
}
const lenNum = Number.parseInt(length, 10)
const lenNum = Number.parseInt(length, 10);
if (!Number.isFinite(lenNum) || lenNum < 0) {
toast.error('La durata deve essere un numero ≥ 0.')
return
toast.error("La durata deve essere un numero ≥ 0.");
return;
}
run(
() =>
@@ -508,35 +483,32 @@ function EditDialog({
id: soundtrack.id,
name: name.trim(),
author: author.trim(),
track: isComposition ? track : (soundtrack.track ?? ''),
track: isComposition ? track : (soundtrack.track ?? ""),
length: lenNum,
}),
{
successMessage: 'Song disk aggiornato.',
errorMessage: 'Impossibile aggiornare il song disk.',
successMessage: "Song disk aggiornato.",
errorMessage: "Impossibile aggiornare il song disk.",
onSuccess: onSaved,
},
)
);
}
return (
<Dialog open onOpenChange={(o) => !o && onOpenChange(false)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Modifica {isComposition ? 'Trax Composition' : 'Song Disk'}</DialogTitle>
<DialogTitle>Modifica {isComposition ? "Trax Composition" : "Song Disk"}</DialogTitle>
<DialogDescription>
<span className="font-mono text-xs">{soundtrack.code}</span>
{isComposition ? (
<>
{' '}
— composition di {soundtrack.samples.length} sample. Modifica il campo{' '}
<code>track</code> solo se sai cosa stai facendo.
{" "}
— composition di {soundtrack.samples.length} sample. Modifica il campo <code>track</code> solo
se sai cosa stai facendo.
</>
) : (
<>
{' '}
— il file MP3 non può essere sostituito: elimina e ricarica se devi cambiare audio.
</>
<> — il file MP3 non può essere sostituito: elimina e ricarica se devi cambiare audio.</>
)}
</DialogDescription>
</DialogHeader>
@@ -572,8 +544,8 @@ function EditDialog({
/>
<p className="text-xs text-muted-foreground">
{isComposition
? 'Durata totale della composition. Lascia 0 se sconosciuta.'
: 'Impostato automaticamente dall’upload. Modifica solo se errato.'}
? "Durata totale della composition. Lascia 0 se sconosciuta."
: "Impostato automaticamente dall’upload. Modifica solo se errato."}
</p>
</div>
@@ -589,20 +561,15 @@ function EditDialog({
placeholder="1:371,8;374,4;378,4;0,4;…"
/>
<p className="text-xs text-muted-foreground">
Formato: <code>canale:sample_id,durata;sample_id,durata;…</code> — <code>0</code> =
silenzio. Sample IDs rilevati:{' '}
<span className="font-mono">{soundtrack.samples.join(', ') || '—'}</span>.
Formato: <code>canale:sample_id,durata;sample_id,durata;…</code> — <code>0</code> = silenzio.
Sample IDs rilevati: <span className="font-mono">{soundtrack.samples.join(", ") || "—"}</span>
.
</p>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Annulla
</Button>
<Button type="submit" disabled={isPending}>
@@ -613,5 +580,5 @@ function EditDialog({
</form>
</DialogContent>
</Dialog>
)
);
}
+107 -107
View File
@@ -1,4 +1,4 @@
'use client'
"use client";
/**
* Plays an Arcturus Trax composition by scheduling each sample on the
@@ -15,196 +15,196 @@
* 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'
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
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
let activeOwner: object | null = null;
let activeStop: (() => void) | null = null;
interface TraxPlayerProps {
channels: TraxChannel[]
channels: TraxChannel[];
/** URL prefix to prepend to `sound_machine_sample_{id}.mp3`. */
baseUrl: string
baseUrl: string;
/** Expected total length in seconds (used for the progress bar). */
expectedLength: number
expectedLength: number;
}
type Status = 'idle' | 'loading' | 'ready' | 'playing' | 'error'
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 [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)
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)
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 selfRef = useRef<object>({});
const stop = useCallback(() => {
for (const src of sourcesRef.current) {
try {
src.stop()
src.stop();
} catch {
/* already stopped */
}
}
sourcesRef.current = []
sourcesRef.current = [];
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
setStatus((prev) => (prev === 'playing' ? 'ready' : prev))
setProgress(0)
setStatus((prev) => (prev === "playing" ? "ready" : prev));
setProgress(0);
if (activeOwner === selfRef.current) {
activeOwner = null
activeStop = null
activeOwner = null;
activeStop = null;
}
}, [])
}, []);
useEffect(() => {
return () => {
stop()
ctxRef.current?.close()
ctxRef.current = null
}
}, [stop])
stop();
ctxRef.current?.close();
ctxRef.current = null;
};
}, [stop]);
async function loadBuffers(): Promise<void> {
setStatus('loading')
setTotalSamples(uniqueIds.length)
setLoaded(0)
setStatus("loading");
setTotalSamples(uniqueIds.length);
setLoaded(0);
const AudioCtor = window.AudioContext
const AudioCtor = window.AudioContext;
if (!AudioCtor) {
toast.error('Web Audio API non supportata da questo browser.')
setStatus('error')
return
toast.error("Web Audio API non supportata da questo browser.");
setStatus("error");
return;
}
const ctx = ctxRef.current ?? new AudioCtor()
ctxRef.current = ctx
const ctx = ctxRef.current ?? new AudioCtor();
ctxRef.current = ctx;
const buffers = buffersRef.current
let done = 0
let failed = 0
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
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)
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)
failed++;
console.warn(`[trax] failed to load sample ${id}:`, err);
} finally {
done++
setLoaded(done)
done++;
setLoaded(done);
}
}),
)
);
if (failed > 0) {
toast.warning(
`${failed}/${uniqueIds.length} sample non caricati — la riproduzione potrebbe avere buchi.`,
)
);
}
if (failed === uniqueIds.length) {
setStatus('error')
return
setStatus("error");
return;
}
setStatus('ready')
setStatus("ready");
}
async function play() {
// Stop any other TraxPlayer currently playing.
if (activeOwner && activeOwner !== selfRef.current) {
activeStop?.()
activeStop?.();
}
if (status !== 'ready') {
await loadBuffers()
if (status !== "ready") {
await loadBuffers();
}
const ctx = ctxRef.current
if (!ctx) return
if (ctx.state === 'suspended') await ctx.resume()
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[] = []
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
let t = 0; // ticks from channel start
for (const ev of ch.events) {
if (ev.sampleId > 0) {
const buf = buffers.get(ev.sampleId)
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)
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
t += ev.ticks;
}
}
sourcesRef.current = scheduled
setStatus('playing')
activeOwner = selfRef.current
activeStop = stop
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)
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
stop();
return;
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}
const isBusy = status === 'loading'
const isPlaying = status === 'playing'
const isBusy = status === "loading";
const isPlaying = status === "playing";
return (
<div className="flex items-center gap-2 w-full">
@@ -237,5 +237,5 @@ export function TraxPlayer({ channels, baseUrl, expectedLength }: TraxPlayerProp
</div>
)}
</div>
)
);
}