openvibers/openvibe.live Commits

Test privately: ten seconds of the preview, recorded on this device and played back (#545)

OP OpenVibers committed 11 days ago · dfd1dfe7ed · 3 files, +156 −0

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex c1460573..96be7368 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -3304,3 +3304,26 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
.gl-pv-off[aria-pressed="true"] i::before { content: "\f06e"; }
@media (pointer: coarse) { .gl-pv-off { width: 44px; height: 44px; } }
@media (prefers-reduced-motion: reduce) { .gl-preview video, .gl-pv-lvl i { transition: none; } }
++
++/* Test privately: the quiet button under Go live; while recording, a red dot and the seconds left; then the test plays
++ back over the preview with Done. */
++.gl-test-btn { all: unset; box-sizing: border-box; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 4px 8px; width: 100%; min-height: 44px; margin-top: 8px;
++ padding: 6px 14px; border-radius: 14px; cursor: pointer; font-size: 0.9rem; font-weight: 700; color: var(--text-primary);
++ border: 1px solid var(--border); background: color-mix(in srgb, var(--text-primary) 4%, transparent); transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s); }
++.gl-test-btn i { color: var(--text-muted); }
++.gl-test-btn small { font-size: 0.74rem; font-weight: 500; color: var(--text-muted); }
++.gl-test-btn:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
++.gl-test-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.gl-test-btn.is-rec { border-color: color-mix(in srgb, var(--live-red, #e11d48) 60%, var(--border)); background: color-mix(in srgb, var(--live-red, #e11d48) 8%, transparent); }
++.gl-test-btn.is-rec i { color: var(--live-red, #e11d48); animation: gl-breathe 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }
++.gl-test-btn.is-rec small { display: none; }
++.gl-test-play { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; background: var(--bg-primary); }
++.gl-preview .gl-test-play video { position: static; inset: auto; opacity: 1; transform: none; flex: 1 1 auto; min-height: 0; width: 100%; height: auto; object-fit: contain; background: #000; }
++.gl-test-play audio { width: calc(100% - 24px); margin: auto 12px 8px; }
++.gl-test-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 12px; font-size: 0.78rem; line-height: 1.35; color: var(--text-secondary); }
++.gl-test-bar span { flex: 1 1 auto; min-width: 0; }
++.gl-test-done { all: unset; box-sizing: border-box; flex: none; min-height: 36px; padding: 0 14px; border-radius: 999px; cursor: pointer; font-weight: 700; font-size: 0.84rem;
++ background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
++.gl-test-done:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++@media (pointer: coarse) { .gl-test-done { min-height: 44px; } }
++@media (prefers-reduced-motion: reduce) { .gl-test-btn.is-rec i { animation: none; } }
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 610be55b..a898112c 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -392,6 +392,9 @@ function _wsRenderPanel() {
onclick="goLiveFromWorkspace()" id="bc-ws-golive-btn">
<i class="fa-solid fa-tower-broadcast"></i> Go Live
</button>
++ ${browserMode === 'screen' ? '' : `<button type="button" class="gl-test-btn" id="gl-test-btn" onclick="_glTestPrivately()">
++ <i class="fa-solid fa-circle-dot" aria-hidden="true"></i><span id="gl-test-label">Test privately</span><small>10 seconds, only on this device</small>
++ </button>`}
<p class="bc-create-reassurance" style="margin-top:6px;font-size:0.82rem">
<i class="fa-solid fa-circle-info"></i>
Live at <strong>openvibe.live${channelPath(currentUser?.username || 'your-channel', ms.slug || ms.id)}</strong>
@@@ -1053,6 +1056,7 @@ function _glPreviewWant() {
}
function _glPreviewStop() {
++ if (_glT.rec) _glTestStop(true);
cancelAnimationFrame(_glPv.raf);
_glPv.raf = 0;
if (_glPv.ctx) { try { _glPv.ctx.close(); } catch { /* */ } _glPv.ctx = null; }
@@@ -1181,6 +1185,101 @@ function _glPreviewWatch() {
window.addEventListener('offline', _glPreviewSync);
}
++/* ── Test privately (golive-setup redesign, part 3): ten seconds of what the preview holds, recorded in this browser
++ and played back in the card, so the camera and the microphone are heard and seen before anyone else does. Nothing
++ is uploaded and nobody is told: the recording lives in this page's memory until Done. ── */
++const _glT = { rec: null, chunks: [], timer: 0, left: 0, url: '' };
++
++function _glTestType(audioOnly) {
++ const list = audioOnly ? ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']
++ : ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
++ if (typeof MediaRecorder === 'undefined') return null;
++ return list.find((t) => { try { return MediaRecorder.isTypeSupported(t); } catch { return false; } }) || '';
++}
++
++function _glTestLabel(text) { const l = document.getElementById('gl-test-label'); if (l) l.textContent = text; }
++
++function _glTestPrivately() {
++ if (_glT.rec) { _glTestStop(); return; }
++ const stream = _glPv.stream;
++ const note = document.getElementById('gl-pv-note');
++ if (!stream || !stream.getTracks().some((t) => t.readyState === 'live')) {
++ if (note) note.textContent = _glPreviewOff() ? 'Turn the preview on (the eye) to record a test.' : 'Allow the camera and microphone below, then record a test.';
++ return;
++ }
++ const audioOnly = !stream.getVideoTracks().length;
++ const type = _glTestType(audioOnly);
++ if (type === null) { if (note) note.textContent = 'This browser cannot record a test. Going live still works.'; return; }
++ _glTestClear();
++ let rec;
++ try { rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined); } catch { if (note) note.textContent = 'This browser cannot record a test. Going live still works.'; return; }
++ _glT.rec = rec;
++ _glT.chunks = [];
++ rec.ondataavailable = (e) => { if (e.data && e.data.size) _glT.chunks.push(e.data); };
++ rec.onstop = () => _glTestShow(rec.mimeType || type || (audioOnly ? 'audio/webm' : 'video/webm'), audioOnly);
++ rec.start(1000);
++ const btn = document.getElementById('gl-test-btn');
++ if (btn) btn.classList.add('is-rec');
++ _glT.left = 10;
++ _glTestLabel(`Recording · ${_glT.left} s · tap to stop`);
++ _glT.timer = setInterval(() => {
++ _glT.left -= 1;
++ if (_glT.left <= 0) { _glTestStop(); return; }
++ _glTestLabel(`Recording · ${_glT.left} s · tap to stop`);
++ }, 1000);
++}
++
++/** Stop recording. discard: drop it (going live, or the page changing) rather than play it back. */
++function _glTestStop(discard = false) {
++ clearInterval(_glT.timer);
++ _glT.timer = 0;
++ const rec = _glT.rec;
++ _glT.rec = null;
++ const btn = document.getElementById('gl-test-btn');
++ if (btn) btn.classList.remove('is-rec');
++ _glTestLabel('Test privately');
++ if (rec && rec.state !== 'inactive') {
++ if (discard) rec.onstop = null;
++ try { rec.stop(); } catch { /* */ }
++ }
++ if (discard) _glTestClear();
++}
++
++function _glTestShow(type, audioOnly) {
++ const box = document.getElementById('gl-preview');
++ if (!box || !_glT.chunks.length) return;
++ _glTestClear();
++ _glT.url = URL.createObjectURL(new Blob(_glT.chunks, { type }));
++ _glT.chunks = [];
++ const wrap = document.createElement('div');
++ wrap.className = 'gl-test-play';
++ wrap.id = 'gl-test-play';
++ const media = document.createElement(audioOnly ? 'audio' : 'video');
++ media.src = _glT.url;
++ media.controls = true;
++ media.playsInline = true;
++ media.setAttribute('aria-label', 'Your private test');
++ const bar = document.createElement('div');
++ bar.className = 'gl-test-bar';
++ const say = document.createElement('span');
++ say.textContent = 'Your test, only on this device. Check the picture and that you can hear yourself.';
++ const done = document.createElement('button');
++ done.type = 'button';
++ done.className = 'gl-test-done';
++ done.textContent = 'Done';
++ done.onclick = _glTestClear;
++ bar.append(say, done);
++ wrap.append(media, bar);
++ box.append(wrap);
++ media.play().catch(() => { /* the controls are there: a tap plays it */ });
++}
++
++function _glTestClear() {
++ const w = document.getElementById('gl-test-play');
++ if (w) w.remove();
++ if (_glT.url) { URL.revokeObjectURL(_glT.url); _glT.url = ''; }
++}
++
/* ── Tab switching ───────────────────────────────────────────── */
function _wsTabSwitch(tabId) {
@@@ -2525,6 +2624,7 @@ async function goLiveFromWorkspace() {
await _wsFlushSave();
// The studio opens the camera itself: the preview lets go of it first (a phone cannot open one camera twice).
_glPv.hold = Date.now() + 20000;
++ _glTestStop(true);
_glPreviewStop();
const p = _wsState.profile;
ddiff --git a/test/golive-test-privately.test.js b/test/golive-test-privately.test.js
nnew file mode 100644
iindex 00000000..76491c90
---- /dev/null
++++ b/test/golive-test-privately.test.js
@@@ -0,0 +1,33 @@
++'use strict';
++
++/**
++ * Test privately (golive-setup redesign, part 3): ten seconds of the preview, recorded in the browser and played back in
++ * the card. Nothing leaves the device: the test code calls no API and no fetch, the recording is an object URL revoked on
++ * Done, and going live (or the preview letting go of the camera) ends a test that is running.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ws = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-workspace.js'), 'utf8');
++const start = ws.indexOf('/* ── Test privately');
++const block = ws.slice(start, ws.indexOf('\n/* ── Tab switching', start));
++assert.ok(start > 0 && block.length > 500, 'the Test privately block');
++
++assert.ok(!/\bapi\(|fetch\(|XMLHttpRequest|sendBeacon|WebSocket/.test(block), 'nothing is sent anywhere');
++assert.ok(block.includes('new MediaRecorder(stream'), 'it records the preview the card already holds (no second camera)');
++assert.ok(/_glT\.left = 10;/.test(block), 'ten seconds');
++assert.ok(block.includes('URL.revokeObjectURL(_glT.url)'), 'Done frees the recording');
++assert.ok(block.includes("rec.onstop = null"), 'a discarded test is never played back');
++
++const golive = ws.slice(ws.indexOf('async function goLiveFromWorkspace('), ws.indexOf('function updateWorkspaceLiveStatus('));
++assert.ok(golive.indexOf('_glTestStop(true);') > 0 && golive.indexOf('_glTestStop(true);') < golive.indexOf('_glPreviewStop();'), 'going live ends a running test first');
++const stop = ws.slice(ws.indexOf('function _glPreviewStop() {'), ws.indexOf('\n}\n', ws.indexOf('function _glPreviewStop() {')));
++assert.ok(stop.includes('if (_glT.rec) _glTestStop(true);'), 'the preview letting go ends a running test');
++
++// The button: under Go live, not for a screen share (the browser picks the screen only when going live).
++const panel = ws.slice(ws.indexOf('function _wsRenderPanel()'), ws.indexOf('\n}\n', ws.indexOf('function _wsRenderPanel()')));
++assert.ok(/\$\{browserMode === 'screen' \? '' : `<button type="button" class="gl-test-btn" id="gl-test-btn" onclick="_glTestPrivately\(\)">/.test(panel), 'the button, except for a screen share');
++assert.ok(panel.indexOf('id="gl-test-btn"') > panel.indexOf('id="bc-ws-golive-btn"'), 'under Go live');
++
++console.log('golive-test-privately: all passed');