openvibers/openvibe.live Commits
Phone studio: reliable after an app switch, up through starts and recoveries, a clearer top bar and cameras (#407)
Changes
@@@ -486,10 +486,31 @@background: #000;}--.bc-stats-overlay .bc-stat-label {-- color: var(--text-muted);-- min-width: 70px;--}++/* Stream health: a small card over the preview (and under the studio's health pill on a phone). */++#bc-stats-overlay.bc-stats-overlay {++ top: 10px; left: 10px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px 9px;++ width: max-content; min-width: 210px; max-width: calc(100% - 20px); padding: 11px 13px 12px; border-radius: 14px;++ font-family: inherit; font-size: 0.8rem; color: #fff; line-height: 1.2;++ background: rgba(12, 16, 26, 0.82); border: 1px solid rgba(255, 255, 255, 0.12);++ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);++ animation: bcStatsIn 0.16s ease-out;++}++#bc-stats-overlay[style*="none"] { animation: none; }++@keyframes bcStatsIn { from { opacity: 0; transform: translateY(-4px); } }++#bc-stats-overlay .bc-stats-head {++ grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; padding-bottom: 8px;++ border-bottom: 1px solid rgba(255, 255, 255, 0.1); font-weight: 700;++}++#bc-stats-overlay .bc-stats-title { flex: 1; }++#bc-stats-overlay .bc-stats-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }++#bc-stats-overlay[data-health="good"] .bc-stats-dot { background: #22c55e; }++#bc-stats-overlay[data-health="fair"] .bc-stats-dot, #bc-stats-overlay[data-health="checking"] .bc-stats-dot { background: #f59e0b; }++#bc-stats-overlay[data-health="poor"] .bc-stats-dot, #bc-stats-overlay[data-health="offline"] .bc-stats-dot { background: #ef4444; }++#bc-stats-overlay .bc-stat { display: contents; }++#bc-stats-overlay .bc-stat > i { width: 16px; text-align: center; font-size: 0.72rem; color: rgba(255, 255, 255, 0.55); }++#bc-stats-overlay .bc-stat-label { color: rgba(255, 255, 255, 0.66); min-width: 0; }++#bc-stats-overlay .bc-stat-v { font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }++@media (prefers-reduced-motion: reduce) { #bc-stats-overlay.bc-stats-overlay { animation: none; } }.bc-status-good { background: var(--success); color: #fff; }.bc-status-warn { background: var(--warning); color: #000; }@@@ -2515,13 +2536,14 @@ body.bc-studio-on #bc-video-preview.bcs-mirror { transform: scaleX(-1); }body.bc-studio-on .bc-video-container .bc-live-badge,body.bc-studio-on .bc-video-container .bc-connection-status,body.bc-studio-on .bc-video-container .bc-video-placeholder { display: none !important; }--body.bc-studio-on .bc-stats-overlay { top: calc(58px + env(safe-area-inset-top, 0px)); left: 12px; z-index: 1205; }++/* On the phone the card hangs under the health pill it opened from (top right). */++body.bc-studio-on #bc-stats-overlay.bc-stats-overlay {++ position: fixed; top: calc(52px + env(safe-area-inset-top, 0px)); left: auto; right: max(12px, env(safe-area-inset-right, 0px)); z-index: 1235;++}body.bc-studio-on .messenger-toggle,body.bc-studio-on .bc-mobile-chat-fab,body.bc-studio-on #navbar-mount,body.bc-studio-on .navbar { display: none !important; }--/* "Reconnecting…" stays visible, just above the chat instead of over its input. */--body.bc-studio-on .ov-conn-pill { left: calc(50% - 38px); bottom: calc(min(24vh, 180px) + 10px + env(safe-area-inset-bottom, 0px)); z-index: 1290; }/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */body.bc-studio-on #bc-chat-sidebar {@@@ -2549,8 +2571,7 @@ body.bc-studio-on #bc-chat-messages .chat-msg {}/* Site update cards and chat connection lines are for viewers; a streamer on the camera gets neither (the card'sreload button would end the stream, and the top bar already shows the connection). */--body.bc-studio-on #bc-chat-messages .chat-deploy,--body.bc-studio-on #bc-chat-messages .chat-conn-status { display: none !important; }++body.bc-studio-on #bc-chat-messages .chat-deploy { display: none !important; }body.bc-studio-on #bc-chat-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }body.bc-studio-on #bc-chat-input {background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;@@@ -2578,9 +2599,13 @@ body.bc-studio-on .toast-container { z-index: 1500; }.bcs-top-l { flex-wrap: wrap; min-width: 0; }.bcs-top-r { flex: none; }.bcs-live {-- display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px;-- background: #e11d48; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.06em;++ display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 12px 0 11px; border-radius: 999px;++ background: #e11d48; font-size: 0.86rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: 0.01em;++ box-shadow: 0 2px 10px rgba(225, 29, 72, 0.35); transition: background 0.2s;}++/* Starting and reconnecting: amber, the dot still pulsing. */++.bcs-live[data-phase="starting"], .bcs-live[data-phase="recovering"] { background: #b45309; box-shadow: none; }++.bcs-live-t { min-width: 2.6em; }.bcs-live-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: bcs-pulse 1.6s ease-in-out infinite; }@keyframes bcs-pulse { 50% { opacity: 0.35; } }.bcs-pill {@@@ -2593,11 +2618,57 @@ body.bc-studio-on .toast-container { z-index: 1500; }.bcs-viewers { -webkit-tap-highlight-color: transparent; }.bcs-viewers[aria-expanded="true"] { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.4); }.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }--.bcs-pill.is-ok .bcs-dot, .bcs-restream.is-ok::before { background: #22c55e; }--.bcs-pill.is-warn .bcs-dot, .bcs-restream.is-warn::before { background: #f59e0b; }--.bcs-pill.is-bad .bcs-dot, .bcs-restream.is-bad::before { background: #ef4444; }--.bcs-restream::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }++.bcs-pill.is-ok .bcs-dot { background: #22c55e; }++.bcs-pill.is-warn .bcs-dot { background: #f59e0b; }++.bcs-pill.is-bad .bcs-dot { background: #ef4444; }.bcs-pill.is-bad { border-color: rgba(239,68,68,0.7); }++.bcs-pill { height: 30px; }++/* A restream connecting or failed: a dot on the viewers pill (its panel lists them). */++.bcs-viewers { position: relative; }++.bcs-viewers[data-rs="warn"]::after, .bcs-viewers[data-rs="bad"]::after {++ content: ''; position: absolute; top: -2px; right: -2px; width: 9px; height: 9px; border-radius: 50%;++ background: #f59e0b; border: 2px solid rgba(0, 0, 0, 0.6);++}++.bcs-viewers[data-rs="bad"]::after { background: #ef4444; }++/* The studio fades in over the page instead of appearing a frame after it. */++.bcs { animation: bcs-in 0.22s ease-out; }++@keyframes bcs-in { from { opacity: 0; } }++/* Starting or reconnecting the camera: said on the picture, not a frozen last frame. */++.bcs-state {++ position: absolute; top: 42%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center;++ gap: 6px; width: min(300px, calc(100vw - 120px)); padding: 16px 18px; border-radius: 18px; text-align: center;++ background: rgba(0, 0, 0, 0.58); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);++}++.bcs-state[hidden] { display: none; }++.bcs-state b { font-size: 0.98rem; }++.bcs-state small { font-size: 0.78rem; color: rgba(255, 255, 255, 0.75); }++.bcs-state-spin { width: 26px; height: 26px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.25); border-top-color: #fff; animation: bcs-spin 0.9s linear infinite; }++@keyframes bcs-spin { to { transform: rotate(360deg); } }++/* Flip with more than two cameras: a small "+" (hold it to choose). */++.bcs-btn[data-act="flip"] { position: relative; }++.bcs-more-cams {++ position: absolute; top: -1px; right: 6px; width: 16px; height: 16px; border-radius: 50%; display: grid; place-items: center;++ background: #fff; color: #111; font-size: 0.7rem; font-weight: 900; line-height: 1; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);++}++.bcs-more-cams::before { content: '+'; }++.bcs-more-cams[hidden] { display: none; }++/* The camera list (hold Flip, or More → Cameras). */++.bcs-cam-group { margin-top: 12px; }++.bcs-cam-group h3 { margin: 0 0 6px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: rgba(255, 255, 255, 0.6); }++.bcs-cam-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }++.bcs-cam {++ display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 14px; border-radius: 14px; text-align: left;++ font: inherit; font-size: 0.92rem; font-weight: 700; color: #fff; cursor: pointer;++ background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.12);++}++.bcs-cam span { flex: 1; }++.bcs-cam > i:first-child { width: 18px; text-align: center; color: rgba(255, 255, 255, 0.7); }++.bcs-cam[aria-pressed="true"] { background: rgba(59, 130, 246, 0.22); border-color: rgba(96, 165, 250, 0.7); }++.bcs-cam[aria-pressed="true"] > i:last-child { color: #93c5fd; }++/* Chat's own reconnecting line sits with the chat; the site-wide pill would say it twice (and read as the stream). */++body.bc-studio-on .ov-conn-pill { display: none !important; }++body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !important; width: fit-content; background: rgba(180, 83, 9, 0.85); font-style: normal; }++@media (prefers-reduced-motion: reduce) { .bcs, .bcs-state-spin { animation: none; } }.bcs-end { height: 34px; padding: 0 16px; border: 0; border-radius: 999px; background: #e11d48; color: #fff; font-size: 0.9rem; font-weight: 800; }.bcs-zoom {position: absolute; top: 38%; left: 50%; transform: translate(-50%, -50%); padding: 8px 16px; border-radius: 999px;@@@ -28,3 +28,19 @@.ovp-n { margin-left: auto; padding-left: 12px; font-weight: 700; }.ovp-n.is-live { font-size: 0.72rem; font-weight: 700; color: #fca5a5; text-transform: uppercase; letter-spacing: 0.05em; }@media (prefers-reduced-motion: reduce) { .ovp { animation: none; } }++/* A section under the places (the live studio's restreams): each with a state dot, and a way to manage them. */++.ovp-extra { margin-top: 4px; padding-top: 4px; border-top: 1px solid rgba(255, 255, 255, 0.1); }++.ovp-extra[hidden] { display: none; }++.ovp-sub { padding: 4px 14px 0; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255, 255, 255, 0.55); }++.ovp-sdot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }++.ovp-state.is-ok .ovp-sdot { background: #22c55e; }++.ovp-state.is-warn .ovp-sdot { background: #f59e0b; }++.ovp-state.is-bad .ovp-sdot { background: #ef4444; }++.ovp-state .ovp-n { font-size: 0.78rem; font-weight: 600; color: rgba(255, 255, 255, 0.7); }++.ovp-state.is-bad .ovp-n { color: #fca5a5; }++.ovp-action {++ display: block; width: calc(100% - 20px); margin: 6px 10px 2px; min-height: 36px; border-radius: 10px; cursor: pointer;++ font: inherit; font-size: 0.82rem; font-weight: 700; color: #fff;++ background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.14);++}++.ovp-action:hover, .ovp-action:focus-visible { background: rgba(255, 255, 255, 0.14); outline: none; }@@@ -644,13 +644,15 @@ WantedBy=multi-user.target</code><span>Camera preview will appear here</span></div><!-- Stats overlay (hidden by default) -->-- <div class="bc-stats-overlay" id="bc-stats-overlay" style="display:none">-- <span><span class="bc-stat-label">Bitrate</span> <span id="bc-stat-bitrate">-- kbps</span></span>-- <span><span class="bc-stat-label">FPS</span> <span id="bc-stat-fps">--</span></span>-- <span><span class="bc-stat-label">Resolution</span> <span id="bc-stat-resolution">--</span></span>-- <span><span class="bc-stat-label">Codec</span> <span id="bc-stat-codec">--</span></span>-- <span><span class="bc-stat-label">Status</span> <span id="bc-stat-status">--</span></span>-- <span><span class="bc-stat-label">Health</span> <span id="bc-stat-health">--</span></span>++ <!-- Stream health: a small card of what the stream is sending (owner, 2026-10-27: the green terminal text++ was confusing). The ids are what broadcast.js fills. -->++ <div class="bc-stats-overlay" id="bc-stats-overlay" style="display:none" role="group" aria-label="Stream health">++ <div class="bc-stats-head"><span class="bc-stats-dot" aria-hidden="true"></span><span class="bc-stats-title">Stream health</span><b id="bc-stat-health">--</b></div>++ <span class="bc-stat"><i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i><span class="bc-stat-label">Upload</span><span class="bc-stat-v" id="bc-stat-bitrate">-- kbps</span></span>++ <span class="bc-stat"><i class="fa-solid fa-expand" aria-hidden="true"></i><span class="bc-stat-label">Picture</span><span class="bc-stat-v" id="bc-stat-resolution">--</span></span>++ <span class="bc-stat"><i class="fa-solid fa-film" aria-hidden="true"></i><span class="bc-stat-label">Frame rate</span><span class="bc-stat-v" id="bc-stat-fps">--</span></span>++ <span class="bc-stat"><i class="fa-solid fa-microchip" aria-hidden="true"></i><span class="bc-stat-label">Codec</span><span class="bc-stat-v" id="bc-stat-codec">--</span></span>++ <span class="bc-stat"><i class="fa-solid fa-signal" aria-hidden="true"></i><span class="bc-stat-label">Connection</span><span class="bc-stat-v" id="bc-stat-status">--</span></span></div><!-- Live badge (hidden until live) --><div class="bc-live-badge" id="bc-live-badge" style="display:none">@@@ -212,6 +212,46 @@ function _getCameraFacingHint(device) {return null;}++/**++ * The cameras as people know them (owner, 2026-10-27: "camera 1, facing front … unformatted camera names is trash").++ * A phone's cameras are named by side: "Front camera" / "Back camera", and when a side has more than one, "Back camera 2"…++ * in the phone's own numbering (Android's "camera 0, facing back" is the main one; the list comes in no useful order).++ * A computer's camera keeps its name without the USB ids ("HD Pro Webcam C920 (046d:082d)" → "HD Pro Webcam C920").++ * Entries with the same label are one camera (some phones list a logical camera twice).++ * Returns [{ deviceId, label, name, facing: 'user'|'environment'|null, side: 1-based index on its side, sides }].++ */++function _bcCameraChoices(devices) {++ const seen = new Set();++ const list = [];++ for (const d of devices || []) {++ if (d.kind && d.kind !== 'videoinput') continue;++ const key = d.label || d.deviceId;++ if (!key || seen.has(key)) continue;++ seen.add(key);++ const num = /camera2?\s*(\d+)/i.exec(d.label || '');++ list.push({ deviceId: d.deviceId, label: d.label || '', facing: _getCameraFacingHint(d), order: num ? Number(num[1]) : 1000 + list.length });++ }++ const sides = { user: list.filter((c) => c.facing === 'user'), environment: list.filter((c) => c.facing === 'environment') };++ for (const side of Object.values(sides)) side.sort((a, b) => a.order - b.order);++ let other = 0;++ for (const c of list) {++ if (c.facing) {++ const mine = sides[c.facing];++ c.side = mine.indexOf(c) + 1;++ c.sides = mine.length;++ const base = c.facing === 'user' ? 'Front camera' : 'Back camera';++ c.name = c.side === 1 ? base : `${base} ${c.side}`;++ } else {++ other += 1;++ const clean = c.label.replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, '').trim();++ c.name = clean || `Camera ${other}`;++ }++ }++ // Front cameras first, then back, then the rest, each side in the phone's order.++ const rank = (c) => (c.facing === 'user' ? 0 : c.facing === 'environment' ? 1 : 2);++ return list.sort((a, b) => rank(a) - rank(b) || (a.side || 0) - (b.side || 0) || a.order - b.order);++}++async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {if (!navigator.mediaDevices?.enumerateDevices) return [];let tempStream = null;@@@ -137,6 +137,16 @@ function startTrackKeepalive(streamId) {watchdogTriggered = false;}++ // A phone pauses the camera of a tab in the background: no frames there is expected, and a recovery started++ // from the background cannot open the camera anyway (it failed four times and gave up while the streamer was in++ // another app). broadcast.js checks the camera the moment the tab is in front again.++ if (mobile && typeof document !== 'undefined' && document.hidden) {++ lastSuccessfulFrame = Date.now();++ watchdogTriggered = false;++ lastFrameCount = null;++ return;++ }++// If recovery is already in progress or suppressed, just keep tickingif (current._suppressRecovery || current.mediaRecoveryInProgress || current.mediaRecoveryTimer) {lastSuccessfulFrame = Date.now(); // reset watchdog during recovery@@@ -23,6 +23,7 @@const st = {root: null, micOff: false, camOff: false, torch: false, chatHidden: false, busy: false, viewer: null, viewerMute: 0,dismissedFor: null, hadLocal: false, sheet: null, confirm: null, parked: [], cameras: 0, lastFocus: null,++ choices: [], startingSince: 0, healthOpen: false,};const $ = (id) => document.getElementById(id);@@@ -50,8 +51,30 @@function videoTrack() { const s = localStream(); return s ? s.getVideoTracks()[0] || null : null; }function onBroadcastPage() { const p = $('page-broadcast'); return !!(p && p.classList.contains('active')); }function browserViewShown() { const v = $('bc-browser-broadcast'); return !!(v && v.style.display !== 'none'); }-- function liveHere() { return onBroadcastPage() && browserViewShown() && !!localStream(); }-- function wanted() { return NARROW.matches && liveHere() && st.dismissedFor !== activeId(); }++ // The studio is up for the whole session: while the camera starts, while a recovery reopens it, and live. Tying it to++ // "a camera stream exists right now" showed the full page for a moment on every start (it mounted on the next++ // second's tick) and for the whole of every recovery, with "Error" and a stray Connecting box (owner, 2026-10-27).++ function sessionHere() { return onBroadcastPage() && browserViewShown() && !!streamState(); }++ function liveHere() { return sessionHere() && !!localStream(); }++ /** 'starting' (no camera yet), 'recovering' (the media restarts) or 'live'. */++ function phase() {++ const ss = streamState();++ if (!ss) return null;++ if (ss.mediaRecoveryInProgress || ss.mediaRecoveryTimer) return 'recovering';++ return ss.localStream ? 'live' : 'starting';++ }++ // A camera that never starts (permission refused) gives the page back after a while, with its error.++ const STARTING_GIVE_UP_MS = 25000;++ function wanted() {++ if (!sessionHere()) { st.startingSince = 0; return false; }++ if (!NARROW.matches || st.dismissedFor === activeId()) return false;++ if (phase() === 'starting') {++ if (!st.startingSince) st.startingSince = Date.now();++ return Date.now() - st.startingSince < STARTING_GIVE_UP_MS;++ }++ st.startingSince = 0;++ return true;++ }// ── Mic, camera and light: the choice holds across replaced tracks ──function applyTracks() {@@@ -168,25 +191,27 @@refresh();}-- /** Front ↔ back in one tap: the first camera facing the other way (a phone's main one), else the next camera. */-- async function flip() {-- if (st.busy) return;++ async function loadCameras() {++ if (typeof _enumerateBroadcastVideoInputs !== 'function') return st.choices;++ const cams = await _enumerateBroadcastVideoInputs({ ensureLabels: false });++ st.cameras = cams.length;++ st.choices = typeof _bcCameraChoices === 'function' ? _bcCameraChoices(cams) : cams.map((d, i) => ({ deviceId: d.deviceId, name: `Camera ${i + 1}`, facing: _getCameraFacingHint(d) }));++ return st.choices;++ }++ const rememberSide = (facing, id) => { try { if (facing && id) localStorage.setItem(`bc-last-camera-${facing}`, id); } catch { /* */ } };++ const lastOnSide = (facing) => { try { return localStorage.getItem(`bc-last-camera-${facing}`); } catch { return null; } };++ async function useCamera(choice) {++ if (st.busy || !choice) return;if (screenSharing()) return say('You are sharing your screen. Stop sharing to use the camera.');-- if (typeof _switchActiveCamera !== 'function' || typeof _enumerateBroadcastVideoInputs !== 'function') return;++ if (typeof _switchActiveCamera !== 'function') return;st.busy = true;refresh();try {-- const cams = await _enumerateBroadcastVideoInputs({ ensureLabels: false });-- st.cameras = cams.length;-- if (cams.length < 2) { say('This device has only one camera'); return; }-- const now = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings()) || {};-- const was = facing();-- const other = was && cams.find((d) => d.deviceId !== now.deviceId && _getCameraFacingHint(d) && _getCameraFacingHint(d) !== was);-- const i = cams.findIndex((d) => d.deviceId === now.deviceId);-- const next = other || cams[(i + 1) % cams.length];st.torch = false;zoom.level = null;-- await _switchActiveCamera(next.deviceId);++ // Quietly: the picture changing is the answer; a toast on every flip covered the chat.++ await _switchActiveCamera(choice.deviceId, { quiet: true });++ rememberSide(choice.facing, choice.deviceId);applyTracks();} catch (err) {say('Could not switch camera: ' + ((err && err.message) || 'unknown error'), 'error');@@@ -195,6 +220,65 @@refresh();}}++ /** One tap: the other side, on the camera last used there (a phone's main one the first time); else the next camera. */++ async function flip() {++ if (st.busy) return;++ const choices = await loadCameras();++ if (choices.length < 2) { say('This device has only one camera'); return; }++ const nowId = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings().deviceId) || null;++ const current = choices.find((c) => c.deviceId === nowId);++ const was = facing() || (current && current.facing);++ const otherSide = was === 'user' ? 'environment' : was === 'environment' ? 'user' : null;++ let next = null;++ if (otherSide) {++ const side = choices.filter((c) => c.facing === otherSide);++ next = side.find((c) => c.deviceId === lastOnSide(otherSide)) || side[0] || null;++ }++ if (!next) { const i = choices.findIndex((c) => c.deviceId === nowId); next = choices[(i + 1) % choices.length]; }++ await useCamera(next);++ }++ /** Every camera, by side, the one in use ticked: what holding Flip opens when there are more than two. */++ async function openCameras() {++ const choices = await loadCameras();++ closeSheet();++ st.lastFocus = document.activeElement;++ const nowId = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings().deviceId) || null;++ const group = (title, list) => list.length ? el('div', { class: 'bcs-cam-group' }, el('h3', { text: title }),++ el('div', { class: 'bcs-cam-list' }, ...list.map((c) => el('button', {++ type: 'button', class: 'bcs-cam', 'aria-pressed': String(c.deviceId === nowId),++ onclick: () => { closeSheet(); if (c.deviceId !== nowId) useCamera(c); },++ }, icon(c.facing === 'user' ? 'fa-user' : c.facing === 'environment' ? 'fa-mountain-sun' : 'fa-video'),++ el('span', { text: c.name }), c.deviceId === nowId ? icon('fa-check') : null)))) : null;++ const sheet = el('div', { class: 'bcs-sheet bcs-cam-sheet', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'bcs-cam-title' },++ el('div', { class: 'bcs-grab', 'aria-hidden': 'true' }),++ el('div', { class: 'bcs-sheet-head' },++ el('div', { class: 'bcs-sheet-who' }, el('b', { id: 'bcs-cam-title', text: 'Cameras' }), el('small', { text: 'Tap Flip to switch sides; this one is remembered for its side' })),++ el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeSheet }, icon('fa-xmark'))),++ group('Front', choices.filter((c) => c.facing === 'user')),++ group('Back', choices.filter((c) => c.facing === 'environment')),++ group('Other cameras', choices.filter((c) => !c.facing)));++ st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);++ document.body.append(st.sheet);++ const on = sheet.querySelector('.bcs-cam[aria-pressed="true"]') || sheet.querySelector('.bcs-cam');++ if (on) on.focus();++ }++ /** Flip's button: a tap flips; holding it (or a long press) lists every camera when there are more than two. */++ function flipButton() {++ const b = railButton('flip', 'fa-camera-rotate', 'Flip', null);++ b.append(el('span', { class: 'bcs-more-cams', hidden: true, 'aria-hidden': 'true' }));++ let timer = 0, held = false;++ const many = () => st.choices.length > 2;++ b.addEventListener('pointerdown', () => {++ held = false;++ clearTimeout(timer);++ if (!many()) return;++ timer = setTimeout(() => { held = true; try { navigator.vibrate && navigator.vibrate(12); } catch { /* */ } openCameras(); }, 450);++ });++ for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) b.addEventListener(ev, () => clearTimeout(timer));++ b.addEventListener('contextmenu', (e) => e.preventDefault());++ b.addEventListener('click', () => { if (held) { held = false; return; } flip(); });++ return b;++ }function quickClip(seconds) {if (typeof createBroadcastClip !== 'function' || typeof _getBroadcastUptimeSeconds !== 'function') return;@@@ -270,7 +354,12 @@function toggleViewers() {if (!window.OVViewerPlaces) return;closeSheet();-- OVViewerPlaces.toggle($('bcs-viewers'), viewerPlaces, { below: true });++ closeHealth();++ OVViewerPlaces.toggle($('bcs-viewers'), viewerPlaces, {++ below: true,++ // Restreams sit with the places they send to ("Also on 1" was a second chip for the same thing).++ extra: () => ({ title: 'Restreams', rows: restreamRows(), action: restreamRows().length ? { label: 'Manage restreams', onClick: () => { OVViewerPlaces.close(); openSheet('restreams'); } } : null }),++ });}// ── The overlay ──@@@ -279,27 +368,30 @@el('span', { class: 'bcs-ico' }, icon(ico)), el('span', { class: 'bcs-lbl', text: label }));}function build() {++ // LIVE and the time were two pills saying one thing (owner, 2026-10-27): one red pill, its dot pulsing, the time in it.const top = el('div', { class: 'bcs-top' },el('div', { class: 'bcs-top-l' },-- el('span', { class: 'bcs-live' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }), 'LIVE'),-- el('span', { class: 'bcs-pill', id: 'bcs-time', title: 'Time live' }, '0:00'),++ el('span', { class: 'bcs-live', id: 'bcs-live', role: 'status' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }),++ el('span', { class: 'bcs-live-t', id: 'bcs-time', text: '0:00' })),el('button', { type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers },-- icon('fa-eye'), el('b', { text: '0' })),-- el('button', { type: 'button', class: 'bcs-pill bcs-restream', id: 'bcs-restream', hidden: true, onclick: () => openSheet('restreams') })),++ icon('fa-eye'), el('b', { text: '0' }))),el('div', { class: 'bcs-top-r' },-- el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', onclick: toggleHealth },++ el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', 'aria-expanded': 'false', 'aria-controls': 'bc-stats-overlay', onclick: toggleHealth },el('span', { class: 'bcs-dot', 'aria-hidden': 'true' }), el('span', { class: 'bcs-health-t', text: 'Connecting' })),el('button', { type: 'button', class: 'bcs-end', id: 'bcs-end', onclick: openConfirm }, 'End')));const rail = el('div', { class: 'bcs-rail', role: 'toolbar', 'aria-label': 'Live controls', 'aria-orientation': 'vertical' },-- railButton('flip', 'fa-camera-rotate', 'Flip', flip),++ flipButton(),railButton('mic', 'fa-microphone', 'Mic', toggleMic),railButton('cam', 'fa-video', 'Camera', toggleCamera),railButton('light', 'fa-bolt', 'Light', toggleLight),railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),railButton('more', 'fa-ellipsis', 'More', () => openSheet()));++ // While the camera starts or a recovery reopens it, the picture says so instead of freezing on its last frame.++ const stateCard = el('div', { class: 'bcs-state', id: 'bcs-state', hidden: true, role: 'status' },++ el('span', { class: 'bcs-state-spin', 'aria-hidden': 'true' }), el('b', { class: 'bcs-state-t' }), el('small', { class: 'bcs-state-d' }));return el('div', { class: 'bcs', id: 'bc-studio' }, top, el('div', { class: 'bcs-flags', id: 'bcs-flags', 'aria-live': 'polite' }), rail,-- el('div', { class: 'bcs-zoom', id: 'bcs-zoom', hidden: true, 'aria-live': 'polite' }));++ el('div', { class: 'bcs-zoom', id: 'bcs-zoom', hidden: true, 'aria-live': 'polite' }), stateCard);}function mount() {@@@ -315,11 +407,12 @@if (v) v.play().catch(() => {});const msgs = $('bc-chat-messages');if (msgs) msgs.scrollTop = msgs.scrollHeight;-- if (typeof _enumerateBroadcastVideoInputs === 'function') _enumerateBroadcastVideoInputs({ ensureLabels: false }).then((c) => { st.cameras = c.length; refresh(); }, () => {});++ loadCameras().then(() => refresh(), () => {});refresh();}function unmount() {if (window.OVViewerPlaces) OVViewerPlaces.close();++ closeHealth();closeSheet();closeConfirm();closeViewer();@@@ -332,6 +425,28 @@}function text(id) { const n = $(id); return n ? n.textContent.trim() : ''; }++ /** Each restream and its state, read from the page's restream panels: [{ name, state: 'ok'|'warn'|'bad'|'idle', text }]. */++ function restreamRows() {++ const rows = [];++ const stateOf = (n) => n.querySelector('.bc-restream-status-live, .bc-rs-live') ? ['ok', 'Live']++ : n.querySelector('.bc-restream-status-starting, .bc-rs-connecting') ? ['warn', 'Connecting']++ : n.querySelector('.bc-restream-status-error, .bc-restream-status-failed, .bc-rs-error') ? ['bad', 'Failed'] : ['idle', 'Off'];++ const panel = $('bc-restream-control');++ if (panel && panel.style.display !== 'none') {++ for (const r of panel.querySelectorAll('.bc-restream-row')) {++ const nameEl = r.querySelector('.bc-restream-name');++ const name = nameEl ? (nameEl.firstChild && nameEl.firstChild.nodeType === 3 ? nameEl.firstChild.textContent : nameEl.textContent).trim() : 'Restream';++ const [state, text] = stateOf(r);++ rows.push({ name, state, text });++ }++ }++ const rs = $('bc-rs-control');++ if (rs && rs.style.display !== 'none' && !rows.some((r) => /robotstreamer/i.test(r.name))) {++ const [state, text] = stateOf(rs);++ if (state !== 'idle') rows.push({ name: 'RobotStreamer', state, text });++ }++ return rows;++ }function restreamSummary() {const scope = [$('bc-rs-control'), $('bc-restream-control')].filter((n) => n && n.style.display !== 'none');let live = 0, starting = 0, failed = 0;@@@ -356,9 +471,19 @@syncDesktopButtons();if (!st.root) return;document.body.classList.toggle('bc-studio-chat-off', st.chatHidden);-- // 0:05:07 → 5:07 while under an hour.++ // 0:05:07 → 5:07 while under an hour; while the camera starts or comes back, the pill says so.++ const ph = phase();const up = /^(\d+):(\d\d):(\d\d)$/.exec(text('bc-uptime'));-- $('bcs-time').textContent = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');++ const time = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');++ const livePill = $('bcs-live');++ livePill.dataset.phase = ph || 'live';++ $('bcs-time').textContent = ph === 'starting' ? 'Starting' : time;++ livePill.setAttribute('aria-label', ph === 'starting' ? 'Starting your stream' : `Live for ${time}`);++ const stateCard = $('bcs-state');++ const stateText = ph === 'starting' ? ['Starting your camera…', 'Viewers see you in a moment']++ : ph === 'recovering' ? ['Reconnecting your camera…', 'You are still live; viewers wait a few seconds'] : null;++ stateCard.hidden = !stateText;++ if (stateText) { stateCard.querySelector('.bcs-state-t').textContent = stateText[0]; stateCard.querySelector('.bcs-state-d').textContent = stateText[1]; }const viewers = $('bcs-viewers').querySelector('b');viewers.textContent = text('bc-viewer-count') || '0';const places = viewerPlaces();@@@ -366,14 +491,16 @@$('bcs-viewers').title = `${viewers.textContent} watching now${where}`;$('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. Show where`);++ // A restream connecting or failed marks the viewers pill; its list is in the pill's panel.const rs = restreamSummary();-- const chip = $('bcs-restream');-- chip.hidden = !rs;-- if (rs) { chip.className = `bcs-pill bcs-restream ${rs.cls}`; chip.textContent = rs.text; }++ $('bcs-viewers').dataset.rs = rs ? rs.cls.replace('is-', '') : '';++ if (rs && rs.cls !== 'is-ok') $('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. ${rs.text}. Show where`);++ if (window.OVViewerPlaces && OVViewerPlaces.isOpen()) OVViewerPlaces.update();const conn = $('bc-connection-status');const bad = conn && conn.classList.contains('bc-status-bad');-- const [cls, label] = bad ? ['is-bad', 'Reconnecting'] : (HEALTH[text('bc-stat-health').toLowerCase()] || ['is-warn', 'Connecting']);++ const [cls, label] = ph === 'recovering' || bad ? ['is-bad', 'Reconnecting'] : ph === 'starting' ? ['is-warn', 'Starting']++ : (HEALTH[text('bc-stat-health').toLowerCase()] || ['is-warn', 'Connecting']);const health = $('bcs-health');health.className = `bcs-pill bcs-health ${cls}`;health.querySelector('.bcs-health-t').textContent = label;@@@ -390,6 +517,11 @@b.classList.toggle('is-busy', !!busy);};set('flip', { hidden: st.cameras === 1 || screenSharing(), busy: st.busy, ico: st.busy ? 'fa-spinner fa-spin' : 'fa-camera-rotate' });++ // More than two cameras: a small "+" on Flip says holding it lists them.++ const moreCams = st.root.querySelector('.bcs-more-cams');++ if (moreCams) moreCams.hidden = st.choices.length <= 2;++ const flipBtn = st.root.querySelector('[data-act="flip"]');++ if (flipBtn) flipBtn.setAttribute('aria-label', st.choices.length > 2 ? 'Flip camera. Hold to choose a camera' : 'Flip camera');set('mic', { on: st.micOff, ico: st.micOff ? 'fa-microphone-slash' : 'fa-microphone', label: st.micOff ? 'Unmute' : 'Mute' });set('cam', { on: st.camOff, ico: st.camOff ? 'fa-video-slash' : 'fa-video', label: st.camOff ? 'Camera on' : 'Camera' });set('light', { hidden: !torchCapable(), on: st.torch, label: st.torch ? 'Light off' : 'Light' });@@@ -429,7 +561,7 @@const fl = $('bc-btn-flip');if (fl) fl.style.display = live && !screenSharing() && st.cameras > 1 ? '' : 'none';const back = $('bcs-return');-- if (back) back.hidden = !(NARROW.matches && liveHere() && st.dismissedFor === activeId());++ if (back) back.hidden = !(NARROW.matches && sessionHere() && st.dismissedFor === activeId());}// ── More ──@@@ -509,12 +641,11 @@const grid = el('div', { class: 'bcs-grid' },tile('fa-tv', 'Watch as a viewer', after(() => openViewerPreview())),-- tile('fa-camera', 'Choose camera', after(() => openCameraSwitcher())),++ tile('fa-camera', 'Cameras', after(() => openCameras()), st.choices.length > 2 ? `${st.choices.length} on this phone` : null),canScreen ? tile('fa-display', screenSharing() ? 'Stop sharing screen' : 'Share screen', after(() => toggleScreenShare())) : null,tile('fa-film', 'Media requests', after(() => toggleMediaPip())),tile('fa-scissors', 'Clip last minute', after(() => quickClip(60))),tile(previewHidden ? 'fa-eye' : 'fa-eye-slash', previewHidden ? 'Show my preview' : 'Hide my preview', after(() => toggleBroadcastPreview()), previewHidden ? null : 'Saves battery'),-- tile('fa-chart-line', 'Stream health', after(toggleHealth)),tile('fa-sliders', 'All settings', after(() => leave('#bc-live-section .bc-settings-group'))),tile('fa-up-right-and-down-left-from-center', 'Full page', after(() => leave())));@@@ -553,7 +684,29 @@if (st.sheet) { st.sheet.remove(); st.sheet = null; if (st.lastFocus && st.lastFocus.focus) st.lastFocus.focus(); }}-- function toggleHealth() { if (typeof toggleBroadcastStats === 'function') toggleBroadcastStats(); }++ function healthCard() { return $('bc-stats-overlay'); }++ function closeHealth() {++ const c = healthCard();++ if (c && c.style.display !== 'none') c.style.display = 'none';++ const p = $('bcs-health');++ if (p) p.setAttribute('aria-expanded', 'false');++ document.removeEventListener('pointerdown', onHealthOutside, true);++ }++ function onHealthOutside(e) {++ const c = healthCard(), p = $('bcs-health');++ if ((c && c.contains(e.target)) || (p && p.contains(e.target))) return;++ closeHealth();++ }++ function toggleHealth() {++ const c = healthCard();++ if (!c) return;++ if (c.style.display !== 'none') { closeHealth(); return; }++ if (window.OVViewerPlaces) OVViewerPlaces.close();++ c.style.display = 'grid';++ const p = $('bcs-health');++ if (p) p.setAttribute('aria-expanded', 'true');++ if (st.root) document.addEventListener('pointerdown', onHealthOutside, true);++ }function openConfirm() {closeConfirm();@@@ -617,8 +770,9 @@st.hadLocal = has;// The camera button was hidden when the live view showed before the camera started.if (typeof syncBroadcastLiveButtonVisibility === 'function') syncBroadcastLiveButtonVisibility();-- if (has && typeof _enumerateBroadcastVideoInputs === 'function') _enumerateBroadcastVideoInputs({ ensureLabels: false }).then((c) => { st.cameras = c.length; syncDesktopButtons(); }, () => {});-- if (!has) { st.micOff = false; st.camOff = false; st.torch = false; st.dismissedFor = null; }++ if (has) loadCameras().then(() => { syncDesktopButtons(); refresh(); }, () => {});++ // A camera gone because the stream ended resets the switches; one gone for a recovery keeps them.++ if (!has && !streamState()) { st.micOff = false; st.camOff = false; st.torch = false; st.dismissedFor = null; }}if (wanted()) { if (!st.root) mount(); refresh(); }else { if (st.root) unmount(); applyTracks(); syncDesktopButtons(); }@@@ -626,6 +780,7 @@}setInterval(tick, 1000);document.addEventListener('visibilitychange', tick);++ document.addEventListener('bc-session', tick);if (NARROW.addEventListener) NARROW.addEventListener('change', tick);ensureReturn();@@@ -390,6 +390,11 @@ function attachLocalStreamRecoveryHandlers(streamId) {function scheduleMediaRecovery(streamId, reason = 'media interrupted') {const ss = getStreamState(streamId);if (!ss || ss.mediaRecoveryInProgress || ss.mediaRecoveryTimer || !broadcastState.streams.has(streamId)) return;++ // A phone cannot open its camera from the background: the resume check runs when the tab is in front again.++ if (document.hidden && isMobileBroadcaster()) {++ console.log(`[Broadcast] Recovery waits for the tab to be in front (${reason})`);++ return;++ }// Recovery cooldown — if media was just recovered, spurious track events from// the fresh capture pipeline should be ignored. This prevents rapid recovery@@@ -440,6 +445,7 @@ async function recoverStreamMedia(streamId, reason = 'media interrupted') {const ss = getStreamState(streamId);if (!ss || ss.mediaRecoveryInProgress) return;ss.mediaRecoveryInProgress = true;++ _bcSessionChanged(); // the phone studio stays up and says "Reconnecting" while the media restartsawait uploadVodRecording(streamId, { finalizeStream: false });// Stop the RS restream BEFORE nulling localStream — prevents the@@@ -480,6 +486,7 @@ async function recoverStreamMedia(streamId, reason = 'media interrupted') {ss.mediaRecoveryAttempts = 0;ss.mediaRecoveryInProgress = false;ss._lastRecoveryCompletedAt = Date.now();++ _bcSessionChanged();if (broadcastState.activeStreamId === streamId) {updateBroadcastStatusFromConnections(streamId);// Only show success toast if recovery was slow enough for the user to notice@@@ -1628,6 +1635,7 @@ function showBrowserBroadcast() {const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';_startViewerPlacesPoll();syncBroadcastLiveButtonVisibility();++ _bcSessionChanged();}let _rtmpStatusPollTimer = null;@@@ -2116,6 +2124,7 @@ async function stopBroadcast() {function cleanupStream(streamId) {const ss = broadcastState.streams.get(streamId);if (!ss) return;++ setTimeout(_bcSessionChanged, 0); // after the state below is gone// Leave the broadcast call if we're in one for this streamif (callState.broadcastMode && callState.streamId === streamId) {@@@ -2303,7 +2312,7 @@ function startGlobalDisplayTimers() {setT('bc-stat-fps', `${Math.round(frameRate)} fps`);setT('bc-stat-resolution', resolution || '--');_reportBroadcastShape(ss, hasStats ? resolution : '');-- setT('bc-stat-status', connState);++ setT('bc-stat-status', ({ connected: 'Connected', completed: 'Connected', checking: 'Connecting', new: 'Connecting', disconnected: 'Dropped', failed: 'Failed', closed: 'Closed' })[connState] || connState || '--');setT('bc-stat-codec', codec || '--');// Health classification@@@ -2328,6 +2337,8 @@ function startGlobalDisplayTimers() {}healthEl.textContent = health;healthEl.style.color = color;++ const card = document.getElementById('bc-stats-overlay');++ if (card) card.dataset.health = ({ Good: 'good', Fair: 'fair', Poor: 'poor', Offline: 'offline' })[health] || 'checking';}_checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);} finally {@@@ -4725,7 +4736,7 @@ async function _switchActiveAudio(audioId) {toast('Microphone switched', 'success');}--async function _switchActiveCamera(cameraId) {++async function _switchActiveCamera(cameraId, opts = {}) {const ss = getActiveStreamState();if (!ss || !ss.localStream) return;@@@ -4738,7 +4749,8 @@ async function _switchActiveCamera(cameraId) {const videoTrack = ss.localStream.getVideoTracks()[0]; if (!videoTrack) return;const oldSettings = videoTrack.getSettings();const oldDeviceId = oldSettings.deviceId;-- if ((cameraId || 'default') === (oldDeviceId || 'default')) return;++ // `force` reopens the same camera: the phone gave it to another app and this tab's track ended or froze.++ if (!opts.force && (cameraId || 'default') === (oldDeviceId || 'default')) return;const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: false });const chosenDevice = devices.find(d => d.deviceId === cameraId) || null;@@@ -4802,7 +4814,8 @@ async function _switchActiveCamera(cameraId) {_warnDeviceMismatch('camera');}_syncCameraSelectionUI(appliedDeviceId);-- toast('Camera switched', 'success');++ if (typeof _bcSessionChanged === 'function') _bcSessionChanged();++ if (!opts.quiet) toast('Camera switched', 'success');} catch (err) {console.error('[Broadcast] switch camera failed:', err);try {@@@ -4823,6 +4836,99 @@ async function _switchActiveCamera(cameraId) {}}++/** Tells the phone studio (broadcast-studio.js) the session changed, so it shows or updates at once, not a second later. */++function _bcSessionChanged() {++ try { document.dispatchEvent(new CustomEvent('bc-session')); } catch { /* */ }++}++++/* ── Back in front after another app (owner, 2026-10-27) ─────────────────++ "It froze after I switched apps real quick": a phone gives the camera to the other app and pauses this tab's++ video, and the track comes back ended, or live but sending nothing. The frame watchdog only noticed 8–15 s later,++ restarted the whole stream (restreams included), and a second switch within its 60 s cooldown never recovered.++++ Now, the moment the tab is in front again: the preview plays again, the audio mix resumes, and if no camera frame++ arrives within about a second and a half (or the track ended) the same camera is opened again and swapped into++ every connection in place (_switchActiveCamera force: viewers, restreams and the recording keep going, nothing++ restarts). A microphone that ended is opened again into the mix. The stream's signalling socket reconnects at once++ instead of waiting out its backoff. */++let _bcHiddenAt = 0;++let _bcResuming = null;++/** Whether the camera is sending frames: the preview's next frame, else the track's frame counter; null if unknowable. */++function _bcFramesFlowing(track, ms = 1500) {++ const preview = document.getElementById('bc-video-preview');++ const canWatch = preview && typeof preview.requestVideoFrameCallback === 'function' && preview.srcObject && preview.offsetParent !== null;++ if (canWatch) {++ return new Promise((resolve) => {++ let done = false;++ const finish = (v) => { if (!done) { done = true; resolve(v); } };++ try { preview.requestVideoFrameCallback(() => finish(true)); } catch { finish(null); }++ setTimeout(() => finish(false), ms);++ });++ }++ const first = typeof _trackFrameCount === 'function' ? _trackFrameCount(track) : null;++ if (first == null) return Promise.resolve(null);++ return new Promise((resolve) => setTimeout(() => {++ const next = _trackFrameCount(track);++ resolve(next == null ? null : next !== first);++ }, ms));++}++async function _bcResumeAfterBackground(awayMs) {++ const streamId = broadcastState.activeStreamId;++ const ss = getActiveStreamState();++ if (!ss || !ss.localStream || ss.mediaRecoveryInProgress) return;++ // The socket that carries the stream: back now, not after a backoff that grew while the phone was away.++ if (!ss.signalingIntentionalClose && (!ss.signalingWs || ss.signalingWs.readyState > WebSocket.OPEN)) {++ if (ss.signalingReconnectTimer) { clearTimeout(ss.signalingReconnectTimer); ss.signalingReconnectTimer = null; }++ ss.signalingReconnectDelay = 3000;++ connectSignaling(streamId);++ }++ // A paused preview looks exactly like a frozen camera.++ const preview = document.getElementById('bc-video-preview');++ if (preview && preview.srcObject && preview.paused) preview.play().catch(() => {});++ try { if (ss._audioMix && ss._audioMix.ctx.state !== 'running') await ss._audioMix.ctx.resume(); } catch { /* */ }++ // The microphone, if the other app took it for good.++ const mic = ss._audioMix && ss._audioMix.mic;++ const micTrack = mic && mic.stream && mic.stream.getAudioTracks()[0];++ if (micTrack && micTrack.readyState === 'ended' && ss._micEnabled !== false) {++ try {++ const s = broadcastState.settings;++ const micStream = await _getUserMediaWithTimeout({ audio: buildAudioConstraints(s, micTrack.getSettings?.().deviceId || s.forceAudio), video: false });++ ss._micStream = micStream;++ _mixSetSource(ss, 'mic', micStream);++ console.log('[Broadcast] Microphone opened again after the app was in the background');++ } catch (err) { console.warn('[Broadcast] Could not reopen the microphone:', err.message); }++ }++ // The camera. Screen sharing owns its own track; a camera the streamer turned off sends nothing on purpose.++ if (broadcastState.settings.screenShare) return;++ const track = ss.localStream.getVideoTracks()[0];++ if (!track) return;++ let stale = track.readyState === 'ended';++ if (!stale && track.enabled) stale = (await _bcFramesFlowing(track)) === false;++ if (!stale) return;++ const deviceId = (track.getSettings && track.getSettings().deviceId) || _getPreferredCameraId() || 'default';++ console.warn(`[Broadcast] Camera ${track.readyState === 'ended' ? 'ended' : 'sent nothing'} after ${Math.round(awayMs / 1000)} s away: opening it again in place`);++ if (typeof updateBroadcastStatus === 'function') updateBroadcastStatus('checking');++ try {++ await _switchActiveCamera(deviceId, { force: true, quiet: true });++ updateBroadcastStatusFromConnections(streamId);++ } catch (err) {++ console.warn('[Broadcast] Reopening the camera failed, restarting the media:', err.message);++ ss._lastRecoveryCompletedAt = 0; // not a spurious event: let the full recovery run now++ scheduleMediaRecovery(streamId, 'camera lost while away');++ }++}++document.addEventListener('visibilitychange', () => {++ if (document.hidden) { _bcHiddenAt = Date.now(); return; }++ const away = _bcHiddenAt ? Date.now() - _bcHiddenAt : 0;++ _bcHiddenAt = 0;++ if (!away || !isStreaming() || _bcResuming) return;++ // A moment for the browser to hand the camera back before judging it.++ _bcResuming = new Promise((r) => setTimeout(r, 400))++ .then(() => _bcResumeAfterBackground(away))++ .catch((err) => console.warn('[Broadcast] Resume after background failed:', err.message))++ .finally(() => { _bcResuming = null; _bcSessionChanged(); });++});++async function chooseBroadcastCamera(cameraId, buttonEl = null) {const previousCameraId = _getPreferredCameraId();const buttons = Array.from(document.querySelectorAll('#bc-camera-sheet .bc-camera-choice'));@@@ -5208,7 +5314,7 @@ function syncBroadcastLiveButtonVisibility() {// Mic / Cam PiP are further gated by _syncScreenShareLiveControls()}--function toggleBroadcastStats() { const el = document.getElementById('bc-stats-overlay'); if (el) el.style.display = el.style.display === 'none' ? 'flex' : 'none'; }++function toggleBroadcastStats() { const el = document.getElementById('bc-stats-overlay'); if (el) el.style.display = el.style.display === 'none' ? 'grid' : 'none'; }/* ── Group Call Controls ───────────────────────────────────────── */let _broadcastCallMode = null;@@@ -1757,6 +1757,15 @@ window.addEventListener('online', () => {clearTimeout(_chatReconnectTimer); _chatReconnectTimer = null;_reconnectChatWs(chatStreamId ?? null);});++// Back in front: a socket that dropped while the phone was in another app reconnects now, not after a backoff that++// kept growing in the background (up to 30 s of "Reconnecting…" on the live studio, 2026-10-27).++document.addEventListener('visibilitychange', () => {++ if (document.visibilityState !== 'visible' || _chatIntentionalClose || !_chatActive) return;++ if (chatWs && chatWs.readyState <= WebSocket.OPEN) return;++ if (_chatReconnectTimer) { clearTimeout(_chatReconnectTimer); _chatReconnectTimer = null; }++ _chatReconnectDelay = CHAT_RECONNECT_BASE;++ _reconnectChatWs(chatStreamId ?? null);++});// Mobile browsers freeze the page (and often the socket) in the background; on return, the widget// asks for what it missed rather than trusting rows that may be an hour old.document.addEventListener('visibilitychange', () => {@@@ -4,7 +4,9 @@count that was tapped. Used by the channel player's count (app-channel.js) and the phone live studio's(broadcast-studio.js). A place with count null is live there without a number (YouTube without an API key).-- OVViewerPlaces.toggle(anchor, getPlaces, { below }) opens or closes the panel by `anchor`++ OVViewerPlaces.toggle(anchor, getPlaces, { below, extra }) opens or closes the panel by `anchor`; `extra()` may add a++ section under the places: { title, rows: [{ name, state, text }],++ action: { label, onClick } } (the live studio's restreams)OVViewerPlaces.update() redraws it from getPlaces() (call when counts change)OVViewerPlaces.close()OVViewerPlaces.total(places), .summary(places) the sum, and "4 on OpenVibe, 5 on Twitch" */@@@ -61,6 +63,20 @@const list = open.panel.querySelector('.ovp-list');list.replaceChildren(...places.map(row));open.panel.querySelector('.ovp-total-n').textContent = fmt(total(places));++ const box = open.panel.querySelector('.ovp-extra');++ const extra = open.extra ? open.extra() : null;++ if (box) {++ box.hidden = !(extra && extra.rows && extra.rows.length);++ if (!box.hidden) {++ const action = extra.action ? el('button', { type: 'button', class: 'ovp-action' }, extra.action.label) : null;++ if (action) action.addEventListener('click', extra.action.onClick);++ box.replaceChildren(el('div', { class: 'ovp-sub', text: extra.title || '' }),++ el('ul', { class: 'ovp-list' }, ...extra.rows.map((r) => el('li', { class: `ovp-row ovp-state is-${r.state || 'idle'}` },++ el('span', { class: 'ovp-ico' }, el('span', { class: 'ovp-sdot', 'aria-hidden': 'true' })),++ el('span', { class: 'ovp-name', text: r.name }), el('span', { class: 'ovp-n', text: r.text || '' })))),++ action);++ }++ }place();}@@@ -101,11 +117,12 @@const panel = el('div', { class: 'ovp', role: 'dialog', 'aria-label': 'Where people are watching' },el('div', { class: 'ovp-head' }, el('span', { text: 'Watching now' }),el('span', { class: 'ovp-total' }, el('i', { class: 'fa-solid fa-eye', 'aria-hidden': 'true' }), el('b', { class: 'ovp-total-n', text: '0' }))),-- el('ul', { class: 'ovp-list' }));++ el('ul', { class: 'ovp-list' }),++ el('div', { class: 'ovp-extra', hidden: true }));// In fullscreen only the fullscreen element shows: the panel goes inside it.const fs = document.fullscreenElement || document.webkitFullscreenElement;(fs && fs.contains(anchor) ? fs : document.body).append(panel);-- open = { panel, anchor, get, below: !!(opts && opts.below) };++ open = { panel, anchor, get, below: !!(opts && opts.below), extra: opts && typeof opts.extra === 'function' ? opts.extra : null };anchor.setAttribute('aria-expanded', 'true');document.addEventListener('pointerdown', onOutside, true);document.addEventListener('keydown', onKey);nnew file mode 100644@@@ -0,0 +1,98 @@++/**++ * Back in front after another app (public/js/broadcast.js _bcResumeAfterBackground; owner, 2026-10-27: "it froze after++ * I switched apps real quick"). A phone gives the camera to the other app; on return the same camera is opened again++ * and swapped in place (_switchActiveCamera force), never a full restart, and only when it is actually stale. The phone++ * watchdog (broadcast-state.js) does nothing while the tab is hidden, and a recovery waits for the tab to be in front.++ *++ * node test/broadcast-resume.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');++const fn = (name) => {++ const start = src.search(new RegExp(`(async )?function ${name}\\(`));++ assert.ok(start >= 0, name);++ let i = src.indexOf('{', src.indexOf(')', start)), depth = 0;++ for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }++ return src.slice(start, i + 1);++};++++function harness({ track, frames, mic, switchFails = false }) {++ const calls = { switched: [], recovery: [], signalling: 0, played: 0, mix: [], status: [] };++ const preview = { srcObject: {}, paused: true, offsetParent: {}, play: async () => { calls.played++; } };++ const env = {++ WebSocket: { OPEN: 1 },++ document: { getElementById: (id) => (id === 'bc-video-preview' ? preview : null) },++ broadcastState: { activeStreamId: 5, settings: { screenShare: false, forceAudio: 'default' } },++ console: { log() {}, warn() {} },++ _trackFrameCount: () => null,++ _getPreferredCameraId: () => 'cam-main',++ connectSignaling: () => { calls.signalling++; },++ updateBroadcastStatus: (s) => calls.status.push(s),++ updateBroadcastStatusFromConnections: () => calls.status.push('from-connections'),++ _switchActiveCamera: async (id, opts) => { calls.switched.push([id, opts]); if (switchFails) throw new Error('NotReadableError'); },++ scheduleMediaRecovery: (id, reason) => calls.recovery.push(reason),++ _getUserMediaWithTimeout: async () => ({ getAudioTracks: () => [{ readyState: 'live' }] }),++ buildAudioConstraints: () => ({}),++ _mixSetSource: (ss, key) => calls.mix.push(key),++ };++ const ss = {++ localStream: { getVideoTracks: () => [track] },++ signalingWs: { readyState: 3 }, signalingReconnectTimer: null, signalingIntentionalClose: false,++ _audioMix: { ctx: { state: 'running' }, mic: mic ? { stream: { getAudioTracks: () => [mic] } } : null },++ };++ env.getActiveStreamState = () => ss;++ // _bcFramesFlowing asks the preview for its next frame: answer as the test says.++ preview.requestVideoFrameCallback = (cb) => { if (frames) setTimeout(cb, 5); };++ const run = new Function(...Object.keys(env), `${fn('_bcFramesFlowing')}\n${fn('_bcResumeAfterBackground')}\nreturn _bcResumeAfterBackground;`)(...Object.values(env));++ return { run, calls, ss };++}++++(async () => {++ // A track that ended: the same camera is opened again in place, quietly; the signalling socket comes back now.++ let h = harness({ track: { readyState: 'ended', enabled: true, getSettings: () => ({ deviceId: 'cam-back' }) }, frames: false });++ await h.run(20000);++ assert.deepStrictEqual(h.calls.switched, [['cam-back', { force: true, quiet: true }]]);++ assert.strictEqual(h.calls.recovery.length, 0, 'no full restart');++ assert.strictEqual(h.calls.signalling, 1, 'a closed signalling socket reconnects at once');++ assert.strictEqual(h.calls.played, 1, 'the paused preview plays again');++++ // Live but sending nothing (frozen): opened again too.++ h = harness({ track: { readyState: 'live', enabled: true, getSettings: () => ({ deviceId: 'cam-front' }) }, frames: false });++ await h.run(5000);++ assert.deepStrictEqual(h.calls.switched.map((c) => c[0]), ['cam-front']);++++ // Frames flowing: nothing is touched.++ h = harness({ track: { readyState: 'live', enabled: true, getSettings: () => ({ deviceId: 'cam-front' }) }, frames: true });++ await h.run(5000);++ assert.deepStrictEqual(h.calls.switched, [], 'a working camera is left alone');++++ // A camera the streamer turned off sends nothing on purpose: left alone.++ h = harness({ track: { readyState: 'live', enabled: false, getSettings: () => ({ deviceId: 'cam-front' }) }, frames: false });++ await h.run(5000);++ assert.deepStrictEqual(h.calls.switched, []);++++ // The microphone the other app took is opened again into the mix.++ h = harness({ track: { readyState: 'live', enabled: true, getSettings: () => ({}) }, frames: true, mic: { readyState: 'ended', getSettings: () => ({ deviceId: 'mic-1' }) } });++ await h.run(5000);++ assert.deepStrictEqual(h.calls.mix, ['mic']);++++ // Reopening fails: the full recovery runs now, its 60 s cooldown cleared (a second app switch used to never recover).++ h = harness({ track: { readyState: 'ended', enabled: true, getSettings: () => ({ deviceId: 'cam-back' }) }, frames: false, switchFails: true });++ h.ss._lastRecoveryCompletedAt = Date.now();++ await h.run(5000);++ assert.deepStrictEqual(h.calls.recovery, ['camera lost while away']);++ assert.strictEqual(h.ss._lastRecoveryCompletedAt, 0, 'the cooldown does not apply');++++ // The phone watchdog sits out the background, and a recovery waits for the tab to be in front.++ const state = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-state.js'), 'utf8');++ assert.ok(/if \(mobile && typeof document !== 'undefined' && document\.hidden\) \{\s*lastSuccessfulFrame = Date\.now\(\);/.test(state), 'the watchdog pauses while hidden on a phone');++ assert.ok(src.includes("if (document.hidden && isMobileBroadcaster()) {"), 'a recovery is not started from the background');++ // Chat reconnects the moment the tab is in front.++ const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');++ assert.ok(/visibilitychange[\s\S]{0,500}_chatReconnectDelay = CHAT_RECONNECT_BASE;\s*_reconnectChatWs\(chatStreamId \?\? null\);/.test(chat), 'chat reconnects on return');++ console.log('broadcast resume: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -248,7 +248,9 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {assert.deepStrictEqual(toasts, ['This camera cannot zoom']);assert.strictEqual(noZoom.prevented, false);-- assert.ok(/zoom\.level = null;\s*await _switchActiveCamera\(next\.deviceId\)/.test(extract(studioSrc, 'flip')), 'a flip starts the new camera at 1x');++ // Every camera change (a flip, or a pick from the camera list) goes through useCamera, which starts it at 1x.++ assert.ok(/zoom\.level = null;\s*\/\/[^\n]*\n\s*await _switchActiveCamera\(choice\.deviceId, \{ quiet: true \}\)/.test(extract(studioSrc, 'useCamera')), 'a flip starts the new camera at 1x');++ assert.ok(/await useCamera\(next\)/.test(extract(studioSrc, 'flip')));assert.ok(/attachZoom\(\)/.test(extract(studioSrc, 'mount')) && /detachZoom\(\)/.test(extract(studioSrc, 'unmount')));assert.ok(/body\.bc-studio-on \.bc-video-container \{ touch-action: none; \}/.test(read('public/css/features/broadcast.css')), 'the browser leaves the pinch to the studio');});@@@ -88,7 +88,15 @@ assert.match(bc, /for \(const id of \['bc-viewer-count', 'bc-rtmp-viewer-count',// ── The phone studio: the pill shows that total, and a tap lists the places under it ──const studio = read('public/js/broadcast-studio.js');assert.match(studio, /el\('button', \{ type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers \}/);--assert.match(studio, /OVViewerPlaces\.toggle\(\$\('bcs-viewers'\), viewerPlaces, \{ below: true \}\)/);++assert.match(studio, /OVViewerPlaces\.toggle\(\$\('bcs-viewers'\), viewerPlaces, \{\s*below: true,/);++// Restreams are listed under the places (2026-10-27: "Also on 1" was a second chip for the same thing), each with its++// state, and one connecting or failed marks the pill.++assert.match(studio, /extra: \(\) => \(\{ title: 'Restreams', rows: restreamRows\(\)/);++assert.match(studio, /\$\('bcs-viewers'\)\.dataset\.rs = rs \? rs\.cls\.replace\('is-', ''\) : '';/);++assert.ok(!studio.includes("id: 'bcs-restream'"), 'no separate restream chip');++const vp = read('public/js/viewer-places.js');++assert.match(vp, /extra: opts && typeof opts\.extra === 'function' \? opts\.extra : null/, 'the panel takes an extra section');++assert.match(vp, /el\('li', \{ class: `ovp-row ovp-state is-\$\{r\.state \|\| 'idle'\}` \}/, 'each row says its state');// ── The channel player: the sum, fed live by the socket and by chat ──const ch = read('public/js/app-channel.js');
@@ -486,10 +486,31 @@
@@ -486,10 +486,31 @@
background: #000;
background: #000;
}
}
-.bc-stats-overlay .bc-stat-label {
- color: var(--text-muted);
- min-width: 70px;
-}
+/* Stream health: a small card over the preview (and under the studio's health pill on a phone). */
+#bc-stats-overlay.bc-stats-overlay {
+ top: 10px; left: 10px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px 9px;
+ width: max-content; min-width: 210px; max-width: calc(100% - 20px); padding: 11px 13px 12px; border-radius: 14px;
+ font-family: inherit; font-size: 0.8rem; color: #fff; line-height: 1.2;
+ background: rgba(12, 16, 26, 0.82); border: 1px solid rgba(255, 255, 255, 0.12);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
+ animation: bcStatsIn 0.16s ease-out;
+}
+#bc-stats-overlay[style*="none"] { animation: none; }
+@keyframes bcStatsIn { from { opacity: 0; transform: translateY(-4px); } }
+#bc-stats-overlay .bc-stats-head {
+ grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; padding-bottom: 8px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1); font-weight: 700;
+}
+#bc-stats-overlay .bc-stats-title { flex: 1; }
+#bc-stats-overlay .bc-stats-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
+#bc-stats-overlay[data-health="good"] .bc-stats-dot { background: #22c55e; }
+#bc-stats-overlay[data-health="fair"] .bc-stats-dot, #bc-stats-overlay[data-health="checking"] .bc-stats-dot { background: #f59e0b; }
+#bc-stats-overlay[data-health="poor"] .bc-stats-dot, #bc-stats-overlay[data-health="offline"] .bc-stats-dot { background: #ef4444; }
+#bc-stats-overlay .bc-stat { display: contents; }
+#bc-stats-overlay .bc-stat > i { width: 16px; text-align: center; font-size: 0.72rem; color: rgba(255, 255, 255, 0.55); }
+#bc-stats-overlay .bc-stat-label { color: rgba(255, 255, 255, 0.66); min-width: 0; }
+#bc-stats-overlay .bc-stat-v { font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+@media (prefers-reduced-motion: reduce) { #bc-stats-overlay.bc-stats-overlay { animation: none; } }
.bc-status-good { background: var(--success); color: #fff; }
.bc-status-good { background: var(--success); color: #fff; }
.bc-status-warn { background: var(--warning); color: #000; }
.bc-status-warn { background: var(--warning); color: #000; }
@@ -2515,13 +2536,14 @@ body.bc-studio-on #bc-video-preview.bcs-mirror { transform: scaleX(-1); }
@@ -2515,13 +2536,14 @@ body.bc-studio-on #bc-video-preview.bcs-mirror { transform: scaleX(-1); }
body.bc-studio-on .bc-video-container .bc-live-badge,
body.bc-studio-on .bc-video-container .bc-live-badge,
body.bc-studio-on .bc-video-container .bc-connection-status,
body.bc-studio-on .bc-video-container .bc-connection-status,
body.bc-studio-on .bc-video-container .bc-video-placeholder { display: none !important; }
body.bc-studio-on .bc-video-container .bc-video-placeholder { display: none !important; }
-body.bc-studio-on .bc-stats-overlay { top: calc(58px + env(safe-area-inset-top, 0px)); left: 12px; z-index: 1205; }
+/* On the phone the card hangs under the health pill it opened from (top right). */
+body.bc-studio-on #bc-stats-overlay.bc-stats-overlay {
+ position: fixed; top: calc(52px + env(safe-area-inset-top, 0px)); left: auto; right: max(12px, env(safe-area-inset-right, 0px)); z-index: 1235;
+}
body.bc-studio-on .messenger-toggle,
body.bc-studio-on .messenger-toggle,
body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on #navbar-mount,
body.bc-studio-on #navbar-mount,
body.bc-studio-on .navbar { display: none !important; }
body.bc-studio-on .navbar { display: none !important; }
-/* "Reconnecting…" stays visible, just above the chat instead of over its input. */
-body.bc-studio-on .ov-conn-pill { left: calc(50% - 38px); bottom: calc(min(24vh, 180px) + 10px + env(safe-area-inset-bottom, 0px)); z-index: 1290; }
/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */
/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */
body.bc-studio-on #bc-chat-sidebar {
body.bc-studio-on #bc-chat-sidebar {
@@ -2549,8 +2571,7 @@ body.bc-studio-on #bc-chat-messages .chat-msg {
@@ -2549,8 +2571,7 @@ body.bc-studio-on #bc-chat-messages .chat-msg {
}
}
/* Site update cards and chat connection lines are for viewers; a streamer on the camera gets neither (the card's
/* Site update cards and chat connection lines are for viewers; a streamer on the camera gets neither (the card's
reload button would end the stream, and the top bar already shows the connection). */
reload button would end the stream, and the top bar already shows the connection). */
-body.bc-studio-on #bc-chat-messages .chat-deploy,
-body.bc-studio-on #bc-chat-messages .chat-conn-status { display: none !important; }
+body.bc-studio-on #bc-chat-messages .chat-deploy { display: none !important; }
body.bc-studio-on #bc-chat-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
body.bc-studio-on #bc-chat-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
body.bc-studio-on #bc-chat-input {
body.bc-studio-on #bc-chat-input {
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
@@ -2578,9 +2599,13 @@ body.bc-studio-on .toast-container { z-index: 1500; }
@@ -2578,9 +2599,13 @@ body.bc-studio-on .toast-container { z-index: 1500; }
.bcs-top-l { flex-wrap: wrap; min-width: 0; }
.bcs-top-l { flex-wrap: wrap; min-width: 0; }
.bcs-top-r { flex: none; }
.bcs-top-r { flex: none; }
.bcs-live {
.bcs-live {
- display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px;
- background: #e11d48; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.06em;
+ display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 12px 0 11px; border-radius: 999px;
+ background: #e11d48; font-size: 0.86rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: 0.01em;
+ box-shadow: 0 2px 10px rgba(225, 29, 72, 0.35); transition: background 0.2s;
}
}
+/* Starting and reconnecting: amber, the dot still pulsing. */
+.bcs-live[data-phase="starting"], .bcs-live[data-phase="recovering"] { background: #b45309; box-shadow: none; }
+.bcs-live-t { min-width: 2.6em; }
.bcs-live-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: bcs-pulse 1.6s ease-in-out infinite; }
.bcs-live-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: bcs-pulse 1.6s ease-in-out infinite; }
@keyframes bcs-pulse { 50% { opacity: 0.35; } }
@keyframes bcs-pulse { 50% { opacity: 0.35; } }
.bcs-pill {
.bcs-pill {
@@ -2593,11 +2618,57 @@ body.bc-studio-on .toast-container { z-index: 1500; }
@@ -2593,11 +2618,57 @@ body.bc-studio-on .toast-container { z-index: 1500; }
.bcs-viewers { -webkit-tap-highlight-color: transparent; }
.bcs-viewers { -webkit-tap-highlight-color: transparent; }
.bcs-viewers[aria-expanded="true"] { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.4); }
.bcs-viewers[aria-expanded="true"] { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.4); }
.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
-.bcs-pill.is-ok .bcs-dot, .bcs-restream.is-ok::before { background: #22c55e; }
-.bcs-pill.is-warn .bcs-dot, .bcs-restream.is-warn::before { background: #f59e0b; }
-.bcs-pill.is-bad .bcs-dot, .bcs-restream.is-bad::before { background: #ef4444; }
-.bcs-restream::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
+.bcs-pill.is-ok .bcs-dot { background: #22c55e; }
+.bcs-pill.is-warn .bcs-dot { background: #f59e0b; }
+.bcs-pill.is-bad .bcs-dot { background: #ef4444; }
.bcs-pill.is-bad { border-color: rgba(239,68,68,0.7); }
.bcs-pill.is-bad { border-color: rgba(239,68,68,0.7); }
+.bcs-pill { height: 30px; }
+/* A restream connecting or failed: a dot on the viewers pill (its panel lists them). */
+.bcs-viewers { position: relative; }
+.bcs-viewers[data-rs="warn"]::after, .bcs-viewers[data-rs="bad"]::after {
+ content: ''; position: absolute; top: -2px; right: -2px; width: 9px; height: 9px; border-radius: 50%;
+ background: #f59e0b; border: 2px solid rgba(0, 0, 0, 0.6);
+}
+.bcs-viewers[data-rs="bad"]::after { background: #ef4444; }
+/* The studio fades in over the page instead of appearing a frame after it. */
+.bcs { animation: bcs-in 0.22s ease-out; }
+@keyframes bcs-in { from { opacity: 0; } }
+/* Starting or reconnecting the camera: said on the picture, not a frozen last frame. */
+.bcs-state {
+ position: absolute; top: 42%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center;
+ gap: 6px; width: min(300px, calc(100vw - 120px)); padding: 16px 18px; border-radius: 18px; text-align: center;
+ background: rgba(0, 0, 0, 0.58); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
+}
+.bcs-state[hidden] { display: none; }
+.bcs-state b { font-size: 0.98rem; }
+.bcs-state small { font-size: 0.78rem; color: rgba(255, 255, 255, 0.75); }
+.bcs-state-spin { width: 26px; height: 26px; border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.25); border-top-color: #fff; animation: bcs-spin 0.9s linear infinite; }
+@keyframes bcs-spin { to { transform: rotate(360deg); } }
+/* Flip with more than two cameras: a small "+" (hold it to choose). */
+.bcs-btn[data-act="flip"] { position: relative; }
+.bcs-more-cams {
+ position: absolute; top: -1px; right: 6px; width: 16px; height: 16px; border-radius: 50%; display: grid; place-items: center;
+ background: #fff; color: #111; font-size: 0.7rem; font-weight: 900; line-height: 1; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
+}
+.bcs-more-cams::before { content: '+'; }
+.bcs-more-cams[hidden] { display: none; }
+/* The camera list (hold Flip, or More → Cameras). */
+.bcs-cam-group { margin-top: 12px; }
+.bcs-cam-group h3 { margin: 0 0 6px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: rgba(255, 255, 255, 0.6); }
+.bcs-cam-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
+.bcs-cam {
+ display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 14px; border-radius: 14px; text-align: left;
+ font: inherit; font-size: 0.92rem; font-weight: 700; color: #fff; cursor: pointer;
+ background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.12);
+}
+.bcs-cam span { flex: 1; }
+.bcs-cam > i:first-child { width: 18px; text-align: center; color: rgba(255, 255, 255, 0.7); }
+.bcs-cam[aria-pressed="true"] { background: rgba(59, 130, 246, 0.22); border-color: rgba(96, 165, 250, 0.7); }
+.bcs-cam[aria-pressed="true"] > i:last-child { color: #93c5fd; }
+/* Chat's own reconnecting line sits with the chat; the site-wide pill would say it twice (and read as the stream). */
+body.bc-studio-on .ov-conn-pill { display: none !important; }
+body.bc-studio-on #bc-chat-messages .chat-conn-status { display: block !important; width: fit-content; background: rgba(180, 83, 9, 0.85); font-style: normal; }
+@media (prefers-reduced-motion: reduce) { .bcs, .bcs-state-spin { animation: none; } }
.bcs-end { height: 34px; padding: 0 16px; border: 0; border-radius: 999px; background: #e11d48; color: #fff; font-size: 0.9rem; font-weight: 800; }
.bcs-end { height: 34px; padding: 0 16px; border: 0; border-radius: 999px; background: #e11d48; color: #fff; font-size: 0.9rem; font-weight: 800; }
.bcs-zoom {
.bcs-zoom {
position: absolute; top: 38%; left: 50%; transform: translate(-50%, -50%); padding: 8px 16px; border-radius: 999px;
position: absolute; top: 38%; left: 50%; transform: translate(-50%, -50%); padding: 8px 16px; border-radius: 999px;
@@ -28,3 +28,19 @@
@@ -28,3 +28,19 @@
.ovp-n { margin-left: auto; padding-left: 12px; font-weight: 700; }
.ovp-n { margin-left: auto; padding-left: 12px; font-weight: 700; }
.ovp-n.is-live { font-size: 0.72rem; font-weight: 700; color: #fca5a5; text-transform: uppercase; letter-spacing: 0.05em; }
.ovp-n.is-live { font-size: 0.72rem; font-weight: 700; color: #fca5a5; text-transform: uppercase; letter-spacing: 0.05em; }
@media (prefers-reduced-motion: reduce) { .ovp { animation: none; } }
@media (prefers-reduced-motion: reduce) { .ovp { animation: none; } }
+/* A section under the places (the live studio's restreams): each with a state dot, and a way to manage them. */
+.ovp-extra { margin-top: 4px; padding-top: 4px; border-top: 1px solid rgba(255, 255, 255, 0.1); }
+.ovp-extra[hidden] { display: none; }
+.ovp-sub { padding: 4px 14px 0; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255, 255, 255, 0.55); }
+.ovp-sdot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
+.ovp-state.is-ok .ovp-sdot { background: #22c55e; }
+.ovp-state.is-warn .ovp-sdot { background: #f59e0b; }
+.ovp-state.is-bad .ovp-sdot { background: #ef4444; }
+.ovp-state .ovp-n { font-size: 0.78rem; font-weight: 600; color: rgba(255, 255, 255, 0.7); }
+.ovp-state.is-bad .ovp-n { color: #fca5a5; }
+.ovp-action {
+ display: block; width: calc(100% - 20px); margin: 6px 10px 2px; min-height: 36px; border-radius: 10px; cursor: pointer;
+ font: inherit; font-size: 0.82rem; font-weight: 700; color: #fff;
+ background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.14);
+}
+.ovp-action:hover, .ovp-action:focus-visible { background: rgba(255, 255, 255, 0.14); outline: none; }
@@ -644,13 +644,15 @@ WantedBy=multi-user.target</code>
@@ -644,13 +644,15 @@ WantedBy=multi-user.target</code>
<span>Camera preview will appear here</span>
<span>Camera preview will appear here</span>
</div>
</div>
<!-- Stats overlay (hidden by default) -->
<!-- Stats overlay (hidden by default) -->
- <div class="bc-stats-overlay" id="bc-stats-overlay" style="display:none">
- <span><span class="bc-stat-label">Bitrate</span> <span id="bc-stat-bitrate">-- kbps</span></span>
- <span><span class="bc-stat-label">FPS</span> <span id="bc-stat-fps">--</span></span>
- <span><span class="bc-stat-label">Resolution</span> <span id="bc-stat-resolution">--</span></span>
- <span><span class="bc-stat-label">Codec</span> <span id="bc-stat-codec">--</span></span>
- <span><span class="bc-stat-label">Status</span> <span id="bc-stat-status">--</span></span>
- <span><span class="bc-stat-label">Health</span> <span id="bc-stat-health">--</span></span>
+ <!-- Stream health: a small card of what the stream is sending (owner, 2026-10-27: the green terminal text
+ was confusing). The ids are what broadcast.js fills. -->
+ <div class="bc-stats-overlay" id="bc-stats-overlay" style="display:none" role="group" aria-label="Stream health">
+ <div class="bc-stats-head"><span class="bc-stats-dot" aria-hidden="true"></span><span class="bc-stats-title">Stream health</span><b id="bc-stat-health">--</b></div>
+ <span class="bc-stat"><i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i><span class="bc-stat-label">Upload</span><span class="bc-stat-v" id="bc-stat-bitrate">-- kbps</span></span>
+ <span class="bc-stat"><i class="fa-solid fa-expand" aria-hidden="true"></i><span class="bc-stat-label">Picture</span><span class="bc-stat-v" id="bc-stat-resolution">--</span></span>
+ <span class="bc-stat"><i class="fa-solid fa-film" aria-hidden="true"></i><span class="bc-stat-label">Frame rate</span><span class="bc-stat-v" id="bc-stat-fps">--</span></span>
+ <span class="bc-stat"><i class="fa-solid fa-microchip" aria-hidden="true"></i><span class="bc-stat-label">Codec</span><span class="bc-stat-v" id="bc-stat-codec">--</span></span>
+ <span class="bc-stat"><i class="fa-solid fa-signal" aria-hidden="true"></i><span class="bc-stat-label">Connection</span><span class="bc-stat-v" id="bc-stat-status">--</span></span>
</div>
</div>
<!-- Live badge (hidden until live) -->
<!-- Live badge (hidden until live) -->
<div class="bc-live-badge" id="bc-live-badge" style="display:none">
<div class="bc-live-badge" id="bc-live-badge" style="display:none">
@@ -212,6 +212,46 @@ function _getCameraFacingHint(device) {
@@ -212,6 +212,46 @@ function _getCameraFacingHint(device) {
return null;
return null;
}
}
+/**
+ * The cameras as people know them (owner, 2026-10-27: "camera 1, facing front … unformatted camera names is trash").
+ * A phone's cameras are named by side: "Front camera" / "Back camera", and when a side has more than one, "Back camera 2"…
+ * in the phone's own numbering (Android's "camera 0, facing back" is the main one; the list comes in no useful order).
+ * A computer's camera keeps its name without the USB ids ("HD Pro Webcam C920 (046d:082d)" → "HD Pro Webcam C920").
+ * Entries with the same label are one camera (some phones list a logical camera twice).
+ * Returns [{ deviceId, label, name, facing: 'user'|'environment'|null, side: 1-based index on its side, sides }].
+ */
+function _bcCameraChoices(devices) {
+ const seen = new Set();
+ const list = [];
+ for (const d of devices || []) {
+ if (d.kind && d.kind !== 'videoinput') continue;
+ const key = d.label || d.deviceId;
+ if (!key || seen.has(key)) continue;
+ seen.add(key);
+ const num = /camera2?\s*(\d+)/i.exec(d.label || '');
+ list.push({ deviceId: d.deviceId, label: d.label || '', facing: _getCameraFacingHint(d), order: num ? Number(num[1]) : 1000 + list.length });
+ }
+ const sides = { user: list.filter((c) => c.facing === 'user'), environment: list.filter((c) => c.facing === 'environment') };
+ for (const side of Object.values(sides)) side.sort((a, b) => a.order - b.order);
+ let other = 0;
+ for (const c of list) {
+ if (c.facing) {
+ const mine = sides[c.facing];
+ c.side = mine.indexOf(c) + 1;
+ c.sides = mine.length;
+ const base = c.facing === 'user' ? 'Front camera' : 'Back camera';
+ c.name = c.side === 1 ? base : `${base} ${c.side}`;
+ } else {
+ other += 1;
+ const clean = c.label.replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, '').trim();
+ c.name = clean || `Camera ${other}`;
+ }
+ }
+ // Front cameras first, then back, then the rest, each side in the phone's order.
+ const rank = (c) => (c.facing === 'user' ? 0 : c.facing === 'environment' ? 1 : 2);
+ return list.sort((a, b) => rank(a) - rank(b) || (a.side || 0) - (b.side || 0) || a.order - b.order);
+}
+
async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {
async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {
if (!navigator.mediaDevices?.enumerateDevices) return [];
if (!navigator.mediaDevices?.enumerateDevices) return [];
let tempStream = null;
let tempStream = null;
@@ -137,6 +137,16 @@ function startTrackKeepalive(streamId) {
@@ -137,6 +137,16 @@ function startTrackKeepalive(streamId) {
watchdogTriggered = false;
watchdogTriggered = false;
}
}
+ // A phone pauses the camera of a tab in the background: no frames there is expected, and a recovery started
+ // from the background cannot open the camera anyway (it failed four times and gave up while the streamer was in
+ // another app). broadcast.js checks the camera the moment the tab is in front again.
+ if (mobile && typeof document !== 'undefined' && document.hidden) {
+ lastSuccessfulFrame = Date.now();
+ watchdogTriggered = false;
+ lastFrameCount = null;
+ return;
+ }
+
// If recovery is already in progress or suppressed, just keep ticking
// If recovery is already in progress or suppressed, just keep ticking
if (current._suppressRecovery || current.mediaRecoveryInProgress || current.mediaRecoveryTimer) {
if (current._suppressRecovery || current.mediaRecoveryInProgress || current.mediaRecoveryTimer) {
lastSuccessfulFrame = Date.now(); // reset watchdog during recovery
lastSuccessfulFrame = Date.now(); // reset watchdog during recovery
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
const st = {
const st = {
root: null, micOff: false, camOff: false, torch: false, chatHidden: false, busy: false, viewer: null, viewerMute: 0,
root: null, micOff: false, camOff: false, torch: false, chatHidden: false, busy: false, viewer: null, viewerMute: 0,
dismissedFor: null, hadLocal: false, sheet: null, confirm: null, parked: [], cameras: 0, lastFocus: null,
dismissedFor: null, hadLocal: false, sheet: null, confirm: null, parked: [], cameras: 0, lastFocus: null,
+ choices: [], startingSince: 0, healthOpen: false,
};
};
const $ = (id) => document.getElementById(id);
const $ = (id) => document.getElementById(id);
@@ -50,8 +51,30 @@
@@ -50,8 +51,30 @@
function videoTrack() { const s = localStream(); return s ? s.getVideoTracks()[0] || null : null; }
function videoTrack() { const s = localStream(); return s ? s.getVideoTracks()[0] || null : null; }
function onBroadcastPage() { const p = $('page-broadcast'); return !!(p && p.classList.contains('active')); }
function onBroadcastPage() { const p = $('page-broadcast'); return !!(p && p.classList.contains('active')); }
function browserViewShown() { const v = $('bc-browser-broadcast'); return !!(v && v.style.display !== 'none'); }
function browserViewShown() { const v = $('bc-browser-broadcast'); return !!(v && v.style.display !== 'none'); }
- function liveHere() { return onBroadcastPage() && browserViewShown() && !!localStream(); }
- function wanted() { return NARROW.matches && liveHere() && st.dismissedFor !== activeId(); }
+ // The studio is up for the whole session: while the camera starts, while a recovery reopens it, and live. Tying it to
+ // "a camera stream exists right now" showed the full page for a moment on every start (it mounted on the next
+ // second's tick) and for the whole of every recovery, with "Error" and a stray Connecting box (owner, 2026-10-27).
+ function sessionHere() { return onBroadcastPage() && browserViewShown() && !!streamState(); }
+ function liveHere() { return sessionHere() && !!localStream(); }
+ /** 'starting' (no camera yet), 'recovering' (the media restarts) or 'live'. */
+ function phase() {
+ const ss = streamState();
+ if (!ss) return null;
+ if (ss.mediaRecoveryInProgress || ss.mediaRecoveryTimer) return 'recovering';
+ return ss.localStream ? 'live' : 'starting';
+ }
+ // A camera that never starts (permission refused) gives the page back after a while, with its error.
+ const STARTING_GIVE_UP_MS = 25000;
+ function wanted() {
+ if (!sessionHere()) { st.startingSince = 0; return false; }
+ if (!NARROW.matches || st.dismissedFor === activeId()) return false;
+ if (phase() === 'starting') {
+ if (!st.startingSince) st.startingSince = Date.now();
+ return Date.now() - st.startingSince < STARTING_GIVE_UP_MS;
+ }
+ st.startingSince = 0;
+ return true;
+ }
// ── Mic, camera and light: the choice holds across replaced tracks ──
// ── Mic, camera and light: the choice holds across replaced tracks ──
function applyTracks() {
function applyTracks() {
@@ -168,25 +191,27 @@
@@ -168,25 +191,27 @@
refresh();
refresh();
}
}
- /** Front ↔ back in one tap: the first camera facing the other way (a phone's main one), else the next camera. */
- async function flip() {
- if (st.busy) return;
+ async function loadCameras() {
+ if (typeof _enumerateBroadcastVideoInputs !== 'function') return st.choices;
+ const cams = await _enumerateBroadcastVideoInputs({ ensureLabels: false });
+ st.cameras = cams.length;
+ st.choices = typeof _bcCameraChoices === 'function' ? _bcCameraChoices(cams) : cams.map((d, i) => ({ deviceId: d.deviceId, name: `Camera ${i + 1}`, facing: _getCameraFacingHint(d) }));
+ return st.choices;
+ }
+ const rememberSide = (facing, id) => { try { if (facing && id) localStorage.setItem(`bc-last-camera-${facing}`, id); } catch { /* */ } };
+ const lastOnSide = (facing) => { try { return localStorage.getItem(`bc-last-camera-${facing}`); } catch { return null; } };
+ async function useCamera(choice) {
+ if (st.busy || !choice) return;
if (screenSharing()) return say('You are sharing your screen. Stop sharing to use the camera.');
if (screenSharing()) return say('You are sharing your screen. Stop sharing to use the camera.');
- if (typeof _switchActiveCamera !== 'function' || typeof _enumerateBroadcastVideoInputs !== 'function') return;
+ if (typeof _switchActiveCamera !== 'function') return;
st.busy = true;
st.busy = true;
refresh();
refresh();
try {
try {
- const cams = await _enumerateBroadcastVideoInputs({ ensureLabels: false });
- st.cameras = cams.length;
- if (cams.length < 2) { say('This device has only one camera'); return; }
- const now = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings()) || {};
- const was = facing();
- const other = was && cams.find((d) => d.deviceId !== now.deviceId && _getCameraFacingHint(d) && _getCameraFacingHint(d) !== was);
- const i = cams.findIndex((d) => d.deviceId === now.deviceId);
- const next = other || cams[(i + 1) % cams.length];
st.torch = false;
st.torch = false;
zoom.level = null;
zoom.level = null;
- await _switchActiveCamera(next.deviceId);
+ // Quietly: the picture changing is the answer; a toast on every flip covered the chat.
+ await _switchActiveCamera(choice.deviceId, { quiet: true });
+ rememberSide(choice.facing, choice.deviceId);
applyTracks();
applyTracks();
} catch (err) {
} catch (err) {
say('Could not switch camera: ' + ((err && err.message) || 'unknown error'), 'error');
say('Could not switch camera: ' + ((err && err.message) || 'unknown error'), 'error');
@@ -195,6 +220,65 @@
@@ -195,6 +220,65 @@
refresh();
refresh();
}
}
}
}
+ /** One tap: the other side, on the camera last used there (a phone's main one the first time); else the next camera. */
+ async function flip() {
+ if (st.busy) return;
+ const choices = await loadCameras();
+ if (choices.length < 2) { say('This device has only one camera'); return; }
+ const nowId = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings().deviceId) || null;
+ const current = choices.find((c) => c.deviceId === nowId);
+ const was = facing() || (current && current.facing);
+ const otherSide = was === 'user' ? 'environment' : was === 'environment' ? 'user' : null;
+ let next = null;
+ if (otherSide) {
+ const side = choices.filter((c) => c.facing === otherSide);
+ next = side.find((c) => c.deviceId === lastOnSide(otherSide)) || side[0] || null;
+ }
+ if (!next) { const i = choices.findIndex((c) => c.deviceId === nowId); next = choices[(i + 1) % choices.length]; }
+ await useCamera(next);
+ }
+ /** Every camera, by side, the one in use ticked: what holding Flip opens when there are more than two. */
+ async function openCameras() {
+ const choices = await loadCameras();
+ closeSheet();
+ st.lastFocus = document.activeElement;
+ const nowId = (videoTrack() && videoTrack().getSettings && videoTrack().getSettings().deviceId) || null;
+ const group = (title, list) => list.length ? el('div', { class: 'bcs-cam-group' }, el('h3', { text: title }),
+ el('div', { class: 'bcs-cam-list' }, ...list.map((c) => el('button', {
+ type: 'button', class: 'bcs-cam', 'aria-pressed': String(c.deviceId === nowId),
+ onclick: () => { closeSheet(); if (c.deviceId !== nowId) useCamera(c); },
+ }, icon(c.facing === 'user' ? 'fa-user' : c.facing === 'environment' ? 'fa-mountain-sun' : 'fa-video'),
+ el('span', { text: c.name }), c.deviceId === nowId ? icon('fa-check') : null)))) : null;
+ const sheet = el('div', { class: 'bcs-sheet bcs-cam-sheet', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'bcs-cam-title' },
+ el('div', { class: 'bcs-grab', 'aria-hidden': 'true' }),
+ el('div', { class: 'bcs-sheet-head' },
+ el('div', { class: 'bcs-sheet-who' }, el('b', { id: 'bcs-cam-title', text: 'Cameras' }), el('small', { text: 'Tap Flip to switch sides; this one is remembered for its side' })),
+ el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeSheet }, icon('fa-xmark'))),
+ group('Front', choices.filter((c) => c.facing === 'user')),
+ group('Back', choices.filter((c) => c.facing === 'environment')),
+ group('Other cameras', choices.filter((c) => !c.facing)));
+ st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
+ document.body.append(st.sheet);
+ const on = sheet.querySelector('.bcs-cam[aria-pressed="true"]') || sheet.querySelector('.bcs-cam');
+ if (on) on.focus();
+ }
+ /** Flip's button: a tap flips; holding it (or a long press) lists every camera when there are more than two. */
+ function flipButton() {
+ const b = railButton('flip', 'fa-camera-rotate', 'Flip', null);
+ b.append(el('span', { class: 'bcs-more-cams', hidden: true, 'aria-hidden': 'true' }));
+ let timer = 0, held = false;
+ const many = () => st.choices.length > 2;
+ b.addEventListener('pointerdown', () => {
+ held = false;
+ clearTimeout(timer);
+ if (!many()) return;
+ timer = setTimeout(() => { held = true; try { navigator.vibrate && navigator.vibrate(12); } catch { /* */ } openCameras(); }, 450);
+ });
+ for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) b.addEventListener(ev, () => clearTimeout(timer));
+ b.addEventListener('contextmenu', (e) => e.preventDefault());
+ b.addEventListener('click', () => { if (held) { held = false; return; } flip(); });
+ return b;
+ }
function quickClip(seconds) {
function quickClip(seconds) {
if (typeof createBroadcastClip !== 'function' || typeof _getBroadcastUptimeSeconds !== 'function') return;
if (typeof createBroadcastClip !== 'function' || typeof _getBroadcastUptimeSeconds !== 'function') return;
@@ -270,7 +354,12 @@
@@ -270,7 +354,12 @@
function toggleViewers() {
function toggleViewers() {
if (!window.OVViewerPlaces) return;
if (!window.OVViewerPlaces) return;
closeSheet();
closeSheet();
- OVViewerPlaces.toggle($('bcs-viewers'), viewerPlaces, { below: true });
+ closeHealth();
+ OVViewerPlaces.toggle($('bcs-viewers'), viewerPlaces, {
+ below: true,
+ // Restreams sit with the places they send to ("Also on 1" was a second chip for the same thing).
+ extra: () => ({ title: 'Restreams', rows: restreamRows(), action: restreamRows().length ? { label: 'Manage restreams', onClick: () => { OVViewerPlaces.close(); openSheet('restreams'); } } : null }),
+ });
}
}
// ── The overlay ──
// ── The overlay ──
@@ -279,27 +368,30 @@
@@ -279,27 +368,30 @@
el('span', { class: 'bcs-ico' }, icon(ico)), el('span', { class: 'bcs-lbl', text: label }));
el('span', { class: 'bcs-ico' }, icon(ico)), el('span', { class: 'bcs-lbl', text: label }));
}
}
function build() {
function build() {
+ // LIVE and the time were two pills saying one thing (owner, 2026-10-27): one red pill, its dot pulsing, the time in it.
const top = el('div', { class: 'bcs-top' },
const top = el('div', { class: 'bcs-top' },
el('div', { class: 'bcs-top-l' },
el('div', { class: 'bcs-top-l' },
- el('span', { class: 'bcs-live' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }), 'LIVE'),
- el('span', { class: 'bcs-pill', id: 'bcs-time', title: 'Time live' }, '0:00'),
+ el('span', { class: 'bcs-live', id: 'bcs-live', role: 'status' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }),
+ el('span', { class: 'bcs-live-t', id: 'bcs-time', text: '0:00' })),
el('button', { type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers },
el('button', { type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers },
- icon('fa-eye'), el('b', { text: '0' })),
- el('button', { type: 'button', class: 'bcs-pill bcs-restream', id: 'bcs-restream', hidden: true, onclick: () => openSheet('restreams') })),
+ icon('fa-eye'), el('b', { text: '0' }))),
el('div', { class: 'bcs-top-r' },
el('div', { class: 'bcs-top-r' },
- el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', onclick: toggleHealth },
+ el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', 'aria-expanded': 'false', 'aria-controls': 'bc-stats-overlay', onclick: toggleHealth },
el('span', { class: 'bcs-dot', 'aria-hidden': 'true' }), el('span', { class: 'bcs-health-t', text: 'Connecting' })),
el('span', { class: 'bcs-dot', 'aria-hidden': 'true' }), el('span', { class: 'bcs-health-t', text: 'Connecting' })),
el('button', { type: 'button', class: 'bcs-end', id: 'bcs-end', onclick: openConfirm }, 'End')));
el('button', { type: 'button', class: 'bcs-end', id: 'bcs-end', onclick: openConfirm }, 'End')));
const rail = el('div', { class: 'bcs-rail', role: 'toolbar', 'aria-label': 'Live controls', 'aria-orientation': 'vertical' },
const rail = el('div', { class: 'bcs-rail', role: 'toolbar', 'aria-label': 'Live controls', 'aria-orientation': 'vertical' },
- railButton('flip', 'fa-camera-rotate', 'Flip', flip),
+ flipButton(),
railButton('mic', 'fa-microphone', 'Mic', toggleMic),
railButton('mic', 'fa-microphone', 'Mic', toggleMic),
railButton('cam', 'fa-video', 'Camera', toggleCamera),
railButton('cam', 'fa-video', 'Camera', toggleCamera),
railButton('light', 'fa-bolt', 'Light', toggleLight),
railButton('light', 'fa-bolt', 'Light', toggleLight),
railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),
railButton('chat', 'fa-comment', 'Chat', () => { st.chatHidden = !st.chatHidden; refresh(); }),
railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),
railButton('clip', 'fa-scissors', 'Clip', () => quickClip(30)),
railButton('more', 'fa-ellipsis', 'More', () => openSheet()));
railButton('more', 'fa-ellipsis', 'More', () => openSheet()));
+ // While the camera starts or a recovery reopens it, the picture says so instead of freezing on its last frame.
+ const stateCard = el('div', { class: 'bcs-state', id: 'bcs-state', hidden: true, role: 'status' },
+ el('span', { class: 'bcs-state-spin', 'aria-hidden': 'true' }), el('b', { class: 'bcs-state-t' }), el('small', { class: 'bcs-state-d' }));
return el('div', { class: 'bcs', id: 'bc-studio' }, top, el('div', { class: 'bcs-flags', id: 'bcs-flags', 'aria-live': 'polite' }), rail,
return el('div', { class: 'bcs', id: 'bc-studio' }, top, el('div', { class: 'bcs-flags', id: 'bcs-flags', 'aria-live': 'polite' }), rail,
- el('div', { class: 'bcs-zoom', id: 'bcs-zoom', hidden: true, 'aria-live': 'polite' }));
+ el('div', { class: 'bcs-zoom', id: 'bcs-zoom', hidden: true, 'aria-live': 'polite' }), stateCard);
}
}
function mount() {
function mount() {
@@ -315,11 +407,12 @@
@@ -315,11 +407,12 @@
if (v) v.play().catch(() => {});
if (v) v.play().catch(() => {});
const msgs = $('bc-chat-messages');
const msgs = $('bc-chat-messages');
if (msgs) msgs.scrollTop = msgs.scrollHeight;
if (msgs) msgs.scrollTop = msgs.scrollHeight;
- if (typeof _enumerateBroadcastVideoInputs === 'function') _enumerateBroadcastVideoInputs({ ensureLabels: false }).then((c) => { st.cameras = c.length; refresh(); }, () => {});
+ loadCameras().then(() => refresh(), () => {});
refresh();
refresh();
}
}
function unmount() {
function unmount() {
if (window.OVViewerPlaces) OVViewerPlaces.close();
if (window.OVViewerPlaces) OVViewerPlaces.close();
+ closeHealth();
closeSheet();
closeSheet();
closeConfirm();
closeConfirm();
closeViewer();
closeViewer();
@@ -332,6 +425,28 @@
@@ -332,6 +425,28 @@
}
}
function text(id) { const n = $(id); return n ? n.textContent.trim() : ''; }
function text(id) { const n = $(id); return n ? n.textContent.trim() : ''; }
+ /** Each restream and its state, read from the page's restream panels: [{ name, state: 'ok'|'warn'|'bad'|'idle', text }]. */
+ function restreamRows() {
+ const rows = [];
+ const stateOf = (n) => n.querySelector('.bc-restream-status-live, .bc-rs-live') ? ['ok', 'Live']
+ : n.querySelector('.bc-restream-status-starting, .bc-rs-connecting') ? ['warn', 'Connecting']
+ : n.querySelector('.bc-restream-status-error, .bc-restream-status-failed, .bc-rs-error') ? ['bad', 'Failed'] : ['idle', 'Off'];
+ const panel = $('bc-restream-control');
+ if (panel && panel.style.display !== 'none') {
+ for (const r of panel.querySelectorAll('.bc-restream-row')) {
+ const nameEl = r.querySelector('.bc-restream-name');
+ const name = nameEl ? (nameEl.firstChild && nameEl.firstChild.nodeType === 3 ? nameEl.firstChild.textContent : nameEl.textContent).trim() : 'Restream';
+ const [state, text] = stateOf(r);
+ rows.push({ name, state, text });
+ }
+ }
+ const rs = $('bc-rs-control');
+ if (rs && rs.style.display !== 'none' && !rows.some((r) => /robotstreamer/i.test(r.name))) {
+ const [state, text] = stateOf(rs);
+ if (state !== 'idle') rows.push({ name: 'RobotStreamer', state, text });
+ }
+ return rows;
+ }
function restreamSummary() {
function restreamSummary() {
const scope = [$('bc-rs-control'), $('bc-restream-control')].filter((n) => n && n.style.display !== 'none');
const scope = [$('bc-rs-control'), $('bc-restream-control')].filter((n) => n && n.style.display !== 'none');
let live = 0, starting = 0, failed = 0;
let live = 0, starting = 0, failed = 0;
@@ -356,9 +471,19 @@
@@ -356,9 +471,19 @@
syncDesktopButtons();
syncDesktopButtons();
if (!st.root) return;
if (!st.root) return;
document.body.classList.toggle('bc-studio-chat-off', st.chatHidden);
document.body.classList.toggle('bc-studio-chat-off', st.chatHidden);
- // 0:05:07 → 5:07 while under an hour.
+ // 0:05:07 → 5:07 while under an hour; while the camera starts or comes back, the pill says so.
+ const ph = phase();
const up = /^(\d+):(\d\d):(\d\d)$/.exec(text('bc-uptime'));
const up = /^(\d+):(\d\d):(\d\d)$/.exec(text('bc-uptime'));
- $('bcs-time').textContent = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');
+ const time = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');
+ const livePill = $('bcs-live');
+ livePill.dataset.phase = ph || 'live';
+ $('bcs-time').textContent = ph === 'starting' ? 'Starting' : time;
+ livePill.setAttribute('aria-label', ph === 'starting' ? 'Starting your stream' : `Live for ${time}`);
+ const stateCard = $('bcs-state');
+ const stateText = ph === 'starting' ? ['Starting your camera…', 'Viewers see you in a moment']
+ : ph === 'recovering' ? ['Reconnecting your camera…', 'You are still live; viewers wait a few seconds'] : null;
+ stateCard.hidden = !stateText;
+ if (stateText) { stateCard.querySelector('.bcs-state-t').textContent = stateText[0]; stateCard.querySelector('.bcs-state-d').textContent = stateText[1]; }
const viewers = $('bcs-viewers').querySelector('b');
const viewers = $('bcs-viewers').querySelector('b');
viewers.textContent = text('bc-viewer-count') || '0';
viewers.textContent = text('bc-viewer-count') || '0';
const places = viewerPlaces();
const places = viewerPlaces();
@@ -366,14 +491,16 @@
@@ -366,14 +491,16 @@
$('bcs-viewers').title = `${viewers.textContent} watching now${where}`;
$('bcs-viewers').title = `${viewers.textContent} watching now${where}`;
$('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. Show where`);
$('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. Show where`);
+ // A restream connecting or failed marks the viewers pill; its list is in the pill's panel.
const rs = restreamSummary();
const rs = restreamSummary();
- const chip = $('bcs-restream');
- chip.hidden = !rs;
- if (rs) { chip.className = `bcs-pill bcs-restream ${rs.cls}`; chip.textContent = rs.text; }
+ $('bcs-viewers').dataset.rs = rs ? rs.cls.replace('is-', '') : '';
+ if (rs && rs.cls !== 'is-ok') $('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. ${rs.text}. Show where`);
+ if (window.OVViewerPlaces && OVViewerPlaces.isOpen()) OVViewerPlaces.update();
const conn = $('bc-connection-status');
const conn = $('bc-connection-status');
const bad = conn && conn.classList.contains('bc-status-bad');
const bad = conn && conn.classList.contains('bc-status-bad');
- const [cls, label] = bad ? ['is-bad', 'Reconnecting'] : (HEALTH[text('bc-stat-health').toLowerCase()] || ['is-warn', 'Connecting']);
+ const [cls, label] = ph === 'recovering' || bad ? ['is-bad', 'Reconnecting'] : ph === 'starting' ? ['is-warn', 'Starting']
+ : (HEALTH[text('bc-stat-health').toLowerCase()] || ['is-warn', 'Connecting']);
const health = $('bcs-health');
const health = $('bcs-health');
health.className = `bcs-pill bcs-health ${cls}`;
health.className = `bcs-pill bcs-health ${cls}`;
health.querySelector('.bcs-health-t').textContent = label;
health.querySelector('.bcs-health-t').textContent = label;
@@ -390,6 +517,11 @@
@@ -390,6 +517,11 @@
b.classList.toggle('is-busy', !!busy);
b.classList.toggle('is-busy', !!busy);
};
};
set('flip', { hidden: st.cameras === 1 || screenSharing(), busy: st.busy, ico: st.busy ? 'fa-spinner fa-spin' : 'fa-camera-rotate' });
set('flip', { hidden: st.cameras === 1 || screenSharing(), busy: st.busy, ico: st.busy ? 'fa-spinner fa-spin' : 'fa-camera-rotate' });
+ // More than two cameras: a small "+" on Flip says holding it lists them.
+ const moreCams = st.root.querySelector('.bcs-more-cams');
+ if (moreCams) moreCams.hidden = st.choices.length <= 2;
+ const flipBtn = st.root.querySelector('[data-act="flip"]');
+ if (flipBtn) flipBtn.setAttribute('aria-label', st.choices.length > 2 ? 'Flip camera. Hold to choose a camera' : 'Flip camera');
set('mic', { on: st.micOff, ico: st.micOff ? 'fa-microphone-slash' : 'fa-microphone', label: st.micOff ? 'Unmute' : 'Mute' });
set('mic', { on: st.micOff, ico: st.micOff ? 'fa-microphone-slash' : 'fa-microphone', label: st.micOff ? 'Unmute' : 'Mute' });
set('cam', { on: st.camOff, ico: st.camOff ? 'fa-video-slash' : 'fa-video', label: st.camOff ? 'Camera on' : 'Camera' });
set('cam', { on: st.camOff, ico: st.camOff ? 'fa-video-slash' : 'fa-video', label: st.camOff ? 'Camera on' : 'Camera' });
set('light', { hidden: !torchCapable(), on: st.torch, label: st.torch ? 'Light off' : 'Light' });
set('light', { hidden: !torchCapable(), on: st.torch, label: st.torch ? 'Light off' : 'Light' });
@@ -429,7 +561,7 @@
@@ -429,7 +561,7 @@
const fl = $('bc-btn-flip');
const fl = $('bc-btn-flip');
if (fl) fl.style.display = live && !screenSharing() && st.cameras > 1 ? '' : 'none';
if (fl) fl.style.display = live && !screenSharing() && st.cameras > 1 ? '' : 'none';
const back = $('bcs-return');
const back = $('bcs-return');
- if (back) back.hidden = !(NARROW.matches && liveHere() && st.dismissedFor === activeId());
+ if (back) back.hidden = !(NARROW.matches && sessionHere() && st.dismissedFor === activeId());
}
}
// ── More ──
// ── More ──
@@ -509,12 +641,11 @@
@@ -509,12 +641,11 @@
const grid = el('div', { class: 'bcs-grid' },
const grid = el('div', { class: 'bcs-grid' },
tile('fa-tv', 'Watch as a viewer', after(() => openViewerPreview())),
tile('fa-tv', 'Watch as a viewer', after(() => openViewerPreview())),
- tile('fa-camera', 'Choose camera', after(() => openCameraSwitcher())),
+ tile('fa-camera', 'Cameras', after(() => openCameras()), st.choices.length > 2 ? `${st.choices.length} on this phone` : null),
canScreen ? tile('fa-display', screenSharing() ? 'Stop sharing screen' : 'Share screen', after(() => toggleScreenShare())) : null,
canScreen ? tile('fa-display', screenSharing() ? 'Stop sharing screen' : 'Share screen', after(() => toggleScreenShare())) : null,
tile('fa-film', 'Media requests', after(() => toggleMediaPip())),
tile('fa-film', 'Media requests', after(() => toggleMediaPip())),
tile('fa-scissors', 'Clip last minute', after(() => quickClip(60))),
tile('fa-scissors', 'Clip last minute', after(() => quickClip(60))),
tile(previewHidden ? 'fa-eye' : 'fa-eye-slash', previewHidden ? 'Show my preview' : 'Hide my preview', after(() => toggleBroadcastPreview()), previewHidden ? null : 'Saves battery'),
tile(previewHidden ? 'fa-eye' : 'fa-eye-slash', previewHidden ? 'Show my preview' : 'Hide my preview', after(() => toggleBroadcastPreview()), previewHidden ? null : 'Saves battery'),
- tile('fa-chart-line', 'Stream health', after(toggleHealth)),
tile('fa-sliders', 'All settings', after(() => leave('#bc-live-section .bc-settings-group'))),
tile('fa-sliders', 'All settings', after(() => leave('#bc-live-section .bc-settings-group'))),
tile('fa-up-right-and-down-left-from-center', 'Full page', after(() => leave())));
tile('fa-up-right-and-down-left-from-center', 'Full page', after(() => leave())));
@@ -553,7 +684,29 @@
@@ -553,7 +684,29 @@
if (st.sheet) { st.sheet.remove(); st.sheet = null; if (st.lastFocus && st.lastFocus.focus) st.lastFocus.focus(); }
if (st.sheet) { st.sheet.remove(); st.sheet = null; if (st.lastFocus && st.lastFocus.focus) st.lastFocus.focus(); }
}
}
- function toggleHealth() { if (typeof toggleBroadcastStats === 'function') toggleBroadcastStats(); }
+ function healthCard() { return $('bc-stats-overlay'); }
+ function closeHealth() {
+ const c = healthCard();
+ if (c && c.style.display !== 'none') c.style.display = 'none';
+ const p = $('bcs-health');
+ if (p) p.setAttribute('aria-expanded', 'false');
+ document.removeEventListener('pointerdown', onHealthOutside, true);
+ }
+ function onHealthOutside(e) {
+ const c = healthCard(), p = $('bcs-health');
+ if ((c && c.contains(e.target)) || (p && p.contains(e.target))) return;
+ closeHealth();
+ }
+ function toggleHealth() {
+ const c = healthCard();
+ if (!c) return;
+ if (c.style.display !== 'none') { closeHealth(); return; }
+ if (window.OVViewerPlaces) OVViewerPlaces.close();
+ c.style.display = 'grid';
+ const p = $('bcs-health');
+ if (p) p.setAttribute('aria-expanded', 'true');
+ if (st.root) document.addEventListener('pointerdown', onHealthOutside, true);
+ }
function openConfirm() {
function openConfirm() {
closeConfirm();
closeConfirm();
@@ -617,8 +770,9 @@
@@ -617,8 +770,9 @@
st.hadLocal = has;
st.hadLocal = has;
// The camera button was hidden when the live view showed before the camera started.
// The camera button was hidden when the live view showed before the camera started.
if (typeof syncBroadcastLiveButtonVisibility === 'function') syncBroadcastLiveButtonVisibility();
if (typeof syncBroadcastLiveButtonVisibility === 'function') syncBroadcastLiveButtonVisibility();
- if (has && typeof _enumerateBroadcastVideoInputs === 'function') _enumerateBroadcastVideoInputs({ ensureLabels: false }).then((c) => { st.cameras = c.length; syncDesktopButtons(); }, () => {});
- if (!has) { st.micOff = false; st.camOff = false; st.torch = false; st.dismissedFor = null; }
+ if (has) loadCameras().then(() => { syncDesktopButtons(); refresh(); }, () => {});
+ // A camera gone because the stream ended resets the switches; one gone for a recovery keeps them.
+ if (!has && !streamState()) { st.micOff = false; st.camOff = false; st.torch = false; st.dismissedFor = null; }
}
}
if (wanted()) { if (!st.root) mount(); refresh(); }
if (wanted()) { if (!st.root) mount(); refresh(); }
else { if (st.root) unmount(); applyTracks(); syncDesktopButtons(); }
else { if (st.root) unmount(); applyTracks(); syncDesktopButtons(); }
@@ -626,6 +780,7 @@
@@ -626,6 +780,7 @@
}
}
setInterval(tick, 1000);
setInterval(tick, 1000);
document.addEventListener('visibilitychange', tick);
document.addEventListener('visibilitychange', tick);
+ document.addEventListener('bc-session', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
ensureReturn();
ensureReturn();
@@ -390,6 +390,11 @@ function attachLocalStreamRecoveryHandlers(streamId) {
@@ -390,6 +390,11 @@ function attachLocalStreamRecoveryHandlers(streamId) {
function scheduleMediaRecovery(streamId, reason = 'media interrupted') {
function scheduleMediaRecovery(streamId, reason = 'media interrupted') {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
if (!ss || ss.mediaRecoveryInProgress || ss.mediaRecoveryTimer || !broadcastState.streams.has(streamId)) return;
if (!ss || ss.mediaRecoveryInProgress || ss.mediaRecoveryTimer || !broadcastState.streams.has(streamId)) return;
+ // A phone cannot open its camera from the background: the resume check runs when the tab is in front again.
+ if (document.hidden && isMobileBroadcaster()) {
+ console.log(`[Broadcast] Recovery waits for the tab to be in front (${reason})`);
+ return;
+ }
// Recovery cooldown — if media was just recovered, spurious track events from
// Recovery cooldown — if media was just recovered, spurious track events from
// the fresh capture pipeline should be ignored. This prevents rapid recovery
// the fresh capture pipeline should be ignored. This prevents rapid recovery
@@ -440,6 +445,7 @@ async function recoverStreamMedia(streamId, reason = 'media interrupted') {
@@ -440,6 +445,7 @@ async function recoverStreamMedia(streamId, reason = 'media interrupted') {
const ss = getStreamState(streamId);
const ss = getStreamState(streamId);
if (!ss || ss.mediaRecoveryInProgress) return;
if (!ss || ss.mediaRecoveryInProgress) return;
ss.mediaRecoveryInProgress = true;
ss.mediaRecoveryInProgress = true;
+ _bcSessionChanged(); // the phone studio stays up and says "Reconnecting" while the media restarts
await uploadVodRecording(streamId, { finalizeStream: false });
await uploadVodRecording(streamId, { finalizeStream: false });
// Stop the RS restream BEFORE nulling localStream — prevents the
// Stop the RS restream BEFORE nulling localStream — prevents the
@@ -480,6 +486,7 @@ async function recoverStreamMedia(streamId, reason = 'media interrupted') {
@@ -480,6 +486,7 @@ async function recoverStreamMedia(streamId, reason = 'media interrupted') {
ss.mediaRecoveryAttempts = 0;
ss.mediaRecoveryAttempts = 0;
ss.mediaRecoveryInProgress = false;
ss.mediaRecoveryInProgress = false;
ss._lastRecoveryCompletedAt = Date.now();
ss._lastRecoveryCompletedAt = Date.now();
+ _bcSessionChanged();
if (broadcastState.activeStreamId === streamId) {
if (broadcastState.activeStreamId === streamId) {
updateBroadcastStatusFromConnections(streamId);
updateBroadcastStatusFromConnections(streamId);
// Only show success toast if recovery was slow enough for the user to notice
// Only show success toast if recovery was slow enough for the user to notice
@@ -1628,6 +1635,7 @@ function showBrowserBroadcast() {
@@ -1628,6 +1635,7 @@ function showBrowserBroadcast() {
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
_startViewerPlacesPoll();
_startViewerPlacesPoll();
syncBroadcastLiveButtonVisibility();
syncBroadcastLiveButtonVisibility();
+ _bcSessionChanged();
}
}
let _rtmpStatusPollTimer = null;
let _rtmpStatusPollTimer = null;
@@ -2116,6 +2124,7 @@ async function stopBroadcast() {
@@ -2116,6 +2124,7 @@ async function stopBroadcast() {
function cleanupStream(streamId) {
function cleanupStream(streamId) {
const ss = broadcastState.streams.get(streamId);
const ss = broadcastState.streams.get(streamId);
if (!ss) return;
if (!ss) return;
+ setTimeout(_bcSessionChanged, 0); // after the state below is gone
// Leave the broadcast call if we're in one for this stream
// Leave the broadcast call if we're in one for this stream
if (callState.broadcastMode && callState.streamId === streamId) {
if (callState.broadcastMode && callState.streamId === streamId) {
@@ -2303,7 +2312,7 @@ function startGlobalDisplayTimers() {
@@ -2303,7 +2312,7 @@ function startGlobalDisplayTimers() {
setT('bc-stat-fps', `${Math.round(frameRate)} fps`);
setT('bc-stat-fps', `${Math.round(frameRate)} fps`);
setT('bc-stat-resolution', resolution || '--');
setT('bc-stat-resolution', resolution || '--');
_reportBroadcastShape(ss, hasStats ? resolution : '');
_reportBroadcastShape(ss, hasStats ? resolution : '');
- setT('bc-stat-status', connState);
+ setT('bc-stat-status', ({ connected: 'Connected', completed: 'Connected', checking: 'Connecting', new: 'Connecting', disconnected: 'Dropped', failed: 'Failed', closed: 'Closed' })[connState] || connState || '--');
setT('bc-stat-codec', codec || '--');
setT('bc-stat-codec', codec || '--');
// Health classification
// Health classification
@@ -2328,6 +2337,8 @@ function startGlobalDisplayTimers() {
@@ -2328,6 +2337,8 @@ function startGlobalDisplayTimers() {
}
}
healthEl.textContent = health;
healthEl.textContent = health;
healthEl.style.color = color;
healthEl.style.color = color;
+ const card = document.getElementById('bc-stats-overlay');
+ if (card) card.dataset.health = ({ Good: 'good', Fair: 'fair', Poor: 'poor', Offline: 'offline' })[health] || 'checking';
}
}
_checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);
_checkLowBitrate(ss, hasStats ? bitrateKbps : 0, connState);
} finally {
} finally {
@@ -4725,7 +4736,7 @@ async function _switchActiveAudio(audioId) {
@@ -4725,7 +4736,7 @@ async function _switchActiveAudio(audioId) {
toast('Microphone switched', 'success');
toast('Microphone switched', 'success');
}
}
-async function _switchActiveCamera(cameraId) {
+async function _switchActiveCamera(cameraId, opts = {}) {
const ss = getActiveStreamState();
const ss = getActiveStreamState();
if (!ss || !ss.localStream) return;
if (!ss || !ss.localStream) return;
@@ -4738,7 +4749,8 @@ async function _switchActiveCamera(cameraId) {
@@ -4738,7 +4749,8 @@ async function _switchActiveCamera(cameraId) {
const videoTrack = ss.localStream.getVideoTracks()[0]; if (!videoTrack) return;
const videoTrack = ss.localStream.getVideoTracks()[0]; if (!videoTrack) return;
const oldSettings = videoTrack.getSettings();
const oldSettings = videoTrack.getSettings();
const oldDeviceId = oldSettings.deviceId;
const oldDeviceId = oldSettings.deviceId;
- if ((cameraId || 'default') === (oldDeviceId || 'default')) return;
+ // `force` reopens the same camera: the phone gave it to another app and this tab's track ended or froze.
+ if (!opts.force && (cameraId || 'default') === (oldDeviceId || 'default')) return;
const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: false });
const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: false });
const chosenDevice = devices.find(d => d.deviceId === cameraId) || null;
const chosenDevice = devices.find(d => d.deviceId === cameraId) || null;
@@ -4802,7 +4814,8 @@ async function _switchActiveCamera(cameraId) {
@@ -4802,7 +4814,8 @@ async function _switchActiveCamera(cameraId) {
_warnDeviceMismatch('camera');
_warnDeviceMismatch('camera');
}
}
_syncCameraSelectionUI(appliedDeviceId);
_syncCameraSelectionUI(appliedDeviceId);
- toast('Camera switched', 'success');
+ if (typeof _bcSessionChanged === 'function') _bcSessionChanged();
+ if (!opts.quiet) toast('Camera switched', 'success');
} catch (err) {
} catch (err) {
console.error('[Broadcast] switch camera failed:', err);
console.error('[Broadcast] switch camera failed:', err);
try {
try {
@@ -4823,6 +4836,99 @@ async function _switchActiveCamera(cameraId) {
@@ -4823,6 +4836,99 @@ async function _switchActiveCamera(cameraId) {
}
}
}
}
+/** Tells the phone studio (broadcast-studio.js) the session changed, so it shows or updates at once, not a second later. */
+function _bcSessionChanged() {
+ try { document.dispatchEvent(new CustomEvent('bc-session')); } catch { /* */ }
+}
+
+/* ── Back in front after another app (owner, 2026-10-27) ─────────────────
+ "It froze after I switched apps real quick": a phone gives the camera to the other app and pauses this tab's
+ video, and the track comes back ended, or live but sending nothing. The frame watchdog only noticed 8–15 s later,
+ restarted the whole stream (restreams included), and a second switch within its 60 s cooldown never recovered.
+
+ Now, the moment the tab is in front again: the preview plays again, the audio mix resumes, and if no camera frame
+ arrives within about a second and a half (or the track ended) the same camera is opened again and swapped into
+ every connection in place (_switchActiveCamera force: viewers, restreams and the recording keep going, nothing
+ restarts). A microphone that ended is opened again into the mix. The stream's signalling socket reconnects at once
+ instead of waiting out its backoff. */
+let _bcHiddenAt = 0;
+let _bcResuming = null;
+/** Whether the camera is sending frames: the preview's next frame, else the track's frame counter; null if unknowable. */
+function _bcFramesFlowing(track, ms = 1500) {
+ const preview = document.getElementById('bc-video-preview');
+ const canWatch = preview && typeof preview.requestVideoFrameCallback === 'function' && preview.srcObject && preview.offsetParent !== null;
+ if (canWatch) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
+ try { preview.requestVideoFrameCallback(() => finish(true)); } catch { finish(null); }
+ setTimeout(() => finish(false), ms);
+ });
+ }
+ const first = typeof _trackFrameCount === 'function' ? _trackFrameCount(track) : null;
+ if (first == null) return Promise.resolve(null);
+ return new Promise((resolve) => setTimeout(() => {
+ const next = _trackFrameCount(track);
+ resolve(next == null ? null : next !== first);
+ }, ms));
+}
+async function _bcResumeAfterBackground(awayMs) {
+ const streamId = broadcastState.activeStreamId;
+ const ss = getActiveStreamState();
+ if (!ss || !ss.localStream || ss.mediaRecoveryInProgress) return;
+ // The socket that carries the stream: back now, not after a backoff that grew while the phone was away.
+ if (!ss.signalingIntentionalClose && (!ss.signalingWs || ss.signalingWs.readyState > WebSocket.OPEN)) {
+ if (ss.signalingReconnectTimer) { clearTimeout(ss.signalingReconnectTimer); ss.signalingReconnectTimer = null; }
+ ss.signalingReconnectDelay = 3000;
+ connectSignaling(streamId);
+ }
+ // A paused preview looks exactly like a frozen camera.
+ const preview = document.getElementById('bc-video-preview');
+ if (preview && preview.srcObject && preview.paused) preview.play().catch(() => {});
+ try { if (ss._audioMix && ss._audioMix.ctx.state !== 'running') await ss._audioMix.ctx.resume(); } catch { /* */ }
+ // The microphone, if the other app took it for good.
+ const mic = ss._audioMix && ss._audioMix.mic;
+ const micTrack = mic && mic.stream && mic.stream.getAudioTracks()[0];
+ if (micTrack && micTrack.readyState === 'ended' && ss._micEnabled !== false) {
+ try {
+ const s = broadcastState.settings;
+ const micStream = await _getUserMediaWithTimeout({ audio: buildAudioConstraints(s, micTrack.getSettings?.().deviceId || s.forceAudio), video: false });
+ ss._micStream = micStream;
+ _mixSetSource(ss, 'mic', micStream);
+ console.log('[Broadcast] Microphone opened again after the app was in the background');
+ } catch (err) { console.warn('[Broadcast] Could not reopen the microphone:', err.message); }
+ }
+ // The camera. Screen sharing owns its own track; a camera the streamer turned off sends nothing on purpose.
+ if (broadcastState.settings.screenShare) return;
+ const track = ss.localStream.getVideoTracks()[0];
+ if (!track) return;
+ let stale = track.readyState === 'ended';
+ if (!stale && track.enabled) stale = (await _bcFramesFlowing(track)) === false;
+ if (!stale) return;
+ const deviceId = (track.getSettings && track.getSettings().deviceId) || _getPreferredCameraId() || 'default';
+ console.warn(`[Broadcast] Camera ${track.readyState === 'ended' ? 'ended' : 'sent nothing'} after ${Math.round(awayMs / 1000)} s away: opening it again in place`);
+ if (typeof updateBroadcastStatus === 'function') updateBroadcastStatus('checking');
+ try {
+ await _switchActiveCamera(deviceId, { force: true, quiet: true });
+ updateBroadcastStatusFromConnections(streamId);
+ } catch (err) {
+ console.warn('[Broadcast] Reopening the camera failed, restarting the media:', err.message);
+ ss._lastRecoveryCompletedAt = 0; // not a spurious event: let the full recovery run now
+ scheduleMediaRecovery(streamId, 'camera lost while away');
+ }
+}
+document.addEventListener('visibilitychange', () => {
+ if (document.hidden) { _bcHiddenAt = Date.now(); return; }
+ const away = _bcHiddenAt ? Date.now() - _bcHiddenAt : 0;
+ _bcHiddenAt = 0;
+ if (!away || !isStreaming() || _bcResuming) return;
+ // A moment for the browser to hand the camera back before judging it.
+ _bcResuming = new Promise((r) => setTimeout(r, 400))
+ .then(() => _bcResumeAfterBackground(away))
+ .catch((err) => console.warn('[Broadcast] Resume after background failed:', err.message))
+ .finally(() => { _bcResuming = null; _bcSessionChanged(); });
+});
+
async function chooseBroadcastCamera(cameraId, buttonEl = null) {
async function chooseBroadcastCamera(cameraId, buttonEl = null) {
const previousCameraId = _getPreferredCameraId();
const previousCameraId = _getPreferredCameraId();
const buttons = Array.from(document.querySelectorAll('#bc-camera-sheet .bc-camera-choice'));
const buttons = Array.from(document.querySelectorAll('#bc-camera-sheet .bc-camera-choice'));
@@ -5208,7 +5314,7 @@ function syncBroadcastLiveButtonVisibility() {
@@ -5208,7 +5314,7 @@ function syncBroadcastLiveButtonVisibility() {
// Mic / Cam PiP are further gated by _syncScreenShareLiveControls()
// Mic / Cam PiP are further gated by _syncScreenShareLiveControls()
}
}
-function toggleBroadcastStats() { const el = document.getElementById('bc-stats-overlay'); if (el) el.style.display = el.style.display === 'none' ? 'flex' : 'none'; }
+function toggleBroadcastStats() { const el = document.getElementById('bc-stats-overlay'); if (el) el.style.display = el.style.display === 'none' ? 'grid' : 'none'; }
/* ── Group Call Controls ───────────────────────────────────────── */
/* ── Group Call Controls ───────────────────────────────────────── */
let _broadcastCallMode = null;
let _broadcastCallMode = null;
@@ -1757,6 +1757,15 @@ window.addEventListener('online', () => {
@@ -1757,6 +1757,15 @@ window.addEventListener('online', () => {
clearTimeout(_chatReconnectTimer); _chatReconnectTimer = null;
clearTimeout(_chatReconnectTimer); _chatReconnectTimer = null;
_reconnectChatWs(chatStreamId ?? null);
_reconnectChatWs(chatStreamId ?? null);
});
});
+// Back in front: a socket that dropped while the phone was in another app reconnects now, not after a backoff that
+// kept growing in the background (up to 30 s of "Reconnecting…" on the live studio, 2026-10-27).
+document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState !== 'visible' || _chatIntentionalClose || !_chatActive) return;
+ if (chatWs && chatWs.readyState <= WebSocket.OPEN) return;
+ if (_chatReconnectTimer) { clearTimeout(_chatReconnectTimer); _chatReconnectTimer = null; }
+ _chatReconnectDelay = CHAT_RECONNECT_BASE;
+ _reconnectChatWs(chatStreamId ?? null);
+});
// Mobile browsers freeze the page (and often the socket) in the background; on return, the widget
// Mobile browsers freeze the page (and often the socket) in the background; on return, the widget
// asks for what it missed rather than trusting rows that may be an hour old.
// asks for what it missed rather than trusting rows that may be an hour old.
document.addEventListener('visibilitychange', () => {
document.addEventListener('visibilitychange', () => {
@@ -4,7 +4,9 @@
@@ -4,7 +4,9 @@
count that was tapped. Used by the channel player's count (app-channel.js) and the phone live studio's
count that was tapped. Used by the channel player's count (app-channel.js) and the phone live studio's
(broadcast-studio.js). A place with count null is live there without a number (YouTube without an API key).
(broadcast-studio.js). A place with count null is live there without a number (YouTube without an API key).
- OVViewerPlaces.toggle(anchor, getPlaces, { below }) opens or closes the panel by `anchor`
+ OVViewerPlaces.toggle(anchor, getPlaces, { below, extra }) opens or closes the panel by `anchor`; `extra()` may add a
+ section under the places: { title, rows: [{ name, state, text }],
+ action: { label, onClick } } (the live studio's restreams)
OVViewerPlaces.update() redraws it from getPlaces() (call when counts change)
OVViewerPlaces.update() redraws it from getPlaces() (call when counts change)
OVViewerPlaces.close()
OVViewerPlaces.close()
OVViewerPlaces.total(places), .summary(places) the sum, and "4 on OpenVibe, 5 on Twitch" */
OVViewerPlaces.total(places), .summary(places) the sum, and "4 on OpenVibe, 5 on Twitch" */
@@ -61,6 +63,20 @@
@@ -61,6 +63,20 @@
const list = open.panel.querySelector('.ovp-list');
const list = open.panel.querySelector('.ovp-list');
list.replaceChildren(...places.map(row));
list.replaceChildren(...places.map(row));
open.panel.querySelector('.ovp-total-n').textContent = fmt(total(places));
open.panel.querySelector('.ovp-total-n').textContent = fmt(total(places));
+ const box = open.panel.querySelector('.ovp-extra');
+ const extra = open.extra ? open.extra() : null;
+ if (box) {
+ box.hidden = !(extra && extra.rows && extra.rows.length);
+ if (!box.hidden) {
+ const action = extra.action ? el('button', { type: 'button', class: 'ovp-action' }, extra.action.label) : null;
+ if (action) action.addEventListener('click', extra.action.onClick);
+ box.replaceChildren(el('div', { class: 'ovp-sub', text: extra.title || '' }),
+ el('ul', { class: 'ovp-list' }, ...extra.rows.map((r) => el('li', { class: `ovp-row ovp-state is-${r.state || 'idle'}` },
+ el('span', { class: 'ovp-ico' }, el('span', { class: 'ovp-sdot', 'aria-hidden': 'true' })),
+ el('span', { class: 'ovp-name', text: r.name }), el('span', { class: 'ovp-n', text: r.text || '' })))),
+ action);
+ }
+ }
place();
place();
}
}
@@ -101,11 +117,12 @@
@@ -101,11 +117,12 @@
const panel = el('div', { class: 'ovp', role: 'dialog', 'aria-label': 'Where people are watching' },
const panel = el('div', { class: 'ovp', role: 'dialog', 'aria-label': 'Where people are watching' },
el('div', { class: 'ovp-head' }, el('span', { text: 'Watching now' }),
el('div', { class: 'ovp-head' }, el('span', { text: 'Watching now' }),
el('span', { class: 'ovp-total' }, el('i', { class: 'fa-solid fa-eye', 'aria-hidden': 'true' }), el('b', { class: 'ovp-total-n', text: '0' }))),
el('span', { class: 'ovp-total' }, el('i', { class: 'fa-solid fa-eye', 'aria-hidden': 'true' }), el('b', { class: 'ovp-total-n', text: '0' }))),
- el('ul', { class: 'ovp-list' }));
+ el('ul', { class: 'ovp-list' }),
+ el('div', { class: 'ovp-extra', hidden: true }));
// In fullscreen only the fullscreen element shows: the panel goes inside it.
// In fullscreen only the fullscreen element shows: the panel goes inside it.
const fs = document.fullscreenElement || document.webkitFullscreenElement;
const fs = document.fullscreenElement || document.webkitFullscreenElement;
(fs && fs.contains(anchor) ? fs : document.body).append(panel);
(fs && fs.contains(anchor) ? fs : document.body).append(panel);
- open = { panel, anchor, get, below: !!(opts && opts.below) };
+ open = { panel, anchor, get, below: !!(opts && opts.below), extra: opts && typeof opts.extra === 'function' ? opts.extra : null };
anchor.setAttribute('aria-expanded', 'true');
anchor.setAttribute('aria-expanded', 'true');
document.addEventListener('pointerdown', onOutside, true);
document.addEventListener('pointerdown', onOutside, true);
document.addEventListener('keydown', onKey);
document.addEventListener('keydown', onKey);
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+/**
+ * Back in front after another app (public/js/broadcast.js _bcResumeAfterBackground; owner, 2026-10-27: "it froze after
+ * I switched apps real quick"). A phone gives the camera to the other app; on return the same camera is opened again
+ * and swapped in place (_switchActiveCamera force), never a full restart, and only when it is actually stale. The phone
+ * watchdog (broadcast-state.js) does nothing while the tab is hidden, and a recovery waits for the tab to be in front.
+ *
+ * node test/broadcast-resume.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');
+const fn = (name) => {
+ const start = src.search(new RegExp(`(async )?function ${name}\\(`));
+ assert.ok(start >= 0, name);
+ let i = src.indexOf('{', src.indexOf(')', start)), depth = 0;
+ for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }
+ return src.slice(start, i + 1);
+};
+
+function harness({ track, frames, mic, switchFails = false }) {
+ const calls = { switched: [], recovery: [], signalling: 0, played: 0, mix: [], status: [] };
+ const preview = { srcObject: {}, paused: true, offsetParent: {}, play: async () => { calls.played++; } };
+ const env = {
+ WebSocket: { OPEN: 1 },
+ document: { getElementById: (id) => (id === 'bc-video-preview' ? preview : null) },
+ broadcastState: { activeStreamId: 5, settings: { screenShare: false, forceAudio: 'default' } },
+ console: { log() {}, warn() {} },
+ _trackFrameCount: () => null,
+ _getPreferredCameraId: () => 'cam-main',
+ connectSignaling: () => { calls.signalling++; },
+ updateBroadcastStatus: (s) => calls.status.push(s),
+ updateBroadcastStatusFromConnections: () => calls.status.push('from-connections'),
+ _switchActiveCamera: async (id, opts) => { calls.switched.push([id, opts]); if (switchFails) throw new Error('NotReadableError'); },
+ scheduleMediaRecovery: (id, reason) => calls.recovery.push(reason),
+ _getUserMediaWithTimeout: async () => ({ getAudioTracks: () => [{ readyState: 'live' }] }),
+ buildAudioConstraints: () => ({}),
+ _mixSetSource: (ss, key) => calls.mix.push(key),
+ };
+ const ss = {
+ localStream: { getVideoTracks: () => [track] },
+ signalingWs: { readyState: 3 }, signalingReconnectTimer: null, signalingIntentionalClose: false,
+ _audioMix: { ctx: { state: 'running' }, mic: mic ? { stream: { getAudioTracks: () => [mic] } } : null },
+ };
+ env.getActiveStreamState = () => ss;
+ // _bcFramesFlowing asks the preview for its next frame: answer as the test says.
+ preview.requestVideoFrameCallback = (cb) => { if (frames) setTimeout(cb, 5); };
+ const run = new Function(...Object.keys(env), `${fn('_bcFramesFlowing')}\n${fn('_bcResumeAfterBackground')}\nreturn _bcResumeAfterBackground;`)(...Object.values(env));
+ return { run, calls, ss };
+}
+
+(async () => {
+ // A track that ended: the same camera is opened again in place, quietly; the signalling socket comes back now.
+ let h = harness({ track: { readyState: 'ended', enabled: true, getSettings: () => ({ deviceId: 'cam-back' }) }, frames: false });
+ await h.run(20000);
+ assert.deepStrictEqual(h.calls.switched, [['cam-back', { force: true, quiet: true }]]);
+ assert.strictEqual(h.calls.recovery.length, 0, 'no full restart');
+ assert.strictEqual(h.calls.signalling, 1, 'a closed signalling socket reconnects at once');
+ assert.strictEqual(h.calls.played, 1, 'the paused preview plays again');
+
+ // Live but sending nothing (frozen): opened again too.
+ h = harness({ track: { readyState: 'live', enabled: true, getSettings: () => ({ deviceId: 'cam-front' }) }, frames: false });
+ await h.run(5000);
+ assert.deepStrictEqual(h.calls.switched.map((c) => c[0]), ['cam-front']);
+
+ // Frames flowing: nothing is touched.
+ h = harness({ track: { readyState: 'live', enabled: true, getSettings: () => ({ deviceId: 'cam-front' }) }, frames: true });
+ await h.run(5000);
+ assert.deepStrictEqual(h.calls.switched, [], 'a working camera is left alone');
+
+ // A camera the streamer turned off sends nothing on purpose: left alone.
+ h = harness({ track: { readyState: 'live', enabled: false, getSettings: () => ({ deviceId: 'cam-front' }) }, frames: false });
+ await h.run(5000);
+ assert.deepStrictEqual(h.calls.switched, []);
+
+ // The microphone the other app took is opened again into the mix.
+ h = harness({ track: { readyState: 'live', enabled: true, getSettings: () => ({}) }, frames: true, mic: { readyState: 'ended', getSettings: () => ({ deviceId: 'mic-1' }) } });
+ await h.run(5000);
+ assert.deepStrictEqual(h.calls.mix, ['mic']);
+
+ // Reopening fails: the full recovery runs now, its 60 s cooldown cleared (a second app switch used to never recover).
+ h = harness({ track: { readyState: 'ended', enabled: true, getSettings: () => ({ deviceId: 'cam-back' }) }, frames: false, switchFails: true });
+ h.ss._lastRecoveryCompletedAt = Date.now();
+ await h.run(5000);
+ assert.deepStrictEqual(h.calls.recovery, ['camera lost while away']);
+ assert.strictEqual(h.ss._lastRecoveryCompletedAt, 0, 'the cooldown does not apply');
+
+ // The phone watchdog sits out the background, and a recovery waits for the tab to be in front.
+ const state = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-state.js'), 'utf8');
+ assert.ok(/if \(mobile && typeof document !== 'undefined' && document\.hidden\) \{\s*lastSuccessfulFrame = Date\.now\(\);/.test(state), 'the watchdog pauses while hidden on a phone');
+ assert.ok(src.includes("if (document.hidden && isMobileBroadcaster()) {"), 'a recovery is not started from the background');
+ // Chat reconnects the moment the tab is in front.
+ const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
+ assert.ok(/visibilitychange[\s\S]{0,500}_chatReconnectDelay = CHAT_RECONNECT_BASE;\s*_reconnectChatWs\(chatStreamId \?\? null\);/.test(chat), 'chat reconnects on return');
+ console.log('broadcast resume: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -248,7 +248,9 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
@@ -248,7 +248,9 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
assert.deepStrictEqual(toasts, ['This camera cannot zoom']);
assert.deepStrictEqual(toasts, ['This camera cannot zoom']);
assert.strictEqual(noZoom.prevented, false);
assert.strictEqual(noZoom.prevented, false);
- assert.ok(/zoom\.level = null;\s*await _switchActiveCamera\(next\.deviceId\)/.test(extract(studioSrc, 'flip')), 'a flip starts the new camera at 1x');
+ // Every camera change (a flip, or a pick from the camera list) goes through useCamera, which starts it at 1x.
+ assert.ok(/zoom\.level = null;\s*\/\/[^\n]*\n\s*await _switchActiveCamera\(choice\.deviceId, \{ quiet: true \}\)/.test(extract(studioSrc, 'useCamera')), 'a flip starts the new camera at 1x');
+ assert.ok(/await useCamera\(next\)/.test(extract(studioSrc, 'flip')));
assert.ok(/attachZoom\(\)/.test(extract(studioSrc, 'mount')) && /detachZoom\(\)/.test(extract(studioSrc, 'unmount')));
assert.ok(/attachZoom\(\)/.test(extract(studioSrc, 'mount')) && /detachZoom\(\)/.test(extract(studioSrc, 'unmount')));
assert.ok(/body\.bc-studio-on \.bc-video-container \{ touch-action: none; \}/.test(read('public/css/features/broadcast.css')), 'the browser leaves the pinch to the studio');
assert.ok(/body\.bc-studio-on \.bc-video-container \{ touch-action: none; \}/.test(read('public/css/features/broadcast.css')), 'the browser leaves the pinch to the studio');
});
});
@@ -88,7 +88,15 @@ assert.match(bc, /for \(const id of \['bc-viewer-count', 'bc-rtmp-viewer-count',
@@ -88,7 +88,15 @@ assert.match(bc, /for \(const id of \['bc-viewer-count', 'bc-rtmp-viewer-count',
// ── The phone studio: the pill shows that total, and a tap lists the places under it ──
// ── The phone studio: the pill shows that total, and a tap lists the places under it ──
const studio = read('public/js/broadcast-studio.js');
const studio = read('public/js/broadcast-studio.js');
assert.match(studio, /el\('button', \{ type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers \}/);
assert.match(studio, /el\('button', \{ type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers \}/);
-assert.match(studio, /OVViewerPlaces\.toggle\(\$\('bcs-viewers'\), viewerPlaces, \{ below: true \}\)/);
+assert.match(studio, /OVViewerPlaces\.toggle\(\$\('bcs-viewers'\), viewerPlaces, \{\s*below: true,/);
+// Restreams are listed under the places (2026-10-27: "Also on 1" was a second chip for the same thing), each with its
+// state, and one connecting or failed marks the pill.
+assert.match(studio, /extra: \(\) => \(\{ title: 'Restreams', rows: restreamRows\(\)/);
+assert.match(studio, /\$\('bcs-viewers'\)\.dataset\.rs = rs \? rs\.cls\.replace\('is-', ''\) : '';/);
+assert.ok(!studio.includes("id: 'bcs-restream'"), 'no separate restream chip');
+const vp = read('public/js/viewer-places.js');
+assert.match(vp, /extra: opts && typeof opts\.extra === 'function' \? opts\.extra : null/, 'the panel takes an extra section');
+assert.match(vp, /el\('li', \{ class: `ovp-row ovp-state is-\$\{r\.state \|\| 'idle'\}` \}/, 'each row says its state');
// ── The channel player: the sum, fed live by the socket and by chat ──
// ── The channel player: the sum, fed live by the socket and by chat ──
const ch = read('public/js/app-channel.js');
const ch = read('public/js/app-channel.js');