openvibers/openvibe.live Commits

Phone studio: reliable after an app switch, up through starts and recoveries, a clearer top bar and cameras (#407)

OP OpenVibers committed 17 days ago · ee814a534b · 12 files, +602 −68

* Phone studio: back from another app, the camera is reopened in place (not frozen, no restart); the studio stays up while it starts and recovers; one live pill; restreams in the viewers panel; a health card; Flip remembers each side and holding it lists the cameras by name

* Phone watchdog and camera switch run where document or the session hook are absent (the flip test's sandbox)

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 6cfa168b..dd04a253 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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's
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-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;
ddiff --git a/public/css/features/viewer-places.css b/public/css/features/viewer-places.css
iindex 232dd6d2..0775c9eb 100644
---- a/public/css/features/viewer-places.css
++++ b/public/css/features/viewer-places.css
@@@ -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; }
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 42cb807b..8d51512f 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -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">
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex 8703e4c8..aa5730b6 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -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;
ddiff --git a/public/js/broadcast-state.js b/public/js/broadcast-state.js
iindex 07af1763..eff2e3c5 100644
---- a/public/js/broadcast-state.js
++++ b/public/js/broadcast-state.js
@@@ -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 ticking
if (current._suppressRecovery || current.mediaRecoveryInProgress || current.mediaRecoveryTimer) {
lastSuccessfulFrame = Date.now(); // reset watchdog during recovery
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex b0e4d37c..1b991599 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -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();
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 14f4fc0c..135be778 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -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 restarts
await 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 stream
if (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;
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 28712be8..12bafb56 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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', () => {
ddiff --git a/public/js/viewer-places.js b/public/js/viewer-places.js
iindex c1ae0dbd..61246a29 100644
---- a/public/js/viewer-places.js
++++ b/public/js/viewer-places.js
@@@ -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);
ddiff --git a/test/broadcast-resume.test.js b/test/broadcast-resume.test.js
nnew file mode 100644
iindex 00000000..6f00417c
---- /dev/null
++++ b/test/broadcast-resume.test.js
@@@ -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); });
ddiff --git a/test/broadcast-studio.test.js b/test/broadcast-studio.test.js
iindex dc039075..d5b30f66 100644
---- a/test/broadcast-studio.test.js
++++ b/test/broadcast-studio.test.js
@@@ -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');
});
ddiff --git a/test/viewer-places.test.js b/test/viewer-places.test.js
iindex b6ba062a..9b978d41 100644
---- a/test/viewer-places.test.js
++++ b/test/viewer-places.test.js
@@@ -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');