This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -2,76 +2,88 @@ 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 {
|
||||
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");
|
||||
}
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
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" },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
author: true,
|
||||
length: true,
|
||||
track: true,
|
||||
},
|
||||
}),
|
||||
prisma.catalogItems.groupBy({
|
||||
by: ["songId"],
|
||||
where: { songId: { gt: 0 } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
siteSettings.get("soundtrack_base_url", DEFAULT_SOUNDTRACK_BASE_URL),
|
||||
]);
|
||||
const [rows, usage, baseUrlSetting] = await Promise.all([
|
||||
prisma.soundtracks.findMany({
|
||||
orderBy: { id: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
author: true,
|
||||
length: true,
|
||||
track: true,
|
||||
},
|
||||
}),
|
||||
prisma.catalogItems.groupBy({
|
||||
by: ["songId"],
|
||||
where: { songId: { gt: 0 } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
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] = row._count._all;
|
||||
}
|
||||
const usageMap: Record<number, number> = {};
|
||||
for (const row of usage) {
|
||||
if (row.songId != null) usageMap[row.songId] = row._count._all;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
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}/`;
|
||||
// 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">
|
||||
<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{" "}
|
||||
<a href="/admin/settings/cms" className="underline">
|
||||
Impostazioni > CMS
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
</div>
|
||||
<SoundsClient soundtracks={soundtracks} canEdit={canEdit} baseUrl={baseUrl} />
|
||||
</div>
|
||||
);
|
||||
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{" "}
|
||||
<a href="/admin/settings/cms" className="underline">
|
||||
Impostazioni > CMS
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
</div>
|
||||
<SoundsClient
|
||||
soundtracks={soundtracks}
|
||||
canEdit={canEdit}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,211 +31,223 @@ 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;
|
||||
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);
|
||||
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);
|
||||
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,
|
||||
);
|
||||
// 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>({});
|
||||
// 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;
|
||||
}
|
||||
}, []);
|
||||
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]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stop();
|
||||
ctxRef.current?.close();
|
||||
ctxRef.current = null;
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
async function loadBuffers(): Promise<void> {
|
||||
setStatus("loading");
|
||||
setTotalSamples(uniqueIds.length);
|
||||
setLoaded(0);
|
||||
async function loadBuffers(): Promise<void> {
|
||||
setStatus("loading");
|
||||
setTotalSamples(uniqueIds.length);
|
||||
setLoaded(0);
|
||||
|
||||
const AudioCtor = window.AudioContext;
|
||||
if (!AudioCtor) {
|
||||
toast.error("Web Audio API non supportata da questo browser.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
const ctx = ctxRef.current ?? new AudioCtor();
|
||||
ctxRef.current = ctx;
|
||||
const AudioCtor = window.AudioContext;
|
||||
if (!AudioCtor) {
|
||||
toast.error("Web Audio API non supportata da questo browser.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
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} sample non caricati — la riproduzione potrebbe avere buchi.`,
|
||||
);
|
||||
}
|
||||
if (failed === uniqueIds.length) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("ready");
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.warning(
|
||||
`${failed}/${uniqueIds.length} sample non caricati — la riproduzione potrebbe avere buchi.`,
|
||||
);
|
||||
}
|
||||
if (failed === uniqueIds.length) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setStatus("ready");
|
||||
}
|
||||
|
||||
async function play() {
|
||||
// Stop any other TraxPlayer currently playing.
|
||||
if (activeOwner && activeOwner !== selfRef.current) {
|
||||
activeStop?.();
|
||||
}
|
||||
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();
|
||||
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[] = [];
|
||||
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;
|
||||
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);
|
||||
}
|
||||
// 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";
|
||||
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>
|
||||
);
|
||||
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