@rydr/game-sdk 3.11.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/client/PlatformClient.d.ts +17 -4
- package/dist/client/PlatformClient.d.ts.map +1 -1
- package/dist/client/PlatformClient.js +1 -1
- package/dist/client/PlatformClient.js.map +1 -1
- package/dist/conversations/voice-samples.d.ts.map +1 -1
- package/dist/conversations/voice-samples.js +183 -2
- package/dist/conversations/voice-samples.js.map +1 -1
- package/dist/conversations/voices.d.ts.map +1 -1
- package/dist/conversations/voices.js +5 -3
- package/dist/conversations/voices.js.map +1 -1
- package/dist/host/PlatformHost.d.ts +4 -2
- package/dist/host/PlatformHost.d.ts.map +1 -1
- package/dist/host/PlatformHost.js +1 -1
- package/dist/host/PlatformHost.js.map +1 -1
- package/dist/protocol/boards.d.ts +4 -0
- package/dist/protocol/boards.d.ts.map +1 -1
- package/dist/protocol/boards.js.map +1 -1
- package/dist/protocol/conversations.d.ts +11 -0
- package/dist/protocol/conversations.d.ts.map +1 -1
- package/dist/protocol/conversations.js +14 -0
- package/dist/protocol/conversations.js.map +1 -1
- package/dist/protocol/messages.d.ts +7 -1
- package/dist/protocol/messages.d.ts.map +1 -1
- package/dist/protocol/version.d.ts +2 -2
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +7 -2
- package/dist/protocol/version.js.map +1 -1
- package/dist/sound-gallery/index.d.ts +41 -0
- package/dist/sound-gallery/index.d.ts.map +1 -0
- package/dist/sound-gallery/index.js +1264 -0
- package/dist/sound-gallery/index.js.map +1 -0
- package/dist/soundboard-editor/index.d.ts +44 -0
- package/dist/soundboard-editor/index.d.ts.map +1 -0
- package/dist/soundboard-editor/index.js +253 -0
- package/dist/soundboard-editor/index.js.map +1 -0
- package/dist/sounds/audio-utils.d.ts +16 -0
- package/dist/sounds/audio-utils.d.ts.map +1 -0
- package/dist/sounds/audio-utils.js +150 -0
- package/dist/sounds/audio-utils.js.map +1 -0
- package/dist/sounds/index.d.ts +25 -0
- package/dist/sounds/index.d.ts.map +1 -0
- package/dist/sounds/index.js +22 -0
- package/dist/sounds/index.js.map +1 -0
- package/dist/sounds/sound-bank.d.ts +73 -0
- package/dist/sounds/sound-bank.d.ts.map +1 -0
- package/dist/sounds/sound-bank.js +321 -0
- package/dist/sounds/sound-bank.js.map +1 -0
- package/dist/sounds/soundboard.d.ts +121 -0
- package/dist/sounds/soundboard.d.ts.map +1 -0
- package/dist/sounds/soundboard.js +154 -0
- package/dist/sounds/soundboard.js.map +1 -0
- package/dist/sounds/types.d.ts +88 -0
- package/dist/sounds/types.d.ts.map +1 -0
- package/dist/sounds/types.js +8 -0
- package/dist/sounds/types.js.map +1 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +2 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/power-race.d.ts +2 -2
- package/dist/ui/power-race.d.ts.map +1 -1
- package/dist/ui/power-race.js +8 -72
- package/dist/ui/power-race.js.map +1 -1
- package/dist/ui/showcase/index.d.ts.map +1 -1
- package/dist/ui/showcase/index.js +42 -2
- package/dist/ui/showcase/index.js.map +1 -1
- package/dist/ui/solo-labeled-diamond.d.ts +69 -0
- package/dist/ui/solo-labeled-diamond.d.ts.map +1 -0
- package/dist/ui/solo-labeled-diamond.js +168 -0
- package/dist/ui/solo-labeled-diamond.js.map +1 -0
- package/dist/ui/styles.js +11 -2
- package/dist/ui/styles.js.map +1 -1
- package/dist/voice-gallery/index.d.ts.map +1 -1
- package/dist/voice-gallery/index.js +132 -12
- package/dist/voice-gallery/index.js.map +1 -1
- package/dist/voiceover-editor/index.d.ts +6 -0
- package/dist/voiceover-editor/index.d.ts.map +1 -1
- package/dist/voiceover-editor/index.js +222 -14
- package/dist/voiceover-editor/index.js.map +1 -1
- package/package.json +16 -1
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@rydr/game-sdk/sound-gallery` — the admin Sound Library editor.
|
|
3
|
+
*
|
|
4
|
+
* Upload SFX/music to R2, name + label + folder + tag them, **crop** them (waveform + draggable
|
|
5
|
+
* handles → OfflineAudioContext render → MP3 re-upload), preview with an advancing playhead, and
|
|
6
|
+
* **Publish** the catalog to the game-facing `sounds/library.json`. Every clip is stored as MP3
|
|
7
|
+
* (re-encoded at ingest and crop). Games then play by stable key with `@rydr/game-sdk/sounds`.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `@rydr/game-sdk/voice-gallery`: a self-contained module that injects its own scoped
|
|
10
|
+
* `.rydr-sg-*` CSS and renders against a narrow {@link SoundGallerySession} (the platform's
|
|
11
|
+
* `PlatformSoundSession` satisfies it). Curation is admin-only; a non-admin session sees a read-only
|
|
12
|
+
* browser.
|
|
13
|
+
*/
|
|
14
|
+
import { SOUND_CATEGORIES } from "../sounds/types.js";
|
|
15
|
+
import { computePeaks, cropBuffer, decodeAudio, encodeMp3, peakDb } from "../sounds/audio-utils.js";
|
|
16
|
+
const CSS = `
|
|
17
|
+
.rydr-sg{position:fixed;inset:0;display:flex;flex-direction:column;font:14px/1.5 system-ui,sans-serif;color:#e7e9ee;background:#0d0f14}
|
|
18
|
+
.rydr-sg *{box-sizing:border-box}
|
|
19
|
+
.rydr-sg-head{display:flex;align-items:center;gap:14px;padding:12px 18px;background:#0d0f14;border-bottom:1px solid #232733}
|
|
20
|
+
.rydr-sg-head h1{font-size:15px;margin:0;font-weight:600}
|
|
21
|
+
.rydr-sg-head .sub{color:#8b93a7;font-size:12px}
|
|
22
|
+
.rydr-sg-actions{margin-left:auto;display:flex;align-items:center;gap:8px}
|
|
23
|
+
.rydr-sg-search{padding:7px 11px;border-radius:8px;border:1px solid #2a3242;background:#12151d;color:#e7e9ee;font:inherit;min-width:180px}
|
|
24
|
+
.rydr-sg-btn{padding:8px 13px;border-radius:8px;border:1px solid #31405f;background:#1b2130;color:#fff;cursor:pointer;font:inherit;white-space:nowrap}
|
|
25
|
+
.rydr-sg-btn:disabled{opacity:.5;cursor:default}
|
|
26
|
+
.rydr-sg-btn.primary{background:#2b5fd6;border-color:#3f74ee}
|
|
27
|
+
.rydr-sg-btn.danger{border-color:#5c2436;background:#2a1119;color:#e2718f}
|
|
28
|
+
.rydr-sg-body{flex:1;display:flex;min-height:0}
|
|
29
|
+
.rydr-sg-side{width:300px;border-right:1px solid #232733;overflow:auto;padding:8px 0}
|
|
30
|
+
.rydr-sg-folder{padding:8px 16px 4px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#8b93a7}
|
|
31
|
+
.rydr-sg-tnode{display:flex;align-items:center;gap:6px;padding:6px 12px;cursor:pointer;color:#c4cbd8;user-select:none}
|
|
32
|
+
.rydr-sg-tnode:hover{background:#11141b}
|
|
33
|
+
.rydr-sg-tnode .chev{width:10px;font-size:9px;color:#8b93a7;flex:none;text-align:center}
|
|
34
|
+
.rydr-sg-tnode .tlabel{font-size:12px;font-weight:600}
|
|
35
|
+
.rydr-sg-tnode.cat .tlabel{color:#e7e9ee;font-weight:700;text-transform:uppercase;letter-spacing:.05em;font-size:11px}
|
|
36
|
+
.rydr-sg-tnode .tcount{margin-left:auto;font-size:10px;color:#6b7385}
|
|
37
|
+
.rydr-sg-item{display:flex;align-items:center;gap:8px;padding:8px 16px;cursor:pointer;border-left:2px solid transparent}
|
|
38
|
+
.rydr-sg-item:hover{background:#11141b}
|
|
39
|
+
.rydr-sg-item.sel{background:#16233c;border-left-color:#3f74ee}
|
|
40
|
+
.rydr-sg-item .k{font-weight:600}
|
|
41
|
+
.rydr-sg-item .c{margin-left:auto;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#8b93a7;border:1px solid #2a3242;border-radius:999px;padding:1px 7px}
|
|
42
|
+
.rydr-sg-empty{padding:24px 16px;color:#8b93a7}
|
|
43
|
+
.rydr-sg-detail{flex:1;overflow:auto;padding:20px 24px}
|
|
44
|
+
.rydr-sg-detail.blank{display:flex;align-items:center;justify-content:center;color:#8b93a7}
|
|
45
|
+
.rydr-sg-row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:14px}
|
|
46
|
+
.rydr-sg-field{display:flex;flex-direction:column;gap:4px}
|
|
47
|
+
.rydr-sg-field label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b93a7}
|
|
48
|
+
.rydr-sg-field input,.rydr-sg-field select{padding:7px 10px;border-radius:8px;border:1px solid #2a3242;background:#12151d;color:#e7e9ee;font:inherit}
|
|
49
|
+
.rydr-sg-wave{position:relative;height:120px;background:#0a0c11;border:1px solid #232733;border-radius:10px;margin:8px 0 6px;overflow:hidden}
|
|
50
|
+
.rydr-sg-wave canvas{width:100%;height:100%;display:block;cursor:pointer}
|
|
51
|
+
.rydr-sg-handle{position:absolute;top:0;bottom:0;width:12px;margin-left:-6px;cursor:ew-resize;z-index:3}
|
|
52
|
+
.rydr-sg-handle::after{content:"";position:absolute;left:5px;top:0;bottom:0;width:2px;background:#3f74ee}
|
|
53
|
+
.rydr-sg-region{position:absolute;top:0;bottom:0;background:rgba(63,116,238,.15);border-left:1px solid #3f74ee;border-right:1px solid #3f74ee;z-index:1;pointer-events:none}
|
|
54
|
+
.rydr-sg-cursor{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:#ffd24a;box-shadow:0 0 6px rgba(255,210,74,.8);z-index:4;pointer-events:none;display:none}
|
|
55
|
+
.rydr-sg-times{display:flex;gap:16px;font-size:12px;color:#9aa4b8;margin-bottom:10px}
|
|
56
|
+
.rydr-sg-times b{color:#e7e9ee}
|
|
57
|
+
.rydr-sg-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}
|
|
58
|
+
.rydr-sg-meta{font-size:12px;color:#8b93a7;margin-top:16px;border-top:1px solid #1a1e28;padding-top:12px}
|
|
59
|
+
.rydr-sg-toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1b2130;border:1px solid #31405f;padding:10px 16px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.5);z-index:9}
|
|
60
|
+
.rydr-sg-ro{padding:2px 8px;border-radius:999px;background:#2a1119;border:1px solid #5c2436;color:#e2718f;font-size:11px}
|
|
61
|
+
.rydr-sg-sideactions{padding:4px 12px 8px}
|
|
62
|
+
.rydr-sg-sideactions .rydr-sg-btn{width:100%;font-size:12px;padding:6px 10px}
|
|
63
|
+
.rydr-sg-tnode.sel{background:#16233c}
|
|
64
|
+
.rydr-sg-tnode.drop,.rydr-sg-item.drop{outline:2px dashed #3f74ee;outline-offset:-2px;background:#16233c}
|
|
65
|
+
.rydr-sg-item[draggable="true"],.rydr-sg-tnode[draggable="true"]{cursor:grab}
|
|
66
|
+
.rydr-sg-menu{position:fixed;z-index:20;min-width:160px;background:#161b25;border:1px solid #31405f;border-radius:8px;padding:4px;box-shadow:0 10px 30px rgba(0,0,0,.5)}
|
|
67
|
+
.rydr-sg-menu-item{padding:8px 12px;border-radius:6px;cursor:pointer;font-size:13px}
|
|
68
|
+
.rydr-sg-menu-item:hover{background:#22304a}
|
|
69
|
+
.rydr-sg-menu-item.dis{opacity:.4;cursor:default}
|
|
70
|
+
.rydr-sg-folderval{padding:7px 10px;border-radius:8px;border:1px dashed #2a3242;background:#0f1218;color:#9aa4b8}
|
|
71
|
+
.rydr-sg-fv-head{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}
|
|
72
|
+
.rydr-sg-fv-crumb{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b93a7}
|
|
73
|
+
.rydr-sg-fv-name{font-size:20px;font-weight:700;background:transparent;border:1px solid transparent;border-radius:8px;color:#e7e9ee;padding:4px 8px;margin-left:-8px}
|
|
74
|
+
.rydr-sg-fv-name:hover,.rydr-sg-fv-name:focus{border-color:#2a3242;background:#12151d;outline:none}
|
|
75
|
+
.rydr-sg-fv-title{font-size:20px;font-weight:700;margin:0}
|
|
76
|
+
.rydr-sg-batch{display:flex;align-items:center;gap:10px;padding:10px 12px;margin:8px 0;border-radius:10px;background:#12203a;border:1px solid #234}
|
|
77
|
+
.rydr-sg-batch .cnt{font-weight:600;font-size:13px}
|
|
78
|
+
.rydr-sg-batch select{padding:6px 10px;border-radius:8px;border:1px solid #2a3242;background:#0d1017;color:#e7e9ee;font:inherit}
|
|
79
|
+
.rydr-sg-fv-list{margin-top:6px}
|
|
80
|
+
.rydr-sg-fv-all{display:flex;align-items:center;gap:8px;padding:8px 4px;color:#9aa4b8;font-size:12px;border-bottom:1px solid #1a1e28;cursor:pointer}
|
|
81
|
+
.rydr-sg-fv-row{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:1px solid #14171f}
|
|
82
|
+
.rydr-sg-fv-row:hover{background:#11141b}
|
|
83
|
+
.rydr-sg-fv-row .nm{font-weight:600;cursor:pointer}
|
|
84
|
+
.rydr-sg-fv-row .nm:hover{color:#6fb4ff}
|
|
85
|
+
.rydr-sg-fv-row .c{margin-left:auto;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#8b93a7;border:1px solid #2a3242;border-radius:999px;padding:1px 7px}
|
|
86
|
+
`;
|
|
87
|
+
const STYLE_ID = "rydr-sg-style";
|
|
88
|
+
function ensureStyle() {
|
|
89
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID))
|
|
90
|
+
return;
|
|
91
|
+
const s = document.createElement("style");
|
|
92
|
+
s.id = STYLE_ID;
|
|
93
|
+
s.textContent = CSS;
|
|
94
|
+
document.head.appendChild(s);
|
|
95
|
+
}
|
|
96
|
+
function el(tag, cls, text) {
|
|
97
|
+
const n = document.createElement(tag);
|
|
98
|
+
if (cls)
|
|
99
|
+
n.className = cls;
|
|
100
|
+
if (text != null)
|
|
101
|
+
n.textContent = text;
|
|
102
|
+
return n;
|
|
103
|
+
}
|
|
104
|
+
const fmtTime = (s) => `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}`;
|
|
105
|
+
const safeKey = (s) => s.toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "sound";
|
|
106
|
+
export function mountSoundGallery(root, opts) {
|
|
107
|
+
ensureStyle();
|
|
108
|
+
const { session } = opts;
|
|
109
|
+
const isAdmin = !!session.identity.isAdmin;
|
|
110
|
+
let sounds = [];
|
|
111
|
+
let selectedKey = null;
|
|
112
|
+
let query = "";
|
|
113
|
+
let dirty = false; // catalog changed since last Publish
|
|
114
|
+
// Crop state (only meaningful while a sound is selected and its buffer decoded).
|
|
115
|
+
let cropBuf = null;
|
|
116
|
+
let regionStart = 0;
|
|
117
|
+
let regionEnd = 0;
|
|
118
|
+
const audio = new Audio();
|
|
119
|
+
// Playback cursor (a seekable playhead over the waveform). Set while a detail view is mounted.
|
|
120
|
+
let waveCursor = null;
|
|
121
|
+
let cursorDur = 0; // duration the waveform spans, in seconds (the full clip)
|
|
122
|
+
let cursorSec = 0; // playback position / start, in full-clip seconds
|
|
123
|
+
let cursorRaf = null;
|
|
124
|
+
let playBtnRef = null; // current toolbar Play button, for its label
|
|
125
|
+
// Folder hierarchy (entities) + tree state. `expanded` holds open folder ids; roots auto-open once.
|
|
126
|
+
let folders = [];
|
|
127
|
+
const expanded = new Set();
|
|
128
|
+
let treeSeeded = false;
|
|
129
|
+
let selectedFolderId = null; // when set, the detail panel shows the folder view
|
|
130
|
+
const batch = new Set(); // multi-selected sound keys inside the folder view
|
|
131
|
+
let uploadTargetFolderId; // folder new uploads drop into ("Add sound here")
|
|
132
|
+
// Drag-and-drop transfer state (sound leaf or folder node being dragged).
|
|
133
|
+
let dragKind = null;
|
|
134
|
+
let dragId = null;
|
|
135
|
+
const container = el("div", "rydr-sg");
|
|
136
|
+
// ── Header ──
|
|
137
|
+
const head = el("div", "rydr-sg-head");
|
|
138
|
+
const titleBox = el("div");
|
|
139
|
+
titleBox.appendChild(el("h1", undefined, "Sound Library"));
|
|
140
|
+
const sub = el("div", "sub", "");
|
|
141
|
+
titleBox.appendChild(sub);
|
|
142
|
+
head.appendChild(titleBox);
|
|
143
|
+
const actions = el("div", "rydr-sg-actions");
|
|
144
|
+
const search = el("input", "rydr-sg-search");
|
|
145
|
+
search.placeholder = "Search name / key / tag…";
|
|
146
|
+
search.addEventListener("input", () => {
|
|
147
|
+
query = search.value.trim().toLowerCase();
|
|
148
|
+
renderList();
|
|
149
|
+
});
|
|
150
|
+
actions.appendChild(search);
|
|
151
|
+
const fileInput = el("input");
|
|
152
|
+
fileInput.type = "file";
|
|
153
|
+
fileInput.accept = "audio/*";
|
|
154
|
+
fileInput.multiple = true;
|
|
155
|
+
fileInput.style.display = "none";
|
|
156
|
+
fileInput.addEventListener("change", () => void onFilesPicked());
|
|
157
|
+
const addBtn = el("button", "rydr-sg-btn primary", "+ Add sound");
|
|
158
|
+
addBtn.addEventListener("click", () => fileInput.click());
|
|
159
|
+
const publishBtn = el("button", "rydr-sg-btn", "Publish library");
|
|
160
|
+
publishBtn.addEventListener("click", () => void onPublish());
|
|
161
|
+
if (isAdmin) {
|
|
162
|
+
actions.appendChild(addBtn);
|
|
163
|
+
actions.appendChild(publishBtn);
|
|
164
|
+
actions.appendChild(fileInput);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
actions.appendChild(el("span", "rydr-sg-ro", "read-only"));
|
|
168
|
+
}
|
|
169
|
+
head.appendChild(actions);
|
|
170
|
+
container.appendChild(head);
|
|
171
|
+
// ── Body ──
|
|
172
|
+
const body = el("div", "rydr-sg-body");
|
|
173
|
+
const side = el("div", "rydr-sg-side");
|
|
174
|
+
const detail = el("div", "rydr-sg-detail blank");
|
|
175
|
+
detail.textContent = "Select a sound";
|
|
176
|
+
body.appendChild(side);
|
|
177
|
+
body.appendChild(detail);
|
|
178
|
+
container.appendChild(body);
|
|
179
|
+
root.appendChild(container);
|
|
180
|
+
// ── Helpers ──
|
|
181
|
+
function toast(msg) {
|
|
182
|
+
const t = el("div", "rydr-sg-toast", msg);
|
|
183
|
+
container.appendChild(t);
|
|
184
|
+
setTimeout(() => t.remove(), 2600);
|
|
185
|
+
}
|
|
186
|
+
function updateSub() {
|
|
187
|
+
sub.textContent = `${sounds.length} sounds · ${folders.length} folders${dirty ? " · unpublished changes" : ""}`;
|
|
188
|
+
}
|
|
189
|
+
function markDirty() {
|
|
190
|
+
dirty = true;
|
|
191
|
+
updateSub();
|
|
192
|
+
}
|
|
193
|
+
function stopPreview() {
|
|
194
|
+
audio.pause();
|
|
195
|
+
stopCursor();
|
|
196
|
+
}
|
|
197
|
+
// ── Playback cursor & transport ──
|
|
198
|
+
function stopCursor() {
|
|
199
|
+
if (cursorRaf != null) {
|
|
200
|
+
cancelAnimationFrame(cursorRaf);
|
|
201
|
+
cursorRaf = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Clamp `sec` into the selection, store it, and paint the cursor there (visible). */
|
|
205
|
+
function placeCursor(sec) {
|
|
206
|
+
cursorSec = Math.max(regionStart, Math.min(regionEnd, sec));
|
|
207
|
+
if (!waveCursor)
|
|
208
|
+
return;
|
|
209
|
+
const w = waveCursor.parentElement?.clientWidth ?? 0;
|
|
210
|
+
const frac = cursorDur > 0 ? cursorSec / cursorDur : 0;
|
|
211
|
+
waveCursor.style.left = `${Math.max(0, Math.min(1, frac)) * w}px`;
|
|
212
|
+
waveCursor.style.display = "block";
|
|
213
|
+
}
|
|
214
|
+
function refreshPlayBtn() {
|
|
215
|
+
if (playBtnRef)
|
|
216
|
+
playBtnRef.textContent = audio.paused ? "▶ Play" : "⏸ Pause";
|
|
217
|
+
}
|
|
218
|
+
/** Play the shared <audio> from the cursor; auto-pause at the selection end (never leave it). */
|
|
219
|
+
function playFromCursor() {
|
|
220
|
+
const asset = sounds.find((s) => s.key === selectedKey);
|
|
221
|
+
const clip = asset?.clips[0];
|
|
222
|
+
if (!clip)
|
|
223
|
+
return;
|
|
224
|
+
if (audio.src !== clip.url)
|
|
225
|
+
audio.src = clip.url;
|
|
226
|
+
if (cursorSec >= regionEnd - 0.001)
|
|
227
|
+
cursorSec = regionStart; // finished → restart at selection start
|
|
228
|
+
// Seek to the cursor; before metadata is loaded the seek is ignored, so re-apply once it is.
|
|
229
|
+
const seekTo = cursorSec;
|
|
230
|
+
const seek = () => {
|
|
231
|
+
try {
|
|
232
|
+
audio.currentTime = seekTo;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
/* noop */
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
if (audio.readyState >= 1 /* HAVE_METADATA */)
|
|
239
|
+
seek();
|
|
240
|
+
else
|
|
241
|
+
audio.addEventListener("loadedmetadata", seek, { once: true });
|
|
242
|
+
void audio.play().catch(() => undefined);
|
|
243
|
+
refreshPlayBtn();
|
|
244
|
+
stopCursor();
|
|
245
|
+
const tick = () => {
|
|
246
|
+
if (audio.paused || audio.ended) {
|
|
247
|
+
cursorRaf = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (audio.currentTime >= regionEnd) {
|
|
251
|
+
audio.pause();
|
|
252
|
+
placeCursor(regionEnd);
|
|
253
|
+
cursorRaf = null;
|
|
254
|
+
refreshPlayBtn();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
placeCursor(audio.currentTime);
|
|
258
|
+
cursorRaf = requestAnimationFrame(tick);
|
|
259
|
+
};
|
|
260
|
+
tick();
|
|
261
|
+
}
|
|
262
|
+
function pausePlayback() {
|
|
263
|
+
audio.pause();
|
|
264
|
+
stopCursor();
|
|
265
|
+
placeCursor(audio.currentTime); // keep the playhead visible where we stopped
|
|
266
|
+
refreshPlayBtn();
|
|
267
|
+
}
|
|
268
|
+
function togglePlay() {
|
|
269
|
+
if (audio.paused)
|
|
270
|
+
playFromCursor();
|
|
271
|
+
else
|
|
272
|
+
pausePlayback();
|
|
273
|
+
}
|
|
274
|
+
function stopPlayback() {
|
|
275
|
+
audio.pause();
|
|
276
|
+
stopCursor();
|
|
277
|
+
try {
|
|
278
|
+
audio.currentTime = regionStart;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* noop */
|
|
282
|
+
}
|
|
283
|
+
placeCursor(regionStart);
|
|
284
|
+
refreshPlayBtn();
|
|
285
|
+
}
|
|
286
|
+
// ── Sidebar: folder hierarchy (entities) → sounds. Folders are authoring-only. ──
|
|
287
|
+
const UNFILED = "__unfiled__"; // pseudo-folder id for sounds with no folderId
|
|
288
|
+
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
289
|
+
function genId() {
|
|
290
|
+
try {
|
|
291
|
+
return crypto.randomUUID();
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return `f_${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const folderById = (id) => (id ? folders.find((f) => f.id === id) : undefined);
|
|
298
|
+
/** Ancestor folder ids of `id`, root-first (excludes `id`). */
|
|
299
|
+
function ancestorsOf(id) {
|
|
300
|
+
const out = [];
|
|
301
|
+
const guard = new Set();
|
|
302
|
+
let cur = folderById(id)?.parentId ?? null;
|
|
303
|
+
while (cur && !guard.has(cur)) {
|
|
304
|
+
guard.add(cur);
|
|
305
|
+
out.unshift(cur);
|
|
306
|
+
cur = folderById(cur)?.parentId ?? null;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
/** Human path for a folder, e.g. "Racing / Engine". */
|
|
311
|
+
const folderPath = (id) => [...ancestorsOf(id).map((a) => folderById(a)?.name ?? "?"), folderById(id)?.name ?? "?"].join(" / ");
|
|
312
|
+
/** Is `candidate` equal to, or inside the subtree of, `rootId`? (cycle guard for folder moves) */
|
|
313
|
+
function inSubtree(rootId, candidate) {
|
|
314
|
+
const guard = new Set();
|
|
315
|
+
let cur = candidate;
|
|
316
|
+
while (cur && !guard.has(cur)) {
|
|
317
|
+
if (cur === rootId)
|
|
318
|
+
return true;
|
|
319
|
+
guard.add(cur);
|
|
320
|
+
cur = folderById(cur)?.parentId ?? null;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
function expandToFolder(id) {
|
|
325
|
+
for (const a of ancestorsOf(id))
|
|
326
|
+
expanded.add(a);
|
|
327
|
+
if (id)
|
|
328
|
+
expanded.add(id);
|
|
329
|
+
}
|
|
330
|
+
function expandToSound(key) {
|
|
331
|
+
const a = key ? sounds.find((s) => s.key === key) : null;
|
|
332
|
+
if (a)
|
|
333
|
+
expandToFolder(a.folderId ?? null);
|
|
334
|
+
}
|
|
335
|
+
const folderIsEmpty = (id) => !folders.some((f) => f.parentId === id) && !sounds.some((s) => s.folderId === id);
|
|
336
|
+
const foldersSortedByPath = () => [...folders].sort((a, b) => folderPath(a.id).localeCompare(folderPath(b.id)));
|
|
337
|
+
/** Build the folder forest + the unfiled bucket from the (possibly filtered) sound list. */
|
|
338
|
+
function buildForest(list) {
|
|
339
|
+
const nodes = new Map();
|
|
340
|
+
for (const f of folders)
|
|
341
|
+
nodes.set(f.id, { folder: f, children: [], sounds: [] });
|
|
342
|
+
const roots = [];
|
|
343
|
+
for (const f of folders) {
|
|
344
|
+
const node = nodes.get(f.id);
|
|
345
|
+
if (!node)
|
|
346
|
+
continue;
|
|
347
|
+
const parent = f.parentId ? nodes.get(f.parentId) : undefined;
|
|
348
|
+
if (parent)
|
|
349
|
+
parent.children.push(node);
|
|
350
|
+
else
|
|
351
|
+
roots.push(node);
|
|
352
|
+
}
|
|
353
|
+
const unfiled = [];
|
|
354
|
+
for (const s of list) {
|
|
355
|
+
const node = s.folderId ? nodes.get(s.folderId) : undefined;
|
|
356
|
+
if (node)
|
|
357
|
+
node.sounds.push(s);
|
|
358
|
+
else
|
|
359
|
+
unfiled.push(s);
|
|
360
|
+
}
|
|
361
|
+
return { roots, unfiled };
|
|
362
|
+
}
|
|
363
|
+
function countNodeSounds(node) {
|
|
364
|
+
let n = node.sounds.length;
|
|
365
|
+
for (const c of node.children)
|
|
366
|
+
n += countNodeSounds(c);
|
|
367
|
+
return n;
|
|
368
|
+
}
|
|
369
|
+
/** Wire an element as a drop target for a dragged sound (→ folderId) and optionally a folder (→ parentId). */
|
|
370
|
+
function makeDropTarget(elm, onSound, onFolder) {
|
|
371
|
+
if (!isAdmin)
|
|
372
|
+
return;
|
|
373
|
+
elm.addEventListener("dragover", (e) => {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
elm.classList.add("drop");
|
|
376
|
+
});
|
|
377
|
+
elm.addEventListener("dragleave", () => elm.classList.remove("drop"));
|
|
378
|
+
elm.addEventListener("drop", (e) => {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
e.stopPropagation();
|
|
381
|
+
elm.classList.remove("drop");
|
|
382
|
+
if (!dragId)
|
|
383
|
+
return;
|
|
384
|
+
if (dragKind === "sound")
|
|
385
|
+
onSound(dragId);
|
|
386
|
+
else if (dragKind === "folder" && onFolder)
|
|
387
|
+
onFolder(dragId);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function renderLeaf(s, depth) {
|
|
391
|
+
const item = el("div", `rydr-sg-item ${s.key === selectedKey ? "sel" : ""}`);
|
|
392
|
+
item.style.paddingLeft = `${18 + depth * 14}px`;
|
|
393
|
+
if (isAdmin)
|
|
394
|
+
item.draggable = true;
|
|
395
|
+
item.appendChild(el("span", "k", s.name || s.key));
|
|
396
|
+
item.appendChild(el("span", "c", s.category));
|
|
397
|
+
item.addEventListener("click", () => void selectSound(s.key));
|
|
398
|
+
item.addEventListener("dragstart", (e) => {
|
|
399
|
+
dragKind = "sound";
|
|
400
|
+
dragId = s.key;
|
|
401
|
+
e.dataTransfer?.setData("text/plain", s.key);
|
|
402
|
+
});
|
|
403
|
+
item.addEventListener("dragend", () => {
|
|
404
|
+
dragKind = null;
|
|
405
|
+
dragId = null;
|
|
406
|
+
});
|
|
407
|
+
return item;
|
|
408
|
+
}
|
|
409
|
+
function renderFolderNode(node, depth) {
|
|
410
|
+
const f = node.folder;
|
|
411
|
+
const open = query !== "" || expanded.has(f.id);
|
|
412
|
+
const header = el("div", `rydr-sg-tnode ${depth === 0 ? "cat" : ""} ${selectedFolderId === f.id ? "sel" : ""}`);
|
|
413
|
+
header.style.paddingLeft = `${10 + depth * 14}px`;
|
|
414
|
+
if (isAdmin)
|
|
415
|
+
header.draggable = true;
|
|
416
|
+
const chev = el("span", "chev", node.children.length ? (open ? "▼" : "▶") : "·");
|
|
417
|
+
chev.addEventListener("click", (e) => {
|
|
418
|
+
e.stopPropagation();
|
|
419
|
+
if (expanded.has(f.id))
|
|
420
|
+
expanded.delete(f.id);
|
|
421
|
+
else
|
|
422
|
+
expanded.add(f.id);
|
|
423
|
+
renderList();
|
|
424
|
+
});
|
|
425
|
+
header.appendChild(chev);
|
|
426
|
+
header.appendChild(el("span", "tlabel", f.name));
|
|
427
|
+
header.appendChild(el("span", "tcount", String(countNodeSounds(node))));
|
|
428
|
+
header.addEventListener("click", () => selectFolder(f.id));
|
|
429
|
+
header.addEventListener("contextmenu", (e) => {
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
openFolderMenu(f, e.clientX, e.clientY);
|
|
432
|
+
});
|
|
433
|
+
header.addEventListener("dragstart", (e) => {
|
|
434
|
+
dragKind = "folder";
|
|
435
|
+
dragId = f.id;
|
|
436
|
+
e.dataTransfer?.setData("text/plain", f.id);
|
|
437
|
+
});
|
|
438
|
+
header.addEventListener("dragend", () => {
|
|
439
|
+
dragKind = null;
|
|
440
|
+
dragId = null;
|
|
441
|
+
});
|
|
442
|
+
makeDropTarget(header, (key) => void moveSound(key, f.id), (fid) => void moveFolder(fid, f.id));
|
|
443
|
+
side.appendChild(header);
|
|
444
|
+
if (!open)
|
|
445
|
+
return;
|
|
446
|
+
for (const child of [...node.children].sort((a, b) => a.folder.name.localeCompare(b.folder.name)))
|
|
447
|
+
renderFolderNode(child, depth + 1);
|
|
448
|
+
for (const s of [...node.sounds].sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key)))
|
|
449
|
+
side.appendChild(renderLeaf(s, depth + 1));
|
|
450
|
+
}
|
|
451
|
+
function renderUnfiled(list) {
|
|
452
|
+
if (list.length === 0 && folders.length > 0) {
|
|
453
|
+
// still render as a drop target so sounds can be dragged OUT of folders
|
|
454
|
+
}
|
|
455
|
+
const open = query !== "" || folders.length === 0 || expanded.has(UNFILED);
|
|
456
|
+
const header = el("div", `rydr-sg-tnode cat ${selectedFolderId === UNFILED ? "sel" : ""}`);
|
|
457
|
+
header.style.paddingLeft = "10px";
|
|
458
|
+
const chev = el("span", "chev", list.length ? (open ? "▼" : "▶") : "·");
|
|
459
|
+
chev.addEventListener("click", (e) => {
|
|
460
|
+
e.stopPropagation();
|
|
461
|
+
if (expanded.has(UNFILED))
|
|
462
|
+
expanded.delete(UNFILED);
|
|
463
|
+
else
|
|
464
|
+
expanded.add(UNFILED);
|
|
465
|
+
renderList();
|
|
466
|
+
});
|
|
467
|
+
header.appendChild(chev);
|
|
468
|
+
header.appendChild(el("span", "tlabel", "Unfiled"));
|
|
469
|
+
header.appendChild(el("span", "tcount", String(list.length)));
|
|
470
|
+
header.addEventListener("click", () => selectFolder(UNFILED));
|
|
471
|
+
makeDropTarget(header, (key) => void moveSound(key, null), null);
|
|
472
|
+
side.appendChild(header);
|
|
473
|
+
if (!open)
|
|
474
|
+
return;
|
|
475
|
+
for (const s of [...list].sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key)))
|
|
476
|
+
side.appendChild(renderLeaf(s, 1));
|
|
477
|
+
}
|
|
478
|
+
function renderList() {
|
|
479
|
+
side.innerHTML = "";
|
|
480
|
+
if (isAdmin) {
|
|
481
|
+
const bar = el("div", "rydr-sg-sideactions");
|
|
482
|
+
const nf = el("button", "rydr-sg-btn", "+ New folder");
|
|
483
|
+
nf.addEventListener("click", () => void createFolder(null));
|
|
484
|
+
bar.appendChild(nf);
|
|
485
|
+
side.appendChild(bar);
|
|
486
|
+
}
|
|
487
|
+
const filtered = query
|
|
488
|
+
? sounds.filter((s) => [s.key, s.name, s.label, ...(s.tags ?? [])].some((v) => (v ?? "").toLowerCase().includes(query)))
|
|
489
|
+
: sounds;
|
|
490
|
+
const { roots, unfiled } = buildForest(filtered);
|
|
491
|
+
if (!treeSeeded) {
|
|
492
|
+
for (const r of roots)
|
|
493
|
+
expanded.add(r.folder.id); // root folders open on first load
|
|
494
|
+
treeSeeded = true;
|
|
495
|
+
}
|
|
496
|
+
if (folders.length === 0 && filtered.length === 0) {
|
|
497
|
+
side.appendChild(el("div", "rydr-sg-empty", sounds.length ? "No matches." : "No sounds yet. Add one, or make a folder."));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
for (const r of [...roots].sort((a, b) => a.folder.name.localeCompare(b.folder.name)))
|
|
501
|
+
renderFolderNode(r, 0);
|
|
502
|
+
renderUnfiled(unfiled);
|
|
503
|
+
}
|
|
504
|
+
function selectFolder(id) {
|
|
505
|
+
stopPreview();
|
|
506
|
+
selectedFolderId = id;
|
|
507
|
+
selectedKey = null;
|
|
508
|
+
batch.clear();
|
|
509
|
+
if (id !== UNFILED)
|
|
510
|
+
expandToFolder(id);
|
|
511
|
+
renderList();
|
|
512
|
+
renderDetail();
|
|
513
|
+
}
|
|
514
|
+
async function selectSound(key) {
|
|
515
|
+
stopPreview();
|
|
516
|
+
selectedKey = key;
|
|
517
|
+
selectedFolderId = null;
|
|
518
|
+
cropBuf = null;
|
|
519
|
+
expandToSound(key);
|
|
520
|
+
renderList();
|
|
521
|
+
renderDetail();
|
|
522
|
+
}
|
|
523
|
+
// ── Folder CRUD ──
|
|
524
|
+
async function createFolder(parentId) {
|
|
525
|
+
const name = (prompt("New folder name:", "New folder") || "").trim();
|
|
526
|
+
if (!name)
|
|
527
|
+
return;
|
|
528
|
+
const folder = { id: genId(), name, parentId };
|
|
529
|
+
try {
|
|
530
|
+
await session.saveFolder(folder);
|
|
531
|
+
folders = folders.concat(folder);
|
|
532
|
+
if (parentId)
|
|
533
|
+
expanded.add(parentId);
|
|
534
|
+
expanded.add(folder.id);
|
|
535
|
+
selectFolder(folder.id);
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
toast(`Create folder failed: ${errMsg(e)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async function renameFolder(f, name) {
|
|
542
|
+
const next = (name ?? prompt("Rename folder:", f.name) ?? "").trim();
|
|
543
|
+
if (!next || next === f.name)
|
|
544
|
+
return;
|
|
545
|
+
const updated = { ...f, name: next };
|
|
546
|
+
try {
|
|
547
|
+
await session.saveFolder(updated);
|
|
548
|
+
folders = folders.map((x) => (x.id === f.id ? updated : x));
|
|
549
|
+
renderList();
|
|
550
|
+
if (selectedFolderId === f.id)
|
|
551
|
+
renderDetail();
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
toast(`Rename failed: ${errMsg(e)}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async function deleteFolderById(f) {
|
|
558
|
+
if (!folderIsEmpty(f.id)) {
|
|
559
|
+
toast("Folder isn't empty — move or delete its contents first.");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (!confirm(`Delete folder "${f.name}"?`))
|
|
563
|
+
return;
|
|
564
|
+
try {
|
|
565
|
+
await session.deleteFolder(f.id);
|
|
566
|
+
folders = folders.filter((x) => x.id !== f.id);
|
|
567
|
+
if (selectedFolderId === f.id)
|
|
568
|
+
selectedFolderId = null;
|
|
569
|
+
renderList();
|
|
570
|
+
renderDetail();
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
toast(`Delete failed: ${errMsg(e)}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function moveFolder(id, newParentId) {
|
|
577
|
+
const f = folderById(id);
|
|
578
|
+
if (!f || (f.parentId ?? null) === newParentId)
|
|
579
|
+
return;
|
|
580
|
+
if (newParentId && inSubtree(id, newParentId)) {
|
|
581
|
+
toast("Can't move a folder into its own subtree.");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const updated = { ...f, parentId: newParentId };
|
|
585
|
+
try {
|
|
586
|
+
await session.saveFolder(updated);
|
|
587
|
+
folders = folders.map((x) => (x.id === id ? updated : x));
|
|
588
|
+
if (newParentId)
|
|
589
|
+
expanded.add(newParentId);
|
|
590
|
+
renderList();
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
toast(`Move failed: ${errMsg(e)}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function moveSound(key, folderId) {
|
|
597
|
+
const asset = sounds.find((s) => s.key === key);
|
|
598
|
+
if (!asset || (asset.folderId ?? null) === folderId)
|
|
599
|
+
return;
|
|
600
|
+
if (folderId)
|
|
601
|
+
expanded.add(folderId);
|
|
602
|
+
await patch(asset, { folderId });
|
|
603
|
+
}
|
|
604
|
+
// ── Folder right-click menu ──
|
|
605
|
+
let ctxMenu = null;
|
|
606
|
+
function closeMenu() {
|
|
607
|
+
ctxMenu?.remove();
|
|
608
|
+
ctxMenu = null;
|
|
609
|
+
}
|
|
610
|
+
function openFolderMenu(f, x, y) {
|
|
611
|
+
if (!isAdmin)
|
|
612
|
+
return;
|
|
613
|
+
closeMenu();
|
|
614
|
+
const menu = el("div", "rydr-sg-menu");
|
|
615
|
+
menu.style.left = `${x}px`;
|
|
616
|
+
menu.style.top = `${y}px`;
|
|
617
|
+
const item = (label, fn, disabled = false) => {
|
|
618
|
+
const it = el("div", `rydr-sg-menu-item ${disabled ? "dis" : ""}`, label);
|
|
619
|
+
if (!disabled)
|
|
620
|
+
it.addEventListener("click", () => { closeMenu(); fn(); });
|
|
621
|
+
menu.appendChild(it);
|
|
622
|
+
};
|
|
623
|
+
item("New subfolder", () => void createFolder(f.id));
|
|
624
|
+
item("Rename", () => void renameFolder(f));
|
|
625
|
+
item("Delete", () => void deleteFolderById(f), !folderIsEmpty(f.id));
|
|
626
|
+
container.appendChild(menu);
|
|
627
|
+
ctxMenu = menu;
|
|
628
|
+
}
|
|
629
|
+
// ── Batch actions (folder view) ──
|
|
630
|
+
async function batchApply(fn) {
|
|
631
|
+
for (const k of [...batch]) {
|
|
632
|
+
const a = sounds.find((s) => s.key === k);
|
|
633
|
+
if (!a)
|
|
634
|
+
continue;
|
|
635
|
+
const next = fn(a);
|
|
636
|
+
try {
|
|
637
|
+
await session.saveSound(next);
|
|
638
|
+
replaceAsset(next);
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
toast(errMsg(e));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
batch.clear();
|
|
645
|
+
markDirty();
|
|
646
|
+
renderList();
|
|
647
|
+
renderDetail();
|
|
648
|
+
}
|
|
649
|
+
const batchMove = (folderId) => batchApply((a) => ({ ...a, folderId, updatedAt: Date.now() }));
|
|
650
|
+
const batchChannel = (c) => batchApply((a) => ({ ...a, category: c, updatedAt: Date.now() }));
|
|
651
|
+
async function batchDelete() {
|
|
652
|
+
if (!confirm(`Delete ${batch.size} sound(s)?`))
|
|
653
|
+
return;
|
|
654
|
+
for (const k of [...batch]) {
|
|
655
|
+
try {
|
|
656
|
+
await session.deleteSound(k);
|
|
657
|
+
sounds = sounds.filter((s) => s.key !== k);
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
toast(errMsg(e));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
batch.clear();
|
|
664
|
+
markDirty();
|
|
665
|
+
renderList();
|
|
666
|
+
renderDetail();
|
|
667
|
+
}
|
|
668
|
+
function optionEl(value, label) {
|
|
669
|
+
const o = el("option", undefined, label);
|
|
670
|
+
o.value = value;
|
|
671
|
+
return o;
|
|
672
|
+
}
|
|
673
|
+
// ── Folder view (detail panel when a folder is selected) ──
|
|
674
|
+
function renderFolderView(id) {
|
|
675
|
+
detail.className = "rydr-sg-detail";
|
|
676
|
+
detail.innerHTML = "";
|
|
677
|
+
waveCursor = null;
|
|
678
|
+
playBtnRef = null;
|
|
679
|
+
const isUnfiled = id === UNFILED;
|
|
680
|
+
const folder = isUnfiled ? null : folderById(id);
|
|
681
|
+
if (!isUnfiled && !folder) {
|
|
682
|
+
selectedFolderId = null;
|
|
683
|
+
detail.className = "rydr-sg-detail blank";
|
|
684
|
+
detail.textContent = "Select a sound";
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const items = sounds
|
|
688
|
+
.filter((s) => (s.folderId ?? null) === (isUnfiled ? null : id))
|
|
689
|
+
.sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key));
|
|
690
|
+
const header = el("div", "rydr-sg-fv-head");
|
|
691
|
+
header.appendChild(el("div", "rydr-sg-fv-crumb", isUnfiled ? "Library" : ["Library", ...ancestorsOf(id).map((a) => folderById(a)?.name ?? "?")].join(" / ")));
|
|
692
|
+
if (!isUnfiled && folder && isAdmin) {
|
|
693
|
+
const nameInp = el("input", "rydr-sg-fv-name");
|
|
694
|
+
nameInp.value = folder.name;
|
|
695
|
+
nameInp.addEventListener("change", () => void renameFolder(folder, nameInp.value));
|
|
696
|
+
header.appendChild(nameInp);
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
header.appendChild(el("h2", "rydr-sg-fv-title", isUnfiled ? "Unfiled" : folder?.name ?? ""));
|
|
700
|
+
}
|
|
701
|
+
detail.appendChild(header);
|
|
702
|
+
if (isAdmin) {
|
|
703
|
+
const bar = el("div", "rydr-sg-toolbar");
|
|
704
|
+
if (!isUnfiled) {
|
|
705
|
+
const sub = el("button", "rydr-sg-btn", "+ New subfolder");
|
|
706
|
+
sub.addEventListener("click", () => void createFolder(id));
|
|
707
|
+
bar.appendChild(sub);
|
|
708
|
+
}
|
|
709
|
+
const addHere = el("button", "rydr-sg-btn primary", "+ Add sound here");
|
|
710
|
+
addHere.addEventListener("click", () => {
|
|
711
|
+
uploadTargetFolderId = isUnfiled ? null : id;
|
|
712
|
+
fileInput.click();
|
|
713
|
+
});
|
|
714
|
+
bar.appendChild(addHere);
|
|
715
|
+
if (!isUnfiled && folder) {
|
|
716
|
+
const del = el("button", "rydr-sg-btn danger", "Delete folder");
|
|
717
|
+
del.disabled = !folderIsEmpty(id);
|
|
718
|
+
del.title = del.disabled ? "Folder isn't empty" : "Delete this folder";
|
|
719
|
+
del.addEventListener("click", () => void deleteFolderById(folder));
|
|
720
|
+
bar.appendChild(del);
|
|
721
|
+
}
|
|
722
|
+
detail.appendChild(bar);
|
|
723
|
+
}
|
|
724
|
+
// Batch action bar (shown only when a selection exists).
|
|
725
|
+
const batchBar = el("div", "rydr-sg-batch");
|
|
726
|
+
const renderBatchBar = () => {
|
|
727
|
+
batchBar.innerHTML = "";
|
|
728
|
+
if (batch.size === 0) {
|
|
729
|
+
batchBar.style.display = "none";
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
batchBar.style.display = "flex";
|
|
733
|
+
batchBar.appendChild(el("span", "cnt", `${batch.size} selected`));
|
|
734
|
+
const moveSel = el("select");
|
|
735
|
+
moveSel.appendChild(optionEl("", "Move to…"));
|
|
736
|
+
moveSel.appendChild(optionEl(UNFILED, "Unfiled"));
|
|
737
|
+
for (const f of foldersSortedByPath())
|
|
738
|
+
moveSel.appendChild(optionEl(f.id, folderPath(f.id)));
|
|
739
|
+
moveSel.addEventListener("change", () => {
|
|
740
|
+
if (moveSel.value)
|
|
741
|
+
void batchMove(moveSel.value === UNFILED ? null : moveSel.value);
|
|
742
|
+
});
|
|
743
|
+
batchBar.appendChild(moveSel);
|
|
744
|
+
const chSel = el("select");
|
|
745
|
+
chSel.appendChild(optionEl("", "Set channel…"));
|
|
746
|
+
for (const c of SOUND_CATEGORIES)
|
|
747
|
+
chSel.appendChild(optionEl(c, c));
|
|
748
|
+
chSel.addEventListener("change", () => {
|
|
749
|
+
if (chSel.value)
|
|
750
|
+
void batchChannel(chSel.value);
|
|
751
|
+
});
|
|
752
|
+
batchBar.appendChild(chSel);
|
|
753
|
+
const del = el("button", "rydr-sg-btn danger", "Delete selected");
|
|
754
|
+
del.addEventListener("click", () => void batchDelete());
|
|
755
|
+
batchBar.appendChild(del);
|
|
756
|
+
};
|
|
757
|
+
detail.appendChild(batchBar);
|
|
758
|
+
const listWrap = el("div", "rydr-sg-fv-list");
|
|
759
|
+
if (items.length === 0) {
|
|
760
|
+
listWrap.appendChild(el("div", "rydr-sg-empty", "Empty folder — drag sounds here, or use “Add sound here”."));
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
const rowsBox = el("div");
|
|
764
|
+
const renderRows = () => {
|
|
765
|
+
rowsBox.innerHTML = "";
|
|
766
|
+
for (const s of items) {
|
|
767
|
+
const row = el("div", "rydr-sg-fv-row");
|
|
768
|
+
const cb = el("input");
|
|
769
|
+
cb.type = "checkbox";
|
|
770
|
+
cb.checked = batch.has(s.key);
|
|
771
|
+
cb.addEventListener("change", () => {
|
|
772
|
+
if (cb.checked)
|
|
773
|
+
batch.add(s.key);
|
|
774
|
+
else
|
|
775
|
+
batch.delete(s.key);
|
|
776
|
+
renderBatchBar();
|
|
777
|
+
});
|
|
778
|
+
row.appendChild(cb);
|
|
779
|
+
const nm = el("span", "nm", s.name || s.key);
|
|
780
|
+
nm.addEventListener("click", () => void selectSound(s.key));
|
|
781
|
+
row.appendChild(nm);
|
|
782
|
+
row.appendChild(el("span", "c", s.category));
|
|
783
|
+
rowsBox.appendChild(row);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
const allRow = el("label", "rydr-sg-fv-all");
|
|
787
|
+
const allCb = el("input");
|
|
788
|
+
allCb.type = "checkbox";
|
|
789
|
+
allCb.checked = items.every((s) => batch.has(s.key));
|
|
790
|
+
allCb.addEventListener("change", () => {
|
|
791
|
+
for (const s of items)
|
|
792
|
+
allCb.checked ? batch.add(s.key) : batch.delete(s.key);
|
|
793
|
+
renderRows();
|
|
794
|
+
renderBatchBar();
|
|
795
|
+
});
|
|
796
|
+
allRow.appendChild(allCb);
|
|
797
|
+
allRow.appendChild(el("span", undefined, `Select all (${items.length})`));
|
|
798
|
+
listWrap.appendChild(allRow);
|
|
799
|
+
listWrap.appendChild(rowsBox);
|
|
800
|
+
renderRows();
|
|
801
|
+
}
|
|
802
|
+
detail.appendChild(listWrap);
|
|
803
|
+
renderBatchBar();
|
|
804
|
+
}
|
|
805
|
+
// ── Detail / editor ──
|
|
806
|
+
function renderDetail() {
|
|
807
|
+
stopPreview();
|
|
808
|
+
if (selectedFolderId) {
|
|
809
|
+
renderFolderView(selectedFolderId);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const asset = sounds.find((s) => s.key === selectedKey);
|
|
813
|
+
detail.className = "rydr-sg-detail";
|
|
814
|
+
detail.innerHTML = "";
|
|
815
|
+
if (!asset) {
|
|
816
|
+
detail.className = "rydr-sg-detail blank";
|
|
817
|
+
detail.textContent = "Select a sound";
|
|
818
|
+
waveCursor = null;
|
|
819
|
+
playBtnRef = null;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const clip = asset.clips[0];
|
|
823
|
+
// Metadata fields
|
|
824
|
+
const mkField = (label, value, onChange, opts2) => {
|
|
825
|
+
const f = el("div", "rydr-sg-field");
|
|
826
|
+
f.appendChild(el("label", undefined, label));
|
|
827
|
+
if (opts2?.select) {
|
|
828
|
+
const sel = el("select");
|
|
829
|
+
for (const o of opts2.select) {
|
|
830
|
+
const opt = el("option", undefined, o);
|
|
831
|
+
opt.value = o;
|
|
832
|
+
if (o === value)
|
|
833
|
+
opt.selected = true;
|
|
834
|
+
sel.appendChild(opt);
|
|
835
|
+
}
|
|
836
|
+
sel.disabled = !isAdmin;
|
|
837
|
+
sel.addEventListener("change", () => onChange(sel.value));
|
|
838
|
+
f.appendChild(sel);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
const inp = el("input");
|
|
842
|
+
inp.value = value;
|
|
843
|
+
inp.disabled = !isAdmin;
|
|
844
|
+
if (opts2?.width)
|
|
845
|
+
inp.style.width = opts2.width;
|
|
846
|
+
inp.addEventListener("change", () => onChange(inp.value));
|
|
847
|
+
f.appendChild(inp);
|
|
848
|
+
}
|
|
849
|
+
return f;
|
|
850
|
+
};
|
|
851
|
+
const row1 = el("div", "rydr-sg-row");
|
|
852
|
+
row1.appendChild(mkField("Key (play id)", asset.key, (v) => void renameKey(asset, v)));
|
|
853
|
+
row1.appendChild(mkField("Name", asset.name, (v) => void patch(asset, { name: v })));
|
|
854
|
+
row1.appendChild(mkField("Channel", asset.category, (v) => void patch(asset, { category: v }), {
|
|
855
|
+
select: SOUND_CATEGORIES,
|
|
856
|
+
}));
|
|
857
|
+
detail.appendChild(row1);
|
|
858
|
+
const row2 = el("div", "rydr-sg-row");
|
|
859
|
+
// Folder is set by dragging in the tree / the folder view — shown read-only here as a breadcrumb.
|
|
860
|
+
const folderField = el("div", "rydr-sg-field");
|
|
861
|
+
folderField.appendChild(el("label", undefined, "Folder"));
|
|
862
|
+
const filed = asset.folderId && folderById(asset.folderId);
|
|
863
|
+
const fval = el("div", "rydr-sg-folderval", filed ? folderPath(asset.folderId) : "Unfiled");
|
|
864
|
+
fval.title = "Move via drag-and-drop in the sidebar, or the folder view";
|
|
865
|
+
folderField.appendChild(fval);
|
|
866
|
+
row2.appendChild(folderField);
|
|
867
|
+
row2.appendChild(mkField("Label", asset.label ?? "", (v) => void patch(asset, { label: v || undefined })));
|
|
868
|
+
row2.appendChild(mkField("Tags (comma-sep)", (asset.tags ?? []).join(", "), (v) => void patch(asset, { tags: v ? v.split(",").map((t) => t.trim()).filter(Boolean) : undefined })));
|
|
869
|
+
row2.appendChild(mkField("Gain (dB)", String(asset.gainDb ?? 0), (v) => void patch(asset, { gainDb: Number(v) || 0 }), {
|
|
870
|
+
width: "80px",
|
|
871
|
+
}));
|
|
872
|
+
detail.appendChild(row2);
|
|
873
|
+
// Waveform + crop
|
|
874
|
+
const waveWrap = el("div", "rydr-sg-wave");
|
|
875
|
+
const canvas = el("canvas");
|
|
876
|
+
waveWrap.appendChild(canvas);
|
|
877
|
+
const region = el("div", "rydr-sg-region");
|
|
878
|
+
const hStart = el("div", "rydr-sg-handle");
|
|
879
|
+
const hEnd = el("div", "rydr-sg-handle");
|
|
880
|
+
const cursor = el("div", "rydr-sg-cursor");
|
|
881
|
+
waveWrap.appendChild(region);
|
|
882
|
+
waveWrap.appendChild(hStart);
|
|
883
|
+
waveWrap.appendChild(hEnd);
|
|
884
|
+
waveWrap.appendChild(cursor);
|
|
885
|
+
detail.appendChild(waveWrap);
|
|
886
|
+
drawWave(canvas, asset.peaks ?? []);
|
|
887
|
+
waveCursor = cursor;
|
|
888
|
+
// Click the waveform to place the playback cursor (clamped into the selection). Bound on the
|
|
889
|
+
// canvas so the crop handles (siblings, above it) keep their own drag hit-zones.
|
|
890
|
+
canvas.addEventListener("pointerdown", (e) => {
|
|
891
|
+
if (dur <= 0)
|
|
892
|
+
return;
|
|
893
|
+
const rect = waveWrap.getBoundingClientRect();
|
|
894
|
+
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
|
895
|
+
placeCursor(frac * dur);
|
|
896
|
+
if (!audio.paused) {
|
|
897
|
+
try {
|
|
898
|
+
audio.currentTime = cursorSec;
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
/* noop */
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
const times = el("div", "rydr-sg-times");
|
|
906
|
+
const tStart = el("span");
|
|
907
|
+
const tEnd = el("span");
|
|
908
|
+
const tDur = el("span");
|
|
909
|
+
times.appendChild(tStart);
|
|
910
|
+
times.appendChild(tEnd);
|
|
911
|
+
times.appendChild(tDur);
|
|
912
|
+
detail.appendChild(times);
|
|
913
|
+
const dur = clip?.durationSec ?? 0;
|
|
914
|
+
cursorDur = dur;
|
|
915
|
+
regionStart = 0;
|
|
916
|
+
regionEnd = dur;
|
|
917
|
+
cursorSec = 0;
|
|
918
|
+
const layout = () => {
|
|
919
|
+
const w = waveWrap.clientWidth || 1;
|
|
920
|
+
const a = dur > 0 ? regionStart / dur : 0;
|
|
921
|
+
const b = dur > 0 ? regionEnd / dur : 1;
|
|
922
|
+
hStart.style.left = `${a * w}px`;
|
|
923
|
+
hEnd.style.left = `${b * w}px`;
|
|
924
|
+
region.style.left = `${a * w}px`;
|
|
925
|
+
region.style.width = `${(b - a) * w}px`;
|
|
926
|
+
tStart.innerHTML = `Start <b>${fmtTime(regionStart)}</b>`;
|
|
927
|
+
tEnd.innerHTML = `End <b>${fmtTime(regionEnd)}</b>`;
|
|
928
|
+
tDur.innerHTML = `Selection <b>${fmtTime(Math.max(0, regionEnd - regionStart))}</b>`;
|
|
929
|
+
};
|
|
930
|
+
layout();
|
|
931
|
+
placeCursor(regionStart); // show the playhead at the selection start on load
|
|
932
|
+
const dragHandle = (handle, which) => {
|
|
933
|
+
handle.addEventListener("pointerdown", (e) => {
|
|
934
|
+
if (!isAdmin || dur <= 0)
|
|
935
|
+
return;
|
|
936
|
+
e.preventDefault();
|
|
937
|
+
handle.setPointerCapture(e.pointerId);
|
|
938
|
+
const rect = waveWrap.getBoundingClientRect();
|
|
939
|
+
const move = (ev) => {
|
|
940
|
+
const frac = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width));
|
|
941
|
+
const t = frac * dur;
|
|
942
|
+
if (which === "start")
|
|
943
|
+
regionStart = Math.min(t, regionEnd - 0.02);
|
|
944
|
+
else
|
|
945
|
+
regionEnd = Math.max(t, regionStart + 0.02);
|
|
946
|
+
layout();
|
|
947
|
+
placeCursor(cursorSec); // keep the playhead inside the new selection
|
|
948
|
+
};
|
|
949
|
+
const up = () => {
|
|
950
|
+
handle.removeEventListener("pointermove", move);
|
|
951
|
+
handle.removeEventListener("pointerup", up);
|
|
952
|
+
};
|
|
953
|
+
handle.addEventListener("pointermove", move);
|
|
954
|
+
handle.addEventListener("pointerup", up);
|
|
955
|
+
});
|
|
956
|
+
};
|
|
957
|
+
dragHandle(hStart, "start");
|
|
958
|
+
dragHandle(hEnd, "end");
|
|
959
|
+
// Toolbar — one Play/Pause that plays the selection from the cursor, plus Stop (reset).
|
|
960
|
+
const bar = el("div", "rydr-sg-toolbar");
|
|
961
|
+
const playBtn = el("button", "rydr-sg-btn", "▶ Play");
|
|
962
|
+
playBtn.addEventListener("click", () => togglePlay());
|
|
963
|
+
bar.appendChild(playBtn);
|
|
964
|
+
playBtnRef = playBtn;
|
|
965
|
+
const stopBtn = el("button", "rydr-sg-btn", "⏹ Stop");
|
|
966
|
+
stopBtn.addEventListener("click", () => stopPlayback());
|
|
967
|
+
bar.appendChild(stopBtn);
|
|
968
|
+
if (isAdmin) {
|
|
969
|
+
const cropBtn = el("button", "rydr-sg-btn primary", "✂ Crop & save");
|
|
970
|
+
cropBtn.addEventListener("click", () => void doCrop(asset, cropBtn));
|
|
971
|
+
bar.appendChild(cropBtn);
|
|
972
|
+
const dupBtn = el("button", "rydr-sg-btn", "⧉ Duplicate");
|
|
973
|
+
dupBtn.addEventListener("click", () => void duplicate(asset));
|
|
974
|
+
bar.appendChild(dupBtn);
|
|
975
|
+
const delBtn = el("button", "rydr-sg-btn danger", "Delete");
|
|
976
|
+
delBtn.addEventListener("click", () => void removeSound(asset));
|
|
977
|
+
bar.appendChild(delBtn);
|
|
978
|
+
}
|
|
979
|
+
detail.appendChild(bar);
|
|
980
|
+
// Meta footer
|
|
981
|
+
const meta = el("div", "rydr-sg-meta");
|
|
982
|
+
meta.appendChild(el("div", undefined, `${clip?.filename ?? "—"} · ${dur ? fmtTime(dur) : "?"} · ${asset.loudness?.peakDb != null ? `peak ${asset.loudness.peakDb} dBFS` : "peak ?"}`));
|
|
983
|
+
if (clip) {
|
|
984
|
+
const link = el("a", undefined, "open file ↗");
|
|
985
|
+
link.href = clip.url;
|
|
986
|
+
link.target = "_blank";
|
|
987
|
+
link.style.color = "#6fb4ff";
|
|
988
|
+
meta.appendChild(link);
|
|
989
|
+
}
|
|
990
|
+
detail.appendChild(meta);
|
|
991
|
+
}
|
|
992
|
+
function drawWave(canvas, peaks) {
|
|
993
|
+
const dpr = window.devicePixelRatio || 1;
|
|
994
|
+
const w = canvas.clientWidth || 600;
|
|
995
|
+
const h = canvas.clientHeight || 120;
|
|
996
|
+
canvas.width = w * dpr;
|
|
997
|
+
canvas.height = h * dpr;
|
|
998
|
+
const g = canvas.getContext("2d");
|
|
999
|
+
if (!g)
|
|
1000
|
+
return;
|
|
1001
|
+
g.scale(dpr, dpr);
|
|
1002
|
+
g.clearRect(0, 0, w, h);
|
|
1003
|
+
g.fillStyle = "#3f74ee";
|
|
1004
|
+
const n = peaks.length || 1;
|
|
1005
|
+
const bw = w / n;
|
|
1006
|
+
for (let i = 0; i < n; i++) {
|
|
1007
|
+
const p = peaks[i] ?? 0;
|
|
1008
|
+
const bh = Math.max(1, p * (h - 6));
|
|
1009
|
+
g.fillRect(i * bw, (h - bh) / 2, Math.max(1, bw - 0.5), bh);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function ensureCropBuf(asset) {
|
|
1013
|
+
if (cropBuf)
|
|
1014
|
+
return cropBuf;
|
|
1015
|
+
const clip = asset.clips[0];
|
|
1016
|
+
if (!clip)
|
|
1017
|
+
return null;
|
|
1018
|
+
try {
|
|
1019
|
+
const res = await fetch(clip.url);
|
|
1020
|
+
cropBuf = await decodeAudio(await res.arrayBuffer());
|
|
1021
|
+
return cropBuf;
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
toast("Could not load audio for editing.");
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
async function doCrop(asset, btn) {
|
|
1029
|
+
const buf = await ensureCropBuf(asset);
|
|
1030
|
+
if (!buf)
|
|
1031
|
+
return;
|
|
1032
|
+
btn.disabled = true;
|
|
1033
|
+
btn.textContent = "Cropping…";
|
|
1034
|
+
try {
|
|
1035
|
+
const cropped = await cropBuffer(buf, regionStart, regionEnd);
|
|
1036
|
+
const mp3 = encodeMp3(cropped);
|
|
1037
|
+
// Overwrite in place, cache-safe: upload a FRESH object and repoint the row's url.
|
|
1038
|
+
const filename = `${asset.key}.mp3`;
|
|
1039
|
+
const url = await session.uploadClip({ filename, blob: mp3 });
|
|
1040
|
+
const peaks = computePeaks(cropped);
|
|
1041
|
+
const next = {
|
|
1042
|
+
...asset,
|
|
1043
|
+
clips: [{ url, filename, durationSec: cropped.duration }],
|
|
1044
|
+
peaks,
|
|
1045
|
+
loudness: { ...asset.loudness, peakDb: peakDb(cropped) },
|
|
1046
|
+
updatedAt: Date.now(),
|
|
1047
|
+
};
|
|
1048
|
+
await session.saveSound(next);
|
|
1049
|
+
replaceAsset(next);
|
|
1050
|
+
cropBuf = null;
|
|
1051
|
+
markDirty();
|
|
1052
|
+
renderDetail();
|
|
1053
|
+
toast("Cropped & saved. Publish to push to games.");
|
|
1054
|
+
}
|
|
1055
|
+
catch (e) {
|
|
1056
|
+
toast(`Crop failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1057
|
+
}
|
|
1058
|
+
finally {
|
|
1059
|
+
btn.disabled = false;
|
|
1060
|
+
btn.textContent = "✂ Crop & save";
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async function patch(asset, changes) {
|
|
1064
|
+
const next = { ...asset, ...changes, updatedAt: Date.now() };
|
|
1065
|
+
try {
|
|
1066
|
+
await session.saveSound(next);
|
|
1067
|
+
replaceAsset(next);
|
|
1068
|
+
markDirty();
|
|
1069
|
+
renderList();
|
|
1070
|
+
}
|
|
1071
|
+
catch (e) {
|
|
1072
|
+
toast(`Save failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
async function renameKey(asset, raw) {
|
|
1076
|
+
const key = safeKey(raw);
|
|
1077
|
+
if (key === asset.key)
|
|
1078
|
+
return;
|
|
1079
|
+
if (sounds.some((s) => s.key === key)) {
|
|
1080
|
+
toast(`Key "${key}" already exists.`);
|
|
1081
|
+
renderDetail();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
try {
|
|
1085
|
+
const next = { ...asset, key, updatedAt: Date.now() };
|
|
1086
|
+
await session.saveSound(next);
|
|
1087
|
+
await session.deleteSound(asset.key);
|
|
1088
|
+
sounds = sounds.filter((s) => s.key !== asset.key).concat(next);
|
|
1089
|
+
selectedKey = key;
|
|
1090
|
+
expandToSound(key);
|
|
1091
|
+
markDirty();
|
|
1092
|
+
renderList();
|
|
1093
|
+
renderDetail();
|
|
1094
|
+
}
|
|
1095
|
+
catch (e) {
|
|
1096
|
+
toast(`Rename failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
async function duplicate(asset) {
|
|
1100
|
+
let key = `${asset.key}_copy`;
|
|
1101
|
+
let i = 2;
|
|
1102
|
+
while (sounds.some((s) => s.key === key))
|
|
1103
|
+
key = `${asset.key}_copy${i++}`;
|
|
1104
|
+
const next = { ...asset, key, name: `${asset.name} copy`, updatedAt: Date.now() };
|
|
1105
|
+
try {
|
|
1106
|
+
await session.saveSound(next);
|
|
1107
|
+
sounds = sounds.concat(next);
|
|
1108
|
+
selectedKey = key;
|
|
1109
|
+
expandToSound(key);
|
|
1110
|
+
markDirty();
|
|
1111
|
+
renderList();
|
|
1112
|
+
renderDetail();
|
|
1113
|
+
}
|
|
1114
|
+
catch (e) {
|
|
1115
|
+
toast(`Duplicate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
async function removeSound(asset) {
|
|
1119
|
+
if (!confirm(`Delete "${asset.name}" (${asset.key})?`))
|
|
1120
|
+
return;
|
|
1121
|
+
try {
|
|
1122
|
+
await session.deleteSound(asset.key);
|
|
1123
|
+
sounds = sounds.filter((s) => s.key !== asset.key);
|
|
1124
|
+
if (selectedKey === asset.key)
|
|
1125
|
+
selectedKey = null;
|
|
1126
|
+
markDirty();
|
|
1127
|
+
renderList();
|
|
1128
|
+
renderDetail();
|
|
1129
|
+
}
|
|
1130
|
+
catch (e) {
|
|
1131
|
+
toast(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function replaceAsset(next) {
|
|
1135
|
+
sounds = sounds.map((s) => (s.key === next.key ? next : s));
|
|
1136
|
+
}
|
|
1137
|
+
// ── Add / ingest ──
|
|
1138
|
+
async function onFilesPicked() {
|
|
1139
|
+
const files = Array.from(fileInput.files ?? []);
|
|
1140
|
+
fileInput.value = "";
|
|
1141
|
+
// Where new uploads land: an explicit "Add sound here" target, else the selected folder, else unfiled.
|
|
1142
|
+
const target = uploadTargetFolderId !== undefined ? uploadTargetFolderId : selectedFolderId && selectedFolderId !== UNFILED ? selectedFolderId : null;
|
|
1143
|
+
uploadTargetFolderId = undefined;
|
|
1144
|
+
let lastKey = null;
|
|
1145
|
+
for (const file of files) {
|
|
1146
|
+
try {
|
|
1147
|
+
const buf = await decodeAudio(file);
|
|
1148
|
+
// Everything in the library is stored as MP3, whatever was uploaded (wav/ogg/…).
|
|
1149
|
+
const mp3 = encodeMp3(buf);
|
|
1150
|
+
const filename = `${file.name.replace(/\.[a-z0-9]+$/i, "")}.mp3`;
|
|
1151
|
+
const url = await session.uploadClip({ filename, blob: mp3 });
|
|
1152
|
+
let key = safeKey(file.name);
|
|
1153
|
+
let i = 2;
|
|
1154
|
+
while (sounds.some((s) => s.key === key))
|
|
1155
|
+
key = `${safeKey(file.name)}_${i++}`;
|
|
1156
|
+
const asset = {
|
|
1157
|
+
key,
|
|
1158
|
+
name: file.name.replace(/\.[a-z0-9]+$/i, ""),
|
|
1159
|
+
category: "sfx",
|
|
1160
|
+
folderId: target,
|
|
1161
|
+
clips: [{ url, filename, durationSec: buf.duration }],
|
|
1162
|
+
peaks: computePeaks(buf),
|
|
1163
|
+
loudness: { peakDb: peakDb(buf) },
|
|
1164
|
+
updatedAt: Date.now(),
|
|
1165
|
+
};
|
|
1166
|
+
await session.saveSound(asset);
|
|
1167
|
+
sounds = sounds.concat(asset);
|
|
1168
|
+
lastKey = key;
|
|
1169
|
+
}
|
|
1170
|
+
catch (e) {
|
|
1171
|
+
toast(`Failed to add ${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
markDirty();
|
|
1175
|
+
if (target)
|
|
1176
|
+
expanded.add(target);
|
|
1177
|
+
// Stay in the folder view if that's where the upload came from; otherwise open the new sound.
|
|
1178
|
+
if (selectedFolderId) {
|
|
1179
|
+
renderList();
|
|
1180
|
+
renderDetail();
|
|
1181
|
+
}
|
|
1182
|
+
else if (lastKey) {
|
|
1183
|
+
void selectSound(lastKey);
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
renderList();
|
|
1187
|
+
renderDetail();
|
|
1188
|
+
}
|
|
1189
|
+
updateSub();
|
|
1190
|
+
}
|
|
1191
|
+
async function onPublish() {
|
|
1192
|
+
publishBtn.disabled = true;
|
|
1193
|
+
publishBtn.textContent = "Publishing…";
|
|
1194
|
+
try {
|
|
1195
|
+
const url = await session.publish(sounds);
|
|
1196
|
+
dirty = false;
|
|
1197
|
+
updateSub();
|
|
1198
|
+
toast(`Published ${sounds.length} sounds → ${url}`);
|
|
1199
|
+
try {
|
|
1200
|
+
await navigator.clipboard.writeText(url);
|
|
1201
|
+
toast("Library URL copied to clipboard.");
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
/* clipboard blocked */
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch (e) {
|
|
1208
|
+
toast(`Publish failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1209
|
+
}
|
|
1210
|
+
finally {
|
|
1211
|
+
publishBtn.disabled = false;
|
|
1212
|
+
publishBtn.textContent = "Publish library";
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
// ── Boot ──
|
|
1216
|
+
// When the clip plays out on its own, snap the cursor back to the selection start.
|
|
1217
|
+
audio.addEventListener("ended", () => {
|
|
1218
|
+
stopCursor();
|
|
1219
|
+
placeCursor(regionStart);
|
|
1220
|
+
refreshPlayBtn();
|
|
1221
|
+
});
|
|
1222
|
+
// Spacebar toggles play/pause, unless a metadata field is focused.
|
|
1223
|
+
const onKey = (e) => {
|
|
1224
|
+
if (e.code !== "Space")
|
|
1225
|
+
return;
|
|
1226
|
+
const t = e.target;
|
|
1227
|
+
const tag = t?.tagName;
|
|
1228
|
+
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA" || t?.isContentEditable)
|
|
1229
|
+
return;
|
|
1230
|
+
if (!selectedKey)
|
|
1231
|
+
return;
|
|
1232
|
+
e.preventDefault();
|
|
1233
|
+
togglePlay();
|
|
1234
|
+
};
|
|
1235
|
+
document.addEventListener("keydown", onKey);
|
|
1236
|
+
// Any click that isn't inside the open context menu dismisses it.
|
|
1237
|
+
const onDocClick = (e) => {
|
|
1238
|
+
if (ctxMenu && !ctxMenu.contains(e.target))
|
|
1239
|
+
closeMenu();
|
|
1240
|
+
};
|
|
1241
|
+
document.addEventListener("click", onDocClick);
|
|
1242
|
+
void (async () => {
|
|
1243
|
+
try {
|
|
1244
|
+
const [s, f] = await Promise.all([session.listSounds(), session.listFolders()]);
|
|
1245
|
+
sounds = s;
|
|
1246
|
+
folders = f;
|
|
1247
|
+
}
|
|
1248
|
+
catch (e) {
|
|
1249
|
+
toast(`Failed to load library: ${e instanceof Error ? e.message : String(e)}`);
|
|
1250
|
+
}
|
|
1251
|
+
updateSub();
|
|
1252
|
+
renderList();
|
|
1253
|
+
})();
|
|
1254
|
+
return {
|
|
1255
|
+
dispose() {
|
|
1256
|
+
stopPreview();
|
|
1257
|
+
closeMenu();
|
|
1258
|
+
document.removeEventListener("keydown", onKey);
|
|
1259
|
+
document.removeEventListener("click", onDocClick);
|
|
1260
|
+
container.remove();
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
//# sourceMappingURL=index.js.map
|