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)
Changes
@@@ -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 orshow 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.@@@ -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; }@@@ -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 };})();@@@ -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',@@@ -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 || '');@@@ -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'));
@@ -35,9 +35,11 @@ You don't need any of the following:
@@ -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 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
- 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.
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.
your restreams, stream health, all settings and the full page.
- **End** asks before it ends the stream.
- **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.
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.
@@ -2494,31 +2494,36 @@ body.bc-studio-on .bc-mobile-chat-fab,
@@ -2494,31 +2494,36 @@ body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on #navbar-mount,
body.bc-studio-on #navbar-mount,
body.bc-studio-on .navbar { display: none !important; }
body.bc-studio-on .navbar { display: none !important; }
/* "Reconnecting…" stays visible, just above the chat instead of over its input. */
/* "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. */
/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */
body.bc-studio-on #bc-chat-sidebar {
body.bc-studio-on #bc-chat-sidebar {
display: flex !important; flex-direction: column; position: fixed; z-index: 1210;
display: flex !important; flex-direction: column; position: fixed; z-index: 1210;
left: 0; right: 76px; bottom: 0; top: auto; width: auto; max-width: none;
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);
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;
background: none; border: 0; border-radius: 0; box-shadow: none; transform: none; transition: none;
}
}
body.bc-studio-on #bc-chat-sidebar::before {
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-header,
body.bc-studio-on #bc-chat-sidebar .chat-users-panel,
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-sidebar .chat-tools-row { display: none !important; }
body.bc-studio-on #bc-chat-messages {
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%);
-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 {
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-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
body.bc-studio-on #bc-chat-input {
body.bc-studio-on #bc-chat-input {
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
@@ -2664,3 +2669,14 @@ body.bc-studio-on .toast-container { z-index: 1500; }
@@ -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-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; }
.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; }
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; }
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
// A phone held upright, or turned sideways (wide but short, with a touch screen).
// 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 NARROW = window.matchMedia('(max-width: 760px), (pointer: coarse) and (max-height: 520px)');
const st = {
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,
dismissedFor: null, hadLocal: false, sheet: null, confirm: null, parked: [], cameras: 0, lastFocus: null,
};
};
@@ -121,18 +121,50 @@
@@ -121,18 +121,50 @@
createBroadcastClip();
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;
const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : null;
if (!user) return;
if (!user) return;
const url = `${location.origin}/@${encodeURIComponent(user)}`;
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 ──
// ── The overlay ──
function railButton(act, ico, label, onClick) {
function railButton(act, ico, label, onClick) {
@@ -177,6 +209,7 @@
@@ -177,6 +209,7 @@
function unmount() {
function unmount() {
closeSheet();
closeSheet();
closeConfirm();
closeConfirm();
+ closeViewer();
document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
document.body.classList.remove('bc-studio-on', 'bc-studio-chat-off');
if (st.root) st.root.remove();
if (st.root) st.root.remove();
st.root = null;
st.root = null;
@@ -343,7 +376,7 @@
@@ -343,7 +376,7 @@
el('div', { class: 'bcs-grab', 'aria-hidden': 'true' }),
el('div', { class: 'bcs-grab', 'aria-hidden': 'true' }),
el('div', { class: 'bcs-sheet-head' },
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('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'))),
el('button', { type: 'button', class: 'bcs-x', 'aria-label': 'Close', onclick: closeSheet }, icon('fa-xmark'))),
grid, voice, st.parked.length ? restreams : null);
grid, voice, st.parked.length ? restreams : null);
st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
st.sheet = el('div', { class: 'bcs-sheet-wrap', onclick: (e) => { if (e.target === st.sheet) closeSheet(); } }, sheet);
@@ -378,7 +411,8 @@
@@ -378,7 +411,8 @@
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (e.key !== 'Escape') return;
- if (st.confirm) closeConfirm();
+ if (st.viewer) closeViewer();
+ else if (st.confirm) closeConfirm();
else if (st.sheet) closeSheet();
else if (st.sheet) closeSheet();
});
});
@@ -429,5 +463,5 @@
@@ -429,5 +463,5 @@
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
ensureReturn();
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 };
})();
})();
@@ -6192,6 +6192,9 @@ function openViewerPreview() {
@@ -6192,6 +6192,9 @@ function openViewerPreview() {
const streamData = ss?.streamData || null;
const streamData = ss?.streamData || null;
const slot = streamData?.managed_stream_slug || streamData?.managed_stream_id || null;
const slot = streamData?.managed_stream_slug || streamData?.managed_stream_id || null;
const url = currentUser?.username ? channelPath(currentUser.username, slot) : '/';
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(
_viewerPreviewWindow = window.open(
url,
url,
'viewer-preview',
'viewer-preview',
@@ -4071,7 +4071,7 @@ function renderDeployCard(data, opts = {}) {
@@ -4071,7 +4071,7 @@ function renderDeployCard(data, opts = {}) {
const rest = commits.length - SHOW;
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>
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>
<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');
if (wasOpen) el.classList.add('is-open');
el.classList.toggle('is-collapsed', !wasExpanded);
el.classList.toggle('is-collapsed', !wasExpanded);
const head = el.querySelector('.chat-deploy-h');
const head = el.querySelector('.chat-deploy-h');
@@ -4079,12 +4079,20 @@ function renderDeployCard(data, opts = {}) {
@@ -4079,12 +4079,20 @@ function renderDeployCard(data, opts = {}) {
const more = el.querySelector('.chat-deploy-more');
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)); });
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');
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.
// 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);
if (!el.isConnected || opts.live) container.appendChild(el);
scrollChat();
scrollChat();
if (opts.live && _chatUserScrolledUp) _onNewChatMessageWhileScrolledUp();
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. */
/** Old rows were prose: "🚀 Server restarted (abc1234) — a · b · c (2m ago)". Pull the subjects back out. */
function _legacyDeploy(m) {
function _legacyDeploy(m) {
const t = String(m.message || '');
const t = String(m.message || '');
@@ -71,7 +71,7 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
@@ -71,7 +71,7 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
const ticks = [];
const ticks = [];
const els = {};
const els = {};
const g = {
const g = {
- window: { matchMedia: () => ({ matches: true, addEventListener() {} }) },
+ window: { matchMedia: () => ({ matches: true, addEventListener() {} }), addEventListener() {}, innerHeight: 900 },
document: {
document: {
getElementById: (id) => els[id] || null,
getElementById: (id) => els[id] || null,
createElement: fakeElement, createComment: () => fakeElement('#comment'),
createElement: fakeElement, createComment: () => fakeElement('#comment'),
@@ -174,6 +174,21 @@ function loadStudio({ stream, cameras = [], screenShare = false }) {
@@ -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');
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 () => {
await check('the studio loads with the broadcast feature, after broadcast.js', async () => {
const js = features.broadcast.js;
const js = features.broadcast.js;
assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));
assert.ok(js.indexOf('/js/broadcast-studio.js') > js.indexOf('/js/broadcast.js'));