openvibers/openvibe.cam Commits
The camera app's Live mode: pick who is live and save a still of their stream (#99)
Changes
@@@ -226,6 +226,26 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--texopacity: 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. */@@@ -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 () {});});@@@ -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 : {};@@@ -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 };@@@ -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>@@@ -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]];@@@ -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'));});@@@ -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);
@@ -226,6 +226,26 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -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; }
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.is-on { opacity: 1; transform: translate(-50%, 0); }
.cm-mode-say:empty { display: none; }
.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); }
.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; } }
@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. */
/* Off: a calm card on the dark stage, one big button. */
@@ -173,6 +173,7 @@
@@ -173,6 +173,7 @@
}
}
function shoot() {
function shoot() {
+ if (mode === 'live') { takeStill(); return; }
// The shutter turns the camera on when it is off, like opening a phone camera.
// The shutter turns the camera on when it is off, like opening a phone camera.
if (!stream) { start(); return; }
if (!stream) { start(); return; }
var n = parseInt(timerSel.value, 10) || 0;
var n = parseInt(timerSel.value, 10) || 0;
@@ -202,8 +203,100 @@
@@ -202,8 +203,100 @@
mode = b.dataset.cmMode;
mode = b.dataset.cmMode;
root.dataset.mode = mode;
root.dataset.mode = mode;
modeBtns.forEach(function (x) { x.setAttribute('aria-checked', x === b ? 'true' : 'false'); });
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
// 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.
// 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');
var stageEl = root.querySelector('.cm-stage');
@@ -226,7 +319,7 @@
@@ -226,7 +319,7 @@
if (stageEl && modeBtns.length > 1) {
if (stageEl && modeBtns.length > 1) {
var swipe = null;
var swipe = null;
stageEl.addEventListener('pointerdown', function (e) {
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) {
stageEl.addEventListener('pointerup', function (e) {
if (!swipe) return;
if (!swipe) return;
@@ -250,15 +343,16 @@
@@ -250,15 +343,16 @@
upload(f, f.type, 'none');
upload(f, f.type, 'none');
fileInput.value = '';
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();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); else wire();
document.addEventListener('ov:boost:load', 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) {
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;
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 () {});
});
});
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* GET /cameras/:id/grants owner
* GET /cameras/:id/grants owner
* POST /cameras/:id/grants owner: @username or subject, and days
* POST /cameras/:id/grants owner: @username or subject, and days
* DELETE /cameras/:id/grants/:grantId owner: revoke at once
* 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
* POST /stills the Live cam button: fetch Media's frame, save to Pics
* GET /stills your stills
* GET /stills your stills
* GET /stills/:id one still (unlisted: the link; private: its owner)
* GET /stills/:id one still (unlisted: the link; private: its owner)
@@ -516,6 +517,16 @@ function createApi(ctx) {
@@ -516,6 +517,16 @@ function createApi(ctx) {
return res.json(photo);
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 ─────────────────────────
// ── Stills: the Live cam button ─────────────────────────
r.post('/stills', requirePerson, async (req, res) => {
r.post('/stills', requirePerson, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
@@ -36,6 +36,29 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }
@@ -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. */
/** The public Live stream row, or null when it does not exist. */
async function liveStream(id) {
async function liveStream(id) {
const n = Number(id);
const n = Number(id);
@@ -204,7 +227,7 @@ function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }
@@ -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 { 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 };
module.exports = { createUpstreams, UpstreamError, USERNAME_RE };
@@ -201,6 +201,11 @@ function app({ signedIn = false, photos = [] } = {}) {
@@ -201,6 +201,11 @@ function app({ signedIn = false, photos = [] } = {}) {
<div class="cm-flash" data-cm-flash aria-hidden="true"></div>
<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-count" data-cm-count aria-hidden="true"></div>
<div class="cm-mode-say" data-cm-mode-say 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>
<div class="cm-off" data-cm-off>
<span class="ov-icon" data-icon="cam" data-size="64" aria-hidden="true"></span>
<span class="ov-icon" data-icon="cam" data-size="64" aria-hidden="true"></span>
<h1 class="cm-off-title">Camera</h1>
<h1 class="cm-off-title">Camera</h1>
@@ -218,7 +223,7 @@ ${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign
@@ -218,7 +223,7 @@ ${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign
<div class="cm-bar">
<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>
<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-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>
<button class="cm-shutter" type="button" data-cm-shoot aria-label="Take the photo"><span></span></button>
</div>
</div>
<button class="cm-round cm-flip" type="button" data-cm-flip aria-label="Switch camera">${ICONS.flip}</button>
<button class="cm-round cm-flip" type="button" data-cm-flip aria-label="Switch camera">${ICONS.flip}</button>
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
* In-process stand-ins for OpenVibe.Media, OpenVibe.Live and OpenVibe.Pics on one HTTP server. Boot points
* 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.
* 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 /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
* 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
* POST /api/v1/images Pics upload; records the person's token and returns a slug and links
@@ -58,6 +59,9 @@ async function startUpstreams() {
@@ -58,6 +59,9 @@ async function startUpstreams() {
const p = url.pathname;
const p = url.pathname;
state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, bytes: raw.length });
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+)$/);
const stream = p.match(/^\/api\/streams\/(\d+)$/);
if (stream) {
if (stream) {
const row = state.streams[stream[1]];
const row = state.streams[stream[1]];
@@ -46,7 +46,7 @@ function jsonLd(html) {
@@ -46,7 +46,7 @@ function jsonLd(html) {
const r = await t.get('/app');
const r = await t.get('/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
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'));
assert.ok(r.text.includes('Sign in'));
});
});
@@ -106,6 +106,13 @@ const { boot, check, done } = require('./helpers/boot');
@@ -106,6 +106,13 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(long.status, 400);
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 () => {
await check('your stills are listed; another person\'s are not', async () => {
const mine = await t.get('/api/v1/stills', { as: alice });
const mine = await t.get('/api/v1/stills', { as: alice });
assert.strictEqual(mine.status, 200);
assert.strictEqual(mine.status, 200);