openvibers/openvibe.live Commits

Go Live on a phone: a camera chooser, a useful Active Streams tab, a Resume bar, and a full page that scrolls as one (#408)

OP OpenVibers committed 17 days ago · cfb9856ea8 · 9 files, +392 −27

* Go Live: a camera chooser (Front | Back and a chip per camera on a phone, named cards on a computer, remembered per side); Active Streams shows a live card and the last streams with replays; the phone bar never says Go live while a stream is live (Resume instead) or before the streams are known

* The live page on a phone is one scroll (the settings were a 414 px box with 4,000 px in it); RobotStreamer's state, Stop and details laid out at every width; two-column buttons with words beside icons; the info bar's count lists where people watch

* Replays link through videoHref (openvibe.video), as every watch link does

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex dd04a253..0f1f925c 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2867,3 +2867,120 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
.bc-clip-name { animation: none; }
.bc-clip-name-timer { animation: none; display: none; }
}
++
++/* ── The camera chooser before going live (broadcast-devices.js _bcRenderCameraChooser) ──
++ Phones: Front | Back, and a chip per camera when a side has several. Computers: a card per camera. */
++.bc-cam-chooser { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.bc-cam-chooser[hidden], .bc-cam-select-row[hidden] { display: none !important; }
++.bc-cam-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 8px; flex: 1 1 100%; }
++.bc-cam-side, .bc-cam-card, .bc-cam-lens, .bc-cam-refresh {
++ font: inherit; cursor: pointer; color: var(--text-primary); -webkit-tap-highlight-color: transparent;
++ background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); border: 1px solid var(--border);
++ transition: border-color 0.15s, background 0.15s, transform 0.1s;
++}
++.bc-cam-side {
++ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
++ min-height: 74px; border-radius: 16px; font-size: 0.92rem; font-weight: 700;
++}
++.bc-cam-side i { font-size: 1.25rem; color: var(--text-muted); }
++.bc-cam-side.is-on, .bc-cam-card.is-on, .bc-cam-lens.is-on {
++ border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--bg-secondary));
++}
++.bc-cam-side.is-on i, .bc-cam-card.is-on > i:first-child { color: var(--accent); }
++.bc-cam-side:active, .bc-cam-card:active, .bc-cam-lens:active { transform: scale(0.98); }
++.bc-cam-lenses { display: flex; gap: 6px; flex-wrap: wrap; }
++.bc-cam-lens { min-width: 52px; min-height: 36px; padding: 0 12px; border-radius: 999px; font-size: 0.82rem; font-weight: 700; }
++.bc-cam-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; flex: 1 1 0; min-width: 0; }
++.bc-cam-card {
++ display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 0 12px; border-radius: 12px; text-align: left;
++ font-size: 0.88rem; font-weight: 600;
++}
++.bc-cam-card span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.bc-cam-card > i:first-child { width: 18px; text-align: center; color: var(--text-muted); }
++.bc-cam-tick { color: var(--accent); visibility: hidden; }
++.bc-cam-card.is-on .bc-cam-tick { visibility: visible; }
++.bc-cam-refresh { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; color: var(--text-muted); margin-left: auto; }
++.bc-cam-side:focus-visible, .bc-cam-card:focus-visible, .bc-cam-lens:focus-visible, .bc-cam-refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++@media (prefers-reduced-motion: reduce) { .bc-cam-side, .bc-cam-card, .bc-cam-lens { transition: none; } }
++/* With the chooser the camera takes the row's whole width; the microphone goes under it. */
++.bc-ws-device-row #bc-ws-camera-group.bc-cam-has-chooser { flex: 1 1 100% !important; } /* over its inline flex: 1 */
++
++/* ── Active Streams: a card per live stream, then the last few with their replays (broadcast.js) ── */
++.bc-live-card {
++ position: relative; overflow: hidden; padding: 16px 18px 18px; border-radius: 18px;
++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, #e11d48 18%, transparent), transparent 60%),
++ color-mix(in srgb, var(--bg-secondary) 88%, transparent);
++ border: 1px solid color-mix(in srgb, #e11d48 45%, var(--border));
++}
++.bc-live-card + .bc-live-card { margin-top: 12px; }
++.bc-live-card-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
++.bc-live-card-pill {
++ display: inline-flex; align-items: center; gap: 7px; height: 28px; padding: 0 12px; border-radius: 999px;
++ background: #e11d48; color: #fff; font-size: 0.8rem; font-weight: 800; font-variant-numeric: tabular-nums;
++}
++.bc-live-card-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: bcs-pulse 1.6s ease-in-out infinite; }
++.bc-live-card-watch { font-size: 0.86rem; font-weight: 700; color: var(--text-secondary); }
++.bc-live-card-title { margin: 12px 0 2px; font-size: 1.2rem; line-height: 1.25; overflow-wrap: anywhere; }
++.bc-live-card-sub { margin: 0; font-size: 0.84rem; color: var(--text-muted); }
++.bc-live-card-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
++.bc-live-card-acts .btn { min-height: 42px; display: inline-flex; align-items: center; gap: 7px; }
++.bc-live-card-main { flex: 1 1 180px; justify-content: center; font-size: 0.98rem; }
++.bc-last-streams { margin-top: 18px; }
++.bc-last-streams h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; font-size: 0.8rem; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); }
++.bc-last-item {
++ display: flex; align-items: center; gap: 12px; padding: 8px; border-radius: 12px;
++ background: color-mix(in srgb, var(--bg-secondary) 70%, transparent); border: 1px solid var(--border);
++}
++.bc-last-item + .bc-last-item { margin-top: 6px; }
++.bc-last-thumb {
++ flex: none; width: 76px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; display: grid; place-items: center;
++ background: color-mix(in srgb, var(--bg-primary) 80%, #000); color: var(--text-muted);
++}
++.bc-last-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.bc-last-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
++.bc-last-meta b { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.bc-last-meta small { font-size: 0.78rem; color: var(--text-muted); }
++.bc-last-replay {
++ flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px;
++ font-size: 0.82rem; font-weight: 700; text-decoration: none; color: var(--text-primary);
++ background: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
++}
++.bc-last-replay:hover, .bc-last-replay:focus-visible { background: color-mix(in srgb, var(--accent) 24%, transparent); outline: none; }
++/* The phone bar fades up instead of appearing (and it no longer shows before the page knows the streams). */
++.bcs-golive:not([hidden]) { animation: bcs-dock-in 0.2s ease-out; }
++.bcs-golive-t { margin-left: 8px; }
++@keyframes bcs-dock-in { from { opacity: 0; transform: translateY(8px); } }
++@media (prefers-reduced-motion: reduce) { .bcs-golive:not([hidden]), .bc-live-card-dot { animation: none; } }
++
++/* RobotStreamer, at every width: its state and Stop on one line, the details under them in full (on a phone the Stop
++ button stood between them as a tall tile and the details ran off the right; on a computer the state sat at the far
++ right with its details under the left). */
++#bc-rs-control.bc-rs-control { flex-direction: column; flex-wrap: nowrap; align-items: stretch; gap: 6px; padding: 10px 12px; }
++#bc-rs-control .bc-rs-status-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: nowrap; margin-left: 0; }
++#bc-rs-control .bc-rs-status { min-width: 0; }
++#bc-rs-control .bc-ctrl-btn-sm { flex: none; flex-direction: row; width: auto; min-height: 36px; padding: 0 14px; gap: 6px; font-size: 0.82rem; }
++#bc-rs-control .bc-ctrl-btn-sm i { font-size: 0.8rem; }
++#bc-rs-control .bc-restream-detail { white-space: normal; overflow-wrap: anywhere; font-size: 0.76rem; line-height: 1.35; text-align: left; flex-basis: auto; }
++
++/* ── The live view's full page on a phone (owner, 2026-10-27: "huge wasted space", cut-off cards, a Stop button floating
++ in the RobotStreamer box). One scroll: the settings under the buttons were their own 414 px scroll box inside the
++ page's, with 4,000 px in it, so the Text To Speech card was cut off and the rest of the screen looked empty. */
++@media (max-width: 768px) {
++ #bc-browser-broadcast { flex: none; }
++ #bc-browser-broadcast .bc-controls { overflow: visible; }
++ /* The buttons: two to a row, icon beside the words (three tall tiles to a row wrapped "Skip spoken message"). */
++ .bc-ctrl-buttons { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .bc-ctrl-btn:not(.bc-ctrl-btn-danger) {
++ flex-direction: row; justify-content: flex-start; gap: 10px; min-height: 48px; padding: 8px 12px; text-align: left; font-size: 0.84rem;
++ }
++ .bc-ctrl-btn:not(.bc-ctrl-btn-danger) i { width: 18px; flex: none; text-align: center; font-size: 0.95rem; }
++}
++
++/* The info bar's count is a button: a tap lists where people are watching. On a phone that list replaces the badges. */
++.bc-info-viewers { font: inherit; color: inherit; background: none; border: 0; padding: 4px 8px; margin: -4px -8px; border-radius: 999px; cursor: pointer; }
++.bc-info-viewers:hover, .bc-info-viewers[aria-expanded="true"] { background: color-mix(in srgb, var(--text-primary) 8%, transparent); }
++.bc-info-viewers:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.bc-info-more { font-size: 0.62rem; opacity: 0.6; margin-left: 2px; }
++@media (max-width: 768px) {
++ #bc-info-bar #bc-rs-viewer-count, #bc-info-bar #bc-platform-viewer-badges { display: none !important; }
++}
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 8d51512f..789bcd8f 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -664,9 +664,9 @@ WantedBy=multi-user.target</code>
<!-- Info bar -->
<div class="bc-info-bar" id="bc-info-bar" style="display:none">
-- <div class="bc-info-item">
-- <i class="fa-solid fa-eye"></i> <strong id="bc-viewer-count">0</strong> watching
-- </div>
++ <button type="button" class="bc-info-item bc-info-viewers" id="bc-info-viewers" onclick="toggleBroadcastViewerPlaces(this)" aria-haspopup="dialog" aria-expanded="false" title="Where people are watching">
++ <i class="fa-solid fa-eye"></i> <strong id="bc-viewer-count">0</strong> watching <i class="fa-solid fa-chevron-down bc-info-more" aria-hidden="true"></i>
++ </button>
<div class="bc-info-item" id="bc-rs-viewer-count" style="display:none">
<i class="fa-solid fa-robot"></i> <strong>0</strong> on RS
</div>
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex aa5730b6..65b8532b 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -142,10 +142,20 @@ function _setSelectValueIfPresent(select, value) {
let _switchingCamera = false;
function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
const normalized = cameraId || 'default';
++ // The camera chosen on each side is remembered too, so Flip and the Front / Back buttons go back to it.
++ if (persist && normalized !== 'default') {
++ try {
++ const sel = document.getElementById('bc-create-camera');
++ const opt = sel && Array.from(sel.options).find((o) => o.value === normalized);
++ const facing = opt && opt.dataset.facing;
++ if (facing) localStorage.setItem(`bc-last-camera-${facing}`, normalized);
++ } catch { /* */ }
++ }
_setSelectValueIfPresent(document.getElementById('bc-forceCamera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-create-camera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-screen-camera'), normalized);
broadcastState.settings.forceCamera = normalized;
++ _bcRenderCameraChooser();
if (persist) {
saveBroadcastSettings();
try { localStorage.setItem('bc-last-camera', normalized); } catch {}
@@@ -252,6 +262,101 @@ function _bcCameraChoices(devices) {
return list.sort((a, b) => rank(a) - rank(b) || (a.side || 0) - (b.side || 0) || a.order - b.order);
}
++/**
++ * The camera chooser before going live (owner, 2026-10-27: "rather than a drop down … tap a camera button that looks
++ * like the front or back camera … remember whichever you last had selected … default ass shit with unformatted camera
++ * names is trash"). It sits over the go-live camera select (#bc-create-camera), which stays the source of truth.
++ *
++ * On a phone: Front | Back, two big buttons, and when the chosen side has more than one camera, a chip per camera on
++ * it (Main, 2, 3). On a computer: a card per camera with its name. Picking one goes through _syncCameraSelectionUI
++ * (remembered as the last camera, and as the last on its side).
++ */
++function _bcRenderCameraChooser() {
++ const sel = document.getElementById('bc-create-camera');
++ if (!sel) return;
++ const group = document.getElementById('bc-ws-camera-group');
++ let box = document.getElementById('bc-cam-chooser');
++ const options = Array.from(sel.options).filter((o) => o.value && o.value !== 'default');
++ if (!options.length) { if (box) box.hidden = true; sel.closest('.bc-cam-select-row')?.removeAttribute('hidden'); return; }
++ if (!box) {
++ box = document.createElement('div');
++ box.id = 'bc-cam-chooser';
++ box.className = 'bc-cam-chooser';
++ box.setAttribute('role', 'group');
++ box.setAttribute('aria-label', 'Camera');
++ (sel.closest('.bc-cam-select-row') || sel).after(box);
++ }
++ box.hidden = false;
++ const row = sel.closest('.bc-cam-select-row');
++ if (row) row.hidden = true;
++ const choices = options.map((o) => ({ deviceId: o.value, name: o.textContent, facing: o.dataset.facing || null }));
++ const phone = window.matchMedia && window.matchMedia('(max-width: 760px), (pointer: coarse)').matches;
++ const sides = { user: choices.filter((c) => c.facing === 'user'), environment: choices.filter((c) => c.facing === 'environment') };
++ // "Default" is whatever the browser picks: show (and use) a real camera instead, the front one on a phone.
++ if (!choices.some((c) => c.deviceId === sel.value)) {
++ _syncCameraSelectionUI((phone && sides.user[0] ? sides.user[0] : choices[0]).deviceId, { persist: false });
++ return;
++ }
++ const current = choices.find((c) => c.deviceId === sel.value) || null;
++ const pick = (id) => { if (id && id !== sel.value) _syncCameraSelectionUI(id); };
++ const btn = (cls, attrs, html, onClick) => {
++ const b = document.createElement('button');
++ b.type = 'button';
++ b.className = cls;
++ for (const [k, v] of Object.entries(attrs)) b.setAttribute(k, v);
++ b.innerHTML = html;
++ b.addEventListener('click', onClick);
++ return b;
++ };
++ const nodes = [];
++ if (phone && (sides.user.length || sides.environment.length)) {
++ const lastOn = (facing) => { try { return localStorage.getItem(`bc-last-camera-${facing}`); } catch { return null; } };
++ const seg = document.createElement('div');
++ seg.className = 'bc-cam-sides';
++ for (const [facing, label, icon] of [['user', 'Front', 'fa-user'], ['environment', 'Back', 'fa-mountain-sun']]) {
++ const list = sides[facing];
++ if (!list.length) continue;
++ const on = current && current.facing === facing;
++ seg.append(btn(`bc-cam-side${on ? ' is-on' : ''}`, { 'aria-pressed': String(!!on) },
++ `<i class="fa-solid ${icon}" aria-hidden="true"></i><span>${label}</span>`,
++ () => pick((list.find((c) => c.deviceId === lastOn(facing)) || list[0]).deviceId)));
++ }
++ nodes.push(seg);
++ const side = current && current.facing ? sides[current.facing] : [];
++ if (side.length > 1) {
++ const lenses = document.createElement('div');
++ lenses.className = 'bc-cam-lenses';
++ lenses.setAttribute('aria-label', 'Cameras on this side');
++ side.forEach((c, i) => lenses.append(btn(`bc-cam-lens${c.deviceId === sel.value ? ' is-on' : ''}`,
++ { 'aria-pressed': String(c.deviceId === sel.value), 'aria-label': c.name, title: c.name },
++ i === 0 ? 'Main' : String(i + 1), () => pick(c.deviceId))));
++ nodes.push(lenses);
++ }
++ // A computer camera plugged into a phone or tablet (USB) is still choosable.
++ for (const c of choices.filter((x) => !x.facing)) {
++ nodes.push(btn(`bc-cam-card${c.deviceId === sel.value ? ' is-on' : ''}`, { 'aria-pressed': String(c.deviceId === sel.value) },
++ `<i class="fa-solid fa-video" aria-hidden="true"></i><span></span>`, () => pick(c.deviceId)));
++ nodes[nodes.length - 1].querySelector('span').textContent = c.name;
++ }
++ } else {
++ const cards = document.createElement('div');
++ cards.className = 'bc-cam-cards';
++ for (const c of choices) {
++ const on = c.deviceId === sel.value;
++ const icon = c.facing === 'user' ? 'fa-user' : c.facing === 'environment' ? 'fa-mountain-sun' : 'fa-video';
++ const b = btn(`bc-cam-card${on ? ' is-on' : ''}`, { 'aria-pressed': String(on) },
++ `<i class="fa-solid ${icon}" aria-hidden="true"></i><span></span><i class="fa-solid fa-check bc-cam-tick" aria-hidden="true"></i>`, () => pick(c.deviceId));
++ b.querySelector('span').textContent = c.name;
++ cards.append(b);
++ }
++ nodes.push(cards);
++ }
++ const refresh = btn('bc-cam-refresh', { title: 'Look for cameras again', 'aria-label': 'Look for cameras again' },
++ '<i class="fa-solid fa-rotate" aria-hidden="true"></i>', () => { if (typeof refreshDeviceLists === 'function') refreshDeviceLists(); });
++ box.replaceChildren(...nodes, refresh);
++ if (group) group.classList.toggle('bc-cam-has-chooser', true);
++}
++
async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {
if (!navigator.mediaDevices?.enumerateDevices) return [];
let tempStream = null;
@@@ -448,14 +553,16 @@ function _populateCreateDeviceDropdowns(devices) {
const audioSelect = document.getElementById('bc-create-audio');
if (camSelect) {
camSelect.innerHTML = '<option value="default">Default</option>';
-- devices.filter(d => d.kind === 'videoinput').forEach(d => {
++ for (const c of _bcCameraChoices(devices)) {
const opt = document.createElement('option');
-- opt.value = d.deviceId;
-- opt.textContent = d.label || `Camera ${d.deviceId.slice(0, 8)}`;
++ opt.value = c.deviceId;
++ opt.textContent = c.name;
++ if (c.facing) opt.dataset.facing = c.facing;
camSelect.appendChild(opt);
-- });
++ }
_syncCameraSelectionUI(_getPreferredCameraId(), { persist: false });
camSelect.onchange = () => _syncCameraSelectionUI(camSelect.value);
++ _bcRenderCameraChooser();
}
if (audioSelect) {
audioSelect.innerHTML = '<option value="default">Default</option>';
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 1b991599..78b00d66 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -736,28 +736,49 @@
// The slot workspace puts the real Go Live button below the title, method, capture mode, quality and devices,
// about two screens down on a phone. While it is out of view, a bar at the bottom presses it.
function goLiveButton() { const b = $('bc-ws-golive-btn'); return b && b.offsetParent ? b : null; }
++ // Nothing until the page knows the streamer's streams (broadcast.js loadExistingStreams): the bar said "Go live" for
++ // a moment on every load, and while a stream of theirs was already live (owner, 2026-10-27). With one live and this
++ // page not holding it, the bar resumes it instead of starting another.
++ function inView(n) { if (!n || !n.offsetParent) return false; const r = n.getBoundingClientRect(); return r.top < window.innerHeight - 8 && r.bottom > 0; }
function syncGoLiveDock() {
-- const real = NARROW.matches && onBroadcastPage() && !localStream() ? goLiveButton() : null;
-- let inView = false;
-- if (real) { const r = real.getBoundingClientRect(); inView = r.top < window.innerHeight - 8 && r.bottom > 0; }
-- const show = !!real && !inView;
++ const ready = NARROW.matches && onBroadcastPage() && !localStream() && !streamState() && !!window._bcMyLoaded;
++ const liveMine = ready ? (window._bcMyLive || []) : [];
++ let mode = null, real = null;
++ if (ready && liveMine.length) {
++ const card = document.querySelector('#bc-active-list .bc-live-card-main');
++ if (!inView(card)) mode = 'resume';
++ } else if (ready) {
++ real = goLiveButton();
++ if (real && !inView(real)) mode = 'golive';
++ }
let dock = $('bcs-golive');
-- if (show && !dock) {
++ if (mode && !dock) {
dock = el('div', { class: 'bcs-golive', id: 'bcs-golive' },
-- el('button', { type: 'button', class: 'bcs-golive-btn', onclick: () => { const b = goLiveButton(); if (b && !b.disabled) b.click(); } },
-- icon('fa-tower-broadcast'), ' Go live'),
++ el('button', { type: 'button', class: 'bcs-golive-btn', onclick: () => {
++ if (dock.dataset.mode === 'resume') { const s = (window._bcMyLive || [])[0]; if (s && typeof resumeStream === 'function') resumeStream(s.id); return; }
++ const b = goLiveButton(); if (b && !b.disabled) b.click();
++ } }, icon('fa-tower-broadcast'), el('span', { class: 'bcs-golive-t', text: 'Go live' })),
el('small', { class: 'bcs-golive-where' }));
document.body.append(dock);
}
if (dock) {
-- dock.hidden = !show;
-- if (show) {
++ dock.hidden = !mode;
++ dock.dataset.mode = mode || '';
++ if (mode === 'resume') {
++ const s = liveMine[0];
++ dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-play';
++ dock.querySelector('.bcs-golive-t').textContent = liveMine.length > 1 ? 'Resume a live stream' : 'Resume your stream';
++ dock.querySelector('.bcs-golive-where').textContent = liveMine.length > 1 ? `${liveMine.length} of your streams are live` : `"${(s && s.title) || 'Your stream'}" is still live`;
++ dock.querySelector('.bcs-golive-btn').disabled = false;
++ } else if (mode === 'golive') {
++ dock.querySelector('.bcs-golive-btn i').className = 'fa-solid fa-tower-broadcast';
++ dock.querySelector('.bcs-golive-t').textContent = 'Go live';
const where = real.parentElement && real.parentElement.querySelector('.bc-create-reassurance');
dock.querySelector('.bcs-golive-where').textContent = where ? where.textContent.trim() : '';
dock.querySelector('.bcs-golive-btn').disabled = !!real.disabled;
}
}
-- document.body.classList.toggle('bc-golive-dock-on', show);
++ document.body.classList.toggle('bc-golive-dock-on', !!mode);
}
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex ac31bde3..bef8c834 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -559,9 +559,9 @@ function _wsRenderPanel() {
<!-- Class B: Device-local preferences (browser only) -->
<div id="bc-ws-device-wrap">
<div class="bc-ws-device-local-label">
-- <i class="fa-solid fa-laptop"></i>
-- <strong>This Device Only</strong>
-- <span class="muted">&mdash; camera &amp; mic selection is saved after going live</span>
++ <i class="fa-solid fa-mobile-screen-button"></i>
++ <strong>On this device</strong>
++ <span class="muted">&mdash; your camera and mic are remembered here</span>
</div>
<div id="bc-perm-request" class="bc-perm-request">
<p class="bc-perm-hint"><i class="fa-solid fa-shield-halved"></i>
@@@ -585,7 +585,7 @@ function _wsRenderPanel() {
<div class="bc-ws-device-row">
<div class="form-group" style="margin:0;flex:1" id="bc-ws-camera-group">
<label style="font-size:0.82rem"><i class="fa-solid fa-camera"></i> Camera</label>
-- <div style="display:flex;gap:4px;align-items:center">
++ <div class="bc-cam-select-row" style="display:flex;gap:4px;align-items:center">
<select id="bc-create-camera" class="form-input form-input-sm" style="flex:1">
<option value="default">Default</option>
</select>
@@@ -1449,6 +1449,7 @@ async function _wsInitDevicePicker(method, browserMode) {
// Restore persisted device selections for this slot (localStorage first, then server profile)
_wsRestoreSlotDevices();
_wsRestoreDevicesFromProfile();
++ if (typeof _bcRenderCameraChooser === 'function') _bcRenderCameraChooser();
} else {
if (permReq) permReq.style.display = '';
if (devSelects) devSelects.style.display = 'none';
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 135be778..d094483f 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -1236,21 +1236,49 @@ async function loadExistingStreams(excludeStreamId) {
if (!activeListEl) return;
try {
-- // Use the /mine endpoint to get all user's streams
-- const data = await api('/streams/mine');
++ // Use the /mine endpoint to get all user's streams (the newest ended ones with their replays)
++ const data = await api('/streams/mine?limit=30&vods=1');
// Exclude the just-ended stream (background DELETE may not have fired yet)
const all = (data.streams || []).filter(s => !excludeStreamId || s.id !== excludeStreamId);
--
-- // Only show ACTIVE streams in the top section
++
++ // The live ones as cards, then the last few streams with their replays: the tab was one small card on an
++ // empty screen (owner, 2026-10-27).
const live = all.filter(s => s.is_live);
-- activeListEl.innerHTML = live.map(s => renderStreamItem(s)).join('');
++ const ended = all.filter(s => !s.is_live).slice(0, 4);
++ activeListEl.innerHTML = live.map(s => renderStreamItem(s)).join('')
++ + (ended.length ? `<div class="bc-last-streams"><h3><i class="fa-solid fa-clock-rotate-left"></i> Your last streams</h3>${ended.map(_renderEndedStream).join('')}</div>` : '');
++ window._bcMyLive = live;
++ window._bcMyLoaded = true;
_updateBroadcastTopTabs(live.length, live);
} catch (err) {
console.error('[Broadcast] Failed to load active streams:', err);
++ window._bcMyLoaded = true;
_updateBroadcastTopTabs(0);
}
}
++/** How long ago, in words ("3 min", "2 h", "Oct 25"). */
++function _bcAgo(iso) {
++ const t = iso ? Date.parse(String(iso).replace(' ', 'T') + (String(iso).includes('Z') ? '' : 'Z')) : NaN;
++ if (!Number.isFinite(t)) return '';
++ const m = Math.round((Date.now() - t) / 60000);
++ if (m < 1) return 'just now';
++ if (m < 60) return `${m} min ago`;
++ if (m < 24 * 60) return `${Math.round(m / 60)} h ago`;
++ return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
++}
++function _renderEndedStream(s) {
++ const len = s.duration_seconds ? formatDuration(s.duration_seconds) : (s.vod_duration ? formatDuration(s.vod_duration) : '');
++ const thumb = s.vod_thumbnail_url
++ ? `<img src="${esc(s.vod_thumbnail_url)}" alt="" loading="lazy" onerror="this.remove()">`
++ : '<i class="fa-solid fa-video" aria-hidden="true"></i>';
++ const replay = s.vod_id
++ ? `<a class="bc-last-replay" href="${esc(videoHref('vod', s.vod_id))}"><i class="fa-solid fa-play"></i> Replay</a>`
++ : '';
++ return `<div class="bc-last-item"><span class="bc-last-thumb">${thumb}</span>
++ <span class="bc-last-meta"><b>${esc(s.title || 'Untitled')}</b><small>${esc(_bcAgo(s.ended_at || s.started_at))}${len ? ` · ${esc(len)}` : ''}</small></span>${replay}</div>`;
++}
++
/** Switch the top-level Go Live tab ('active' | 'mine'). */
function switchBroadcastTopTab(tab) {
window._bcTopTab = tab;
@@@ -1293,6 +1321,7 @@ function _updateBroadcastTopTabs(activeCount, liveStreams = []) {
}
function renderStreamItem(s) {
++ if (s.is_live) return _renderLiveStreamCard(s);
const badge = s.is_live ? '<span class="badge badge-live">LIVE</span>' : '<span class="badge badge-offline">Ended</span>';
const time = s.started_at ? new Date(s.started_at.replace(' ', 'T') + 'Z').toLocaleString() : '';
const duration = s.duration_seconds ? formatDuration(s.duration_seconds) : '';
@@@ -1310,6 +1339,30 @@ function renderStreamItem(s) {
</div>`;
}
++/**
++ * A stream that is live right now: how long and how many are watching, and what to do with it — resume it here (this
++ * page lost it: a reload, another device), end it, or open the channel.
++ */
++function _renderLiveStreamCard(s) {
++ const started = s.started_at ? Date.parse(String(s.started_at).replace(' ', 'T') + 'Z') : NaN;
++ const mins = Number.isFinite(started) ? Math.max(0, Math.round((Date.now() - started) / 60000)) : null;
++ const forTime = mins == null ? '' : mins < 60 ? `${mins} min` : `${Math.floor(mins / 60)} h ${mins % 60} min`;
++ const here = broadcastState.streams.has(s.id);
++ const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : '';
++ const via = { webrtc: 'from a browser', whip: 'from an app (WHIP)', rtmp: 'from OBS or an app (RTMP)', jsmpeg: 'from a command line' }[s.protocol] || '';
++ return `<div class="bc-live-card">
++ <div class="bc-live-card-top"><span class="bc-live-card-pill"><span class="bc-live-card-dot" aria-hidden="true"></span>Live${forTime ? ` · ${forTime}` : ''}</span>
++ <span class="bc-live-card-watch"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${Number(s.viewer_count) || 0} watching</span></div>
++ <h3 class="bc-live-card-title">${esc(s.title || 'Untitled')}</h3>
++ <p class="bc-live-card-sub">${via ? `Streaming ${esc(via)}` : 'Streaming'}${here ? ' · on this page' : ''}</p>
++ <div class="bc-live-card-acts">
++ <button class="btn btn-primary bc-live-card-main" onclick="resumeStream(${s.id})"><i class="fa-solid fa-play"></i> ${here ? 'Back to the stream' : 'Resume here'}</button>
++ <button class="btn btn-outline" onclick="endExistingStream(${s.id})"><i class="fa-solid fa-stop"></i> End</button>
++ ${user ? `<a class="btn btn-ghost" href="/@${encodeURIComponent(user)}" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square"></i> Channel</a>` : ''}
++ </div>
++ </div>`;
++}
++
async function resumeStream(streamId) {
try {
const data = await api(`/streams/${streamId}`);
@@@ -4484,6 +4537,11 @@ function broadcastViewerPlaces() {
return [{ key: 'openvibe', name: 'OpenVibe', count: here }, ..._serverViewerPlaces.filter((p) => p.key !== 'openvibe')];
}
++/** The info bar's count opens where people are watching (viewer-places.js), as the phone studio's pill does. */
++function toggleBroadcastViewerPlaces(anchor) {
++ if (window.OVViewerPlaces) OVViewerPlaces.toggle(anchor, broadcastViewerPlaces, { below: true });
++}
++
function _updateTotalViewerCount() {
const places = broadcastViewerPlaces();
const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 967acdb3..98ccd327 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1178,6 +1178,11 @@ router.get('/mine', requireAuth, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const streams = await db.getStreamsByUserId(req.user.id, limit);
++ // ?vods=1: the newest ended streams carry their public VOD (the Go Live page's "Your last streams" replays).
++ if (req.query.vods === '1') {
++ const ended = streams.filter((s) => !s.is_live).slice(0, 8);
++ try { await require('../media-proxy/lookups').attachPublicVods(ended); } catch { /* the list still shows, without replays */ }
++ }
res.json({ streams });
} catch (err) {
console.error('[Streams] My streams error:', err.message);
ddiff --git a/test/broadcast-cameras.test.js b/test/broadcast-cameras.test.js
nnew file mode 100644
iindex 00000000..7786b16d
---- /dev/null
++++ b/test/broadcast-cameras.test.js
@@@ -0,0 +1,55 @@
++/**
++ * Cameras by name (public/js/broadcast-devices.js _bcCameraChoices; owner, 2026-10-27: "camera 1, facing front …
++ * unformatted camera names is trash"), the go-live chooser over the camera select, and the Go Live page's phone bar
++ * (broadcast-studio.js syncGoLiveDock), which no longer says "Go live" while a stream of theirs is live.
++ *
++ * node test/broadcast-cameras.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++const devices = read('public/js/broadcast-devices.js');
++const fn = (src, name) => {
++ const start = src.indexOf(`function ${name}(`);
++ assert.ok(start >= 0, name);
++ let i = src.indexOf('{', src.indexOf(') {', start)), depth = 0; // the body, past a destructured parameter
++ for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }
++ return src.slice(start, i + 1);
++};
++// eslint-disable-next-line no-new-func
++const choices = new Function(`${fn(devices, '_getCameraFacingHint')}\n${fn(devices, '_bcCameraChoices')}; return _bcCameraChoices;`)();
++const dev = (deviceId, label) => ({ kind: 'videoinput', deviceId, label });
++
++// Android lists its cameras in no useful order; "camera 0, facing back" is the main one. A camera listed twice is one.
++const phone = choices([dev('a', 'camera 1, facing front'), dev('b', 'camera 3, facing front'), dev('c', 'camera 2, facing back'), dev('d', 'camera 0, facing back'), dev('e', 'camera 0, facing back')]);
++assert.deepStrictEqual(phone.map((c) => [c.deviceId, c.name]), [['a', 'Front camera'], ['b', 'Front camera 2'], ['d', 'Back camera'], ['c', 'Back camera 2']]);
++assert.deepStrictEqual(phone.map((c) => c.facing), ['user', 'user', 'environment', 'environment']);
++// iOS and older Android names.
++assert.deepStrictEqual(choices([dev('f', 'Front Camera'), dev('r', 'Back Camera'), dev('u', 'Back Ultra Wide Camera')]).map((c) => c.name), ['Front camera', 'Back camera', 'Back camera 2']);
++// A computer's cameras keep their names, without the USB ids.
++assert.deepStrictEqual(choices([dev('x', 'HD Pro Webcam C920 (046d:082d)'), dev('y', 'Integrated Camera (04f2:b6dd)'), dev('z', 'OBS Virtual Camera')]).map((c) => c.name),
++ ['HD Pro Webcam C920', 'Integrated Camera', 'OBS Virtual Camera']);
++// No labels (no permission yet): nothing to name.
++assert.deepStrictEqual(choices([dev('', ''), dev('', '')]), []);
++
++// The chooser: over the select (still the source of truth), Front | Back with lens chips on a phone, cards elsewhere,
++// remembered per side; "Default" is replaced by a real camera.
++const render = fn(devices, '_bcRenderCameraChooser');
++assert.ok(render.includes("['user', 'Front', 'fa-user'], ['environment', 'Back', 'fa-mountain-sun']"));
++assert.ok(render.includes("i === 0 ? 'Main' : String(i + 1)"), 'a chip per camera on the chosen side');
++assert.ok(render.includes('_syncCameraSelectionUI((phone && sides.user[0] ? sides.user[0] : choices[0]).deviceId, { persist: false })'), 'no "Default"');
++assert.ok(/localStorage\.setItem\(`bc-last-camera-\$\{facing\}`, normalized\)/.test(fn(devices, '_syncCameraSelectionUI')), 'the last camera on each side is remembered');
++assert.ok(read('public/js/broadcast-workspace.js').includes('<div class="bc-cam-select-row"'), 'the select row the chooser replaces');
++
++// The Go Live page's phone bar: nothing before the streams are known; "Resume your stream" while one of theirs is live.
++const dock = fn(read('public/js/broadcast-studio.js'), 'syncGoLiveDock');
++assert.ok(dock.includes('!!window._bcMyLoaded'), 'no bar before the page knows the streams (it flashed "Go live" on every load)');
++assert.ok(dock.includes("mode = 'resume'") && dock.includes("'Resume your stream'"));
++const bc = read('public/js/broadcast.js');
++assert.ok(bc.includes("api('/streams/mine?limit=30&vods=1')") && bc.includes('window._bcMyLive = live;'));
++assert.ok(bc.includes('if (s.is_live) return _renderLiveStreamCard(s);'));
++assert.ok(read('server/streaming/routes.js').includes("if (req.query.vods === '1') {"), 'the last streams carry their replays');
++console.log('broadcast cameras: all checks passed');
ddiff --git a/test/broadcast-studio.test.js b/test/broadcast-studio.test.js
iindex d5b30f66..a26b4cf4 100644
---- a/test/broadcast-studio.test.js
++++ b/test/broadcast-studio.test.js
@@@ -169,7 +169,8 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
await check('before going live on a phone, a "Go live" bar presses the slot\'s own button while it is off screen', async () => {
const dock = extract(studioSrc, 'syncGoLiveDock');
assert.ok(/NARROW\.matches && onBroadcastPage\(\) && !localStream\(\)/.test(dock), 'phones, on /broadcast, not live yet');
-- assert.ok(/getBoundingClientRect\(\)/.test(dock) && /const show = !!real && !inView/.test(dock), 'only while the real button is out of view');
++ assert.ok(/real = goLiveButton\(\);\s*if \(real && !inView\(real\)\) mode = 'golive';/.test(dock), 'only while the real button is out of view');
++ assert.ok(/function inView\(n\) \{[^}]*getBoundingClientRect\(\)/.test(studioSrc));
assert.ok(/b && !b\.disabled\) b\.click\(\)/.test(dock), 'it clicks the real Go Live button (same checks, same settings)');
assert.ok(/id="bc-ws-golive-btn"/.test(read('public/js/broadcast-workspace.js')), 'the button it presses exists');
});