openvibers/openvibe.live Commits
Gate phone studio camera and use shared Cam controls (#729)
Changes
nnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-1280-dark-allow.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-1280-dark-ready.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-1280-light-allow.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-1280-light-ready.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-390-dark-allow.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-390-dark-ready.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-390-light-allow.png differnnew file mode 100644BBinary files /dev/null and b/docs/shots/live-studio-390-light-ready.png differ@@@ -2845,6 +2845,10 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importanbackground: 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'sright 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(--ovnadisplay: 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; }@@@ -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 directlyif (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+videotry {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 videotry { 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 });}}--@@@ -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() {@@@ -380,22 +380,20 @@ function _wsRenderPanel() {<!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens andthe 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 & 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 abrowser slot that is not live, with permission already given, and lets go when the tab hides, the page changes, astream 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();nnew file mode 100644@@@ -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; });@@@ -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 };@@@ -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) {@@@ -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');@@@ -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');});@@@ -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');
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-1280-dark-allow.png differ
Binary files /dev/null and b/docs/shots/live-studio-1280-dark-allow.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-1280-dark-ready.png differ
Binary files /dev/null and b/docs/shots/live-studio-1280-dark-ready.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-1280-light-allow.png differ
Binary files /dev/null and b/docs/shots/live-studio-1280-light-allow.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-1280-light-ready.png differ
Binary files /dev/null and b/docs/shots/live-studio-1280-light-ready.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-390-dark-allow.png differ
Binary files /dev/null and b/docs/shots/live-studio-390-dark-allow.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-390-dark-ready.png differ
Binary files /dev/null and b/docs/shots/live-studio-390-dark-ready.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-390-light-allow.png differ
Binary files /dev/null and b/docs/shots/live-studio-390-light-allow.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/shots/live-studio-390-light-ready.png differ
Binary files /dev/null and b/docs/shots/live-studio-390-light-ready.png differ
@@ -2845,6 +2845,10 @@ body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !importan
@@ -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%);
background: linear-gradient(to bottom, transparent, var(--bg-primary, #0b0b10) 34%);
}
}
.bcs-golive[hidden] { display: none; }
.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 {
.bcs-golive-btn {
width: 100%; max-width: 520px; min-height: 52px; border: 0; border-radius: 14px; cursor: pointer;
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;
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
@@ -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
/* 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. */
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"]) {
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. */
/* 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. */
/* What viewers see, over the studio (bcStudio.showViewer): a phone stays on this tab. */
.bcs-viewer {
.bcs-viewer {
@@ -2985,6 +2989,29 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
@@ -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;
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);
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 { 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-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; }
.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
@@ -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);
.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));
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; }
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 { 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 video.is-mirror { transform: scaleX(-1); }
.gl-preview[data-state="on"] video { opacity: 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
@@ -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); }
.gl-label--ai { border-style: dashed; color: var(--accent); }
@media (pointer: coarse) { .gl-label { min-height: 44px; } }
@media (pointer: coarse) { .gl-label { min-height: 44px; } }
@media (prefers-reduced-motion: reduce) { .gl-label { transition: none; } }
@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; }
@@ -403,7 +403,7 @@ async function populateCreateFormDevices() {
@@ -403,7 +403,7 @@ async function populateCreateFormDevices() {
const hasLabels = devices.some(d => d.label);
const hasLabels = devices.some(d => d.label);
const permReq = document.getElementById('bc-perm-request');
const permReq = document.getElementById('bc-perm-request');
const devSelects = document.getElementById('bc-device-selects');
const devSelects = document.getElementById('bc-device-selects');
- if (hasLabels) {
+ if (hasLabels && window._glCameraAllowed) {
// Already have permissions — show dropdowns directly
// Already have permissions — show dropdowns directly
if (permReq) permReq.style.display = 'none';
if (permReq) permReq.style.display = 'none';
if (devSelects) devSelects.style.display = '';
if (devSelects) devSelects.style.display = '';
@@ -499,32 +499,40 @@ async function requestMediaPermissions() {
@@ -499,32 +499,40 @@ async function requestMediaPermissions() {
try {
try {
let tempStream;
let tempStream;
+ const core = await _glCamCore();
+ const mode = _wsState.selectedMs?.browser_mode || _wsState.profile?.browserMode || 'camera';
// Strategy 1: combined audio+video
// Strategy 1: combined audio+video
try {
try {
if (dbg) dbg.textContent = 'Requesting camera + mic...';
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) {
} catch (firstErr) {
console.warn('[Broadcast] Combined getUserMedia failed:', firstErr.message, '— trying separately');
console.warn('[Broadcast] Combined getUserMedia failed:', firstErr.message, '— trying separately');
// Strategy 2: separate requests (common on Android)
// Strategy 2: separate requests (common on Android)
if (dbg) dbg.textContent = 'Combined failed, trying separately...';
if (dbg) dbg.textContent = 'Combined failed, trying separately...';
let vidStream, audStream;
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);
console.warn('[Broadcast] Video facingMode failed:', ve.message);
// Strategy 2b: bare minimum video
// Strategy 2b: bare minimum video
try { vidStream = await _getUserMediaWithTimeout({ video: true }, 10000); } catch (ve2) {
try { vidStream = await _getUserMediaWithTimeout({ video: true }, 10000); } catch (ve2) {
console.warn('[Broadcast] Video-only getUserMedia failed:', ve2.message);
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');
if (!vidStream && !audStream) throw new Error('No camera or microphone available');
tempStream = new MediaStream();
tempStream = new MediaStream();
if (vidStream) vidStream.getTracks().forEach(t => { tempStream.addTrack(t); });
if (vidStream) vidStream.getTracks().forEach(t => { tempStream.addTrack(t); });
if (audStream) audStream.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());
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...';
if (dbg) dbg.textContent = 'Enumerating devices...';
- const devices = await navigator.mediaDevices.enumerateDevices();
+ const devices = await core.devices();
_populateCreateDeviceDropdowns(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
// 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,
// and try to match to the user's saved preference. If no preference exists,
@@ -537,15 +545,30 @@ async function requestMediaPermissions() {
@@ -537,15 +545,30 @@ async function requestMediaPermissions() {
if (savedCamera && savedCamera !== 'default') {
if (savedCamera && savedCamera !== 'default') {
_syncCameraSelectionUI(savedCamera, { persist: false });
_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 (permReq) permReq.style.display = 'none';
if (devSelects) devSelects.style.display = '';
if (devSelects) devSelects.style.display = '';
+ window._glCameraAllowed = true;
+ document.getElementById('gl-preview')?.classList.add('is-allowed');
+ _glPreviewSync();
toast('Camera & microphone access granted', 'success');
toast('Camera & microphone access granted', 'success');
} catch (err) {
} catch (err) {
console.warn('[Broadcast] Permission request failed:', err.message, err.name);
console.warn('[Broadcast] Permission request failed:', err.message, err.name);
const errDetail = `${err.name || 'Error'}: ${err.message}`;
const errDetail = `${err.name || 'Error'}: ${err.message}`;
if (dbg) { dbg.style.display = ''; dbg.textContent = errDetail; }
if (dbg) { dbg.style.display = ''; dbg.textContent = errDetail; }
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
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');
toast('Camera/mic permission denied — check your browser settings or tap the lock icon in the address bar', 'error');
} else if (err.name === 'NotFoundError') {
} else if (err.name === 'NotFoundError') {
toast('No camera or microphone found on this device', 'error');
toast('No camera or microphone found on this device', 'error');
@@ -608,4 +631,3 @@ function _populateCreateDeviceDropdowns(devices) {
@@ -608,4 +631,3 @@ function _populateCreateDeviceDropdowns(devices) {
_syncCameraSelectionUI(_getPreferredCameraId(), { persist: false });
_syncCameraSelectionUI(_getPreferredCameraId(), { persist: false });
}
}
}
}
-
@@ -32,7 +32,15 @@
@@ -32,7 +32,15 @@
// self-view, like a selfie camera, is the person's choice for their own preview only, remembered on this device.
// 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';
const MIRROR_KEY = 'ov_bc_mirror_preview';
function mirrorWanted() { try { return localStorage.getItem(MIRROR_KEY) === '1'; } catch { return false; } }
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. */
/** Is the preview mirrored right now: asked for, a front camera, and not a shared screen. */
function mirrored() { return mirrorWanted() && facing() === 'user' && !screenSharing(); }
function mirrored() { return mirrorWanted() && facing() === 'user' && !screenSharing(); }
function el(tag, attrs, ...kids) {
function el(tag, attrs, ...kids) {
@@ -830,22 +838,27 @@
@@ -830,22 +838,27 @@
// page not holding it, the bar resumes it instead of starting another.
// 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 inView(n) { if (!n || !n.offsetParent) return false; const r = n.getBoundingClientRect(); return r.top < window.innerHeight - 8 && r.bottom > 0; }
function syncGoLiveDock() {
function syncGoLiveDock() {
- const ready = NARROW.matches && onBroadcastPage() && !localStream() && !streamState() && !!window._bcMyLoaded;
+ const ready = NARROW.matches && onBroadcastPage() && !!window._bcMyLoaded;
const liveMine = ready ? (window._bcMyLive || []) : [];
const liveMine = ready ? (window._bcMyLive || []) : [];
let mode = null, real = null;
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');
const card = document.querySelector('#bc-active-list .bc-live-card-main');
if (!inView(card)) mode = 'resume';
if (!inView(card)) mode = 'resume';
} else if (ready) {
} else if (ready) {
real = goLiveButton();
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');
let dock = $('bcs-golive');
if (mode && !dock) {
if (mode && !dock) {
dock = el('div', { class: 'bcs-golive', id: 'bcs-golive' },
dock = el('div', { class: 'bcs-golive', id: 'bcs-golive' },
el('button', { type: 'button', class: 'bcs-golive-btn', onclick: () => {
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; }
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' })),
} }, icon('fa-tower-broadcast'), el('span', { class: 'bcs-golive-t', text: 'Go live' })),
el('small', { class: 'bcs-golive-where' }));
el('small', { class: 'bcs-golive-where' }));
document.body.append(dock);
document.body.append(dock);
@@ -853,7 +866,26 @@
@@ -853,7 +866,26 @@
if (dock) {
if (dock) {
dock.hidden = !mode;
dock.hidden = !mode;
dock.dataset.mode = 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];
const s = liveMine[0];
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-play';
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';
dock.querySelector('.bcs-golive-t').textContent = liveMine.length > 1 ? 'Resume a live stream' : 'Resume your stream';
@@ -863,13 +895,17 @@
@@ -863,13 +895,17 @@
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-tower-broadcast';
dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-tower-broadcast';
dock.querySelector('.bcs-golive-t').textContent = 'Go live';
dock.querySelector('.bcs-golive-t').textContent = 'Go live';
const where = real.parentElement && real.parentElement.querySelector('.bc-create-reassurance');
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;
dock.querySelector('.bcs-golive-btn').disabled = !!real.disabled;
}
}
}
}
document.body.classList.toggle('bc-golive-dock-on', !!mode);
document.body.classList.toggle('bc-golive-dock-on', !!mode);
}
}
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
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 ──
// ── Once a second: mount, update or leave ──
function tick() {
function tick() {
@@ -380,22 +380,20 @@ function _wsRenderPanel() {
@@ -380,22 +380,20 @@ function _wsRenderPanel() {
<!-- Class B: device-local choices (browser only): one card, a row each for the camera, its lens and
<!-- 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. -->
the microphone; remembered on this device, never saved with the slot. -->
<div id="bc-ws-device-wrap">
<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>
<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()">
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 & Mic'}</span>
+ <i class="fa-solid fa-video" aria-hidden="true"></i><span id="bc-perm-btn-text">Turn on camera</span>
</button>
</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>
<p id="bc-perm-debug" style="display:none;font-size:0.75rem;color:var(--text-secondary);margin-top:6px"></p>
</div>
</div>
<div id="bc-device-selects" style="display:none">
<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-card">
<div class="bc-setup-item" id="bc-ws-camera-group">
<div class="bc-setup-item" id="bc-ws-camera-group">
<div class="bc-setup-row">
<div class="bc-setup-row">
@@ -422,6 +420,7 @@ function _wsRenderPanel() {
@@ -422,6 +420,7 @@ function _wsRenderPanel() {
</div>
</div>
</div>
</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">
<p class="bc-setup-note">
<span>Remembered on this device</span>
<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>
<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() {
@@ -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
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
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. ── */
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';
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) {
function _glPreviewHTML(mode) {
if (mode === 'screen') {
if (mode === 'screen') {
@@ -1084,6 +1127,7 @@ function _glPreviewHTML(mode) {
@@ -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>'}
${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>
<span class="gl-pv-chip" id="gl-pv-net" hidden></span>
</div>
</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">
<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>
<i class="fa-solid fa-eye-slash" aria-hidden="true"></i><span class="bc-visually-hidden">Turn the preview off</span>
</button>
</button>
@@ -1104,7 +1148,7 @@ function _glPreviewWant() {
@@ -1104,7 +1148,7 @@ function _glPreviewWant() {
const box = document.getElementById('gl-preview');
const box = document.getElementById('gl-preview');
const ms = _wsState.selectedMs;
const ms = _wsState.selectedMs;
if (!box || !box.isConnected || !ms || box.classList.contains('is-still') && !box.classList.contains('is-audio')) return '';
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.
// The studio's page is shown (its address is /@you/studio or /broadcast); another page hides its section.
if (!box.getClientRects().length) return '';
if (!box.getClientRects().length) return '';
if ((ms.streaming_method || 'browser') !== 'browser') return '';
if ((ms.streaming_method || 'browser') !== 'browser') return '';
@@ -1116,7 +1160,7 @@ function _glPreviewWant() {
@@ -1116,7 +1160,7 @@ function _glPreviewWant() {
if (mode === 'screen') return '';
if (mode === 'screen') return '';
const cam = mode === 'mic_only' ? '-' : (document.getElementById('bc-create-camera')?.value || 'default');
const cam = mode === 'mic_only' ? '-' : (document.getElementById('bc-create-camera')?.value || 'default');
const mic = mode === 'camera_only' ? '-' : (document.getElementById('bc-create-audio')?.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() {
function _glPreviewStop() {
@@ -1124,6 +1168,7 @@ function _glPreviewStop() {
@@ -1124,6 +1168,7 @@ function _glPreviewStop() {
cancelAnimationFrame(_glPv.raf);
cancelAnimationFrame(_glPv.raf);
_glPv.raf = 0;
_glPv.raf = 0;
if (_glPv.ctx) { try { _glPv.ctx.close(); } catch { /* */ } _glPv.ctx = null; }
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; }
if (_glPv.stream) { _glPv.stream.getTracks().forEach((t) => { try { t.stop(); } catch { /* */ } }); _glPv.stream = null; }
_glPv.key = '';
_glPv.key = '';
const v = document.getElementById('gl-preview-video');
const v = document.getElementById('gl-preview-video');
@@ -1140,13 +1185,10 @@ async function _glPreviewStart(key) {
@@ -1140,13 +1185,10 @@ async function _glPreviewStart(key) {
if (_glPv.starting) return;
if (_glPv.starting) return;
_glPv.starting = true;
_glPv.starting = true;
_glPreviewStop();
_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 {
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.
// Things moved on while the camera opened: let it go again.
if (_glPreviewWant() !== key) { stream.getTracks().forEach((t) => t.stop()); return; }
if (_glPreviewWant() !== key) { stream.getTracks().forEach((t) => t.stop()); return; }
_glPv.stream = stream;
_glPv.stream = stream;
@@ -1169,57 +1211,30 @@ async function _glPreviewStart(key) {
@@ -1169,57 +1211,30 @@ async function _glPreviewStart(key) {
if (box) box.dataset.state = 'on';
if (box) box.dataset.state = 'on';
const note = document.getElementById('gl-pv-note');
const note = document.getElementById('gl-pv-note');
if (note) note.textContent = '';
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) {
} catch (err) {
_glPv.failed = key;
_glPv.failed = key;
const note = document.getElementById('gl-pv-note');
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 {
} finally {
_glPv.starting = false;
_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. */
/** The network in a word: navigator.connection where the browser has it, and offline everywhere. */
function _glPreviewNetwork() {
function _glPreviewNetwork() {
const chip = document.getElementById('gl-pv-net');
const chip = document.getElementById('gl-pv-net');
if (!chip) return;
if (!chip) return;
+ const shared = window.OpenVibeCam?.core?.network?.();
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
let text = '', cls = '';
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';
text = weak ? 'Weak network · 720p suggested' : 'Good network';
cls = weak ? 'is-warn' : 'is-ok';
cls = weak ? 'is-warn' : 'is-ok';
}
}
@@ -1229,6 +1244,7 @@ function _glPreviewNetwork() {
@@ -1229,6 +1244,7 @@ function _glPreviewNetwork() {
}
}
function _glPreviewSync() {
function _glPreviewSync() {
+ document.getElementById('gl-preview')?.classList.toggle('is-allowed', !!window._glCameraAllowed);
const want = _glPreviewWant();
const want = _glPreviewWant();
_glPreviewNetwork();
_glPreviewNetwork();
if (!want) { if (_glPv.stream || _glPv.key) _glPreviewStop(); return; }
if (!want) { if (_glPv.stream || _glPv.key) _glPreviewStop(); return; }
@@ -1239,6 +1255,34 @@ function _glPreviewSync() {
@@ -1239,6 +1255,34 @@ function _glPreviewSync() {
function _glPreviewWatch() {
function _glPreviewWatch() {
_glPreviewSync();
_glPreviewSync();
if (_glPv.timer) return;
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(() => {
_glPv.timer = setInterval(() => {
_glPreviewSync();
_glPreviewSync();
// Off the page for good: nothing left to watch.
// Off the page for good: nothing left to watch.
@@ -1973,13 +2017,21 @@ async function _wsInitDevicePicker(method, browserMode) {
@@ -1973,13 +2017,21 @@ async function _wsInitDevicePicker(method, browserMode) {
const hasLabels = devices.some(d => d.label);
const hasLabels = devices.some(d => d.label);
const permReq = document.getElementById('bc-perm-request');
const permReq = document.getElementById('bc-perm-request');
const devSelects = document.getElementById('bc-device-selects');
const devSelects = document.getElementById('bc-device-selects');
- if (hasLabels) {
+ if (hasLabels && window._glCameraAllowed) {
if (permReq) permReq.style.display = 'none';
if (permReq) permReq.style.display = 'none';
if (devSelects) devSelects.style.display = '';
if (devSelects) devSelects.style.display = '';
if (typeof populateCreateFormDevices === 'function') await populateCreateFormDevices();
if (typeof populateCreateFormDevices === 'function') await populateCreateFormDevices();
// Restore persisted device selections for this slot (localStorage first, then server profile)
// Restore persisted device selections for this slot (localStorage first, then server profile)
_wsRestoreSlotDevices();
_wsRestoreSlotDevices();
_wsRestoreDevicesFromProfile();
_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();
if (typeof _bcRenderCameraChooser === 'function') _bcRenderCameraChooser();
} else {
} else {
if (permReq) permReq.style.display = '';
if (permReq) permReq.style.display = '';
@@ -2380,8 +2432,8 @@ function _wsSelectBrowserMode(mode) {
@@ -2380,8 +2432,8 @@ function _wsSelectBrowserMode(mode) {
permHintText.textContent = _wsPermHint(mode);
permHintText.textContent = _wsPermHint(mode);
}
}
if (permBtnText) {
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;
permBtnText.textContent = labels[mode] || labels.camera;
}
}
if (permBtn) {
if (permBtn) {
@@ -2472,7 +2524,7 @@ async function _wsCheckScreenInlinePermissions() {
@@ -2472,7 +2524,7 @@ async function _wsCheckScreenInlinePermissions() {
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-device', true);
_wsShowEl('bc-ws-screen-mic-perm', false);
_wsShowEl('bc-ws-screen-mic-perm', false);
// Grant already held (labels are populated), so preview straight away.
// 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) {
} else if (document.getElementById('bc-ws-screen-mic')?.checked) {
_wsShowEl('bc-ws-screen-mic-perm', true);
_wsShowEl('bc-ws-screen-mic-perm', true);
}
}
@@ -2730,6 +2782,11 @@ async function goLiveFromWorkspace() {
@@ -2730,6 +2782,11 @@ async function goLiveFromWorkspace() {
toast('Select a stream slot first', 'error');
toast('Select a stream slot first', 'error');
return;
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.
// What was just typed (the title above all) is saved before the stream starts.
await _wsFlushSave();
await _wsFlushSave();
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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; });
@@ -48,4 +48,16 @@ async function createStill({ token, slot, streamId, caption }) {
@@ -48,4 +48,16 @@ async function createStill({ token, slot, streamId, caption }) {
return data;
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 };
@@ -21,6 +21,20 @@ const router = express.Router();
@@ -21,6 +21,20 @@ const router = express.Router();
const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
const SHARE_TEXT_MAX = 500;
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) {
async function liveStream(req, res) {
const streamId = req.body && Number(req.body.stream_id);
const streamId = req.body && Number(req.body.stream_id);
if (!Number.isInteger(streamId) || streamId <= 0) {
if (!Number.isInteger(streamId) || streamId <= 0) {
@@ -11,5 +11,5 @@ assert.ok(!/html:has\(#page-broadcast\.active\) \.ovnav-appbar \{ display: none;
@@ -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.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, /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, /\.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');
console.log('go live dock: setting up keeps the app bar; the dock sits above it and never covers the last fields');
@@ -168,10 +168,12 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
@@ -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 () => {
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');
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(/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');
assert.ok(/id="bc-ws-golive-btn"/.test(read('public/js/broadcast-workspace.js')), 'the button it presses exists');
});
});
@@ -12,9 +12,9 @@ const fs = require('fs');
@@ -12,9 +12,9 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/features/broadcast.css'), 'utf8');
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(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)');
'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');
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');
console.log('go live dock: the Dock pill rides above it, never on the button');