privateboard 0.1.9 → 0.1.11
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/dist/cli.js +3732 -2160
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/agent-overlay.js +59 -36
- package/public/agent-profile.css +392 -100
- package/public/agent-profile.js +551 -59
- package/public/app.js +1341 -681
- package/public/i18n.js +1990 -0
- package/public/index.html +251 -94
- package/public/keys-store.js +63 -0
- package/public/new-agent.css +60 -0
- package/public/new-agent.js +121 -53
- package/public/room-settings.js +2 -1
- package/public/user-settings.css +68 -0
- package/public/user-settings.js +241 -87
- package/public/voice-replay.css +607 -0
- package/public/voice-replay.js +533 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice Replay · adjourned-room transcript playback.
|
|
3
|
+
*
|
|
4
|
+
* Public API · `window.boardroomVoiceReplay`:
|
|
5
|
+
* open({ roomId, messages, members, chair })
|
|
6
|
+
* close()
|
|
7
|
+
* isOpen()
|
|
8
|
+
*
|
|
9
|
+
* Flow on `open`:
|
|
10
|
+
* 1. Key gate · GET /api/voices · if no usable provider beyond
|
|
11
|
+
* `browser` is configured, swap into the key-prompt mode
|
|
12
|
+
* (small CTA that deep-links to user-settings).
|
|
13
|
+
* 2. Build playlist · filter messages to chair + directors (and
|
|
14
|
+
* optionally user, off by default), drop system / procedural
|
|
15
|
+
* markers. Earliest first.
|
|
16
|
+
* 3. Mount the floating overlay · controls + speaker card +
|
|
17
|
+
* progress + per-message preview.
|
|
18
|
+
* 4. Synthesise + play sequentially via a single `<audio>`
|
|
19
|
+
* element, pre-fetching N+1 while N plays for gapless
|
|
20
|
+
* handoff. Each message is highlighted + smooth-scrolled
|
|
21
|
+
* into view in the chat as it begins.
|
|
22
|
+
*
|
|
23
|
+
* Skipped always: system messages, round-open / round-prompt /
|
|
24
|
+
* settings / no-brief / convening / chair-pick / web-search status
|
|
25
|
+
* markers (anything where `meta.kind` is one of the procedural
|
|
26
|
+
* kinds the chat renders as inline cards rather than spoken text).
|
|
27
|
+
*/
|
|
28
|
+
(function (root) {
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
/** Procedural meta kinds that carry no speakable content · the
|
|
32
|
+
* chat renders these as cards (round dividers, milestones,
|
|
33
|
+
* status updates) so reading them aloud just adds noise. */
|
|
34
|
+
const PROCEDURAL_KINDS = new Set([
|
|
35
|
+
"round-open",
|
|
36
|
+
"round-prompt",
|
|
37
|
+
"settings",
|
|
38
|
+
"no-brief",
|
|
39
|
+
"convening",
|
|
40
|
+
"chair-pick",
|
|
41
|
+
"web-search",
|
|
42
|
+
"web-search-result",
|
|
43
|
+
"tool-use",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Phase labels rotated through the loading state while the
|
|
47
|
+
* first message synthesises. Same vocabulary as the rest of
|
|
48
|
+
* the app's voice surface. */
|
|
49
|
+
const STATE = {
|
|
50
|
+
overlay: null,
|
|
51
|
+
audio: null,
|
|
52
|
+
playlist: [],
|
|
53
|
+
members: [],
|
|
54
|
+
chair: null,
|
|
55
|
+
idx: 0,
|
|
56
|
+
paused: false,
|
|
57
|
+
speed: 1,
|
|
58
|
+
skipUser: true,
|
|
59
|
+
abortCtrl: null,
|
|
60
|
+
prefetched: new Map(), // idx → { audioBase64, mimeType }
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function isOpen() {
|
|
64
|
+
return !!STATE.overlay;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build the ordered playlist · keep chronological order, drop
|
|
68
|
+
* procedural / system messages, optionally drop user messages.
|
|
69
|
+
* Each entry carries everything the playback loop and the UI
|
|
70
|
+
* preview need so we don't re-derive on every step. */
|
|
71
|
+
function buildPlaylist(messages, opts) {
|
|
72
|
+
const skipUser = opts && opts.skipUser !== false; // default true
|
|
73
|
+
const members = (opts && opts.members) || [];
|
|
74
|
+
const chair = (opts && opts.chair) || null;
|
|
75
|
+
const byId = new Map(members.map((a) => [a.id, a]));
|
|
76
|
+
if (chair) byId.set(chair.id, chair);
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || !m.body || !m.body.trim()) continue;
|
|
80
|
+
if (m.authorKind === "system") continue;
|
|
81
|
+
const kind = m.meta && typeof m.meta.kind === "string" ? m.meta.kind : null;
|
|
82
|
+
if (kind && PROCEDURAL_KINDS.has(kind)) continue;
|
|
83
|
+
// Skip the streaming placeholder that hasn't finalized yet
|
|
84
|
+
// (its body is empty or its meta marks it as in-flight).
|
|
85
|
+
if (m.meta && m.meta.streaming === true) continue;
|
|
86
|
+
const isUser = m.authorKind === "user";
|
|
87
|
+
if (isUser && skipUser) continue;
|
|
88
|
+
const agent = m.authorId ? byId.get(m.authorId) : null;
|
|
89
|
+
out.push({
|
|
90
|
+
messageId: m.id,
|
|
91
|
+
kind: isUser ? "user" : (agent && agent.roleKind === "moderator" ? "chair" : "director"),
|
|
92
|
+
authorId: m.authorId || null,
|
|
93
|
+
authorName: agent
|
|
94
|
+
? agent.name
|
|
95
|
+
: isUser
|
|
96
|
+
? "you"
|
|
97
|
+
: (m.authorKind === "agent" ? "Director" : ""),
|
|
98
|
+
authorRole: agent && agent.roleTag ? agent.roleTag : "",
|
|
99
|
+
authorAvatar: agent && agent.avatarPath ? agent.avatarPath : null,
|
|
100
|
+
body: m.body.trim(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Key gate ────────────────────────────────────────────────
|
|
107
|
+
async function checkUsableTtsKey() {
|
|
108
|
+
try {
|
|
109
|
+
const r = await fetch("/api/voices");
|
|
110
|
+
if (!r.ok) return false;
|
|
111
|
+
const j = await r.json();
|
|
112
|
+
const list = Array.isArray(j.voices) ? j.voices : [];
|
|
113
|
+
// browser provider is the always-on no-cost fallback; for a
|
|
114
|
+
// satisfying replay experience we want a real TTS key.
|
|
115
|
+
return list.some((v) => v && v.provider !== "browser" && v.configured);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Open / close ────────────────────────────────────────────
|
|
122
|
+
async function open(opts) {
|
|
123
|
+
if (isOpen()) close();
|
|
124
|
+
const messages = (opts && opts.messages) || [];
|
|
125
|
+
const members = (opts && opts.members) || [];
|
|
126
|
+
const chair = (opts && opts.chair) || null;
|
|
127
|
+
STATE.members = members;
|
|
128
|
+
STATE.chair = chair;
|
|
129
|
+
STATE.skipUser = true;
|
|
130
|
+
STATE.speed = 1;
|
|
131
|
+
STATE.paused = false;
|
|
132
|
+
STATE.idx = 0;
|
|
133
|
+
STATE.prefetched = new Map();
|
|
134
|
+
|
|
135
|
+
// Mount overlay shell first so the user gets immediate feedback
|
|
136
|
+
// (loading state) while the key check + playlist build run.
|
|
137
|
+
STATE.overlay = mountOverlay();
|
|
138
|
+
setBusy(true, "Checking voice configuration…");
|
|
139
|
+
|
|
140
|
+
const usable = await checkUsableTtsKey();
|
|
141
|
+
if (!usable) {
|
|
142
|
+
renderKeyPrompt();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const playlist = buildPlaylist(messages, { members, chair, skipUser: STATE.skipUser });
|
|
147
|
+
if (playlist.length === 0) {
|
|
148
|
+
renderEmpty();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
STATE.playlist = playlist;
|
|
152
|
+
setBusy(false);
|
|
153
|
+
renderPlayer();
|
|
154
|
+
void playCurrent();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function close() {
|
|
158
|
+
if (STATE.audio) {
|
|
159
|
+
try { STATE.audio.pause(); } catch { /* noop */ }
|
|
160
|
+
STATE.audio.src = "";
|
|
161
|
+
STATE.audio = null;
|
|
162
|
+
}
|
|
163
|
+
if (STATE.abortCtrl) {
|
|
164
|
+
try { STATE.abortCtrl.abort(); } catch { /* noop */ }
|
|
165
|
+
STATE.abortCtrl = null;
|
|
166
|
+
}
|
|
167
|
+
if (STATE.overlay) {
|
|
168
|
+
try { STATE.overlay.remove(); } catch { /* noop */ }
|
|
169
|
+
STATE.overlay = null;
|
|
170
|
+
}
|
|
171
|
+
clearActiveHighlight();
|
|
172
|
+
STATE.playlist = [];
|
|
173
|
+
STATE.prefetched = new Map();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Mount + render ──────────────────────────────────────────
|
|
177
|
+
function mountOverlay() {
|
|
178
|
+
const el = document.createElement("div");
|
|
179
|
+
el.className = "voice-replay-overlay";
|
|
180
|
+
el.setAttribute("role", "region");
|
|
181
|
+
el.setAttribute("aria-label", "Voice replay");
|
|
182
|
+
el.innerHTML = `
|
|
183
|
+
<div class="vr-head">
|
|
184
|
+
<span class="vr-kicker"><span class="vr-kicker-glyph">♪</span> voice replay</span>
|
|
185
|
+
<button type="button" class="vr-close" data-vr-close aria-label="Close">✕</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="vr-body" data-vr-body>
|
|
188
|
+
<div class="vr-spinner-row">
|
|
189
|
+
<span class="vr-spinner-dots">
|
|
190
|
+
<span class="vr-spinner-dot"></span>
|
|
191
|
+
<span class="vr-spinner-dot"></span>
|
|
192
|
+
<span class="vr-spinner-dot"></span>
|
|
193
|
+
</span>
|
|
194
|
+
<span class="vr-spinner-text" data-vr-spinner-text>Loading…</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
`;
|
|
198
|
+
document.body.appendChild(el);
|
|
199
|
+
el.addEventListener("click", (ev) => {
|
|
200
|
+
const target = ev.target;
|
|
201
|
+
if (!target || !(target instanceof Element)) return;
|
|
202
|
+
if (target.closest("[data-vr-close]")) { ev.preventDefault(); close(); return; }
|
|
203
|
+
if (target.closest("[data-vr-pause]")) { ev.preventDefault(); togglePause(); return; }
|
|
204
|
+
if (target.closest("[data-vr-skip]")) { ev.preventDefault(); skipCurrent(); return; }
|
|
205
|
+
if (target.closest("[data-vr-speed]")) { ev.preventDefault(); cycleSpeed(); return; }
|
|
206
|
+
if (target.closest("[data-vr-include-user]")) { ev.preventDefault(); toggleIncludeUser(); return; }
|
|
207
|
+
if (target.closest("[data-vr-config]")) {
|
|
208
|
+
ev.preventDefault();
|
|
209
|
+
// Dismiss the replay overlay first · without this the
|
|
210
|
+
// user-settings panel mounts on top while the replay
|
|
211
|
+
// overlay's key-prompt is still visible behind it,
|
|
212
|
+
// reading as two stacked dialogs. The user came here
|
|
213
|
+
// to configure a key — voice replay has nothing more to
|
|
214
|
+
// do until they come back and re-trigger it.
|
|
215
|
+
close();
|
|
216
|
+
if (typeof root.openUserSettings === "function") {
|
|
217
|
+
root.openUserSettings({ section: "keys", focusProvider: "minimax" });
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return el;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setBusy(busy, msg) {
|
|
226
|
+
if (!STATE.overlay) return;
|
|
227
|
+
const t = STATE.overlay.querySelector("[data-vr-spinner-text]");
|
|
228
|
+
if (t && busy) t.textContent = msg || "Working…";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderKeyPrompt() {
|
|
232
|
+
if (!STATE.overlay) return;
|
|
233
|
+
const body = STATE.overlay.querySelector("[data-vr-body]");
|
|
234
|
+
if (!body) return;
|
|
235
|
+
body.innerHTML = `
|
|
236
|
+
<div class="vr-key-prompt">
|
|
237
|
+
<div class="vr-key-icon">♪</div>
|
|
238
|
+
<div class="vr-key-text">
|
|
239
|
+
<div class="vr-key-title">No TTS key configured</div>
|
|
240
|
+
<div class="vr-key-deck">Voice replay needs a TTS provider · MiniMax, ElevenLabs, or OpenAI. Add one in settings, then come back.</div>
|
|
241
|
+
</div>
|
|
242
|
+
<div class="vr-key-actions">
|
|
243
|
+
<button type="button" class="vr-cta" data-vr-config>[ Configure ]</button>
|
|
244
|
+
<button type="button" class="vr-ghost" data-vr-close>Dismiss</button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderEmpty() {
|
|
251
|
+
if (!STATE.overlay) return;
|
|
252
|
+
const body = STATE.overlay.querySelector("[data-vr-body]");
|
|
253
|
+
if (!body) return;
|
|
254
|
+
body.innerHTML = `
|
|
255
|
+
<div class="vr-key-prompt">
|
|
256
|
+
<div class="vr-key-icon">○</div>
|
|
257
|
+
<div class="vr-key-text">
|
|
258
|
+
<div class="vr-key-title">Nothing to replay</div>
|
|
259
|
+
<div class="vr-key-deck">This room has no playable messages — the directors haven't spoken yet, or every message was a system marker.</div>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="vr-key-actions">
|
|
262
|
+
<button type="button" class="vr-ghost" data-vr-close>Dismiss</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderPlayer() {
|
|
269
|
+
if (!STATE.overlay) return;
|
|
270
|
+
const body = STATE.overlay.querySelector("[data-vr-body]");
|
|
271
|
+
if (!body) return;
|
|
272
|
+
const cur = STATE.playlist[STATE.idx];
|
|
273
|
+
if (!cur) return;
|
|
274
|
+
const total = STATE.playlist.length;
|
|
275
|
+
const pct = Math.round(((STATE.idx) / total) * 100);
|
|
276
|
+
const avatarHtml = cur.authorAvatar
|
|
277
|
+
? `<img class="vr-avatar" src="${escapeAttr(cur.authorAvatar)}" alt="${escapeAttr(cur.authorName)}">`
|
|
278
|
+
: `<div class="vr-avatar vr-avatar-placeholder">${escapeText((cur.authorName || "?").charAt(0).toUpperCase())}</div>`;
|
|
279
|
+
const roleLine = cur.authorRole
|
|
280
|
+
? `<span class="vr-author-role"> · ${escapeText(cur.authorRole)}</span>`
|
|
281
|
+
: "";
|
|
282
|
+
body.innerHTML = `
|
|
283
|
+
<div class="vr-speaker">
|
|
284
|
+
${avatarHtml}
|
|
285
|
+
<div class="vr-speaker-text">
|
|
286
|
+
<div class="vr-speaker-name">${escapeText(cur.authorName || "—")}${roleLine}</div>
|
|
287
|
+
<div class="vr-speaker-kind">${escapeText(cur.kind)}</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="vr-preview" data-vr-preview>${escapeText(truncatePreview(cur.body))}</div>
|
|
291
|
+
<div class="vr-progress-row">
|
|
292
|
+
<span class="vr-progress-counter">${STATE.idx + 1} / ${total}</span>
|
|
293
|
+
<div class="vr-progress-bar"><div class="vr-progress-fill" style="width: ${pct}%"></div></div>
|
|
294
|
+
<span class="vr-progress-pct">${pct}%</span>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="vr-controls">
|
|
297
|
+
<button type="button" class="vr-btn" data-vr-pause>${STATE.paused ? "▶ Resume" : "❚❚ Pause"}</button>
|
|
298
|
+
<button type="button" class="vr-btn" data-vr-skip>⏭ Skip</button>
|
|
299
|
+
<button type="button" class="vr-btn vr-btn-speed" data-vr-speed>${STATE.speed}×</button>
|
|
300
|
+
<label class="vr-toggle">
|
|
301
|
+
<input type="checkbox" data-vr-include-user${STATE.skipUser ? "" : " checked"} aria-label="Include my interjections">
|
|
302
|
+
<span>include me</span>
|
|
303
|
+
</label>
|
|
304
|
+
</div>
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Playback loop ───────────────────────────────────────────
|
|
309
|
+
async function playCurrent() {
|
|
310
|
+
if (!STATE.overlay) return;
|
|
311
|
+
const cur = STATE.playlist[STATE.idx];
|
|
312
|
+
if (!cur) { close(); return; }
|
|
313
|
+
renderPlayer();
|
|
314
|
+
highlightActive(cur.messageId);
|
|
315
|
+
let payload;
|
|
316
|
+
try {
|
|
317
|
+
payload = await fetchAudio(STATE.idx);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
// Surface error inline · don't crash the whole player.
|
|
320
|
+
const body = STATE.overlay && STATE.overlay.querySelector("[data-vr-body]");
|
|
321
|
+
if (body) {
|
|
322
|
+
body.insertAdjacentHTML("beforeend", `
|
|
323
|
+
<div class="vr-error">${escapeText(e && e.message ? e.message : String(e))}</div>
|
|
324
|
+
`);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!STATE.overlay) return;
|
|
329
|
+
if (!payload || !payload.audioBase64) {
|
|
330
|
+
// Skip silently to next message (e.g. browser-provider fallback).
|
|
331
|
+
advance();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (STATE.audio) { try { STATE.audio.pause(); } catch { /* noop */ } }
|
|
335
|
+
STATE.audio = new Audio(`data:${payload.mimeType || "audio/mp3"};base64,${payload.audioBase64}`);
|
|
336
|
+
STATE.audio.playbackRate = STATE.speed;
|
|
337
|
+
STATE.audio.addEventListener("ended", () => advance());
|
|
338
|
+
STATE.audio.addEventListener("error", () => advance());
|
|
339
|
+
if (!STATE.paused) {
|
|
340
|
+
try { await STATE.audio.play(); }
|
|
341
|
+
catch (e) {
|
|
342
|
+
// Autoplay block · pause and let the user click resume.
|
|
343
|
+
STATE.paused = true;
|
|
344
|
+
renderPlayer();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Pre-fetch the next message while this one plays so the
|
|
348
|
+
// handoff is gapless. Single in-flight pre-fetch.
|
|
349
|
+
void prefetch(STATE.idx + 1);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function advance() {
|
|
353
|
+
clearActiveHighlight();
|
|
354
|
+
STATE.idx += 1;
|
|
355
|
+
if (STATE.idx >= STATE.playlist.length) {
|
|
356
|
+
// Playback complete · keep the overlay open with a "done"
|
|
357
|
+
// message so the user can dismiss explicitly.
|
|
358
|
+
const body = STATE.overlay && STATE.overlay.querySelector("[data-vr-body]");
|
|
359
|
+
if (body) {
|
|
360
|
+
body.innerHTML = `
|
|
361
|
+
<div class="vr-key-prompt">
|
|
362
|
+
<div class="vr-key-icon vr-key-icon-done">✓</div>
|
|
363
|
+
<div class="vr-key-text">
|
|
364
|
+
<div class="vr-key-title">Replay complete</div>
|
|
365
|
+
<div class="vr-key-deck">Every message in this room has played back. Close to return to the chat.</div>
|
|
366
|
+
</div>
|
|
367
|
+
<div class="vr-key-actions">
|
|
368
|
+
<button type="button" class="vr-ghost" data-vr-close>Close</button>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
void playCurrent();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function fetchAudio(idx) {
|
|
379
|
+
if (idx < 0 || idx >= STATE.playlist.length) return null;
|
|
380
|
+
if (STATE.prefetched.has(idx)) {
|
|
381
|
+
const cached = STATE.prefetched.get(idx);
|
|
382
|
+
STATE.prefetched.delete(idx);
|
|
383
|
+
return cached;
|
|
384
|
+
}
|
|
385
|
+
const item = STATE.playlist[idx];
|
|
386
|
+
const r = await fetch("/api/voices/by-message/" + encodeURIComponent(item.messageId), {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: { "content-type": "application/json" },
|
|
389
|
+
body: JSON.stringify({ asUser: item.kind === "user" }),
|
|
390
|
+
});
|
|
391
|
+
if (!r.ok) {
|
|
392
|
+
const j = await r.json().catch(() => ({}));
|
|
393
|
+
const code = j && j.code ? j.code : "tts-error";
|
|
394
|
+
const msg = (j && j.error) || ("HTTP " + r.status);
|
|
395
|
+
// Tag the error so callers can route to the key-prompt if
|
|
396
|
+
// the failure was provider-key related.
|
|
397
|
+
const err = new Error(msg);
|
|
398
|
+
err.code = code;
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
return r.json();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function prefetch(idx) {
|
|
405
|
+
if (idx < 0 || idx >= STATE.playlist.length) return;
|
|
406
|
+
if (STATE.prefetched.has(idx)) return;
|
|
407
|
+
try {
|
|
408
|
+
const payload = await fetchAudio(idx);
|
|
409
|
+
if (payload) STATE.prefetched.set(idx, payload);
|
|
410
|
+
} catch { /* swallow · the real fetch on advance will re-error */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── Controls ────────────────────────────────────────────────
|
|
414
|
+
function togglePause() {
|
|
415
|
+
if (!STATE.audio) {
|
|
416
|
+
STATE.paused = !STATE.paused;
|
|
417
|
+
renderPlayer();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (STATE.audio.paused) {
|
|
421
|
+
try { void STATE.audio.play(); } catch { /* noop */ }
|
|
422
|
+
STATE.paused = false;
|
|
423
|
+
} else {
|
|
424
|
+
try { STATE.audio.pause(); } catch { /* noop */ }
|
|
425
|
+
STATE.paused = true;
|
|
426
|
+
}
|
|
427
|
+
renderPlayer();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function skipCurrent() {
|
|
431
|
+
if (STATE.audio) { try { STATE.audio.pause(); } catch { /* noop */ } }
|
|
432
|
+
advance();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function cycleSpeed() {
|
|
436
|
+
const order = [1, 1.25, 1.5, 2];
|
|
437
|
+
const i = order.indexOf(STATE.speed);
|
|
438
|
+
STATE.speed = order[(i + 1) % order.length];
|
|
439
|
+
if (STATE.audio) STATE.audio.playbackRate = STATE.speed;
|
|
440
|
+
renderPlayer();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function toggleIncludeUser() {
|
|
444
|
+
STATE.skipUser = !STATE.skipUser;
|
|
445
|
+
// Rebuild playlist from scratch · we kept the original state
|
|
446
|
+
// on the calling side via app.currentMessages / members /
|
|
447
|
+
// chair. Re-derive from the live app state.
|
|
448
|
+
const app = root.app;
|
|
449
|
+
if (!app) { renderPlayer(); return; }
|
|
450
|
+
const newPlaylist = buildPlaylist(
|
|
451
|
+
Array.isArray(app.currentMessages) ? app.currentMessages.slice() : [],
|
|
452
|
+
{ members: STATE.members, chair: STATE.chair, skipUser: STATE.skipUser },
|
|
453
|
+
);
|
|
454
|
+
if (newPlaylist.length === 0) { renderEmpty(); return; }
|
|
455
|
+
// Try to keep the user near the same speaker · find the
|
|
456
|
+
// playlist entry whose messageId matches the currently-playing
|
|
457
|
+
// one; if the toggle removed that message, snap to the closest
|
|
458
|
+
// surviving index.
|
|
459
|
+
const curId = STATE.playlist[STATE.idx]?.messageId;
|
|
460
|
+
let nextIdx = newPlaylist.findIndex((p) => p.messageId === curId);
|
|
461
|
+
if (nextIdx < 0) {
|
|
462
|
+
// Closest survivor · binary-style scan forward from curIdx.
|
|
463
|
+
const oldIdx = STATE.idx;
|
|
464
|
+
for (let i = oldIdx; i < STATE.playlist.length; i += 1) {
|
|
465
|
+
const id = STATE.playlist[i]?.messageId;
|
|
466
|
+
const found = newPlaylist.findIndex((p) => p.messageId === id);
|
|
467
|
+
if (found >= 0) { nextIdx = found; break; }
|
|
468
|
+
}
|
|
469
|
+
if (nextIdx < 0) nextIdx = Math.min(oldIdx, newPlaylist.length - 1);
|
|
470
|
+
}
|
|
471
|
+
STATE.playlist = newPlaylist;
|
|
472
|
+
STATE.idx = Math.max(0, nextIdx);
|
|
473
|
+
STATE.prefetched = new Map();
|
|
474
|
+
if (STATE.audio) { try { STATE.audio.pause(); } catch { /* noop */ } }
|
|
475
|
+
void playCurrent();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ─── Chat highlight / scroll-into-view ───────────────────────
|
|
479
|
+
function highlightActive(messageId) {
|
|
480
|
+
clearActiveHighlight();
|
|
481
|
+
if (!messageId) return;
|
|
482
|
+
const el = document.querySelector(`[data-message-id="${cssAttrEscape(messageId)}"]`);
|
|
483
|
+
if (!el) return;
|
|
484
|
+
el.classList.add("is-replay-active");
|
|
485
|
+
// Inject the floating "▶ SPEAKING" chip · pinned absolute
|
|
486
|
+
// so it doesn't disturb the bubble's flow. The dot inside
|
|
487
|
+
// pulses to make the audio activity visceral. Removed in
|
|
488
|
+
// clearActiveHighlight on advance / close.
|
|
489
|
+
const chip = document.createElement("div");
|
|
490
|
+
chip.className = "vr-now-playing";
|
|
491
|
+
chip.setAttribute("data-vr-now-playing", "1");
|
|
492
|
+
chip.innerHTML =
|
|
493
|
+
'<span class="vr-np-mark">▶</span>' +
|
|
494
|
+
'<span class="vr-np-dot" aria-hidden="true"></span>' +
|
|
495
|
+
'<span class="vr-np-text">speaking</span>';
|
|
496
|
+
el.appendChild(chip);
|
|
497
|
+
try {
|
|
498
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
499
|
+
} catch { /* noop */ }
|
|
500
|
+
}
|
|
501
|
+
function clearActiveHighlight() {
|
|
502
|
+
document.querySelectorAll(".is-replay-active").forEach((n) => n.classList.remove("is-replay-active"));
|
|
503
|
+
document.querySelectorAll('[data-vr-now-playing="1"]').forEach((n) => n.remove());
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
507
|
+
function escapeText(s) {
|
|
508
|
+
return String(s == null ? "" : s)
|
|
509
|
+
.replace(/&/g, "&")
|
|
510
|
+
.replace(/</g, "<")
|
|
511
|
+
.replace(/>/g, ">")
|
|
512
|
+
.replace(/"/g, """);
|
|
513
|
+
}
|
|
514
|
+
function escapeAttr(s) {
|
|
515
|
+
return escapeText(s).replace(/'/g, "'");
|
|
516
|
+
}
|
|
517
|
+
function cssAttrEscape(s) {
|
|
518
|
+
return String(s == null ? "" : s).replace(/(["\\])/g, "\\$1");
|
|
519
|
+
}
|
|
520
|
+
function truncatePreview(body) {
|
|
521
|
+
const flat = String(body || "").replace(/\s+/g, " ").trim();
|
|
522
|
+
return flat.length > 240 ? flat.slice(0, 237) + "…" : flat;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Public API.
|
|
526
|
+
root.boardroomVoiceReplay = {
|
|
527
|
+
open: open,
|
|
528
|
+
close: close,
|
|
529
|
+
isOpen: isOpen,
|
|
530
|
+
// Exposed for testing.
|
|
531
|
+
_internals: { buildPlaylist, PROCEDURAL_KINDS },
|
|
532
|
+
};
|
|
533
|
+
})(typeof window !== "undefined" ? window : globalThis);
|