openvibers/openvibe.live Commits

PiP: listen to it instead of the stream, or pop it out of the browser (#812)

OP OpenVibers committed 2 days ago · 9a0b573c78 · 3 files, +35 −2

Ask 365 ("expand on this, be creative, come up with more ideas"):
- Listen (headphones, L): hear the PIP instead of the stream; the stream's video is muted while it lasts and gets its
  sound back after only if it had it; a ring on the PIP says it has the sound.
- Out of the browser (P), where the browser can: the system's own picture-in-picture window, on top of everything.
- On a small PIP both wait for a bigger one (a double tap makes it large); closing a PIP gives the sound back and
  closes its window.
- docs/broadcasting.md says both.

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 595b5dcb..dbe11688 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -111,6 +111,8 @@ camera goes out**):
- Throw it past a side (or press its »): it tucks into that side, a tab with its picture still going; a tap brings it back.
- Size it from its corner, with two fingers, or with a double tap (small and large); mute it; put it back where you put
it (↺).
++- Listen to it instead of the stream (the headphones: the stream is muted until they press it again), or pop it out of
++ the browser into the system's own picture-in-picture window, on top of everything (where the browser can).
- While it connects, or when its picture stops, it shows the OpenVibe mark at work over its last frame and tries again
by itself.
- Where they put it is kept per slot in their browser and, signed in, in their account, so it is there next time.
ddiff --git a/public/css/features/pip.css b/public/css/features/pip.css
iindex 2966334e..a3f8b89d 100644
---- a/public/css/features/pip.css
++++ b/public/css/features/pip.css
@@@ -74,6 +74,8 @@
.ov-pip-actions button:hover { background: rgba(255, 255, 255, .22); transform: translateY(-1px); }
.ov-pip-actions button:active { transform: scale(.92); }
.ov-pip-actions button[aria-pressed="true"] { background: var(--accent, #3b82f6); border-color: transparent; }
++/* Listening: this PIP has the sound; a ring says so while it lasts. */
++.ov-pip.is-listening { box-shadow: 0 0 0 2px var(--accent, #3b82f6), 0 10px 28px -8px rgba(0, 0, 0, .7); }
/* The corner that sizes it: a soft grip that shows on hover and is always there to a finger. */
.ov-pip-resize {
@@@ -126,6 +128,8 @@
.ov-pip-title { display: none; }
.ov-pip-bar { justify-content: flex-end; padding: 5px 5px 12px; }
.ov-pip-state-t { display: none; }
++ /* The two extras (listen, out of the browser) wait for a bigger PIP (a double tap makes it large). */
++ .ov-pip-actions .is-extra { display: none; }
/* Its wait keeps the whole picture: the bar comes up on a touch or a hover, not by itself. */
.ov-pip:not([data-state="live"]):not(:hover):not(.is-active):not(:focus-within) .ov-pip-bar { opacity: 0; pointer-events: none; }
}
ddiff --git a/public/js/stream-pip.js b/public/js/stream-pip.js
iindex 705857c2..1e870627 100644
---- a/public/js/stream-pip.js
++++ b/public/js/stream-pip.js
@@@ -290,6 +290,8 @@
<span class="ov-pip-title" title="${escapeHtml(label)}"><i class="fa-solid fa-video" aria-hidden="true"></i> ${href
? `<a href="${escapeHtml(href)}" onclick="return handleLinkClick(event, '${escapeHtml(href)}')">${escapeHtml(label)}</a>` : escapeHtml(label)}</span>
<span class="ov-pip-actions">
++ <button type="button" class="is-extra" data-act="listen" aria-pressed="false" title="Listen to ${escapeHtml(label)} instead of the stream" aria-label="Listen to ${escapeHtml(label)} instead of the stream"><i class="fa-solid fa-headphones"></i></button>
++ ${typeof document !== 'undefined' && document.pictureInPictureEnabled ? `<button type="button" class="is-extra" data-act="popout" title="Out of the browser: ${escapeHtml(label)} in its own window on top of everything" aria-label="Pop ${escapeHtml(label)} out of the browser"><i class="fa-solid fa-up-right-from-square"></i></button>` : ''}
<button type="button" data-act="mute"></button>
<button type="button" data-act="reset" title="Back to where the streamer put it" aria-label="Back to where the streamer put it"><i class="fa-solid fa-rotate-left"></i></button>
<button type="button" data-act="tuck" title="Tuck ${escapeHtml(label)} to the side" aria-label="Tuck ${escapeHtml(label)} to the side"><i class="fa-solid fa-angles-right"></i></button>
@@@ -301,7 +303,7 @@
o.container = container;
el.tabIndex = 0;
el.setAttribute('role', 'group');
-- el.setAttribute('aria-label', `${label}, picture-in-picture. Drag it anywhere; arrow keys move it, plus and minus size it, M mutes it, T tucks it aside, F pops it out of the player.`);
++ el.setAttribute('aria-label', `${label}, picture-in-picture. Drag it anywhere; arrow keys move it, plus and minus size it, M mutes it, L listens to it instead of the stream, T tucks it aside, F floats it over the page, P pops it out of the browser.`);
muteButton(el.querySelector('[data-act="mute"]'), o.geom.muted, label);
watchShape(o, el.querySelector('.ov-pip-video'));
setState(o, 'connecting');
@@@ -334,7 +336,22 @@
saveGeom(o); land(o, from);
} else if (what === 'reset') { resetGeom(o); settle(o); saveGeom(o); land(o, from); }
else if (what === 'float') { toMode(o, o.geom.mode === 'float' ? 'dock' : 'float', from); saveGeom(o); land(o, from); }
-- else if (what === 'mute') {
++ else if (what === 'listen') {
++ // Hear this one instead of the stream (and back): the stream's own video is muted while it is on, and gets
++ // its sound back when it ends only if it had it.
++ const v = el.querySelector('.ov-pip-video');
++ const main = mainVideo(o);
++ o.listening = !o.listening;
++ if (o.listening) { o.mainWasMuted = main ? main.muted : null; if (main) main.muted = true; v.muted = false; v.play().catch(() => {}); }
++ else { if (main && o.mainWasMuted === false) main.muted = false; v.muted = o.geom.muted; }
++ el.querySelector('[data-act="listen"]')?.setAttribute('aria-pressed', String(!!o.listening));
++ el.classList.toggle('is-listening', !!o.listening);
++ } else if (what === 'popout') {
++ // The browser's own picture-in-picture: a window on top of everything, even outside the browser.
++ const v = el.querySelector('.ov-pip-video');
++ if (document.pictureInPictureElement === v) document.exitPictureInPicture().catch(() => {});
++ else if (v && document.pictureInPictureEnabled) v.requestPictureInPicture().catch((e) => console.warn('[PiP] pop out refused:', e.message));
++ } else if (what === 'mute') {
o.geom.muted = !o.geom.muted;
const v = el.querySelector('.ov-pip-video');
v.muted = o.geom.muted;
@@@ -344,6 +361,12 @@
}
}
++ /** The stream's own video under the PIPs (the player's, not a PIP's). */
++ function mainVideo(o) {
++ const c = o.container || document.getElementById('video-container');
++ return c ? Array.from(c.querySelectorAll('video')).find((v) => !v.closest('.ov-pip')) || null : null;
++ }
++
/** Switch frames keeping its size and place on screen (`rect`: where it is now). */
function toMode(o, mode, rect) {
o.geom.mode = mode;
@@@ -374,6 +397,8 @@
else if (k === 'm' || k === 'M') { act(o, 'mute'); moved = false; }
else if (k === 't' || k === 'T' || k === 'h' || k === 'H') { act(o, o.geom.tuck ? 'untuck' : 'tuck'); moved = false; }
else if (k === 'f' || k === 'F') { act(o, 'float'); moved = false; }
++ else if (k === 'l' || k === 'L') { act(o, 'listen'); moved = false; }
++ else if ((k === 'p' || k === 'P') && document.pictureInPictureEnabled) { act(o, 'popout'); moved = false; }
else return;
e.preventDefault();
if (!moved) return;
@@@ -692,6 +717,8 @@
}
function close(o, animate) {
++ if (o.listening) act(o, 'listen'); // the stream gets its sound back
++ try { if (document.pictureInPictureElement && o.el && o.el.contains(document.pictureInPictureElement)) document.exitPictureInPicture().catch(() => {}); } catch { /* */ }
o.closed = true;
clearInterval(o.watch);
clearTimeout(o.retryTimer);