openvibers/openvibe.cam Commits

The camera app's Live mode: pick who is live and save a still of their stream (#99)

OP OpenVibers committed 6 days ago · 93989e5a1e · 8 files, +173 −9

A third mode beside Photo and Burst (the owner's 11-07 'stream still'). It lists who is live on OpenVibe.Live
(GET /api/v1/live, from Live's public list, cached 15 s), shows the chosen stream's picture from Media's frame
endpoint (every five seconds while on screen), and the shutter saves a still of it to the person's Pics through
POST /api/v1/stills, as the cam button on a stream does. The camera is not needed, so its off card, the filters,
grid, timer and flip step aside and the shutter turns red. Nobody live says so, with a link to go live. A saved
still pops into the thumb and the gallery, with Copy link.

Changes

ddiff --git a/public/css/cam.css b/public/css/cam.css
iindex 4c7f5c5..6198f6d 100644
---- a/public/css/cam.css
++++ b/public/css/cam.css
@@@ -226,6 +226,26 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
opacity: 0; transform: translate(-50%, -6px); pointer-events: none; transition: opacity .18s ease, transform .18s ease; }
.cm-mode-say.is-on { opacity: 1; transform: translate(-50%, 0); }
.cm-mode-say:empty { display: none; }
++/* Live: who is live on OpenVibe.Live, their picture on the stage and a picker where the filters were. The camera is not
++ needed, so its off card, the filters and the grid and timer step aside; the shutter turns red. */
++.cm-live { position: absolute; inset: 0; z-index: 2; background: #04060b; }
++.cm-live[hidden] { display: none; }
++.cm-live-frame { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
++.cm-live-frame[hidden] { display: none; }
++.cm-live-empty { position: absolute; inset: 0; margin: 0; display: grid; place-content: center; gap: 10px; padding: 24px 24px 80px; text-align: center; color: rgba(232,236,243,.8); font-weight: 650; line-height: 1.5; }
++.cm-live-empty[hidden] { display: none; }
++.cm-live-empty a { justify-self: center; display: inline-flex; align-items: center; min-height: 44px; padding: 0 20px; border-radius: 999px; background: var(--room-accent); color: var(--room-on, #fff); text-decoration: none; font-weight: 800; }
++.cm-live-empty br { display: none; }
++.cm-live-picks { position: absolute; left: 0; right: 0; bottom: 12px; display: flex; gap: 8px; padding: 4px 14px; overflow-x: auto; scrollbar-width: none; justify-content: safe center; }
++.cm-live-picks::-webkit-scrollbar { display: none; }
++.cm-live-pick { flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 999px; color: #fff; background: rgba(4,6,11,.55);
++ -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); font: inherit; font-size: 14px; font-weight: 750; cursor: pointer; }
++.cm-live-pick::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
++.cm-live-pick[aria-checked="true"] { background: color-mix(in srgb, var(--room-accent) 80%, rgba(4,6,11,.4)); }
++.cm-camera[data-mode="live"] .cm-off, .cm-camera[data-mode="live"] .cm-filters, .cm-camera[data-mode="live"] .cm-top { display: none; }
++.cm-camera[data-mode="live"] .cm-shutter span { background: #ef4444; }
++.cm-camera[data-mode="live"] .cm-flip { visibility: hidden; }
++.cm-status .cm-copy { margin-left: 8px; min-height: 32px; padding: 0 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-card, #0f1729); color: inherit; font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; }
.cm-count.is-tick { animation: cm-tick .9s cubic-bezier(.2,1.3,.4,1); }
@keyframes cm-tick { 0% { transform: scale(1.6); opacity: 0; } 30% { transform: scale(1); opacity: 1; } 100% { opacity: .2; } }
/* Off: a calm card on the dark stage, one big button. */
ddiff --git a/public/js/cam.js b/public/js/cam.js
iindex 9677225..f41250c 100644
---- a/public/js/cam.js
++++ b/public/js/cam.js
@@@ -173,6 +173,7 @@
}
function shoot() {
++ if (mode === 'live') { takeStill(); return; }
// The shutter turns the camera on when it is off, like opening a phone camera.
if (!stream) { start(); return; }
var n = parseInt(timerSel.value, 10) || 0;
@@@ -202,8 +203,100 @@
mode = b.dataset.cmMode;
root.dataset.mode = mode;
modeBtns.forEach(function (x) { x.setAttribute('aria-checked', x === b ? 'true' : 'false'); });
-- shootBtn.setAttribute('aria-label', mode === 'burst' ? 'Take three photos' : 'Take the photo');
++ shootBtn.setAttribute('aria-label', mode === 'burst' ? 'Take three photos' : mode === 'live' ? 'Save a still of this stream' : 'Take the photo');
++ if (mode === 'live') enterLive(); else leaveLive();
}); });
++
++ // Live (11-07, the owner's "stream still"): pick who is live on OpenVibe.Live, see their picture, and the shutter
++ // saves a still of it to your Pics, as the cam button on a stream does (POST /api/v1/stills). The camera itself
++ // is not needed. The picture is Media's frame, fetched again every five seconds while it is on screen.
++ var liveEl = root.querySelector('[data-cm-live]');
++ var liveFrame = root.querySelector('[data-cm-live-frame]');
++ var liveEmpty = root.querySelector('[data-cm-live-empty]');
++ var livePicks = root.querySelector('[data-cm-live-picks]');
++ var liveSel = null;
++ var liveTimer = null;
++ var MEDIA = 'https://openvibe.media';
++ function liveSay(text, link) {
++ if (!liveEmpty) return;
++ liveEmpty.textContent = text;
++ if (link) { var a = document.createElement('a'); a.href = link.href; a.textContent = link.text; liveEmpty.appendChild(document.createElement('br')); liveEmpty.appendChild(a); }
++ liveEmpty.hidden = false;
++ }
++ function refreshFrame() {
++ if (!liveSel || !liveFrame || document.hidden) return;
++ liveFrame.src = MEDIA + '/live/' + encodeURIComponent(liveSel.slot) + '/frame.jpg?w=960&t=' + Date.now();
++ }
++ function pickLive(item) {
++ liveSel = item;
++ Array.prototype.forEach.call(livePicks.children, function (b) { b.setAttribute('aria-checked', b.dataset.slot === item.slot ? 'true' : 'false'); });
++ if (liveFrame) liveFrame.alt = item.name + (item.title ? ': ' + item.title : '');
++ liveSay('Loading ' + item.name + '…');
++ refreshFrame();
++ clearInterval(liveTimer);
++ liveTimer = setInterval(refreshFrame, 5000);
++ }
++ function enterLive() {
++ if (!liveEl) return;
++ liveEl.hidden = false;
++ say('Pick who is live, then press the shutter to save a still to your Pics.');
++ liveSay('Looking for who is live…');
++ fetch('/api/v1/live', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
++ .then(function (res) { return res.ok ? res.json() : null; })
++ .then(function (data) {
++ if (mode !== 'live') return;
++ var list = data && Array.isArray(data.streams) ? data.streams : null;
++ livePicks.textContent = '';
++ if (!list) { liveSay('OpenVibe.Live did not answer. Try again in a moment.'); return; }
++ if (!list.length) { if (liveFrame) liveFrame.hidden = true; liveSay('Nobody is live on OpenVibe.Live right now.', { href: 'https://openvibe.live/', text: 'Go live yourself' }); return; }
++ list.forEach(function (item) {
++ var b = document.createElement('button');
++ b.type = 'button'; b.className = 'cm-live-pick'; b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', 'false');
++ b.dataset.slot = item.slot; b.textContent = item.name;
++ if (item.title) b.title = item.title;
++ b.addEventListener('click', function () { pickLive(item); });
++ livePicks.appendChild(b);
++ });
++ var keep = liveSel && list.filter(function (x) { return x.slot === liveSel.slot; })[0];
++ pickLive(keep || list[0]);
++ })
++ .catch(function () { if (mode === 'live') liveSay('OpenVibe.Live did not answer. Try again in a moment.'); });
++ }
++ function leaveLive() {
++ clearInterval(liveTimer); liveTimer = null;
++ if (liveEl && !liveEl.hidden) say(stream ? 'The camera is on. Nothing is recorded.' : 'The camera is off.');
++ if (liveEl) liveEl.hidden = true;
++ if (liveFrame) { liveFrame.removeAttribute('src'); liveFrame.hidden = true; }
++ }
++ if (liveFrame) {
++ liveFrame.addEventListener('load', function () { liveFrame.hidden = false; if (liveEmpty) liveEmpty.hidden = true; });
++ liveFrame.addEventListener('error', function () { liveFrame.hidden = true; if (liveSel) liveSay(liveSel.name + ' has no picture right now.'); });
++ }
++ document.addEventListener('visibilitychange', function () { if (!document.hidden && mode === 'live') refreshFrame(); });
++ function takeStill() {
++ if (!liveSel) { say('Pick who is live first.'); return; }
++ if (busy) return;
++ busy = true;
++ flash();
++ say('Saving a still of ' + liveSel.name + '…');
++ var body = { slot: liveSel.slot, stream_id: liveSel.stream_id, w: 1280, caption: captionEl && captionEl.value ? captionEl.value : '' };
++ fetch('/api/v1/stills', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) })
++ .then(function (res) {
++ return res.json().catch(function () { return {}; }).then(function (out) {
++ if (res.status === 401) { say('Sign in to save a still to your Pics.'); return; }
++ if (!res.ok) { say((out && out.detail) || 'The still was not saved.'); return; }
++ addToGallery(out.still);
++ say('Saved a still of ' + liveSel.name + ' to your Pics. ');
++ if (statusEl && out.share && out.share.url) {
++ var c = document.createElement('button');
++ c.type = 'button'; c.className = 'cm-copy'; c.setAttribute('data-copy', out.share.url); c.textContent = 'Copy link';
++ statusEl.appendChild(c);
++ }
++ });
++ })
++ .catch(function () { say('The still was not saved: the network did not answer.'); })
++ .then(function () { busy = false; });
++ }
// Swipe the viewfinder sideways to change mode, as a phone camera does (11-07). Not from the filter strip or the
// top controls, which scroll or press on their own; the mode's name shows on the picture for a moment.
var stageEl = root.querySelector('.cm-stage');
@@@ -226,7 +319,7 @@
if (stageEl && modeBtns.length > 1) {
var swipe = null;
stageEl.addEventListener('pointerdown', function (e) {
-- swipe = (root.dataset.state === 'off' || (e.target.closest && e.target.closest('.cm-filters, .cm-top, .cm-off'))) ? null : { x: e.clientX, y: e.clientY, t: Date.now() };
++ swipe = (e.target.closest && e.target.closest('.cm-filters, .cm-top, .cm-off, .cm-live-picks')) ? null : { x: e.clientX, y: e.clientY, t: Date.now() };
});
stageEl.addEventListener('pointerup', function (e) {
if (!swipe) return;
@@@ -250,15 +343,16 @@
upload(f, f.type, 'none');
fileInput.value = '';
});
-- stopCamera = function () { if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; };
++ stopCamera = function () { if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; leaveLive(); };
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); else wire();
document.addEventListener('ov:boost:load', wire);
})();
--// The home's Copy button beside the <ov-camera> snippet (11-07).
++// The home's Copy button beside the <ov-camera> snippet, and a saved still's Copy link (11-07).
document.addEventListener('click', function (e) {
-- var b = e.target && e.target.closest ? e.target.closest('.cm-build-copy[data-copy]') : null;
++ var b = e.target && e.target.closest ? e.target.closest('.cm-build-copy[data-copy], .cm-copy[data-copy]') : null;
if (!b || !navigator.clipboard) return;
-- navigator.clipboard.writeText(b.getAttribute('data-copy')).then(function () { b.textContent = 'Copied'; setTimeout(function () { b.textContent = 'Copy'; }, 1500); }, function () {});
++ var label = b.dataset.label || (b.dataset.label = b.textContent);
++ navigator.clipboard.writeText(b.getAttribute('data-copy')).then(function () { b.textContent = 'Copied'; setTimeout(function () { b.textContent = label; }, 1500); }, function () {});
});
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 2c2ccb3..f94231b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -15,6 +15,7 @@
* GET /cameras/:id/grants owner
* POST /cameras/:id/grants owner: @username or subject, and days
* DELETE /cameras/:id/grants/:grantId owner: revoke at once
++ * GET /live who is live on OpenVibe.Live now (the camera app's Live mode)
* POST /stills the Live cam button: fetch Media's frame, save to Pics
* GET /stills your stills
* GET /stills/:id one still (unlisted: the link; private: its owner)
@@@ -516,6 +517,16 @@ function createApi(ctx) {
return res.json(photo);
});
++ // ── Who is live: the camera app's Live mode picks a stream from this ─────────────────────────
++ let liveCache = { at: 0, streams: null };
++ r.get('/live', async (req, res) => {
++ if (!liveCache.streams || Date.now() - liveCache.at > 15000) {
++ try { liveCache = { at: Date.now(), streams: await upstreams.liveNow() }; } catch (err) { return upstreamProblem(res, err); }
++ }
++ res.set('Cache-Control', 'public, max-age=15');
++ return res.json({ count: liveCache.streams.length, streams: liveCache.streams });
++ });
++
// ── Stills: the Live cam button ─────────────────────────
r.post('/stills', requirePerson, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
ddiff --git a/server/upstreams.js b/server/upstreams.js
iindex 8c0304b..a4c5db2 100644
---- a/server/upstreams.js
++++ b/server/upstreams.js
@@@ -36,6 +36,29 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }
}
}
++ /**
++ * Who is live on OpenVibe.Live now, from its public list: each stream's Media slot (its slot slug, else its id),
++ * the stream id, the channel's name and the title. At most 24.
++ */
++ async function liveNow() {
++ const res = await call(`${base(live)}/api/streams`);
++ if (!res.ok) throw new UpstreamError('cam.live.unavailable', `OpenVibe.Live answered ${res.status}`);
++ const data = await res.json().catch(() => null);
++ const rows = data && Array.isArray(data.streams) ? data.streams : [];
++ const out = [];
++ for (const s of rows) {
++ if (!s || s.is_live === false) continue;
++ const slot = s.managed_stream_slug ? String(s.managed_stream_slug) : (s.managed_stream_id != null ? String(s.managed_stream_id) : '');
++ const id = Number(s.id);
++ if (!slot || !Number.isInteger(id) || id <= 0) continue;
++ const ch = s.channel && typeof s.channel === 'object' ? s.channel : {};
++ const username = String(ch.username || s.username || '').slice(0, 64);
++ out.push({ stream_id: id, slot, username, name: String(ch.display_name || username || slot).slice(0, 80), title: String(s.title || '').slice(0, 140) });
++ if (out.length >= 24) break;
++ }
++ return out;
++ }
++
/** The public Live stream row, or null when it does not exist. */
async function liveStream(id) {
const n = Number(id);
@@@ -204,7 +227,7 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }
return { sent: !data.skipped, notification_id: data.notification ? data.notification.id : null };
}
-- return { liveStream, liveFrame, picsUpload, resolveUsername, vision: visionApi, pushNotification, UpstreamError, base };
++ return { liveStream, liveNow, liveFrame, picsUpload, resolveUsername, vision: visionApi, pushNotification, UpstreamError, base };
}
module.exports = { createUpstreams, UpstreamError, USERNAME_RE };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 07bf310..20047a1 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -201,6 +201,11 @@ function app({ signedIn = false, photos = [] } = {}) {
<div class="cm-flash" data-cm-flash aria-hidden="true"></div>
<div class="cm-count" data-cm-count aria-hidden="true"></div>
<div class="cm-mode-say" data-cm-mode-say aria-hidden="true"></div>
++<div class="cm-live" data-cm-live hidden>
++<img class="cm-live-frame" data-cm-live-frame alt="" hidden>
++<p class="cm-live-empty" data-cm-live-empty>Looking for who is live…</p>
++<div class="cm-live-picks" role="radiogroup" aria-label="Who is live" data-cm-live-picks></div>
++</div>
<div class="cm-off" data-cm-off>
<span class="ov-icon" data-icon="cam" data-size="64" aria-hidden="true"></span>
<h1 class="cm-off-title">Camera</h1>
@@@ -218,7 +223,7 @@ ${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign
<div class="cm-bar">
<a class="cm-thumb" data-cm-thumb href="${signedIn ? '#cm-gallery' : '/auth/login?next=%2Fapp'}" aria-label="Your photos">${thumb}</a>
<div class="cm-shoot-wrap">
--<div class="cm-modes" role="radiogroup" aria-label="Mode"><button class="cm-mode" type="button" role="radio" aria-checked="true" data-cm-mode="photo">Photo</button><button class="cm-mode" type="button" role="radio" aria-checked="false" data-cm-mode="burst">Burst</button></div>
++<div class="cm-modes" role="radiogroup" aria-label="Mode"><button class="cm-mode" type="button" role="radio" aria-checked="true" data-cm-mode="photo">Photo</button><button class="cm-mode" type="button" role="radio" aria-checked="false" data-cm-mode="burst">Burst</button><button class="cm-mode" type="button" role="radio" aria-checked="false" data-cm-mode="live">Live</button></div>
<button class="cm-shutter" type="button" data-cm-shoot aria-label="Take the photo"><span></span></button>
</div>
<button class="cm-round cm-flip" type="button" data-cm-flip aria-label="Switch camera">${ICONS.flip}</button>
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
iindex 9283d7a..12dd191 100644
---- a/test/helpers/upstreams.js
++++ b/test/helpers/upstreams.js
@@@ -4,6 +4,7 @@
* In-process stand-ins for OpenVibe.Media, OpenVibe.Live and OpenVibe.Pics on one HTTP server. Boot points
* OV_MEDIA_URL, OV_LIVE_URL and OV_PICS_URL at it.
*
++ * GET /api/streams who is live now (the camera app's Live mode)
* GET /api/streams/:id a stream with its channel (ownership check)
* GET /live/:sel/frame.jpg a JPEG frame; state.frames[sel] = { status: 404 } makes it offline
* POST /api/v1/images Pics upload; records the person's token and returns a slug and links
@@@ -58,6 +59,9 @@ async function startUpstreams() {
const p = url.pathname;
state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, bytes: raw.length });
++ // Live's public list: who is live now (the camera app's Live mode).
++ if (p === '/api/streams' && req.method === 'GET') return json(200, { streams: Object.values(state.streams).filter((s) => s.is_live), agents: [] });
++
const stream = p.match(/^\/api\/streams\/(\d+)$/);
if (stream) {
const row = state.streams[stream[1]];
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex b3d8428..80cd679 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -46,7 +46,7 @@ function jsonLd(html) {
const r = await t.get('/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
-- for (const marker of ['id="cm-app"', 'id="cm-camera"', 'id="cm-gallery"', 'data-cm-preview', 'data-cm-shoot', 'data-cm-timer', 'data-cm-burst', 'data-cm-filter', 'data-cm-file', 'data-cm-grid', 'data-cm-mode-say']) assert.ok(r.text.includes(marker), marker);
++ for (const marker of ['id="cm-app"', 'id="cm-camera"', 'id="cm-gallery"', 'data-cm-preview', 'data-cm-shoot', 'data-cm-timer', 'data-cm-burst', 'data-cm-filter', 'data-cm-file', 'data-cm-grid', 'data-cm-mode-say', 'data-cm-mode="live"', 'data-cm-live-picks']) assert.ok(r.text.includes(marker), marker);
assert.ok(r.text.includes('Sign in'));
});
ddiff --git a/test/stills.test.js b/test/stills.test.js
iindex 3f52dc5..2f95cd7 100644
---- a/test/stills.test.js
++++ b/test/stills.test.js
@@@ -106,6 +106,13 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(long.status, 400);
});
++ await check('who is live: each live stream\'s slot, id and name, for anyone; cached briefly', async () => {
++ const r = await t.get('/api/v1/live');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=15');
++ assert.deepStrictEqual(r.json().streams, [{ stream_id: 1234, slot: 'whip', username: 'alice', name: 'alice', title: 'Alice live' }]);
++ });
++
await check('your stills are listed; another person\'s are not', async () => {
const mine = await t.get('/api/v1/stills', { as: alice });
assert.strictEqual(mine.status, 200);