openvibers/openvibe.live Commits

Broadcast studio: nothing takes a phone off the live tab (no Reload to update for the broadcaster, the viewer preview opens over the page muted, Copy link instead of the share sheet, leaving asks first, a note after coming back); the chat keeps to the bottom quarter without update cards (#160)

OP OpenVibers committed 28 days ago · 4643576481 · 6 files, +101 −23

Changes

ddiff --git a/docs/go-live-in-your-browser.md b/docs/go-live-in-your-browser.md
iindex 64fc9f33..65bdfcbf 100644
---- a/docs/go-live-in-your-browser.md
++++ b/docs/go-live-in-your-browser.md
@@@ -35,9 +35,11 @@ You don't need any of the following:
- The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection.
- The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide or
show it), Clip (the last 30 seconds) and More.
--- **More** opens share, watch as a viewer, choose any camera, media requests, a one-minute clip, chat read aloud,
++- **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip, chat read aloud,
your restreams, stream health, all settings and the full page.
- **End** asks before it ends the stream.
++- Keep the OpenVibe tab open and in front while you are live. A phone pauses the camera in other tabs and apps, and
++ the stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab.
Your stream reaches viewers over **WebRTC**, with less than a second of delay. That is fast enough for a real conversation with chat, and for viewers to control a robot or camera in real time.
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex ee47921f..ec7bb767 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2494,31 +2494,36 @@ body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on #navbar-mount,
body.bc-studio-on .navbar { display: none !important; }
/* "Reconnecting…" stays visible, just above the chat instead of over its input. */
--body.bc-studio-on .ov-conn-pill { left: calc(50% - 38px); bottom: calc(min(40vh, 340px) + 10px + env(safe-area-inset-bottom, 0px)); z-index: 1290; }
++body.bc-studio-on .ov-conn-pill { left: calc(50% - 38px); bottom: calc(min(24vh, 180px) + 10px + env(safe-area-inset-bottom, 0px)); z-index: 1290; }
/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */
body.bc-studio-on #bc-chat-sidebar {
display: flex !important; flex-direction: column; position: fixed; z-index: 1210;
left: 0; right: 76px; bottom: 0; top: auto; width: auto; max-width: none;
-- height: min(40vh, 340px); height: min(40dvh, 340px); max-height: none;
++ height: min(24vh, 180px); height: min(24dvh, 180px); max-height: none;
padding: 0 0 env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
background: none; border: 0; border-radius: 0; box-shadow: none; transform: none; transition: none;
}
body.bc-studio-on #bc-chat-sidebar::before {
-- content: ''; position: absolute; left: 0; right: -76px; bottom: 0; height: 130%; z-index: -1; pointer-events: none;
-- background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.6));
++ content: ''; position: absolute; left: 0; right: -76px; bottom: 0; height: 100%; z-index: -1; pointer-events: none;
++ background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.45));
}
body.bc-studio-on #bc-chat-sidebar .chat-header,
body.bc-studio-on #bc-chat-sidebar .chat-users-panel,
body.bc-studio-on #bc-chat-sidebar .chat-tools-row { display: none !important; }
body.bc-studio-on #bc-chat-messages {
-- flex: 1; min-height: 0; background: none; padding: 0 10px 4px; overscroll-behavior: contain;
++ flex: 1; min-height: 0; background: none; border: 0; padding: 0 10px 4px; overscroll-behavior: contain; scrollbar-width: none;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 28%); mask-image: linear-gradient(to bottom, transparent, #000 28%);
}
++body.bc-studio-on #bc-chat-messages::-webkit-scrollbar { display: none; }
body.bc-studio-on #bc-chat-messages .chat-msg {
-- width: fit-content; max-width: 100%; margin: 3px 0; padding: 4px 9px; border-radius: 12px;
-- background: rgba(0,0,0,0.38); color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.85);
++ width: fit-content; max-width: 88%; margin: 2px 0; padding: 3px 9px; border-radius: 12px; font-size: 0.86rem; line-height: 1.3;
++ background: rgba(0,0,0,0.42); color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.85);
}
++/* Site update cards and chat connection lines are for viewers; a streamer on the camera gets neither (the card's
++ reload button would end the stream, and the top bar already shows the connection). */
++body.bc-studio-on #bc-chat-messages .chat-deploy,
++body.bc-studio-on #bc-chat-messages .chat-conn-status { display: none !important; }
body.bc-studio-on #bc-chat-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
body.bc-studio-on #bc-chat-input {
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
@@@ -2664,3 +2669,14 @@ body.bc-studio-on .toast-container { z-index: 1500; }
.bcs-golive-btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
.bcs-golive-where { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body.bc-golive-dock-on .messenger-toggle { display: none !important; }
++
++/* What viewers see, over the studio (bcStudio.showViewer): a phone stays on this tab. */
++.bcs-viewer {
++ display: flex; flex-direction: column; width: 100%; max-width: 560px; height: min(80vh, 760px); height: min(80dvh, 760px);
++ border-radius: 18px; overflow: hidden; background: var(--bg-secondary, #15151c); color: var(--text-primary, #f4f4f5);
++ box-shadow: 0 20px 60px rgba(0,0,0,0.5);
++}
++.bcs-viewer-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px 10px 16px; }
++.bcs-viewer-head > div { display: grid; }
++.bcs-viewer-head small { color: var(--text-secondary, #a1a1aa); font-size: 0.76rem; }
++.bcs-viewer-frame { flex: 1; width: 100%; border: 0; background: #000; }
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 157b74fb..56e70dbc 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -20,7 +20,7 @@
// A phone held upright, or turned sideways (wide but short, with a touch screen).
const NARROW = window.matchMedia('(max-width: 760px), (pointer: coarse) and (max-height: 520px)');
const st = {
-- root: null, micOff: false, camOff: false, torch: false, chatHidden: false, busy: false,
++ root: null, micOff: false, camOff: false, torch: false, chatHidden: false, busy: false, viewer: null, viewerMute: 0,
dismissedFor: null, hadLocal: false, sheet: null, confirm: null, parked: [], cameras: 0, lastFocus: null,
};
@@@ -121,18 +121,50 @@
createBroadcastClip();
}
-- async function share() {
++ /** Copies the channel link. (The system share sheet takes a phone away from this tab, which pauses the camera.) */
++ function share() {
const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : null;
if (!user) return;
const url = `${location.origin}/@${encodeURIComponent(user)}`;
-- const ss = streamState();
-- const title = (ss && ss.streamData && ss.streamData.title) || 'My stream';
-- if (navigator.share) {
-- try { await navigator.share({ title, url }); return; } catch (err) { if (err && err.name === 'AbortError') return; }
-- }
-- if (typeof copyToClipboard === 'function') copyToClipboard(url);
-- else if (navigator.clipboard) navigator.clipboard.writeText(url).then(() => say('Link copied', 'success'), () => {});
++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(() => say('Link copied. Paste it anywhere to share your stream.', 'success'), () => say(url));
++ else if (typeof copyToClipboard === 'function') copyToClipboard(url);
++ else say(url);
++ }
++
++ // ── Staying on this tab ──
++ // A phone pauses the camera of a tab it leaves, and the stream ends a minute later: every way out of the page
++ // while live either stays in it (the viewer preview below), asks first (leaving), or says what happened.
++ /** What viewers see, over the page: the channel in a frame, its sound off (the phone's mic would hear it). */
++ function showViewer(url) {
++ closeViewer();
++ const frame = el('iframe', { class: 'bcs-viewer-frame', src: url, title: 'Your stream, as viewers see it' });
++ const box = el('div', { class: 'bcs-viewer', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'What viewers see' },
++ el('div', { class: 'bcs-viewer-head' },
++ el('div', null, el('b', { text: 'What viewers see' }), el('small', { text: 'A few seconds behind you, sound off' })),
++ el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeViewer }, icon('fa-xmark'))),
++ frame);
++ st.viewer = el('div', { class: 'bcs-sheet-wrap bcs-center', onclick: (e) => { if (e.target === st.viewer) closeViewer(); } }, box);
++ document.body.append(st.viewer);
++ st.viewerMute = setInterval(() => {
++ try { for (const m of frame.contentDocument.querySelectorAll('video, audio')) { m.muted = true; m.volume = 0; } } catch { /* not loaded yet */ }
++ }, 400);
}
++ function closeViewer() {
++ clearInterval(st.viewerMute);
++ if (st.viewer) { st.viewer.remove(); st.viewer = null; }
++ }
++ window.addEventListener('beforeunload', (e) => {
++ if (!localStream()) return;
++ e.preventDefault();
++ e.returnValue = 'You are live. Leaving this page ends your stream.';
++ });
++ let hiddenAt = 0;
++ document.addEventListener('visibilitychange', () => {
++ if (!localStream()) { hiddenAt = 0; return; }
++ if (document.hidden) { hiddenAt = Date.now(); return; }
++ if (hiddenAt && NARROW.matches && Date.now() - hiddenAt > 3000) say('Keep this tab open in front while you are live. A phone pauses the camera in other tabs and apps, and the stream ends after a minute.');
++ hiddenAt = 0;
++ });
// ── The overlay ──
function railButton(act, ico, label, onClick) {
@@@ -177,6 +209,7 @@
function unmount() {
closeSheet();
closeConfirm();
++ closeViewer();
document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
if (st.root) st.root.remove();
st.root = null;
@@@ -343,7 +376,7 @@
el('div', { class: 'bcs-grab', 'aria-hidden': 'true' }),
el('div', { class: 'bcs-sheet-head' },
el('div', { class: 'bcs-sheet-who' }, el('b', { id: 'bcs-sheet-title', text: title }), user ? el('small', { text: `openvibe.live/@${user}` }) : null),
-- el('button', { type: 'button', class: 'bcs-chip bcs-chip-strong', onclick: share }, icon('fa-share-nodes'), ' Share'),
++ el('button', { type: 'button', class: 'bcs-chip bcs-chip-strong', onclick: share }, icon('fa-link'), ' Copy link'),
el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeSheet }, icon('fa-xmark'))),
grid, voice, st.parked.length ? restreams : null);
st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
@@@ -378,7 +411,8 @@
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
-- if (st.confirm) closeConfirm();
++ if (st.viewer) closeViewer();
++ else if (st.confirm) closeConfirm();
else if (st.sheet) closeSheet();
});
@@@ -429,5 +463,5 @@
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
ensureReturn();
-- window.bcStudio = { toggleMic, toggleCamera, flip, open: () => { st.dismissedFor = null; tick(); }, _state: st };
++ window.bcStudio = { toggleMic, toggleCamera, flip, showViewer, phone: () => NARROW.matches, open: () => { st.dismissedFor = null; tick(); }, _state: st };
})();
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 89898dde..5ec4d7d2 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -6192,6 +6192,9 @@ function openViewerPreview() {
const streamData = ss?.streamData || null;
const slot = streamData?.managed_stream_slug || streamData?.managed_stream_id || null;
const url = currentUser?.username ? channelPath(currentUser.username, slot) : '/';
++ // On a phone a popup is a new tab, and a phone pauses the camera of the tab left behind: the stream ended
++ // (owner, 10-16). There the preview opens over the page instead (broadcast-studio.js).
++ if (window.bcStudio && bcStudio.phone() && getStreamState(streamId)?.localStream) return bcStudio.showViewer(url);
_viewerPreviewWindow = window.open(
url,
'viewer-preview',
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex a78cbafd..04f0cfd6 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -4071,7 +4071,7 @@ function renderDeployCard(data, opts = {}) {
const rest = commits.length - SHOW;
el.innerHTML = `<button type="button" class="chat-deploy-h" aria-expanded="${wasExpanded ? 'true' : 'false'}"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span></button>
<ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : '<i></i>'}<span>${esc(c.subject)}</span></li>`).join('')}</ul>
-- <div class="chat-deploy-f">${rest > 0 ? `<button type="button" class="chat-deploy-more" aria-expanded="${wasOpen ? 'true' : 'false'}">${wasOpen ? 'Show fewer' : `Show ${rest} more`}</button>` : ''}<span class="chat-deploy-a"><a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload to update</button>' : ''}</span></div>`;
++ <div class="chat-deploy-f">${rest > 0 ? `<button type="button" class="chat-deploy-more" aria-expanded="${wasOpen ? 'true' : 'false'}">${wasOpen ? 'Show fewer' : `Show ${rest} more`}</button>` : ''}<span class="chat-deploy-a"><a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live && !_broadcastingHere() ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload to update</button>' : ''}</span></div>`;
if (wasOpen) el.classList.add('is-open');
el.classList.toggle('is-collapsed', !wasExpanded);
const head = el.querySelector('.chat-deploy-h');
@@@ -4079,12 +4079,20 @@ function renderDeployCard(data, opts = {}) {
const more = el.querySelector('.chat-deploy-more');
if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show fewer' : `Show ${rest} more`; more.setAttribute('aria-expanded', String(open)); });
const reload = el.querySelector('.chat-deploy-reload');
-- if (reload) reload.addEventListener('click', () => { location.href = location.pathname + '?_=' + Date.now(); });
++ if (reload) reload.addEventListener('click', () => {
++ // Reloading the broadcast page ends a stream sent from this browser (the owner lost one that way, 10-16).
++ if (_broadcastingHere()) { if (typeof toast === 'function') toast('Reloading would end your stream. Reload after you end it.', 'info'); return; }
++ location.href = location.pathname + '?_=' + Date.now();
++ });
// A live update moves the card to the end (it IS the newest message); history keeps its position.
if (!el.isConnected || opts.live) container.appendChild(el);
scrollChat();
if (opts.live && _chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
}
++/** Is this browser sending a live stream (the broadcast page's camera or screen)? Then nothing here may reload it. */
++function _broadcastingHere() {
++ try { return typeof broadcastState !== 'undefined' && [...broadcastState.streams.values()].some((s) => s && s.localStream); } catch { return false; }
++}
/** Old rows were prose: "🚀 Server restarted (abc1234) — a · b · c (2m ago)". Pull the subjects back out. */
function _legacyDeploy(m) {
const t = String(m.message || '');
ddiff --git a/test/broadcast-studio.test.js b/test/broadcast-studio.test.js
iindex 360fec97..fcf6397c 100644
---- a/test/broadcast-studio.test.js
++++ b/test/broadcast-studio.test.js
@@@ -71,7 +71,7 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
const ticks = [];
const els = {};
const g = {
-- window: { matchMedia: () => ({ matches: true, addEventListener() {} }) },
++ window: { matchMedia: () => ({ matches: true, addEventListener() {} }), addEventListener() {}, innerHeight: 900 },
document: {
getElementById: (id) => els[id] || null,
createElement: fakeElement, createComment: () => fakeElement('#comment'),
@@@ -174,6 +174,21 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
assert.ok(/id="bc-ws-golive-btn"/.test(read('public/js/broadcast-workspace.js')), 'the button it presses exists');
});
++ await check('nothing in the studio takes a phone off the live tab (that ended the owner\'s stream, 10-16)', async () => {
++ const chat = read('public/js/chat.js');
++ assert.ok(/opts\.live && !_broadcastingHere\(\) \? '<button type="button" class="chat-deploy-reload"/.test(chat), 'no "Reload to update" for the broadcaster');
++ assert.ok(/if \(_broadcastingHere\(\)\) \{[^}]*Reloading would end your stream/.test(chat), 'and a reload click is refused while broadcasting');
++ const viewer = extract(bc, 'openViewerPreview');
++ assert.ok(/bcStudio\.phone\(\) && getStreamState\(streamId\)\?\.localStream\) return bcStudio\.showViewer\(url\)/.test(viewer), 'the viewer preview opens in the page on a phone');
++ const show = extract(studioSrc, 'showViewer');
++ assert.ok(/m\.muted = true/.test(show), 'with its sound off (the mic would hear it)');
++ assert.ok(!/navigator\.share/.test(studioSrc), 'Copy link, not the system share sheet');
++ assert.ok(/addEventListener\('beforeunload'[\s\S]{0,120}localStream\(\)[\s\S]{0,80}preventDefault/.test(studioSrc), 'leaving while live asks first');
++ const css = read('public/css/features/broadcast.css');
++ assert.ok(/body\.bc-studio-on #bc-chat-messages \.chat-deploy/.test(css), 'update cards stay out of the studio chat');
++ assert.ok(/height: min\(24dvh, 180px\)/.test(css), 'the studio chat keeps to the bottom quarter');
++ });
++
await check('the studio loads with the broadcast feature, after broadcast.js', async () => {
const js = features.broadcast.js;
assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));