privateboard 0.1.11 → 0.1.13

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.
@@ -58,12 +58,50 @@
58
58
  skipUser: true,
59
59
  abortCtrl: null,
60
60
  prefetched: new Map(), // idx → { audioBase64, mimeType }
61
+ /** Currently-active replay item · what the round-table stage
62
+ * reads via getActive() to drive seat highlights, the rt-bubble,
63
+ * and the subtitle bar. `state` flips to "thinking" while the
64
+ * next message is being fetched / synthesised, then "speaking"
65
+ * once audio.play resolves. Cleared on close + on playlist end. */
66
+ active: null, // { messageId, authorId, kind, state, body }
61
67
  };
62
68
 
63
69
  function isOpen() {
64
70
  return !!STATE.overlay;
65
71
  }
66
72
 
73
+ function getActive() {
74
+ return STATE.active;
75
+ }
76
+
77
+ /** Expose the live audio element so the round-table stage's
78
+ * subtitle bar can sync with playback time (currentTime /
79
+ * duration) when replay is active. The replay's audio is a
80
+ * single full-message clip (not a chunked stream), so the
81
+ * subtitle has to interpolate sentence position from the
82
+ * playhead — there's no per-chunk timing metadata available. */
83
+ function getActiveAudio() {
84
+ return STATE.audio || null;
85
+ }
86
+
87
+ /** Fire a DOM event so listeners (the room view's renderRoundTable
88
+ * + renderRoundTableHud) can repaint without each having to
89
+ * poll. Bubble + composed so a subtree listener catches it. */
90
+ function emitActiveChanged() {
91
+ try {
92
+ const ev = new CustomEvent("boardroom:replay-active", {
93
+ detail: STATE.active ? { ...STATE.active } : null,
94
+ bubbles: true,
95
+ });
96
+ document.dispatchEvent(ev);
97
+ } catch { /* IE-style envs · CustomEvent missing → noop */ }
98
+ }
99
+
100
+ function setActive(next) {
101
+ STATE.active = next;
102
+ emitActiveChanged();
103
+ }
104
+
67
105
  /** Build the ordered playlist · keep chronological order, drop
68
106
  * procedural / system messages, optionally drop user messages.
69
107
  * Each entry carries everything the playback loop and the UI
@@ -169,8 +207,10 @@
169
207
  STATE.overlay = null;
170
208
  }
171
209
  clearActiveHighlight();
210
+ removeInlineExpand(); // any inline pill in the adjourned-bar drops too
172
211
  STATE.playlist = [];
173
212
  STATE.prefetched = new Map();
213
+ setActive(null); // round-table stage clears its replay seat / subtitle
174
214
  }
175
215
 
176
216
  // ─── Mount + render ──────────────────────────────────────────
@@ -182,7 +222,10 @@
182
222
  el.innerHTML = `
183
223
  <div class="vr-head">
184
224
  <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>
225
+ <div class="vr-head-actions">
226
+ <button type="button" class="vr-collapse" data-vr-collapse aria-label="Collapse" title="Collapse">_</button>
227
+ <button type="button" class="vr-close" data-vr-close aria-label="Close">✕</button>
228
+ </div>
186
229
  </div>
187
230
  <div class="vr-body" data-vr-body>
188
231
  <div class="vr-spinner-row">
@@ -196,10 +239,25 @@
196
239
  </div>
197
240
  `;
198
241
  document.body.appendChild(el);
242
+ // Every fresh `open()` starts with the floating panel
243
+ // expanded — collapse is a per-session preference, not a
244
+ // sticky one. The previous version persisted `voice-replay.
245
+ // collapsed` to localStorage so a re-open would mount
246
+ // already-collapsed; that confused users who clicked the
247
+ // bottom-bar Voice Replay button and saw the inline group
248
+ // appear without the floating panel ever showing. The bug
249
+ // looked like "Voice Replay morphs into Pause/Next/Stop
250
+ // /Expand instead of opening the player." Solve by NOT
251
+ // restoring the collapsed flag on cold open. Also clear any
252
+ // legacy "1" left in storage from earlier builds so users
253
+ // upgrading from those don't keep getting the same bug for
254
+ // one more session before re-toggling.
255
+ try { localStorage.removeItem("voice-replay.collapsed"); } catch { /* noop */ }
199
256
  el.addEventListener("click", (ev) => {
200
257
  const target = ev.target;
201
258
  if (!target || !(target instanceof Element)) return;
202
259
  if (target.closest("[data-vr-close]")) { ev.preventDefault(); close(); return; }
260
+ if (target.closest("[data-vr-collapse]")) { ev.preventDefault(); toggleCollapsed(); return; }
203
261
  if (target.closest("[data-vr-pause]")) { ev.preventDefault(); togglePause(); return; }
204
262
  if (target.closest("[data-vr-skip]")) { ev.preventDefault(); skipCurrent(); return; }
205
263
  if (target.closest("[data-vr-speed]")) { ev.preventDefault(); cycleSpeed(); return; }
@@ -222,6 +280,169 @@
222
280
  return el;
223
281
  }
224
282
 
283
+ /** Doc-level handler for the inline replay control group in the
284
+ * adjourned-bar — those buttons are mounted OUTSIDE the overlay
285
+ * so the overlay-scoped click delegate above can't see them.
286
+ * Bound once per page lifetime; safe even when no replay is
287
+ * active (the buttons simply aren't in the DOM until
288
+ * toggleCollapsed mounts them). */
289
+ if (!root.__vrInlineExpandBound && typeof document !== "undefined"
290
+ && typeof document.addEventListener === "function") {
291
+ root.__vrInlineExpandBound = true;
292
+ document.addEventListener("click", (ev) => {
293
+ const target = ev.target;
294
+ if (!target || !(target instanceof Element)) return;
295
+ if (target.closest("[data-vr-inline-next]")) {
296
+ ev.preventDefault();
297
+ skipCurrent();
298
+ return;
299
+ }
300
+ if (target.closest("[data-vr-inline-pause]")) {
301
+ ev.preventDefault();
302
+ togglePause();
303
+ return;
304
+ }
305
+ if (target.closest("[data-vr-inline-stop]")) {
306
+ ev.preventDefault();
307
+ close();
308
+ return;
309
+ }
310
+ if (target.closest("[data-vr-inline-expand]")) {
311
+ ev.preventDefault();
312
+ toggleCollapsed();
313
+ return;
314
+ }
315
+ });
316
+ }
317
+
318
+ /** Collapse the player by hiding the floating overlay entirely +
319
+ * surfacing the inline replay control group in the adjourned-bar
320
+ * right after where the Voice Replay button used to be. Audio
321
+ * keeps playing in the background; the user's content is no
322
+ * longer blocked.
323
+ *
324
+ * The collapsed posture is per-session only. We deliberately
325
+ * do NOT persist to localStorage — restoring a collapsed flag
326
+ * on cold open would make a fresh `open()` look like the panel
327
+ * never appeared (it'd mount already-collapsed and hide the
328
+ * floating overlay), confusing users who expect the player to
329
+ * show every time they click Voice Replay. */
330
+ function toggleCollapsed() {
331
+ if (!STATE.overlay) return;
332
+ const collapsed = STATE.overlay.classList.toggle("is-collapsed");
333
+ if (collapsed) mountInlineExpand();
334
+ else removeInlineExpand();
335
+ }
336
+
337
+ /** Slot the inline replay control group into the bottom-bar
338
+ * action group right after the existing Voice Replay anchor.
339
+ * Three buttons: Next (skip current message), Pause/Resume
340
+ * (toggles audio playback), Expand (re-opens the panel). The
341
+ * group reads as a sibling of Export / Voice Replay / Convene
342
+ * Follow-up via `.ghost-btn` chrome. Idempotent · re-mount is
343
+ * a no-op when the group is already present. */
344
+ function mountInlineExpand() {
345
+ if (document.querySelector("[data-vr-inline-group]")) return;
346
+ const replayBtn = document.querySelector(".adjourned-bar [data-room-replay]");
347
+ if (!replayBtn) return;
348
+ const group = document.createElement("span");
349
+ group.className = "vr-inline-group";
350
+ group.setAttribute("data-vr-inline-group", "1");
351
+ // Inline-SVG icons · all `currentColor` so they inherit
352
+ // `.ghost-btn`'s text + lime-on-hover treatment. Standard
353
+ // media-player vocabulary: filled triangles for play/skip,
354
+ // filled bars for pause, stroked corner-out arrows for expand.
355
+ // 14×14 viewBox sized down to 12px so the buttons stay tight.
356
+ // Hover / aria-label carry the action text.
357
+ const NEXT_SVG = `
358
+ <svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
359
+ <path d="M2 2 L7.5 7 L2 12 Z" fill="currentColor"/>
360
+ <path d="M7 2 L12.5 7 L7 12 Z" fill="currentColor"/>
361
+ </svg>
362
+ `;
363
+ const PAUSE_SVG = `
364
+ <svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
365
+ <rect x="3.5" y="2.5" width="2.5" height="9" rx="0.6" fill="currentColor"/>
366
+ <rect x="8" y="2.5" width="2.5" height="9" rx="0.6" fill="currentColor"/>
367
+ </svg>
368
+ `;
369
+ const PLAY_SVG = `
370
+ <svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
371
+ <path d="M3.5 2 L12 7 L3.5 12 Z" fill="currentColor"/>
372
+ </svg>
373
+ `;
374
+ // Stash the play SVG on the constructor so refreshInlinePauseButton
375
+ // can swap between Pause / Play without re-wiring the markup.
376
+ group.dataset.vrPlaySvg = PLAY_SVG.trim();
377
+ group.dataset.vrPauseSvg = PAUSE_SVG.trim();
378
+ const STOP_SVG = `
379
+ <svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
380
+ <rect x="3" y="3" width="8" height="8" rx="0.6" fill="currentColor"/>
381
+ </svg>
382
+ `;
383
+ const EXPAND_SVG = `
384
+ <svg class="vib-icon" viewBox="0 0 14 14" width="12" height="12" aria-hidden="true">
385
+ <!-- Top-right corner out -->
386
+ <polyline points="8.5,3 11,3 11,5.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
387
+ <line x1="11" y1="3" x2="7.5" y2="6.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
388
+ <!-- Bottom-left corner out -->
389
+ <polyline points="5.5,11 3,11 3,8.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
390
+ <line x1="3" y1="11" x2="6.5" y2="7.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
391
+ </svg>
392
+ `;
393
+ group.innerHTML = `
394
+ <button type="button" class="ghost-btn vr-inline-btn" data-vr-inline-next aria-label="Next message" title="Next message">${NEXT_SVG}</button>
395
+ <button type="button" class="ghost-btn vr-inline-btn" data-vr-inline-pause aria-label="Pause" title="Pause"><span data-vib-pause-mark>${PAUSE_SVG}</span></button>
396
+ <button type="button" class="ghost-btn vr-inline-btn" data-vr-inline-stop aria-label="Stop replay" title="Stop replay">${STOP_SVG}</button>
397
+ <button type="button" class="ghost-btn vr-inline-btn vr-inline-expand" data-vr-inline-expand aria-label="Expand voice replay" title="Expand voice replay">${EXPAND_SVG}<span class="vie-pulse" aria-hidden="true"></span></button>
398
+ `;
399
+ replayBtn.insertAdjacentElement("afterend", group);
400
+ // Hide the original Voice Replay anchor while the inline group
401
+ // is showing — its job (open the player) is supplanted by the
402
+ // inline Expand button. Stash the previous display so we can
403
+ // restore it cleanly on remove.
404
+ replayBtn.dataset.vrPrevDisplay = replayBtn.style.display || "";
405
+ replayBtn.style.display = "none";
406
+ refreshInlinePauseButton();
407
+ }
408
+
409
+ function removeInlineExpand() {
410
+ const group = document.querySelector("[data-vr-inline-group]");
411
+ if (group) group.remove();
412
+ // Restore the original Voice Replay anchor in the bottom bar
413
+ // so the user can re-trigger the player. We stashed the prior
414
+ // inline display when we hid it; restore it (empty string ==
415
+ // CSS default).
416
+ const replayBtn = document.querySelector(".adjourned-bar [data-room-replay]");
417
+ if (replayBtn) {
418
+ const prev = replayBtn.dataset.vrPrevDisplay;
419
+ replayBtn.style.display = (prev === undefined || prev === null) ? "" : prev;
420
+ delete replayBtn.dataset.vrPrevDisplay;
421
+ }
422
+ }
423
+
424
+ /** Sync the inline pause/resume button with the live STATE.paused
425
+ * flag. Called from mount, togglePause, and on advance so the
426
+ * glyph + label always read the current state. No-op when the
427
+ * inline group isn't mounted. */
428
+ function refreshInlinePauseButton() {
429
+ const mark = document.querySelector("[data-vib-pause-mark]");
430
+ if (!mark) return;
431
+ const group = document.querySelector("[data-vr-inline-group]");
432
+ if (!group) return;
433
+ const playing = STATE.audio && !STATE.paused && !STATE.audio.paused;
434
+ // Pull the cached SVG sources stashed at mount time. innerHTML
435
+ // assignment is fine — SVG strings are author-controlled
436
+ // constants, not user input.
437
+ mark.innerHTML = playing ? group.dataset.vrPauseSvg : group.dataset.vrPlaySvg;
438
+ const btn = mark.closest("[data-vr-inline-pause]");
439
+ if (btn) {
440
+ const label = playing ? "Pause" : "Resume";
441
+ btn.setAttribute("aria-label", label);
442
+ btn.setAttribute("title", label);
443
+ }
444
+ }
445
+
225
446
  function setBusy(busy, msg) {
226
447
  if (!STATE.overlay) return;
227
448
  const t = STATE.overlay.querySelector("[data-vr-spinner-text]");
@@ -312,6 +533,17 @@
312
533
  if (!cur) { close(); return; }
313
534
  renderPlayer();
314
535
  highlightActive(cur.messageId);
536
+ // Mark the seat as "thinking" while we fetch + synthesise. The
537
+ // round-table stage reads this via getActive() and lights the
538
+ // seat with a thinking bubble. Body is included so the stage
539
+ // subtitle can preview the line that's about to be spoken.
540
+ setActive({
541
+ messageId: cur.messageId,
542
+ authorId: cur.authorId,
543
+ kind: cur.kind,
544
+ state: "thinking",
545
+ body: cur.body || "",
546
+ });
315
547
  let payload;
316
548
  try {
317
549
  payload = await fetchAudio(STATE.idx);
@@ -336,14 +568,40 @@
336
568
  STATE.audio.playbackRate = STATE.speed;
337
569
  STATE.audio.addEventListener("ended", () => advance());
338
570
  STATE.audio.addEventListener("error", () => advance());
571
+ // Tick out a DOM event on every timeupdate (~4 Hz) so the
572
+ // round-table stage's subtitle bar can poll currentTime /
573
+ // duration and interpolate which sentence is being read. We
574
+ // can't capture per-sentence timing for replay (the audio is
575
+ // a single base64-decoded clip, not a chunked stream), so the
576
+ // subtitle has to estimate · firing the event keeps the
577
+ // cadence consistent without coupling the modules.
578
+ STATE.audio.addEventListener("timeupdate", () => {
579
+ try {
580
+ const ev = new CustomEvent("boardroom:replay-tick", { bubbles: true });
581
+ document.dispatchEvent(ev);
582
+ } catch { /* old browsers · noop */ }
583
+ });
339
584
  if (!STATE.paused) {
340
- try { await STATE.audio.play(); }
585
+ try {
586
+ await STATE.audio.play();
587
+ // Audio is running · flip seat from "thinking" → "speaking".
588
+ setActive({
589
+ messageId: cur.messageId,
590
+ authorId: cur.authorId,
591
+ kind: cur.kind,
592
+ state: "speaking",
593
+ body: cur.body || "",
594
+ });
595
+ }
341
596
  catch (e) {
342
597
  // Autoplay block · pause and let the user click resume.
343
598
  STATE.paused = true;
344
599
  renderPlayer();
345
600
  }
346
601
  }
602
+ // Sync the inline (collapsed) pause button so its glyph
603
+ // tracks the live playback state across message handoffs.
604
+ refreshInlinePauseButton();
347
605
  // Pre-fetch the next message while this one plays so the
348
606
  // handoff is gapless. Single in-flight pre-fetch.
349
607
  void prefetch(STATE.idx + 1);
@@ -355,6 +613,7 @@
355
613
  if (STATE.idx >= STATE.playlist.length) {
356
614
  // Playback complete · keep the overlay open with a "done"
357
615
  // message so the user can dismiss explicitly.
616
+ setActive(null);
358
617
  const body = STATE.overlay && STATE.overlay.querySelector("[data-vr-body]");
359
618
  if (body) {
360
619
  body.innerHTML = `
@@ -415,6 +674,7 @@
415
674
  if (!STATE.audio) {
416
675
  STATE.paused = !STATE.paused;
417
676
  renderPlayer();
677
+ refreshInlinePauseButton();
418
678
  return;
419
679
  }
420
680
  if (STATE.audio.paused) {
@@ -425,6 +685,7 @@
425
685
  STATE.paused = true;
426
686
  }
427
687
  renderPlayer();
688
+ refreshInlinePauseButton();
428
689
  }
429
690
 
430
691
  function skipCurrent() {
@@ -527,6 +788,8 @@
527
788
  open: open,
528
789
  close: close,
529
790
  isOpen: isOpen,
791
+ getActive: getActive,
792
+ getActiveAudio: getActiveAudio,
530
793
  // Exposed for testing.
531
794
  _internals: { buildPlaylist, PROCEDURAL_KINDS },
532
795
  };