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)
Changes
@@@ -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; }++}@@@ -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>@@@ -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>';@@@ -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 });@@@ -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">— camera & 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">— 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';@@@ -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);@@@ -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);nnew file mode 100644@@@ -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');@@@ -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');});
@@ -2867,3 +2867,120 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
@@ -2867,3 +2867,120 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
.bc-clip-name { animation: none; }
.bc-clip-name { animation: none; }
.bc-clip-name-timer { animation: none; display: 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; }
+}
@@ -664,9 +664,9 @@ WantedBy=multi-user.target</code>
@@ -664,9 +664,9 @@ WantedBy=multi-user.target</code>
<!-- Info bar -->
<!-- Info bar -->
<div class="bc-info-bar" id="bc-info-bar" style="display:none">
<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">
<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
<i class="fa-solid fa-robot"></i> <strong>0</strong> on RS
</div>
</div>
@@ -142,10 +142,20 @@ function _setSelectValueIfPresent(select, value) {
@@ -142,10 +142,20 @@ function _setSelectValueIfPresent(select, value) {
let _switchingCamera = false;
let _switchingCamera = false;
function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
function _syncCameraSelectionUI(cameraId, { persist = true } = {}) {
const normalized = cameraId || 'default';
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-forceCamera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-create-camera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-create-camera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-screen-camera'), normalized);
_setSelectValueIfPresent(document.getElementById('bc-screen-camera'), normalized);
broadcastState.settings.forceCamera = normalized;
broadcastState.settings.forceCamera = normalized;
+ _bcRenderCameraChooser();
if (persist) {
if (persist) {
saveBroadcastSettings();
saveBroadcastSettings();
try { localStorage.setItem('bc-last-camera', normalized); } catch {}
try { localStorage.setItem('bc-last-camera', normalized); } catch {}
@@ -252,6 +262,101 @@ function _bcCameraChoices(devices) {
@@ -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);
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 } = {}) {
async function _enumerateBroadcastVideoInputs({ ensureLabels = false } = {}) {
if (!navigator.mediaDevices?.enumerateDevices) return [];
if (!navigator.mediaDevices?.enumerateDevices) return [];
let tempStream = null;
let tempStream = null;
@@ -448,14 +553,16 @@ function _populateCreateDeviceDropdowns(devices) {
@@ -448,14 +553,16 @@ function _populateCreateDeviceDropdowns(devices) {
const audioSelect = document.getElementById('bc-create-audio');
const audioSelect = document.getElementById('bc-create-audio');
if (camSelect) {
if (camSelect) {
camSelect.innerHTML = '<option value="default">Default</option>';
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');
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);
camSelect.appendChild(opt);
- });
+ }
_syncCameraSelectionUI(_getPreferredCameraId(), { persist: false });
_syncCameraSelectionUI(_getPreferredCameraId(), { persist: false });
camSelect.onchange = () => _syncCameraSelectionUI(camSelect.value);
camSelect.onchange = () => _syncCameraSelectionUI(camSelect.value);
+ _bcRenderCameraChooser();
}
}
if (audioSelect) {
if (audioSelect) {
audioSelect.innerHTML = '<option value="default">Default</option>';
audioSelect.innerHTML = '<option value="default">Default</option>';
@@ -736,28 +736,49 @@
@@ -736,28 +736,49 @@
// The slot workspace puts the real Go Live button below the title, method, capture mode, quality and devices,
// 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.
// 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; }
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() {
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');
let dock = $('bcs-golive');
- if (show && !dock) {
+ if (mode && !dock) {
dock = el('div', { class: 'bcs-golive', id: 'bcs-golive' },
dock = el('div', { class: 'bcs-golive', id: 'bcs-golive' },
- el('button', { type: 'button', class: 'bcs-golive-btn', onclick: () => { 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' }));
el('small', { class: 'bcs-golive-where' }));
document.body.append(dock);
document.body.append(dock);
}
}
if (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');
const where = real.parentElement && real.parentElement.querySelector('.bc-create-reassurance');
dock.querySelector('.bcs-golive-where').textContent = where ? where.textContent.trim() : '';
dock.querySelector('.bcs-golive-where').textContent = where ? where.textContent.trim() : '';
dock.querySelector('.bcs-golive-btn').disabled = !!real.disabled;
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 });
document.addEventListener('scroll', () => { if ($('bcs-golive') || goLiveButton()) syncGoLiveDock(); }, { capture: true, passive: true });
@@ -559,9 +559,9 @@ function _wsRenderPanel() {
@@ -559,9 +559,9 @@ function _wsRenderPanel() {
<!-- Class B: Device-local preferences (browser only) -->
<!-- Class B: Device-local preferences (browser only) -->
<div id="bc-ws-device-wrap">
<div id="bc-ws-device-wrap">
<div class="bc-ws-device-local-label">
<div class="bc-ws-device-local-label">
- <i class="fa-solid fa-laptop"></i>
- <strong>This Device Only</strong>
- <span class="muted">— camera & 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">— your camera and mic are remembered here</span>
</div>
</div>
<div id="bc-perm-request" class="bc-perm-request">
<div id="bc-perm-request" class="bc-perm-request">
<p class="bc-perm-hint"><i class="fa-solid fa-shield-halved"></i>
<p class="bc-perm-hint"><i class="fa-solid fa-shield-halved"></i>
@@ -585,7 +585,7 @@ function _wsRenderPanel() {
@@ -585,7 +585,7 @@ function _wsRenderPanel() {
<div class="bc-ws-device-row">
<div class="bc-ws-device-row">
<div class="form-group" style="margin:0;flex:1" id="bc-ws-camera-group">
<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>
<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">
<select id="bc-create-camera" class="form-input form-input-sm" style="flex:1">
<option value="default">Default</option>
<option value="default">Default</option>
</select>
</select>
@@ -1449,6 +1449,7 @@ async function _wsInitDevicePicker(method, browserMode) {
@@ -1449,6 +1449,7 @@ async function _wsInitDevicePicker(method, browserMode) {
// Restore persisted device selections for this slot (localStorage first, then server profile)
// Restore persisted device selections for this slot (localStorage first, then server profile)
_wsRestoreSlotDevices();
_wsRestoreSlotDevices();
_wsRestoreDevicesFromProfile();
_wsRestoreDevicesFromProfile();
+ if (typeof _bcRenderCameraChooser === 'function') _bcRenderCameraChooser();
} else {
} else {
if (permReq) permReq.style.display = '';
if (permReq) permReq.style.display = '';
if (devSelects) devSelects.style.display = 'none';
if (devSelects) devSelects.style.display = 'none';
@@ -1236,21 +1236,49 @@ async function loadExistingStreams(excludeStreamId) {
@@ -1236,21 +1236,49 @@ async function loadExistingStreams(excludeStreamId) {
if (!activeListEl) return;
if (!activeListEl) return;
try {
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)
// Exclude the just-ended stream (background DELETE may not have fired yet)
const all = (data.streams || []).filter(s => !excludeStreamId || s.id !== excludeStreamId);
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);
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);
_updateBroadcastTopTabs(live.length, live);
} catch (err) {
} catch (err) {
console.error('[Broadcast] Failed to load active streams:', err);
console.error('[Broadcast] Failed to load active streams:', err);
+ window._bcMyLoaded = true;
_updateBroadcastTopTabs(0);
_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'). */
/** Switch the top-level Go Live tab ('active' | 'mine'). */
function switchBroadcastTopTab(tab) {
function switchBroadcastTopTab(tab) {
window._bcTopTab = tab;
window._bcTopTab = tab;
@@ -1293,6 +1321,7 @@ function _updateBroadcastTopTabs(activeCount, liveStreams = []) {
@@ -1293,6 +1321,7 @@ function _updateBroadcastTopTabs(activeCount, liveStreams = []) {
}
}
function renderStreamItem(s) {
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 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 time = s.started_at ? new Date(s.started_at.replace(' ', 'T') + 'Z').toLocaleString() : '';
const duration = s.duration_seconds ? formatDuration(s.duration_seconds) : '';
const duration = s.duration_seconds ? formatDuration(s.duration_seconds) : '';
@@ -1310,6 +1339,30 @@ function renderStreamItem(s) {
@@ -1310,6 +1339,30 @@ function renderStreamItem(s) {
</div>`;
</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) {
async function resumeStream(streamId) {
try {
try {
const data = await api(`/streams/${streamId}`);
const data = await api(`/streams/${streamId}`);
@@ -4484,6 +4537,11 @@ function broadcastViewerPlaces() {
@@ -4484,6 +4537,11 @@ function broadcastViewerPlaces() {
return [{ key: 'openvibe', name: 'OpenVibe', count: here }, ..._serverViewerPlaces.filter((p) => p.key !== 'openvibe')];
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() {
function _updateTotalViewerCount() {
const places = broadcastViewerPlaces();
const places = broadcastViewerPlaces();
const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);
const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);
@@ -1178,6 +1178,11 @@ router.get('/mine', requireAuth, async (req, res) => {
@@ -1178,6 +1178,11 @@ router.get('/mine', requireAuth, async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const streams = await db.getStreamsByUserId(req.user.id, limit);
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 });
res.json({ streams });
} catch (err) {
} catch (err) {
console.error('[Streams] My streams error:', err.message);
console.error('[Streams] My streams error:', err.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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');
@@ -169,7 +169,8 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
@@ -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 () => {
await check('before going live on a phone, a "Go live" bar presses the slot\'s own button while it is off screen', async () => {
const dock = extract(studioSrc, 'syncGoLiveDock');
const dock = extract(studioSrc, 'syncGoLiveDock');
assert.ok(/NARROW\.matches && onBroadcastPage\(\) && !localStream\(\)/.test(dock), 'phones, on /broadcast, not live yet');
assert.ok(/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(/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');
assert.ok(/id="bc-ws-golive-btn"/.test(read('public/js/broadcast-workspace.js')), 'the button it presses exists');
});
});