openvibers/openvibe.live Commits

Gate phone studio camera and use shared Cam controls (#729)

OP OpenVibers committed 4 days ago · 2f89ccefc4 · 18 files, +290 −82

* Gate phone studio camera and share Cam controls

* Merge remote-tracking branch 'origin/main' into feat/live-phone-studio-1109

* Phone studio: the Dock pill follows the keyboard with the page, and one Turn on camera

The page's clearance for the Go live dock became keyboard-aware (max of the app bar and the keyboard) but the Dock
pill above it did not: both use it now, and the tests read the one formula. With the dock on a phone, the card's own
Turn on camera button goes (the dock at the thumb carries it); the card keeps its words and the other ways.

* The Cam kit loads at its versioned URL, so a cached older copy never meets the integrity check

* Merge remote-tracking branch 'origin/main' into feat/live-phone-studio-1109

* Merge remote-tracking branch 'origin/main' into feat/live-phone-studio-1109

Changes

ddiff --git a/docs/shots/live-studio-1280-dark-allow.png b/docs/shots/live-studio-1280-dark-allow.png
nnew file mode 100644
iindex 00000000..5e1ea93d
BBinary files /dev/null and b/docs/shots/live-studio-1280-dark-allow.png differ
ddiff --git a/docs/shots/live-studio-1280-dark-ready.png b/docs/shots/live-studio-1280-dark-ready.png
nnew file mode 100644
iindex 00000000..f3a39a6f
BBinary files /dev/null and b/docs/shots/live-studio-1280-dark-ready.png differ
ddiff --git a/docs/shots/live-studio-1280-light-allow.png b/docs/shots/live-studio-1280-light-allow.png
nnew file mode 100644
iindex 00000000..c8caed53
BBinary files /dev/null and b/docs/shots/live-studio-1280-light-allow.png differ
ddiff --git a/docs/shots/live-studio-1280-light-ready.png b/docs/shots/live-studio-1280-light-ready.png
nnew file mode 100644
iindex 00000000..29768d6f
BBinary files /dev/null and b/docs/shots/live-studio-1280-light-ready.png differ
ddiff --git a/docs/shots/live-studio-390-dark-allow.png b/docs/shots/live-studio-390-dark-allow.png
nnew file mode 100644
iindex 00000000..939dd921
BBinary files /dev/null and b/docs/shots/live-studio-390-dark-allow.png differ
ddiff --git a/docs/shots/live-studio-390-dark-ready.png b/docs/shots/live-studio-390-dark-ready.png
nnew file mode 100644
iindex 00000000..ca70533d
BBinary files /dev/null and b/docs/shots/live-studio-390-dark-ready.png differ
ddiff --git a/docs/shots/live-studio-390-light-allow.png b/docs/shots/live-studio-390-light-allow.png
nnew file mode 100644
iindex 00000000..79774d52
BBinary files /dev/null and b/docs/shots/live-studio-390-light-allow.png differ
ddiff --git a/docs/shots/live-studio-390-light-ready.png b/docs/shots/live-studio-390-light-ready.png
nnew file mode 100644
iindex 00000000..cbb1981c
BBinary files /dev/null and b/docs/shots/live-studio-390-light-ready.png differ
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 43416dc7..264676d8 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2845,6 +2845,10 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importan
background: linear-gradient(to bottom, transparent, var(--bg-primary, #0b0b10) 34%);
}
.bcs-golive[hidden] { display: none; }
++.bcs-golive { bottom: max(var(--ovnav-appbar-h, 0px), var(--gl-keyboard, 0px), env(keyboard-inset-height, 0px)); }
++.bcs-golive[data-mode="live"] .bcs-golive-btn { background: #be123c; color: #fff; }
++.bcs-golive[data-mode="connecting"] .bcs-golive-btn { background: var(--bg-card); color: var(--text-primary); }
++.bcs-golive[data-mode="live"], .bcs-golive[data-mode="connecting"] { animation: unmute-pop var(--ov-dur-3, .3s) var(--ov-ease-out, ease-out) both; }
.bcs-golive-btn {
width: 100%; max-width: 520px; min-height: 52px; border: 0; border-radius: 14px; cursor: pointer;
background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font: inherit; font-size: 1.05rem; font-weight: 800;
@@@ -2856,10 +2860,10 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importan
/* The Dock's pill (openvibe-shared dock.js, in a bottom corner) rides above the Go live dock: it sat on the button's
right half, so a tap there opened the Agent panel instead of going live (11-09). Top corners stay where they are. */
body.bc-golive-dock-on .ovdk-pill:not([data-corner^="top"]) {
-- bottom: calc(104px + var(--ovnav-appbar-h, 0px) + env(safe-area-inset-bottom, 0px)); /* the page's own clearance (below) */
++ bottom: calc(104px + max(var(--ovnav-appbar-h, 0px), var(--gl-keyboard, 0px)) + env(safe-area-inset-bottom, 0px)); /* the page's own clearance (below); above the keyboard when it is up */
}
/* The page keeps room at its foot for the dock, so its last fields are never under it. */
--body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovnav-appbar-h, 0px) + env(safe-area-inset-bottom, 0px)); }
++body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + max(var(--ovnav-appbar-h, 0px), var(--gl-keyboard, 0px)) + env(safe-area-inset-bottom, 0px)); }
/* What viewers see, over the studio (bcStudio.showViewer): a phone stays on this tab. */
.bcs-viewer {
@@@ -2985,6 +2989,29 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; margin: 0; padding: 12px; text-align: left;
border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card);
}
++.bc-ws-golive-section .gl-allow { display: grid; justify-items: center; gap: 8px; padding: 22px 16px; text-align: center; }
++.gl-allow-mark { display: grid; place-items: center; width: 64px; height: 64px; border: 3px solid var(--accent); border-radius: 50%; animation: gl-mark-work 1.8s ease-in-out infinite; }
++.gl-allow strong { font-size: 1.05rem; color: var(--text-primary); }
++.gl-allow .bc-perm-hint { justify-content: center; flex: none; }
++.gl-allow .bc-perm-hint i { display: none; }
++.gl-allow-other { border: 0; background: none; color: var(--text-secondary); text-decoration: underline; font: inherit; min-height: 44px; cursor: pointer; }
++@keyframes gl-mark-work { 50% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--accent) 12%, transparent); transform: scale(1.04); } }
++.gl-preview.is-allowed::before { content: '● Camera on'; position: absolute; z-index: 2; top: 12px; left: 12px; color: #4ade80; background: rgba(8,12,20,.78); border: 2px solid #4ade80; border-radius: 99px; padding: 6px 10px; font-size: .76rem; font-weight: 750; }
++.gl-pv-controls { position: absolute; z-index: 3; left: 50%; bottom: 8px; transform: translateX(-50%); display: flex; gap: 6px; padding: 4px; border-radius: 999px; background: rgba(8,12,20,.7); }
++.gl-pv-controls button { display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 50%; background: rgba(255,255,255,.13); color: #fff; cursor: pointer; }
++.gl-pv-controls button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
++.gl-device-sheet-head { display: none; }
++.gl-device-extras { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
++.gl-device-extras label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; flex: 1 1 180px; font-size: .87rem; }
++.gl-device-extras select { min-height: 44px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-primary); color: var(--text-primary); }
++@media (max-width: 760px) {
++ .bc-ws-golive-section #bc-device-selects { position: fixed; z-index: 1300; left: 0; right: 0; bottom: max(var(--ovnav-appbar-h, 0px), var(--gl-keyboard, 0px)); max-height: 70dvh; overflow-y: auto; padding: 16px 16px 22px; border-radius: 22px 22px 0 0; background: var(--bg-card); box-shadow: 0 -12px 40px rgba(0,0,0,.35); transform: translateY(calc(100% + 100px)); transition: transform var(--ov-dur-3, .3s); }
++ body.gl-device-sheet-open .bc-ws-golive-section #bc-device-selects { transform: translateY(0); }
++ .gl-device-sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
++ .gl-device-sheet-head button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: var(--bg-primary); color: var(--text-primary); }
++}
++@media (max-width: 760px) { .gl-preview { min-height: min(46vh, 390px); max-height: min(58vh, 520px); } .gl-pv-chips { bottom: 62px; } }
++@media (prefers-reduced-motion: reduce) { .gl-allow-mark, .bcs-golive[data-mode="live"], .bcs-golive[data-mode="connecting"] { animation: none; } .bcs-golive-btn .fa-spin { animation-iteration-count: 1; } }
.bc-ws-golive-section .bc-perm-hint { flex: 1 1 200px; display: flex; align-items: flex-start; gap: 8px; margin: 0; font-size: 0.84rem; }
.bc-ws-golive-section .bc-perm-hint i { color: var(--accent); margin-top: 3px; }
.bc-ws-golive-section #bc-perm-btn { flex: 1 0 auto; justify-content: center; min-height: 44px; border-radius: 12px; font-weight: 650; }
@@@ -3320,6 +3347,8 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
.gl-preview { position: relative; overflow: hidden; margin: 0 0 12px; border-radius: 14px; aspect-ratio: var(--gl-pv-ratio, 16 / 9); max-height: min(52vh, 520px);
width: 100%; max-width: 100%; background: radial-gradient(120% 120% at 50% 0%, color-mix(in srgb, var(--accent) 14%, var(--bg-primary)), var(--bg-primary));
border: 1px solid var(--border); display: grid; place-items: center; }
++.gl-preview:not(.is-allowed):not(.is-still) { display: none; }
++.gl-preview.is-allowed { animation: unmute-pop var(--ov-dur-3, .3s) var(--ov-ease-out, ease-out) both; }
.gl-preview video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity var(--ov-dur-3, .3s); }
.gl-preview video.is-mirror { transform: scaleX(-1); }
.gl-preview[data-state="on"] video { opacity: 1; }
@@@ -3387,3 +3416,7 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
.gl-label--ai { border-style: dashed; color: var(--accent); }
@media (pointer: coarse) { .gl-label { min-height: 44px; } }
@media (prefers-reduced-motion: reduce) { .gl-label { transition: none; } }
++
++/* One "Turn on camera" (11-09 review): on a phone the Go live dock at the thumb carries it, so the card keeps the words
++ and the other ways, not a second identical button. */
++body.bc-golive-dock-on #bc-perm-btn { display: none; }
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex d1c932ec..a5eb8258 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -403,7 +403,7 @@ async function populateCreateFormDevices() {
const hasLabels = devices.some(d => d.label);
const permReq = document.getElementById('bc-perm-request');
const devSelects = document.getElementById('bc-device-selects');
-- if (hasLabels) {
++ if (hasLabels && window._glCameraAllowed) {
// Already have permissions — show dropdowns directly
if (permReq) permReq.style.display = 'none';
if (devSelects) devSelects.style.display = '';
@@@ -499,32 +499,40 @@ async function requestMediaPermissions() {
try {
let tempStream;
++ const core = await _glCamCore();
++ const mode = _wsState.selectedMs?.browser_mode || _wsState.profile?.browserMode || 'camera';
// Strategy 1: combined audio+video
try {
if (dbg) dbg.textContent = 'Requesting camera + mic...';
-- tempStream = await _getUserMediaWithTimeout({ audio: true, video: true });
++ tempStream = await core.open({ audio: mode !== 'camera_only', video: mode !== 'mic_only', facing: window._glCamSettings?.facing || 'user', resolution: window._glCamSettings?.resolution || '720' });
} catch (firstErr) {
console.warn('[Broadcast] Combined getUserMedia failed:', firstErr.message, '— trying separately');
// Strategy 2: separate requests (common on Android)
if (dbg) dbg.textContent = 'Combined failed, trying separately...';
let vidStream, audStream;
-- try { vidStream = await _getUserMediaWithTimeout({ video: { facingMode: 'user' } }, 10000); } catch (ve) {
++ try { if (mode !== 'mic_only') vidStream = await _getUserMediaWithTimeout({ video: { facingMode: 'user' } }, 10000); } catch (ve) {
console.warn('[Broadcast] Video facingMode failed:', ve.message);
// Strategy 2b: bare minimum video
try { vidStream = await _getUserMediaWithTimeout({ video: true }, 10000); } catch (ve2) {
console.warn('[Broadcast] Video-only getUserMedia failed:', ve2.message);
}
}
-- try { audStream = await _getUserMediaWithTimeout({ audio: true }, 10000); } catch (ae) { console.warn('[Broadcast] Audio-only getUserMedia failed:', ae.message); }
++ try { if (mode !== 'camera_only') audStream = await _getUserMediaWithTimeout({ audio: true }, 10000); } catch (ae) { console.warn('[Broadcast] Audio-only getUserMedia failed:', ae.message); }
if (!vidStream && !audStream) throw new Error('No camera or microphone available');
tempStream = new MediaStream();
if (vidStream) vidStream.getTracks().forEach(t => { tempStream.addTrack(t); });
if (audStream) audStream.getTracks().forEach(t => { tempStream.addTrack(t); });
}
++ const hasVideo = tempStream.getVideoTracks().length > 0;
++ const hasAudio = tempStream.getAudioTracks().length > 0;
tempStream.getTracks().forEach(t => t.stop());
++ if (mode !== 'mic_only' && !hasVideo || mode !== 'camera_only' && !hasAudio) throw new DOMException('The requested device was not available', 'NotFoundError');
if (dbg) dbg.textContent = 'Enumerating devices...';
-- const devices = await navigator.mediaDevices.enumerateDevices();
++ const devices = await core.devices();
_populateCreateDeviceDropdowns(devices);
++ const saved = window._glCamSettings;
++ const camSelect = document.getElementById('bc-create-camera');
++ const micSelect = document.getElementById('bc-create-audio');
// After permission grant, re-check what the browser actually gave us
// and try to match to the user's saved preference. If no preference exists,
@@@ -537,15 +545,30 @@ async function requestMediaPermissions() {
if (savedCamera && savedCamera !== 'default') {
_syncCameraSelectionUI(savedCamera, { persist: false });
}
++ if (camSelect && saved) {
++ const preferred = [...camSelect.options].find((o) => o.value === saved.lens)
++ || [...camSelect.options].find((o) => o.dataset.facing === saved.facing);
++ if (preferred) _syncCameraSelectionUI(preferred.value, { persist: false });
++ }
++ if (saved?.mic && micSelect && [...micSelect.options].some((o) => o.value === saved.mic)) _syncAudioSelectionUI(saved.mic, { persist: false });
if (permReq) permReq.style.display = 'none';
if (devSelects) devSelects.style.display = '';
++ window._glCameraAllowed = true;
++ document.getElementById('gl-preview')?.classList.add('is-allowed');
++ _glPreviewSync();
toast('Camera & microphone access granted', 'success');
} catch (err) {
console.warn('[Broadcast] Permission request failed:', err.message, err.name);
const errDetail = `${err.name || 'Error'}: ${err.message}`;
if (dbg) { dbg.style.display = ''; dbg.textContent = errDetail; }
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
++ const hint = document.getElementById('bc-perm-hint-text');
++ if (hint) hint.textContent = /firefox/i.test(navigator.userAgent)
++ ? 'Camera blocked. Select the permission icon beside this address, clear the block, then try again.'
++ : /safari/i.test(navigator.userAgent) && !/chrome/i.test(navigator.userAgent)
++ ? 'Camera blocked. Open this site’s camera and microphone settings in Safari, allow access, then try again.'
++ : 'Camera blocked. Select the lock or camera icon beside this address, allow access, then try again.';
toast('Camera/mic permission denied — check your browser settings or tap the lock icon in the address bar', 'error');
} else if (err.name === 'NotFoundError') {
toast('No camera or microphone found on this device', 'error');
@@@ -608,4 +631,3 @@ function _populateCreateDeviceDropdowns(devices) {
_syncCameraSelectionUI(_getPreferredCameraId(), { persist: false });
}
}
--
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 615ba8a8..cb032a23 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -32,7 +32,15 @@
// self-view, like a selfie camera, is the person's choice for their own preview only, remembered on this device.
const MIRROR_KEY = 'ov_bc_mirror_preview';
function mirrorWanted() { try { return localStorage.getItem(MIRROR_KEY) === '1'; } catch { return false; } }
-- function toggleMirror() { try { localStorage.setItem(MIRROR_KEY, mirrorWanted() ? '0' : '1'); } catch { /* a private window keeps the default */ } refresh(); }
++ function toggleMirror() {
++ const mirror = !mirrorWanted();
++ try { localStorage.setItem(MIRROR_KEY, mirror ? '1' : '0'); } catch { /* a private window keeps the default */ }
++ if (window._glCamSettings) {
++ window._glCamSettings.mirror = mirror;
++ fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mirror }) }).catch(() => {});
++ }
++ refresh();
++ }
/** Is the preview mirrored right now: asked for, a front camera, and not a shared screen. */
function mirrored() { return mirrorWanted() && facing() === 'user' && !screenSharing(); }
function el(tag, attrs, ...kids) {
@@@ -830,22 +838,27 @@
// page not holding it, the bar resumes it instead of starting another.
function inView(n) { if (!n || !n.offsetParent) return false; const r = n.getBoundingClientRect(); return r.top < window.innerHeight - 8 && r.bottom > 0; }
function syncGoLiveDock() {
-- const ready = NARROW.matches && onBroadcastPage() && !localStream() && !streamState() && !!window._bcMyLoaded;
++ const ready = NARROW.matches && onBroadcastPage() && !!window._bcMyLoaded;
const liveMine = ready ? (window._bcMyLive || []) : [];
let mode = null, real = null;
-- if (ready && liveMine.length) {
++ if (ready && localStream()) mode = 'live';
++ else if (ready && st.startingSince && Date.now() - st.startingSince < 30000) mode = 'connecting';
++ else if (ready && liveMine.length) {
const card = document.querySelector('#bc-active-list .bc-live-card-main');
if (!inView(card)) mode = 'resume';
} else if (ready) {
real = goLiveButton();
-- if (real && !inView(real)) mode = 'golive';
++ if (real && !inView(real)) mode = !window._glCameraAllowed && (_wsState?.selectedMs?.browser_mode || _wsState?.profile?.browserMode || 'camera') !== 'screen' ? 'allow' : 'golive';
}
let dock = $('bcs-golive');
if (mode && !dock) {
dock = el('div', { class: 'bcs-golive', id: 'bcs-golive' },
el('button', { type: 'button', class: 'bcs-golive-btn', onclick: () => {
++ if (dock.dataset.mode === 'live') { openConfirm(); return; }
++ if (dock.dataset.mode === 'connecting') return;
++ if (dock.dataset.mode === 'allow') { $('bc-perm-btn')?.click(); return; }
if (dock.dataset.mode === 'resume') { const s = (window._bcMyLive || [])[0]; if (s && typeof resumeStream === 'function') resumeStream(s.id); return; }
-- const b = goLiveButton(); if (b && !b.disabled) b.click();
++ const b = goLiveButton(); if (b && !b.disabled) { st.startingSince = Date.now(); b.click(); syncGoLiveDock(); }
} }, icon('fa-tower-broadcast'), el('span', { class: 'bcs-golive-t', text: 'Go live' })),
el('small', { class: 'bcs-golive-where' }));
document.body.append(dock);
@@@ -853,7 +866,26 @@
if (dock) {
dock.hidden = !mode;
dock.dataset.mode = mode || '';
-- if (mode === 'resume') {
++ if (mode === 'live') {
++ st.startingSince = 0;
++ dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-stop';
++ dock.querySelector('.bcs-golive-t').textContent = 'End stream';
++ const started = streamState()?.startedAt || streamState()?.streamData?.started_at;
++ const elapsed = started ? Math.max(0, Math.floor((Date.now() - new Date(started).getTime()) / 1000)) : 0;
++ dock.querySelector('.bcs-golive-where').textContent = `Live · ${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, '0')}`;
++ dock.querySelector('.bcs-golive-btn').disabled = false;
++ } else if (mode === 'connecting') {
++ dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-circle-notch fa-spin';
++ dock.querySelector('.bcs-golive-t').textContent = 'Connecting…';
++ dock.querySelector('.bcs-golive-where').textContent = 'Opening your studio';
++ dock.querySelector('.bcs-golive-btn').disabled = true;
++ } else if (mode === 'allow') {
++ const onlyMic = (_wsState?.selectedMs?.browser_mode || _wsState?.profile?.browserMode) === 'mic_only';
++ dock.querySelector('.bcs-golive-btn i').className = onlyMic ? 'fa-solid fa-microphone' : 'fa-solid fa-video';
++ dock.querySelector('.bcs-golive-t').textContent = onlyMic ? 'Turn on microphone' : 'Turn on camera';
++ dock.querySelector('.bcs-golive-where').textContent = onlyMic ? 'Microphone off · you choose when to start' : 'Camera off · you choose when to start';
++ dock.querySelector('.bcs-golive-btn').disabled = false;
++ } else if (mode === 'resume') {
const s = liveMine[0];
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-play';
dock.querySelector('.bcs-golive-t').textContent = liveMine.length > 1 ? 'Resume a live stream' : 'Resume your stream';
@@@ -863,13 +895,17 @@
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-tower-broadcast';
dock.querySelector('.bcs-golive-t').textContent = 'Go live';
const where = real.parentElement && real.parentElement.querySelector('.bc-create-reassurance');
-- dock.querySelector('.bcs-golive-where').textContent = where ? where.textContent.trim() : '';
++ dock.querySelector('.bcs-golive-where').textContent = where ? `Ready · ${where.textContent.trim()}` : 'Ready';
dock.querySelector('.bcs-golive-btn').disabled = !!real.disabled;
}
}
document.body.classList.toggle('bc-golive-dock-on', !!mode);
}
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
++ if (window.visualViewport) window.visualViewport.addEventListener('resize', () => {
++ const vv = window.visualViewport;
++ document.documentElement.style.setProperty('--gl-keyboard', `${Math.max(0, window.innerHeight - vv.height - vv.offsetTop)}px`);
++ });
// ── Once a second: mount, update or leave ──
function tick() {
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 5993489a..5afa26c7 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -380,22 +380,20 @@ function _wsRenderPanel() {
<!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and
the microphone; remembered on this device, never saved with the slot. -->
<div id="bc-ws-device-wrap">
-- <div id="bc-perm-request" class="bc-perm-request">
++ <div id="bc-perm-request" class="bc-perm-request gl-allow">
++ <span class="gl-allow-mark ov-mark" data-size="48" data-tone="brand" aria-hidden="true"></span>
++ <strong id="gl-allow-title">Your camera is off</strong>
<p class="bc-perm-hint"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
-- <span id="bc-perm-hint-text">${_wsPermHint(browserMode)}</span></p>
-- <button id="bc-perm-btn" class="btn btn-outline" type="button"
++ <span id="bc-perm-hint-text">You choose when this browser may use your camera and microphone.</span></p>
++ <button id="bc-perm-btn" class="btn btn-primary" type="button"
onclick="requestMediaPermissions()">
-- <i class="fa-solid ${browserMode === 'mic_only' ? 'fa-microphone'
-- : browserMode === 'screen' ? 'fa-display'
-- : 'fa-video'}"></i>
-- <span id="bc-perm-btn-text">${browserMode === 'mic_only' ? 'Allow Microphone'
-- : browserMode === 'camera_only' ? 'Allow Camera'
-- : browserMode === 'screen' ? 'Allow Screen Share'
-- : 'Allow Camera &amp; Mic'}</span>
++ <i class="fa-solid fa-video" aria-hidden="true"></i><span id="bc-perm-btn-text">Turn on camera</span>
</button>
++ <button type="button" class="gl-allow-other" onclick="_glOtherWays()">Use a screen or an app instead</button>
<p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>
</div>
<div id="bc-device-selects" style="display:none">
++ <div class="gl-device-sheet-head"><strong>Camera settings</strong><button type="button" onclick="_glCloseDeviceSheet()" aria-label="Close camera settings"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>
<div class="bc-setup-card">
<div class="bc-setup-item" id="bc-ws-camera-group">
<div class="bc-setup-row">
@@@ -422,6 +420,7 @@ function _wsRenderPanel() {
</div>
</div>
</div>
++ <div class="gl-device-extras"><label>Resolution <select id="gl-sheet-resolution" onchange="document.getElementById('bc-ws-resolution').value=this.value;document.getElementById('bc-ws-resolution').dispatchEvent(new Event('change',{bubbles:true}))"><option value="480">480p</option><option value="720" selected>720p</option><option value="1080">1080p</option></select></label><label>Mirror my preview <input id="gl-sheet-mirror" type="checkbox" onchange="_glSheetMirror(this.checked)"></label></div>
<p class="bc-setup-note">
<span>Remembered on this device</span>
<button type="button" class="bc-setup-link" onclick="refreshDeviceLists()"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh devices</button>
@@@ -1064,8 +1063,52 @@ function _glCopyLink() {
network, before going live. One watch, once a second, owns the camera: it opens it only while this page shows a
browser slot that is not live, with permission already given, and lets go when the tab hides, the page changes, a
stream starts or Go live is pressed. Turning it off is remembered on this device. ── */
--const _glPv = { stream: null, key: '', starting: false, timer: 0, raf: 0, ctx: null, hold: 0, failed: '' };
++const _glPv = { stream: null, key: '', starting: false, timer: 0, raf: 0, ctx: null, hold: 0, failed: '', stopMeter: null };
const _GL_PV_OFF = 'ov_gl_preview_off';
++let _glCoreLoad;
++function _glCamCore() {
++ if (window.OpenVibeCam?.core) return Promise.resolve(window.OpenVibeCam.core);
++ if (!_glCoreLoad) _glCoreLoad = new Promise((resolve, reject) => {
++ const script = document.createElement('script');
++ // The query names this version (the start of its hash): a new Cam release is a new cache key, so Cloudflare never
++ // hands an older copy to this integrity check (it served one for a day after Cam #116, 11-09).
++ script.src = 'https://openvibe.cam/embed/cam-kit.js?v=yqqjh1FiP7a4';
++ script.integrity = 'sha384-yqqjh1FiP7a4l9uelJHaryd8Y5IwnHVbqjt1d0AXbgCcytI9Z5MMmItFqbwB4YZG';
++ script.crossOrigin = 'anonymous';
++ script.onload = () => window.OpenVibeCam?.core ? resolve(window.OpenVibeCam.core) : reject(new Error('Camera controls unavailable'));
++ script.onerror = () => reject(new Error('Could not load camera controls'));
++ document.head.append(script);
++ });
++ return _glCoreLoad;
++}
++function _glOtherWays() {
++ const fold = document.getElementById('gl-how');
++ if (fold) { fold.open = true; fold.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
++}
++function _glPreviewControl(kind) {
++ if (kind === 'flip') {
++ const sel = document.getElementById('bc-create-camera');
++ if (!sel) return;
++ const current = sel.selectedOptions[0]?.dataset.facing || 'user';
++ const next = [...sel.options].find((o) => o.dataset.facing && o.dataset.facing !== current);
++ if (next) { sel.value = next.value; sel.dispatchEvent(new Event('change', { bubbles: true })); }
++ else if (typeof toast === 'function') toast('No other camera found on this device', 'info');
++ } else {
++ document.body.classList.add('gl-device-sheet-open');
++ if (kind === 'lens') document.getElementById('bc-create-camera')?.focus();
++ else if (kind === 'mic') document.getElementById('bc-create-audio')?.focus();
++ else if (kind === 'more') document.getElementById('gl-quality').open = true;
++ }
++}
++function _glCloseDeviceSheet() { document.body.classList.remove('gl-device-sheet-open'); }
++function _glSheetMirror(wanted) {
++ try { localStorage.setItem('ov_bc_mirror_preview', wanted ? '1' : '0'); } catch {}
++ const cam = document.getElementById('bc-create-camera');
++ const settings = { facing: cam?.selectedOptions[0]?.dataset.facing || 'user', lens: cam?.value || 'main', mic: document.getElementById('bc-create-audio')?.value || 'default', resolution: document.getElementById('bc-ws-resolution')?.value || '720', mirror: wanted };
++ window._glCamSettings = settings;
++ fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});
++ _glPreviewSync();
++}
function _glPreviewHTML(mode) {
if (mode === 'screen') {
@@@ -1084,6 +1127,7 @@ function _glPreviewHTML(mode) {
${mode === 'camera_only' ? '' : '<span class="gl-pv-chip gl-pv-mic" title="Your microphone level"><i class="fa-solid fa-microphone" aria-hidden="true"></i><span class="gl-pv-lvl" aria-hidden="true"><i id="gl-pv-lvl"></i></span></span>'}
<span class="gl-pv-chip" id="gl-pv-net" hidden></span>
</div>
++ ${audioOnly ? '' : '<div class="gl-pv-controls"><button type="button" onclick="_glPreviewControl(\'flip\')" aria-label="Flip camera"><i class="fa-solid fa-camera-rotate" aria-hidden="true"></i></button><button type="button" onclick="_glPreviewControl(\'lens\')" aria-label="Choose lens"><i class="fa-solid fa-camera" aria-hidden="true"></i></button><button type="button" onclick="_glPreviewControl(\'mic\')" aria-label="Choose microphone"><i class="fa-solid fa-microphone" aria-hidden="true"></i></button><button type="button" onclick="_glPreviewControl(\'more\')" aria-label="More camera settings"><i class="fa-solid fa-ellipsis" aria-hidden="true"></i></button></div>'}
<button type="button" class="gl-pv-off" id="gl-pv-off" onclick="_glPreviewToggle()" aria-pressed="false" title="Turn the preview off">
<i class="fa-solid fa-eye-slash" aria-hidden="true"></i><span class="bc-visually-hidden">Turn the preview off</span>
</button>
@@@ -1104,7 +1148,7 @@ function _glPreviewWant() {
const box = document.getElementById('gl-preview');
const ms = _wsState.selectedMs;
if (!box || !box.isConnected || !ms || box.classList.contains('is-still') && !box.classList.contains('is-audio')) return '';
-- if (document.hidden || _glPv.hold > Date.now() || _glPreviewOff()) return '';
++ if (document.hidden || _glPv.hold > Date.now() || _glPreviewOff() || !window._glCameraAllowed) return '';
// The studio's page is shown (its address is /@you/studio or /broadcast); another page hides its section.
if (!box.getClientRects().length) return '';
if ((ms.streaming_method || 'browser') !== 'browser') return '';
@@@ -1116,7 +1160,7 @@ function _glPreviewWant() {
if (mode === 'screen') return '';
const cam = mode === 'mic_only' ? '-' : (document.getElementById('bc-create-camera')?.value || 'default');
const mic = mode === 'camera_only' ? '-' : (document.getElementById('bc-create-audio')?.value || 'default');
-- return `${mode}|${cam}|${mic}`;
++ return `${mode}|${cam}|${mic}|${document.getElementById('bc-ws-resolution')?.value || '720'}`;
}
function _glPreviewStop() {
@@@ -1124,6 +1168,7 @@ function _glPreviewStop() {
cancelAnimationFrame(_glPv.raf);
_glPv.raf = 0;
if (_glPv.ctx) { try { _glPv.ctx.close(); } catch { /* */ } _glPv.ctx = null; }
++ if (_glPv.stopMeter) { _glPv.stopMeter(); _glPv.stopMeter = null; }
if (_glPv.stream) { _glPv.stream.getTracks().forEach((t) => { try { t.stop(); } catch { /* */ } }); _glPv.stream = null; }
_glPv.key = '';
const v = document.getElementById('gl-preview-video');
@@@ -1140,13 +1185,10 @@ async function _glPreviewStart(key) {
if (_glPv.starting) return;
_glPv.starting = true;
_glPreviewStop();
-- const [mode, cam, mic] = key.split('|');
-- const pick = (id) => (id && id !== 'default' ? { deviceId: { exact: id } } : true);
++ const [mode, cam, mic, resolution] = key.split('|');
try {
-- const stream = await navigator.mediaDevices.getUserMedia({
-- video: cam === '-' ? false : { ...(cam !== 'default' ? { deviceId: { exact: cam } } : {}), width: { ideal: 1280 }, height: { ideal: 720 } },
-- audio: mic === '-' ? false : pick(mic),
-- });
++ const core = await _glCamCore();
++ const stream = await core.open({ video: cam !== '-', audio: mic !== '-', lens: cam, mic, facing: window._glCamSettings?.facing || 'user', resolution });
// Things moved on while the camera opened: let it go again.
if (_glPreviewWant() !== key) { stream.getTracks().forEach((t) => t.stop()); return; }
_glPv.stream = stream;
@@@ -1169,57 +1211,30 @@ async function _glPreviewStart(key) {
if (box) box.dataset.state = 'on';
const note = document.getElementById('gl-pv-note');
if (note) note.textContent = '';
-- if (mode !== 'camera_only' && stream.getAudioTracks().length) _glPreviewMeter(stream);
++ if (mode !== 'camera_only' && stream.getAudioTracks().length) _glPv.stopMeter = core.meter(stream, (level) => {
++ const bar = document.getElementById('gl-pv-lvl');
++ if (bar) bar.style.transform = `scaleX(${level.toFixed(3)})`;
++ });
} catch (err) {
_glPv.failed = key;
const note = document.getElementById('gl-pv-note');
-- if (note) note.textContent = err && err.name === 'NotReadableError' ? 'Another app is using the camera. Close it, then come back here.'
-- : 'The camera did not open for the preview. Going live still asks for it.';
++ if (note) note.textContent = err && err.name === 'NotReadableError' ? 'Another app is using the camera. Close it, then try again.'
++ : 'The camera did not open. Choose another camera or turn it on again.';
} finally {
_glPv.starting = false;
}
}
--/** The microphone's level as a bar in its chip (about 20 times a second; nothing when the sound is off). */
--function _glPreviewMeter(stream) {
-- const AC = window.AudioContext || window.webkitAudioContext;
-- if (!AC) return;
-- let ctx;
-- try { ctx = new AC(); } catch { return; }
-- _glPv.ctx = ctx;
-- const an = ctx.createAnalyser();
-- an.fftSize = 512;
-- ctx.createMediaStreamSource(stream).connect(an);
-- const buf = new Uint8Array(an.fftSize);
-- let last = 0;
-- const bar = document.getElementById('gl-pv-lvl');
-- const tick = (t) => {
-- if (!_glPv.ctx) return;
-- _glPv.raf = requestAnimationFrame(tick);
-- if (t - last < 50 || !bar) return;
-- last = t;
-- an.getByteTimeDomainData(buf);
-- let sum = 0;
-- for (let i = 0; i < buf.length; i++) { const d = (buf[i] - 128) / 128; sum += d * d; }
-- const level = Math.min(1, Math.sqrt(sum / buf.length) * 4);
-- bar.style.transform = `scaleX(${level.toFixed(3)})`;
-- };
-- if (ctx.state === 'suspended') {
-- const wake = () => { ctx.resume().catch(() => {}); };
-- document.addEventListener('pointerdown', wake, { once: true, capture: true });
-- }
-- _glPv.raf = requestAnimationFrame(tick);
--}
--
/** The network in a word: navigator.connection where the browser has it, and offline everywhere. */
function _glPreviewNetwork() {
const chip = document.getElementById('gl-pv-net');
if (!chip) return;
++ const shared = window.OpenVibeCam?.core?.network?.();
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
let text = '', cls = '';
-- if (navigator.onLine === false) { text = 'Offline'; cls = 'is-bad'; }
-- else if (c && (c.effectiveType || c.downlink)) {
-- const weak = /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5);
++ if (shared === 'offline' || navigator.onLine === false) { text = 'Offline'; cls = 'is-bad'; }
++ else if (shared || c && (c.effectiveType || c.downlink)) {
++ const weak = shared === 'weak' || !shared && (/(^|-)(2g|3g)$/.test(c.effectiveType || '') || c.downlink && c.downlink < 2.5);
text = weak ? 'Weak network · 720p suggested' : 'Good network';
cls = weak ? 'is-warn' : 'is-ok';
}
@@@ -1229,6 +1244,7 @@ function _glPreviewNetwork() {
}
function _glPreviewSync() {
++ document.getElementById('gl-preview')?.classList.toggle('is-allowed', !!window._glCameraAllowed);
const want = _glPreviewWant();
_glPreviewNetwork();
if (!want) { if (_glPv.stream || _glPv.key) _glPreviewStop(); return; }
@@@ -1239,6 +1255,34 @@ function _glPreviewSync() {
function _glPreviewWatch() {
_glPreviewSync();
if (_glPv.timer) return;
++ fetch('/api/cam/settings', { credentials: 'same-origin' }).then((r) => r.ok ? r.json() : null).then((saved) => {
++ if (!saved) return;
++ window._glCamSettings = saved;
++ const cam = document.getElementById('bc-create-camera');
++ const mic = document.getElementById('bc-create-audio');
++ const res = document.getElementById('bc-ws-resolution');
++ if (cam && [...cam.options].some((o) => o.value === saved.lens)) cam.value = saved.lens;
++ if (mic && [...mic.options].some((o) => o.value === saved.mic)) mic.value = saved.mic;
++ if (res && [...res.options].some((o) => o.value === saved.resolution)) res.value = saved.resolution;
++ const sheetRes = document.getElementById('gl-sheet-resolution');
++ if (sheetRes) sheetRes.value = saved.resolution;
++ const sheetMirror = document.getElementById('gl-sheet-mirror');
++ if (sheetMirror) sheetMirror.checked = saved.mirror;
++ try { localStorage.setItem('ov_bc_mirror_preview', saved.mirror ? '1' : '0'); } catch {}
++ }).catch(() => {});
++ document.addEventListener('change', (event) => {
++ if (!['bc-create-camera', 'bc-create-audio', 'bc-ws-resolution'].includes(event.target?.id)) return;
++ const cam = document.getElementById('bc-create-camera');
++ const settings = {
++ facing: cam?.selectedOptions[0]?.dataset.facing || window._glCamSettings?.facing || 'user',
++ lens: cam?.value || 'main', mic: document.getElementById('bc-create-audio')?.value || 'default',
++ resolution: document.getElementById('bc-ws-resolution')?.value || '720',
++ mirror: (() => { try { return localStorage.getItem('ov_bc_mirror_preview') === '1'; } catch { return true; } })(),
++ };
++ window._glCamSettings = settings;
++ fetch('/api/cam/settings', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }).catch(() => {});
++ _glPreviewSync();
++ });
_glPv.timer = setInterval(() => {
_glPreviewSync();
// Off the page for good: nothing left to watch.
@@@ -1973,13 +2017,21 @@ async function _wsInitDevicePicker(method, browserMode) {
const hasLabels = devices.some(d => d.label);
const permReq = document.getElementById('bc-perm-request');
const devSelects = document.getElementById('bc-device-selects');
-- if (hasLabels) {
++ if (hasLabels && window._glCameraAllowed) {
if (permReq) permReq.style.display = 'none';
if (devSelects) devSelects.style.display = '';
if (typeof populateCreateFormDevices === 'function') await populateCreateFormDevices();
// Restore persisted device selections for this slot (localStorage first, then server profile)
_wsRestoreSlotDevices();
_wsRestoreDevicesFromProfile();
++ const shared = window._glCamSettings;
++ const cam = document.getElementById('bc-create-camera');
++ const mic = document.getElementById('bc-create-audio');
++ if (shared && cam) {
++ const preferred = [...cam.options].find((o) => o.value === shared.lens) || [...cam.options].find((o) => o.dataset.facing === shared.facing);
++ if (preferred) cam.value = preferred.value;
++ }
++ if (shared?.mic && mic && [...mic.options].some((o) => o.value === shared.mic)) mic.value = shared.mic;
if (typeof _bcRenderCameraChooser === 'function') _bcRenderCameraChooser();
} else {
if (permReq) permReq.style.display = '';
@@@ -2380,8 +2432,8 @@ function _wsSelectBrowserMode(mode) {
permHintText.textContent = _wsPermHint(mode);
}
if (permBtnText) {
-- const labels = { mic_only: 'Allow Microphone', camera_only: 'Allow Camera',
-- camera: 'Allow Camera & Mic' };
++ const labels = { mic_only: 'Turn on microphone', camera_only: 'Turn on camera',
++ camera: 'Turn on camera' };
permBtnText.textContent = labels[mode] || labels.camera;
}
if (permBtn) {
@@@ -2472,7 +2524,7 @@ async function _wsCheckScreenInlinePermissions() {
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-perm', false);
// Grant already held (labels are populated), so preview straight away.
-- if (document.getElementById('bc-ws-screen-mic')?.checked) _wsStartMicMeter();
++ if (window._glCameraAllowed && document.getElementById('bc-ws-screen-mic')?.checked) _wsStartMicMeter();
} else if (document.getElementById('bc-ws-screen-mic')?.checked) {
_wsShowEl('bc-ws-screen-mic-perm', true);
}
@@@ -2730,6 +2782,11 @@ async function goLiveFromWorkspace() {
toast('Select a stream slot first', 'error');
return;
}
++ if ((_wsState.selectedMs.streaming_method || 'browser') === 'browser' && (_wsState.selectedMs.browser_mode || _wsState.profile?.browserMode || 'camera') !== 'screen' && !window._glCameraAllowed) {
++ document.getElementById('bc-perm-btn')?.focus();
++ toast('Turn on camera before going live', 'info');
++ return;
++ }
// What was just typed (the title above all) is saved before the stream starts.
await _wsFlushSave();
ddiff --git a/scripts/studio-layout-proof.cjs b/scripts/studio-layout-proof.cjs
nnew file mode 100644
iindex 00000000..d6f9a10e
---- /dev/null
++++ b/scripts/studio-layout-proof.cjs
@@@ -0,0 +1,32 @@
++'use strict';
++// Isolated visual proof of the signed-in studio card. No camera, account or production service is used.
++const fs = require('fs');
++const path = require('path');
++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
++const root = path.join(__dirname, '..');
++const output = path.join(root, 'docs', 'shots');
++fs.mkdirSync(output, { recursive: true });
++const css = fs.readFileSync(path.join(root, 'public/css/features/broadcast.css'), 'utf8');
++const logo = `data:image/svg+xml;base64,${fs.readFileSync(path.join(root, 'public/assets/logo.svg')).toString('base64')}`;
++
++function html(mode) {
++ const preview = mode === 'ready' ? `<div class="gl-preview is-allowed" data-state="on"><div class="shot-feed"></div><div class="gl-pv-chips"><span class="gl-pv-chip">Front camera</span><span class="gl-pv-chip">Mic level ▰▰▱</span><span class="gl-pv-chip gl-pv-net is-ok">Good network</span></div><div class="gl-pv-controls"><button aria-label="Flip camera">⟳</button><button aria-label="Choose lens">◎</button><button aria-label="Choose microphone">♩</button><button aria-label="More camera settings">⋯</button></div></div>` : '';
++ const allow = mode === 'allow' ? `<div class="bc-perm-request gl-allow"><span class="gl-allow-mark"><img src="${logo}" alt=""></span><strong>Your camera is off</strong><p class="bc-perm-hint">You choose when this browser may use your camera and microphone.</p><button class="shot-primary">Turn on camera</button><button class="gl-allow-other">Use a screen or an app instead</button></div>` : '';
++ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body class="bc-golive-dock-on"><main id="page-broadcast"><div class="bc-workspace"><div class="bc-ws-main"><div class="gl"><section class="gl-card"><div class="gl-title-row"><h1 class="gl-title">Goosely goes live</h1><span class="bc-ws-save-status bc-ws-saved">Saved</span></div><div class="gl-chips"><span class="gl-chip">Camera + mic</span><span class="gl-chip">Public</span><span class="gl-chip">IRL</span></div><div class="bc-ws-golive-section is-browser">${preview}${allow}<div class="shot-destination">Live at <strong>openvibe.live/@goosely/irl</strong></div><button class="shot-primary shot-inline">Go live</button></div></section><section class="shot-side"><h2>How you stream</h2><p>Camera and microphone from this browser.</p><h2>Video quality</h2><p>720p · 30 fps</p><h2>Restreams</h2><p>Choose where else to send your stream.</p></section></div></div></div></main><div class="bcs-golive" data-mode="${mode === 'allow' ? 'allow' : 'golive'}"><button class="bcs-golive-btn">${mode === 'allow' ? 'Turn on camera' : 'Go live'}</button><small class="bcs-golive-where">${mode === 'allow' ? 'Camera off · you choose when to start' : 'Ready · openvibe.live/@goosely/irl'}</small></div></body></html>`;
++}
++
++(async () => {
++ const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
++ try {
++ for (const width of [390, 1280]) for (const theme of ['dark', 'light']) for (const mode of ['allow', 'ready']) {
++ const page = await browser.newPage({ viewport: { width, height: 844 }, colorScheme: theme, deviceScaleFactor: 1, isMobile: width < 700, hasTouch: width < 700 });
++ await page.setContent(html(mode));
++ await page.addStyleTag({ content: css });
++ await page.addStyleTag({ content: `:root{--bg-primary:${theme === 'dark' ? '#101116' : '#f5f7fb'};--bg-card:${theme === 'dark' ? '#1b1d25' : '#fff'};--text-primary:${theme === 'dark' ? '#f4f5f8' : '#181c27'};--text-secondary:${theme === 'dark' ? '#b8bdc9' : '#50596c'};--text-muted:${theme === 'dark' ? '#9aa3b4' : '#637087'};--border:${theme === 'dark' ? '#353944' : '#d9dfe9'};--accent:#3285ed;--accent-strong:#286de3;--on-accent-strong:#fff;--ovnav-appbar-h:${width < 700 ? '62px' : '0px'}}*{box-sizing:border-box}body{margin:0;background:var(--bg-primary);color:var(--text-primary);font:16px/1.45 system-ui}#page-broadcast{max-width:1200px;margin:auto;padding:24px 14px 140px}.bc-workspace,.bc-ws-main{display:block}.gl{display:grid;grid-template-columns:${width < 700 ? '1fr' : 'minmax(0,1.2fr) minmax(0,.8fr)'};gap:18px}.gl-title{margin:0}.gl-card{padding:18px}.gl-chip{cursor:default}.shot-side{padding:20px;border:1px solid var(--border);border-radius:18px;background:var(--bg-card)}.shot-side h2{font-size:1rem;margin:0 0 6px}.shot-side p{margin:0 0 24px;color:var(--text-secondary)}.shot-primary{min-height:48px;padding:10px 18px;border:0;border-radius:12px;background:var(--accent-strong);color:#fff;font:700 1rem system-ui}.shot-inline{width:100%;margin-top:16px}.shot-destination{font-size:.85rem;color:var(--text-secondary);margin-top:14px}.gl-allow-mark img{width:43px;height:43px}.shot-feed{position:absolute;inset:0;background:linear-gradient(150deg,#181f29,#364459 46%,#191d27);}.gl-pv-chips{bottom:62px}.gl-pv-controls{bottom:8px}.gl-pv-controls button{font-size:1.3rem}.bcs-golive{display:${width < 700 ? 'grid' : 'none'}}@media(max-width:700px){.shot-side,.shot-inline{display:none}}` });
++ const docWidth = await page.evaluate(() => document.documentElement.scrollWidth);
++ if (docWidth > width) throw new Error(`${width}/${theme}/${mode}: horizontal overflow ${docWidth}`);
++ await page.screenshot({ path: path.join(output, `live-studio-${width}-${theme}-${mode}.png`), fullPage: true });
++ await page.close();
++ }
++ } finally { await browser.close(); }
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/server/cam/cam-client.js b/server/cam/cam-client.js
iindex 0229d085..f695baec 100644
---- a/server/cam/cam-client.js
++++ b/server/cam/cam-client.js
@@@ -48,4 +48,16 @@ async function createStill({ token, slot, streamId, caption }) {
return data;
}
--module.exports = { CAM_URL, available, createStill, CamApiError };
++async function cameraSettings({ token, method = 'GET', settings }) {
++ if (!available()) throw new CamApiError('OpenVibe.Cam is unavailable right now', 503, 'cam.unavailable');
++ if (!token) throw new CamApiError('A signed-in Network account is required', 401, 'cam.sign_in_required');
++ const res = await fetch(`${CAM_URL}/api/v1/me/camera-settings`, {
++ method, headers: { Authorization: `Bearer ${token}`, ...(method === 'PUT' ? { 'Content-Type': 'application/json' } : {}) },
++ ...(method === 'PUT' ? { body: JSON.stringify(settings) } : {}), signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ const data = await res.json();
++ if (!res.ok) throw new CamApiError(data.detail || 'Could not save camera settings', res.status, data.code);
++ return data;
++}
++
++module.exports = { CAM_URL, available, createStill, cameraSettings, CamApiError };
ddiff --git a/server/cam/routes.js b/server/cam/routes.js
iindex f3253569..251584f6 100644
---- a/server/cam/routes.js
++++ b/server/cam/routes.js
@@@ -21,6 +21,20 @@ const router = express.Router();
const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
const SHARE_TEXT_MAX = 500;
++// The browser never receives its Network token. Live forwards only the signed-in person's choices to Cam.
++router.get('/settings', requireAuth, async (req, res) => {
++ try {
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Sign in with your Network account' });
++ return res.json(await cam.cameraSettings({ token: extractToken(req) }));
++ } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }
++});
++router.put('/settings', requireAuth, async (req, res) => {
++ try {
++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Sign in with your Network account' });
++ return res.json(await cam.cameraSettings({ token: extractToken(req), method: 'PUT', settings: req.body }));
++ } catch (err) { return res.status(err.status || 502).json({ error: err.message }); }
++});
++
async function liveStream(req, res) {
const streamId = req.body && Number(req.body.stream_id);
if (!Number.isInteger(streamId) || streamId <= 0) {
ddiff --git a/test/broadcast-golive-dock.test.js b/test/broadcast-golive-dock.test.js
iindex 6fe21801..72faa044 100644
---- a/test/broadcast-golive-dock.test.js
++++ b/test/broadcast-golive-dock.test.js
@@@ -11,5 +11,5 @@ assert.ok(!/html:has\(#page-broadcast\.active\) \.ovnav-appbar \{ display: none;
assert.ok(!/html:has\(#page-broadcast\.active\) \{ --ovnav-appbar-h: 0px; \}/.test(css), 'and keeps its room');
assert.match(css, /body\.bc-studio-on \.ovnav-appbar \{ display: none !important; \}/, 'the live studio still owns the screen');
assert.match(css, /\.bcs-golive \{\s*position: fixed; left: 0; right: 0; bottom: var\(--ovnav-appbar-h, 0px\);/, 'the dock sits above the bottom edge');
--assert.match(css, /body\.bc-golive-dock-on #page-broadcast \{ padding-bottom: calc\(104px \+ var\(--ovnav-appbar-h, 0px\) \+ env\(safe-area-inset-bottom, 0px\)\); \}/, 'room for the dock and the bar');
++assert.match(css, /body\.bc-golive-dock-on #page-broadcast \{ padding-bottom: calc\(104px \+ max\(var\(--ovnav-appbar-h, 0px\), var\(--gl-keyboard, 0px\)\) \+ env\(safe-area-inset-bottom, 0px\)\); \}/, 'room for the dock and the bar');
console.log('go live dock: setting up keeps the app bar; the dock sits above it and never covers the last fields');
ddiff --git a/test/broadcast-studio.test.js b/test/broadcast-studio.test.js
iindex a26b4cf4..803dab2d 100644
---- a/test/broadcast-studio.test.js
++++ b/test/broadcast-studio.test.js
@@@ -168,10 +168,12 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
await check('before going live on a phone, a "Go live" bar presses the slot\'s own button while it is off screen', async () => {
const dock = extract(studioSrc, 'syncGoLiveDock');
-- assert.ok(/NARROW\.matches && onBroadcastPage\(\) && !localStream\(\)/.test(dock), 'phones, on /broadcast, not live yet');
-- assert.ok(/real = goLiveButton\(\);\s*if \(real && !inView\(real\)\) mode = 'golive';/.test(dock), 'only while the real button is out of view');
++ assert.ok(/NARROW\.matches && onBroadcastPage\(\)/.test(dock), 'phones on the studio page');
++ assert.ok(/localStream\(\)\) mode = 'live'/.test(dock), 'the dock follows a live stream');
++ assert.ok(/mode = 'connecting'/.test(dock), 'the dock shows connection progress');
++ assert.ok(/real = goLiveButton\(\);\s*if \(real && !inView\(real\)\) mode = !window\._glCameraAllowed/.test(dock), 'the dock asks for camera access before it can go live');
assert.ok(/function inView\(n\) \{[^}]*getBoundingClientRect\(\)/.test(studioSrc));
-- assert.ok(/b && !b\.disabled\) b\.click\(\)/.test(dock), 'it clicks the real Go Live button (same checks, same settings)');
++ assert.ok(/b && !b\.disabled\) \{ st\.startingSince = Date\.now\(\); b\.click\(\)/.test(dock), 'it clicks the real Go Live button (same checks, same settings)');
assert.ok(/id="bc-ws-golive-btn"/.test(read('public/js/broadcast-workspace.js')), 'the button it presses exists');
});
ddiff --git a/test/golive-dock-clear.test.js b/test/golive-dock-clear.test.js
iindex c2bd7f17..387a2c2b 100644
---- a/test/golive-dock-clear.test.js
++++ b/test/golive-dock-clear.test.js
@@@ -12,9 +12,9 @@ const fs = require('fs');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/features/broadcast.css'), 'utf8');
--const rule = /body\.bc-golive-dock-on \.ovdk-pill:not\(\[data-corner\^="top"\]\) \{\s*bottom: calc\(104px \+ var\(--ovnav-appbar-h, 0px\) \+ env\(safe-area-inset-bottom, 0px\)\);/;
++const rule = /body\.bc-golive-dock-on \.ovdk-pill:not\(\[data-corner\^="top"\]\) \{\s*bottom: calc\(104px \+ max\(var\(--ovnav-appbar-h, 0px\), var\(--gl-keyboard, 0px\)\) \+ env\(safe-area-inset-bottom, 0px\)\);/;
assert.ok(rule.test(css), 'the pill clears the Go live dock');
--assert.ok(/body\.bc-golive-dock-on #page-broadcast \{ padding-bottom: calc\(104px \+ var\(--ovnav-appbar-h, 0px\) \+ env\(safe-area-inset-bottom, 0px\)\); \}/.test(css),
++assert.ok(/body\.bc-golive-dock-on #page-broadcast \{ padding-bottom: calc\(104px \+ max\(var\(--ovnav-appbar-h, 0px\), var\(--gl-keyboard, 0px\)\) \+ env\(safe-area-inset-bottom, 0px\)\); \}/.test(css),
'the same 104 px the page keeps for the dock (change both together)');
assert.ok(!/body\.bc-golive-dock-on \.ovdk-pill[^{]*\{[^}]*display:\s*none/.test(css), 'the pill moves, it is never hidden');
console.log('go live dock: the Dock pill rides above it, never on the button');