openvibers/openvibe.shared Commits

openvibe-shared 4.216.0: one capture core, a message's actions in one cluster, edit and reply in the composer (#489)

OP OpenVibers committed 1 days ago · 2dc8f138f6 · 17 files, +935 −19

One capture core for every camera, and a chat message's actions in one place on every site.

- **`ov-capture.js`: `window.OVCapture`, one way to open a camera, a microphone or a screen, take a photo and record a
  clip**, shared by Live's go-live page, OpenVibe.Cam's recorder and `<ov-camera>`, and the composer (the owner, 11-12:
  Live's capture must be "completely customize[able] … highly over engineered modular editable", then "OpenVibe.cam is
  actually fine … Harmony"). It began as Live's own system (OpenVibe.Live #847) and every site serves its own copy at
  `/shared/ov-capture.js`, so a camera never waits on another site, a content policy or a cache (from 11-09 to 11-12
  nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it).
  - `open({ mode, facing, lens, mic, resolution })` runs the strategies in order (together, then each on its own, then
    anything), each with a time limit; a refusal stops at once; the failure that says most is the one reported.
  - `screen()`, `photo(stream, { mirror })`, `record(stream, { maxSeconds })` → `{ stop() → Blob, cancel() }` (the first
    format the browser supports), `devices()`, `meter()`, `network()`, `can()` (what this browser allows here).
  - `explain(error, mode, { instead })`: plain steps for each browser (Safari's "aA", Android's site settings,
    Firefox's blocked icon, an app's in-app browser, a Mac's Screen Recording switch), with a last step of the site's
    own.
  - Settings: this device first; `settings.sync({ load, save })` lets the choices that mean the same anywhere (facing,
    resolution, mirror, effects) follow the person, for example through OpenVibe.Cam's camera settings; camera and
    microphone ids never leave the device.
  - Every part (`use(name, impl)` returns the one it replaced), every strategy (`strategies.add/remove/replace/reset`)
    and every event (`on('opened' | 'failed' | 'settings' | 'synced' | 'recorded' …)`) can be changed; it fetches
    nothing. A newer copy replaces an older one, never the reverse.
- **A message's actions are one cluster at its corner** (openvibe.design rule 138; the owner, 11-12, with phone
  screenshots of a stream's chat: "The edit button needs improvement … so it never happens again", "it also looks
  stupid as shit after you edit it"). `messages.js`:
  - `render(root, { …, actions, onAction })`: `actions` names what to offer (`reply`, `edit`, `react`, `translate`,
    `copy`, `delete`; edit and delete on your own messages only) as icon buttons in one cluster just above the
    message's right end, on hover or focus on a computer and a tap on a phone (thumb-sized there), never in the words;
    a press calls `onAction(id, message, row)` and bubbles `ovm:action`.
  - `actions(row, ids, { onAction, message, mine })` gives a site's own rows (Live's chat) the same cluster.
  - `edited(textNode, at)`: "edited" is one quiet word right after the words, the time as its title, never a line of
    its own.
- **The composer edits and replies in the box**: `api.edit({ text, onSave, onCancel })` puts your message's words in
  the box under an "Editing your message" bar (Enter saves through `onSave`, never `onSubmit`; Esc or × puts back what
  was in the box; the send is a check); `api.reply({ name, text, data })` shows "Replying to …" and the send carries
  `reply: data`; `clearBar()`, `editing()`.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5d5b757..c1c42f0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,43 @@
# Changelog
++## 4.216.0 — 2026-11-12
++
++One capture core for every camera, and a chat message's actions in one place on every site.
++
++- **`ov-capture.js`: `window.OVCapture`, one way to open a camera, a microphone or a screen, take a photo and record a
++ clip**, shared by Live's go-live page, OpenVibe.Cam's recorder and `<ov-camera>`, and the composer (the owner, 11-12:
++ Live's capture must be "completely customize[able] … highly over engineered modular editable", then "OpenVibe.cam is
++ actually fine … Harmony"). It began as Live's own system (OpenVibe.Live #847) and every site serves its own copy at
++ `/shared/ov-capture.js`, so a camera never waits on another site, a content policy or a cache (from 11-09 to 11-12
++ nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it).
++ - `open({ mode, facing, lens, mic, resolution })` runs the strategies in order (together, then each on its own, then
++ anything), each with a time limit; a refusal stops at once; the failure that says most is the one reported.
++ - `screen()`, `photo(stream, { mirror })`, `record(stream, { maxSeconds })` → `{ stop() → Blob, cancel() }` (the first
++ format the browser supports), `devices()`, `meter()`, `network()`, `can()` (what this browser allows here).
++ - `explain(error, mode, { instead })`: plain steps for each browser (Safari's "aA", Android's site settings,
++ Firefox's blocked icon, an app's in-app browser, a Mac's Screen Recording switch), with a last step of the site's
++ own.
++ - Settings: this device first; `settings.sync({ load, save })` lets the choices that mean the same anywhere (facing,
++ resolution, mirror, effects) follow the person, for example through OpenVibe.Cam's camera settings; camera and
++ microphone ids never leave the device.
++ - Every part (`use(name, impl)` returns the one it replaced), every strategy (`strategies.add/remove/replace/reset`)
++ and every event (`on('opened' | 'failed' | 'settings' | 'synced' | 'recorded' …)`) can be changed; it fetches
++ nothing. A newer copy replaces an older one, never the reverse.
++- **A message's actions are one cluster at its corner** (openvibe.design rule 138; the owner, 11-12, with phone
++ screenshots of a stream's chat: "The edit button needs improvement … so it never happens again", "it also looks
++ stupid as shit after you edit it"). `messages.js`:
++ - `render(root, { …, actions, onAction })`: `actions` names what to offer (`reply`, `edit`, `react`, `translate`,
++ `copy`, `delete`; edit and delete on your own messages only) as icon buttons in one cluster just above the
++ message's right end, on hover or focus on a computer and a tap on a phone (thumb-sized there), never in the words;
++ a press calls `onAction(id, message, row)` and bubbles `ovm:action`.
++ - `actions(row, ids, { onAction, message, mine })` gives a site's own rows (Live's chat) the same cluster.
++ - `edited(textNode, at)`: "edited" is one quiet word right after the words, the time as its title, never a line of
++ its own.
++- **The composer edits and replies in the box**: `api.edit({ text, onSave, onCancel })` puts your message's words in
++ the box under an "Editing your message" bar (Enter saves through `onSave`, never `onSubmit`; Esc or × puts back what
++ was in the box; the send is a check); `api.reply({ name, text, data })` shows "Replying to …" and the send carries
++ `reply: data`; `clearBar()`, `editing()`.
++
## 4.215.0 — 2026-11-12
Composer v2: one elegant composer that every site mounts (the owner, 11-12: "this composer is completely ugly and
ddiff --git a/README.md b/README.md
iindex b045e11..bed2f50 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.215.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.216.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 40ecd6e..6f79a78 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.215.0",
++ "version": "4.216.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 36f17af..fe1b0be 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -58,7 +58,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
-- const VIBE_REV = { 'ov-composer.js': '60c9858ff204' };
++ const VIBE_REV = { 'ov-composer.js': '96e59dd5193b' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex 6392d45..c19e810 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '674e9cf1b2f5', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
++ const DOCK_REV = { 'dock-vibe.js': '5d8876db7e15', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/files.js b/files.js
iindex 48f284d..73ce078 100644
---- a/files.js
++++ b/files.js
@@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-composer.js', // shared composer
++ 'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)
'ov-combo.js', // shared field dropdown
'ov-window.js', // shared dialog, sheet, and panel
'agent-sheet.js', // an agent's sheet in the one window (4.211.0)
ddiff --git a/messages.css b/messages.css
iindex aa21504..4100c94 100644
---- a/messages.css
++++ b/messages.css
@@@ -32,6 +32,26 @@
.ovm-emoji-only { padding: 0 2px; background: transparent !important; font-size: 2.2rem; line-height: 1.15; }
.ovm-reply { display: block; margin-bottom: 6px; padding-left: 8px; border-left: 2px solid currentColor; font-size: .86em; opacity: .75; }
.ovm-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
++/* A message's actions: one cluster just above its right end (openvibe.design rule 138), on hover or focus on a computer,
++ a tap on a phone; never in the words. "edited" is one quiet word after them. */
++.ovm-has-actions { position: relative; }
++.ovm-actions { position: absolute; bottom: calc(100% - 6px); right: 6px; z-index: 6; display: none; align-items: center; gap: 2px; padding: 2px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 10px; background: var(--bg-card, #131c2e); box-shadow: 0 6px 18px rgba(0, 0, 0, .35); }
++.ovm-messages > :first-child .ovm-actions, .ovm-messages > :first-child.ovm-has-actions > .ovm-actions { bottom: auto; top: calc(100% - 6px); }
++.ovm-has-actions:hover > .ovm-actions, .ovm-has-actions:focus-within > .ovm-actions, .ovm-has-actions.is-acting > .ovm-actions { display: inline-flex; }
++.ovm-act { display: grid; place-items: center; width: 28px; height: 26px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
++.ovm-act:hover, .ovm-act:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
++.ovm-act:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #60a5fa)); outline-offset: -2px; }
++.ovm-act-delete:hover { color: #fca5a5; background: color-mix(in srgb, #ef4444 20%, transparent); }
++@media (hover: none) {
++ .ovm-has-actions:hover:not(.is-acting):not(:focus-within) > .ovm-actions { display: none; }
++ .ovm-actions { right: 4px; border-radius: 12px; }
++ .ovm-act { width: 44px; height: 40px; border-radius: 9px; }
++ .ovm-act svg { width: 18px; height: 18px; }
++}
++@media (hover: hover) { .ovm-room.ovm-has-actions:hover { background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); } }
++.ovm-room.is-acting { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); }
++.ovm-edited { margin-left: 5px; font-size: .72em; color: var(--text-muted, #93a0b4); opacity: .85; white-space: nowrap; }
++.ovm-group[data-mine="true"] .ovm-edited { color: inherit; opacity: .7; }
/* Room: one line per message, the name in its colour, the words wrapping under it on a phone. */
.ovm-messages[data-view="room"] { gap: 0; }
ddiff --git a/messages.js b/messages.js
iindex efb22ea..297077a 100644
---- a/messages.js
++++ b/messages.js
@@@ -1,5 +1,17 @@
'use strict';
--/** Render Chat messages as a compact room or grouped conversation bubbles. */
++/**
++ * Render Chat messages as a compact room or grouped conversation bubbles.
++ *
++ * render(root, { messages, view: 'room' | 'bubbles', me, now, actions, onAction })
++ * actions: the ids to offer on each message ('reply', 'edit', 'react', 'translate', 'copy', 'delete'), or
++ * (message, { mine }) → ids. 'edit' and 'delete' are offered only on your own messages.
++ * onAction(id, message, row): a button was pressed (an 'ovm:action' event with { action, message } also bubbles).
++ * actions(row, ids, { onAction, message }) → the cluster: the same buttons for a row a site draws itself (Live's chat).
++ * edited(textNode, at) → the quiet "edited" word after a message's words, its time as the title.
++ *
++ * A message's actions are one cluster at its corner (openvibe.design rule 138): shown on hover or focus on a computer,
++ * on a tap on a phone, never in the words. "edited" is one quiet word after them, never a line of its own.
++ */
const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));
function safeLinkify(node, value) {
const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g;
@@@ -20,7 +32,74 @@ function dayLabel(date, now) {
if (days === 0) return 'Today'; if (days === 1) return 'Yesterday';
return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date);
}
--function render(root, { messages = [], view = 'room', me = null, now = new Date() } = {}) {
++// The actions' icons (24-unit strokes, as the composer's).
++const ICON = {
++ reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
++ edit: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
++ react: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01',
++ translate: 'M4 5h9M8.5 3v2M6 5c.6 3 2.6 5.5 5.5 7M11 5c-.8 3.6-3.2 6.4-6.5 8M13 21l4-10 4 10M14.5 17.5h5',
++ copy: 'M9 9h10v12H9zM5 15V3h10',
++ delete: 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3',
++};
++const LABEL = { reply: 'Reply', edit: 'Edit your message', react: 'React', translate: 'Translate', copy: 'Copy text', delete: 'Delete your message' };
++const OWN = new Set(['edit', 'delete']);
++function svgIcon(path) {
++ const ns = 'http://www.w3.org/2000/svg';
++ const s = document.createElementNS(ns, 'svg');
++ s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('width', '16'); s.setAttribute('height', '16'); s.setAttribute('aria-hidden', 'true');
++ s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round');
++ const p = document.createElementNS(ns, 'path'); p.setAttribute('d', path); s.appendChild(p);
++ return s;
++}
++/** One cluster of icon buttons at a row's corner. A site's own rows (Live's chat) use it too, so every chat matches. */
++function actions(row, ids, { onAction = null, message = null, mine = false } = {}) {
++ if (!row) return null;
++ let box = row.querySelector(':scope > .ovm-actions');
++ if (!box) { box = document.createElement('span'); box.className = 'ovm-actions'; row.appendChild(box); }
++ box.replaceChildren();
++ (ids || []).filter((id) => ICON[id] && (!OWN.has(id) || mine)).forEach((id) => {
++ const b = document.createElement('button');
++ b.type = 'button'; b.className = `ovm-act ovm-act-${id}`; b.dataset.action = id; b.title = LABEL[id]; b.setAttribute('aria-label', LABEL[id]);
++ b.appendChild(svgIcon(ICON[id]));
++ b.addEventListener('click', (e) => {
++ e.stopPropagation();
++ row.classList.remove('is-acting');
++ if (typeof onAction === 'function') onAction(id, message, row);
++ const view = row.ownerDocument && row.ownerDocument.defaultView;
++ const CE = (view && view.CustomEvent) || (typeof CustomEvent === 'function' ? CustomEvent : null);
++ if (CE) row.dispatchEvent(new CE('ovm:action', { bubbles: true, detail: { action: id, message } }));
++ });
++ box.appendChild(b);
++ });
++ row.classList.add('ovm-has-actions');
++ if (!box.children.length) { box.remove(); row.classList.remove('ovm-has-actions'); return null; }
++ return box;
++}
++/** "edited": one quiet word right after the words, the time it changed as its title. */
++function edited(after, at) {
++ if (!after || !after.parentNode) return null;
++ let mark = after.nextElementSibling && after.nextElementSibling.classList.contains('ovm-edited') ? after.nextElementSibling : null;
++ if (!mark) { mark = document.createElement('small'); mark.className = 'ovm-edited'; after.after(mark); }
++ mark.textContent = 'edited';
++ const raw = String(at || '');
++ const d = raw ? new Date(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw.replace(' ', 'T')) ? raw.replace(' ', 'T') : raw.replace(' ', 'T') + 'Z') : null;
++ mark.title = d && !isNaN(d) ? `Edited at ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(d)}` : 'Edited';
++ return mark;
++}
++/** A phone has no hover: a tap on a message shows its actions, a tap elsewhere hides them. */
++function tapToAct(root) {
++ if (!root || root.__ovmTap) return;
++ root.__ovmTap = true;
++ root.addEventListener('click', (e) => {
++ const t = e.target;
++ if (!t || !t.closest || t.closest('.ovm-actions')) return;
++ const noHover = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches;
++ const row = t.closest('.ovm-has-actions');
++ root.querySelectorAll('.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); });
++ if (noHover && row && !t.closest('a, button, input, textarea')) row.classList.toggle('is-acting');
++ });
++}
++function render(root, { messages = [], view = 'room', me = null, now = new Date(), actions: offer = null, onAction = null } = {}) {
if (!root || !root.ownerDocument) throw new TypeError('A message list element is required');
if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"');
root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view;
@@@ -37,6 +116,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time);
const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author);
const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row);
++ if (message.edited_at) edited(body, message.edited_at);
++ const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;
++ if (ids && ids.length) actions(row, ids, { onAction, message, mine });
} else {
const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender);
if (same) group.dataset.continued = 'true';
@@@ -50,6 +132,9 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || '');
if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only');
safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble);
++ if (message.edited_at) edited(content, message.edited_at);
++ const ids = typeof offer === 'function' ? offer(message, { mine }) : offer;
++ if (ids && ids.length) actions(bubble, ids, { onAction, message, mine });
if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) {
const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time);
if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); }
@@@ -59,9 +144,10 @@ function render(root, { messages = [], view = 'room', me = null, now = new Date(
}
previous = { sender, stamp };
});
++ if (offer) tapToAct(root);
return root;
}
--const api = { render };
++const api = { render, actions, edited, tapToAct };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') {
window.OVMessages = api;
ddiff --git a/navbar.js b/navbar.js
iindex 9d50614..0c89424 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -602,7 +602,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aaad15c6adb6', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'c1ca9b80e1b5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-capture.js b/ov-capture.js
nnew file mode 100644
iindex 0000000..d58b4e7
---- /dev/null
++++ b/ov-capture.js
@@@ -0,0 +1,406 @@
++/*
++ * ov-capture.js: OpenVibe's capture core, window.OVCapture. One way to open a camera, a microphone or a screen, take a
++ * photo and record a clip, on every OpenVibe site: Live's go-live page, OpenVibe.Cam's recorder and <ov-camera>, and the
++ * composer's camera button. Built from parts that can each be replaced, and served by each site from its own copy
++ * (/shared/ov-capture.js), so a camera never waits on another site, a content policy or a cache.
++ *
++ * The owner, 2026-11-12: Live's capture must be "completely customize[able] … implemented in a highly over engineered
++ * modular editable way", and then "OpenVibe.cam is actually fine … maybe we should be using that for live … Harmony".
++ * Both hold: this file began as Live's own system (OpenVibe.Live #847) and is now the one Live and Cam share. From 11-09
++ * to 11-12 nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it; a site's own
++ * copy of this file cannot be refused that way.
++ *
++ * Using it:
++ * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. mode: 'camera' (both), 'camera_only',
++ * 'mic_only'. Runs the strategies in order, each with its
++ * own time limit; the first that opens wins.
++ * OVCapture.screen({ audio, frameRate }) → a MediaStream of a screen, a window or a tab.
++ * OVCapture.photo(stream, { type, quality, mirror }) → a Blob: the camera's picture now.
++ * OVCapture.record(stream, { maxSeconds, mimeType }) → { stop() → Promise<Blob>, cancel(), seconds(), mimeType }.
++ * OVCapture.devices() → the browser's cameras and microphones.
++ * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame.
++ * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown).
++ * OVCapture.can() → { camera, screen, record, secure }: what this browser
++ * allows here, so a page can hide what it cannot do.
++ * OVCapture.stop(stream)
++ * OVCapture.explain(error, mode, { instead }) → { title, steps }: what happened and what to tap, for
++ * this browser. `instead` adds a last step of the site's own
++ * (Live: "go live with your screen instead").
++ * OVCapture.settings.load() / .save(partial) → the person's choices: facing, lens, mic, resolution,
++ * mirror, effects (the 'store' part; this browser first).
++ * OVCapture.settings.sync({ load, save }) → unsync(): the choices that are the same on every device
++ * (the 'portable' part: facing, resolution, mirror,
++ * effects) follow the person through a remote store, such
++ * as OpenVibe.Cam's camera settings. Camera and microphone
++ * ids stay on the device that has them.
++ *
++ * Changing it:
++ * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through:
++ * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … }));
++ * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, getDisplayMedia, enumerate,
++ * meter, network, still, recorder, mimeTypes, store, portable, explain, timeoutMs.
++ * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }),
++ * .remove(name), .replace(name, strategy), .reset(). A strategy is
++ * { name, note?, timeoutMs?, open(options, capture) }.
++ * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } ·
++ * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings } ·
++ * 'synced' { settings } · 'sync-failed' { error } · 'recording' { seconds } ·
++ * 'recorded' { blob, seconds }. Returns off().
++ * Every name and every default is in this file. It fetches nothing; a page that wants the person's settings to follow
++ * them passes its own remote store to settings.sync().
++ */
++(function (root) {
++ 'use strict';
++ const VERSION = 2;
++ if (!root || (root.OVCapture && Number(root.OVCapture.version) >= VERSION)) return;
++ const nav = () => root.navigator || {};
++ const doc = () => root.document || null;
++
++ // ── Parts: each one plain, each one replaceable with use(). ─────────────────────────────────────────────────────
++ const parts = {
++ /** What to ask the browser for, from the options (facing, lens, mic, resolution). */
++ constraints(o = {}) {
++ const height = Number(o.resolution) || 720;
++ const video = o.video === false ? false : {
++ facingMode: { ideal: o.facing || 'user' },
++ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
++ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
++ };
++ const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true);
++ return { video, audio };
++ },
++ getUserMedia: (constraints) => nav().mediaDevices.getUserMedia(constraints),
++ getDisplayMedia: (constraints) => nav().mediaDevices.getDisplayMedia(constraints),
++ enumerate: () => nav().mediaDevices.enumerateDevices(),
++ meter(stream, onLevel) {
++ const AC = root.AudioContext || root.webkitAudioContext;
++ if (!AC || !stream || !stream.getAudioTracks().length) return () => {};
++ const ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512);
++ let frame = 0, closed = false;
++ analyser.fftSize = 512;
++ ctx.createMediaStreamSource(stream).connect(analyser);
++ if (ctx.state === 'suspended') {
++ ctx.resume().catch(() => {});
++ const d = doc();
++ if (d) d.addEventListener('pointerdown', () => { ctx.resume().catch(() => {}); }, { once: true, capture: true });
++ }
++ const tick = () => {
++ if (closed) return;
++ analyser.getByteTimeDomainData(data);
++ let sum = 0;
++ for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; }
++ onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
++ frame = root.requestAnimationFrame(tick);
++ };
++ frame = root.requestAnimationFrame(tick);
++ return () => { closed = true; root.cancelAnimationFrame(frame); ctx.close().catch(() => {}); };
++ },
++ network() {
++ const n = nav();
++ if (n.onLine === false) return 'offline';
++ const c = n.connection || n.mozConnection || n.webkitConnection;
++ if (!c || !(c.effectiveType || c.downlink)) return '';
++ return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good';
++ },
++ /** The camera's picture now, as a Blob: the video track drawn once into a canvas. */
++ async still(stream, o = {}) {
++ const d = doc();
++ const track = stream && stream.getVideoTracks()[0];
++ if (!d || !track) throw Object.assign(new Error('No camera picture to take'), { name: 'NotFoundError' });
++ const video = d.createElement('video');
++ video.muted = true; video.playsInline = true; video.srcObject = new root.MediaStream([track]);
++ await video.play().catch(() => {});
++ if (!video.videoWidth) await new Promise((r) => { video.addEventListener('loadeddata', r, { once: true }); root.setTimeout(r, 1500); });
++ const w = video.videoWidth || 1280, h = video.videoHeight || 720;
++ const canvas = d.createElement('canvas');
++ canvas.width = w; canvas.height = h;
++ const g = canvas.getContext('2d');
++ if (o.mirror) { g.translate(w, 0); g.scale(-1, 1); }
++ g.drawImage(video, 0, 0, w, h);
++ video.srcObject = null;
++ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('The picture could not be made'))), o.type || 'image/jpeg', o.quality || 0.92));
++ },
++ /** A MediaRecorder for the stream. */
++ recorder: (stream, options) => new root.MediaRecorder(stream, options),
++ /** The recording formats to try, best first; the first this browser supports is used. */
++ mimeTypes: {
++ video: ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'],
++ audio: ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'],
++ },
++ /** Where the person's choices live on this device. Device ids are this browser's own, so this store is local. */
++ store: {
++ key: 'ov_capture_settings',
++ load() { try { return JSON.parse(root.localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } },
++ save(settings) { try { root.localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } },
++ },
++ /** The choices that mean the same on any device, so they may follow the person (settings.sync). */
++ portable: ['facing', 'resolution', 'mirror', 'effects'],
++ explain: null, // set below (explainFailure): the plain words for a failure, by browser
++ timeoutMs: 10000,
++ };
++
++ // ── Events ──────────────────────────────────────────────────────────────────────────────────────────────────
++ const listeners = new Map();
++ function on(event, fn) {
++ if (!listeners.has(event)) listeners.set(event, new Set());
++ listeners.get(event).add(fn);
++ return () => listeners.get(event).delete(fn);
++ }
++ function emit(event, detail) {
++ (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } });
++ }
++
++ // ── Strategies: the ways to open a camera, tried in order. ──────────────────────────────────────────────────────
++ const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false });
++ const merge = (...streams) => {
++ const out = new root.MediaStream();
++ streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t)));
++ return out;
++ };
++ const defaults = [
++ {
++ name: 'preferred',
++ note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).',
++ open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })),
++ },
++ {
++ name: 'separately',
++ note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).',
++ async open(o, c) {
++ const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints');
++ let video = null, audio = null, last = null;
++ if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } }
++ if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } }
++ if (!video && !audio) throw last || new root.DOMException('No camera or microphone', 'NotFoundError');
++ return merge(video, audio);
++ },
++ },
++ {
++ name: 'basic',
++ note: 'Any camera and any microphone, nothing asked of them.',
++ open: (o, c) => c.part('getUserMedia')(wants(o)),
++ },
++ ];
++ let order = defaults.slice();
++ const strategies = {
++ list: () => order.map((s) => ({ name: s.name, note: s.note || '' })),
++ add(strategy, at = {}) {
++ if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }');
++ order = order.filter((s) => s.name !== strategy.name);
++ const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1;
++ if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy);
++ },
++ remove(name) { order = order.filter((s) => s.name !== name); },
++ replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); },
++ reset() { order = defaults.slice(); },
++ };
++
++ // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently.
++ const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 };
++ const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a);
++ const refused = (e) => !!e && (e.name === 'NotAllowedError' || e.name === 'PermissionDeniedError' || e.name === 'SecurityError');
++ function within(promise, ms, name) {
++ let timer;
++ return Promise.race([promise, new Promise((_, reject) => { timer = root.setTimeout(() => reject(new root.DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })])
++ .finally(() => root.clearTimeout(timer));
++ }
++ const noApi = (what) => Object.assign(new Error(`This browser has no ${what} here`), { name: 'NoApi' });
++
++ async function open(options = {}) {
++ const md = nav().mediaDevices;
++ if (!md || !md.getUserMedia) throw noApi('camera');
++ const o = { ...capture.settings.load(), ...options };
++ let worst = null;
++ for (const s of order) {
++ emit('opening', { strategy: s.name, options: o });
++ try {
++ const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name);
++ const w = wants(o);
++ // A strategy that opened less than was asked for has not opened it: say so and stop the tracks.
++ if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) {
++ stream.getTracks().forEach((t) => t.stop());
++ throw new root.DOMException('The requested device was not available', 'NotFoundError');
++ }
++ emit('opened', { strategy: s.name, stream });
++ return stream;
++ } catch (error) {
++ worst = heavier(worst, error);
++ emit('failed', { strategy: s.name, error });
++ // A refusal is the person's answer: asking again in another way would only ask again.
++ if (refused(error)) break;
++ }
++ }
++ emit('gave-up', { error: worst });
++ throw worst || new Error('The camera did not open');
++ }
++
++ async function screen(options = {}) {
++ const md = nav().mediaDevices;
++ if (!md || !md.getDisplayMedia) throw noApi('screen sharing');
++ const constraints = { video: { frameRate: { ideal: Number(options.frameRate) || 30 } }, audio: options.audio !== false };
++ emit('opening', { strategy: 'screen', options });
++ try {
++ const stream = await parts.getDisplayMedia(constraints);
++ emit('opened', { strategy: 'screen', stream });
++ return stream;
++ } catch (error) {
++ emit('failed', { strategy: 'screen', error });
++ emit('gave-up', { error });
++ throw error;
++ }
++ }
++
++ // ── Recording ─────────────────────────────────────────────────────────────────────────────────────────────
++ function pickMime(kind, wanted) {
++ const MR = root.MediaRecorder;
++ const list = wanted ? [wanted] : (parts.mimeTypes[kind] || []);
++ if (!MR || typeof MR.isTypeSupported !== 'function') return '';
++ return list.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';
++ }
++ function record(stream, o = {}) {
++ if (!root.MediaRecorder) throw noApi('recorder');
++ const kind = stream.getVideoTracks().length ? 'video' : 'audio';
++ const mimeType = pickMime(kind, o.mimeType);
++ const rec = parts.recorder(stream, mimeType ? { mimeType } : undefined);
++ const chunks = [];
++ const max = Math.max(1, Number(o.maxSeconds) || 60);
++ const started = Date.now();
++ let ticker = 0, limit = 0, done = null, cancelled = false;
++ const seconds = () => Math.min(max, Math.round((Date.now() - started) / 100) / 10);
++ const finished = new Promise((resolve, reject) => {
++ rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
++ rec.onerror = (e) => reject((e && e.error) || new Error('The recording failed'));
++ rec.onstop = () => {
++ root.clearInterval(ticker); root.clearTimeout(limit);
++ if (cancelled) { resolve(null); return; }
++ const blob = new root.Blob(chunks, { type: (mimeType || rec.mimeType || `${kind}/webm`).split(';')[0] });
++ emit('recorded', { blob, seconds: seconds() });
++ resolve(blob);
++ };
++ });
++ rec.start(1000);
++ ticker = root.setInterval(() => emit('recording', { seconds: seconds() }), 500);
++ limit = root.setTimeout(() => { if (rec.state !== 'inactive') rec.stop(); }, max * 1000);
++ return {
++ mimeType: mimeType || rec.mimeType || '',
++ seconds,
++ stop() { if (!done) { done = finished; if (rec.state !== 'inactive') rec.stop(); } return done; },
++ cancel() { cancelled = true; if (rec.state !== 'inactive') rec.stop(); },
++ };
++ }
++
++ // ── Plain words for a failure, by browser. ───────────────────────────────────────────────────────────────
++ function explainFailure(err, mode, extra = {}) {
++ const n = nav(), ua = n.userAgent || '';
++ const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && n.maxTouchPoints > 1);
++ const mac = /Macintosh/.test(ua) && !ios;
++ const android = /Android/.test(ua);
++ const firefox = /Firefox|FxiOS/.test(ua);
++ const safari = /Safari/.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua);
++ const inApp = /FBAN|FBAV|Instagram|Line\/|Twitter|Discord|TikTok|musical_ly|Snapchat|GSA\//.test(ua) || /; wv\)/.test(ua);
++ const what = mode === 'mic_only' ? 'microphone' : mode === 'camera_only' ? 'camera' : mode === 'screen' ? 'screen' : 'camera and microphone';
++ const both = mode === 'camera_only' ? '' : ' and Microphone';
++ const name = err && err.name;
++ const instead = extra && extra.instead ? [extra.instead] : [];
++ if (name === 'NoApi' || (inApp && name !== 'NotReadableError')) {
++ return { title: `This browser can't use your ${mode === 'screen' ? 'screen' : 'camera'} here`, steps: inApp
++ ? ['You opened this inside another app (like Discord, Instagram or TikTok). Those can\'t share a camera.', `Tap the ⋮ or ⋯ menu and choose "Open in ${ios ? 'Safari' : 'Chrome'}" (or "Open in browser").`, 'Sign in there if asked, then try again.']
++ : mode === 'screen' && (ios || android) ? ['Phones can\'t share their screen from a browser. Use a computer, or your camera instead.', ...instead]
++ : ['Open this page in Chrome, Safari, Edge or Firefox, then try again.', ...instead] };
++ }
++ if (mode === 'screen' && refused(err)) {
++ return { title: 'Screen sharing was stopped', steps: mac
++ ? ['Try again and pick a screen, a window or a tab.', 'Nothing to pick? Open System Settings → Privacy & Security → Screen Recording, turn on your browser, then reopen it.']
++ : ['Try again and pick a screen, a window or a tab, then press Share.'] };
++ }
++ if (refused(err)) {
++ const steps = ios && safari ? ['Tap "aA" (or the page icon) at the left of the address bar.', 'Tap Website Settings.', `Set Camera${both} to Allow.`]
++ : ios ? ['Open the iPhone Settings app, find this browser, and turn on Camera and Microphone.', 'Come back here.']
++ : android && !firefox ? ['Tap the icon at the left of the address bar (a lock or two sliders).', 'Tap Permissions (or Site settings).', `Set Camera${both} to Allow.`]
++ : firefox ? ['Click the camera icon with a line through it, at the left of the address bar.', 'Remove "Blocked temporarily" (the ×).']
++ : safari ? ['Open Safari\'s menu → Settings for this website.', `Set Camera${both} to Allow.`]
++ : ['Click the camera icon at the right end of the address bar (or the lock at the left).', `Choose "Always allow" for the ${what}, then Done.`];
++ return { title: `Your browser said no to the ${what}`, steps: [...steps, 'Then press Try again.'] };
++ }
++ if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') {
++ return { title: 'Another app is using your camera', steps: ['Close apps that use the camera, like Zoom, FaceTime, WhatsApp, Instagram or the Camera app (and other tabs with a call).', 'Then press Try again.'] };
++ }
++ if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
++ return { title: `No ${what} found`, steps: [mode === 'mic_only' ? 'Plug in a microphone or headset, then press Try again.' : 'Plug in a camera (or open its privacy cover), then press Try again.', ...instead] };
++ }
++ if (name === 'OverconstrainedError') {
++ return { title: 'That camera can\'t start with these settings', steps: ['Press Try again: it starts with simpler settings.', 'Still stuck? Pick another camera in the camera settings.'] };
++ }
++ if (name === 'TimeoutError') {
++ return { title: 'Your browser didn\'t answer', steps: ['Look for the browser\'s question at the top of the screen and tap Allow.', 'Nothing there? Press Try again.'] };
++ }
++ return { title: `The ${mode === 'screen' ? 'screen sharing' : 'camera'} didn't start`, steps: ['Press Try again.', 'Still stuck? Reload this page, or try Chrome or Safari.'] };
++ }
++ parts.explain = explainFailure;
++
++ // ── Settings: this device first; the portable ones may follow the person. ──────────────────────────────────────
++ const DEFAULTS = { facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, effects: 'none' };
++ const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o && o[k] !== undefined).map((k) => [k, o[k]]));
++ let remote = null;
++ const settings = {
++ load: () => ({ ...DEFAULTS, ...parts.store.load() }),
++ save(partial) {
++ const next = { ...settings.load(), ...(partial || {}) };
++ parts.store.save(next);
++ emit('settings', { settings: next });
++ const r = remote;
++ const portable = pick(partial || {}, parts.portable);
++ if (r && Object.keys(portable).length) {
++ Promise.resolve().then(() => r.save(pick(next, parts.portable))).catch((error) => emit('sync-failed', { error }));
++ }
++ return next;
++ },
++ /** Follow the person: take the remote store's portable choices now, and send each later change to it. */
++ sync(store) {
++ if (!store || typeof store.load !== 'function' || typeof store.save !== 'function') throw new TypeError('settings.sync takes { load(), save(settings) }');
++ remote = store;
++ const mine = store;
++ Promise.resolve().then(() => store.load()).then((theirs) => {
++ if (remote !== mine || !theirs || typeof theirs !== 'object') return;
++ const next = { ...settings.load(), ...pick(theirs, parts.portable) };
++ parts.store.save(next);
++ emit('settings', { settings: next });
++ emit('synced', { settings: next });
++ }).catch((error) => emit('sync-failed', { error }));
++ return () => { if (remote === mine) remote = null; };
++ },
++ };
++
++ // ── The API ─────────────────────────────────────────────────────────────────────────────────────────────────
++ const capture = {
++ version: VERSION,
++ part(name) {
++ if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`);
++ return parts[name];
++ },
++ use(name, impl) {
++ const before = capture.part(name);
++ parts[name] = impl;
++ return before;
++ },
++ parts: () => Object.keys(parts),
++ strategies,
++ on,
++ open,
++ screen,
++ photo: (stream, o = {}) => parts.still(stream, { mirror: false, ...o }),
++ record,
++ devices: () => parts.enumerate(),
++ meter: (stream, onLevel) => parts.meter(stream, onLevel),
++ network: () => parts.network(),
++ can() {
++ const md = nav().mediaDevices || {};
++ return { camera: typeof md.getUserMedia === 'function', screen: typeof md.getDisplayMedia === 'function', record: typeof root.MediaRecorder === 'function', secure: root.isSecureContext !== false };
++ },
++ stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },
++ explain: (error, mode, extra) => parts.explain(error, mode, extra),
++ settings,
++ };
++ root.OVCapture = capture;
++ if (typeof module === 'object' && module && module.exports) module.exports = capture;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 4f5ca8a..d8ea422 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -151,6 +151,15 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
.ovc-v2 .ovt-send:disabled { opacity: .45; cursor: default; }
.ovc-v2 .ovt-send--round { display: grid; place-items: center; width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 50%; }
.ovc-v2 .ovt-send--round svg { width: 19px; height: 19px; }
++.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }
++.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
++.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
++.ovc-v2 .ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
++.ovc-v2 .ovc-bar-x:hover, .ovc-v2 .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }
++.ovc-v2 .ovc-bar-x svg { width: 15px; height: 15px; }
++@media (pointer: coarse) { .ovc-v2 .ovc-bar-x { width: 40px; height: 40px; } }
++.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }
.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image, .ovc-v2[data-preset=comment] .ovc-file.ovc-image { flex-direction: column; flex-wrap: nowrap; align-items: stretch; width: 150px; max-width: 150px; height: auto; padding: 0; border-radius: 16px; overflow: hidden; }
@@@ -223,6 +232,9 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
stop: 'M7 7h10v10H7z',
cpu: 'M8 8h8v8H8zM9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3',
gauge: 'M5 18a8 8 0 1 1 14 0M12 14l3-4',
++ reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z',
++ pen: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4',
++ check: 'M5 12.5l4.5 4.5L19 7',
};
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
@@@ -636,7 +648,11 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// submitLabel, upload(file, onProgress), mentions, commands, beforeSend,
// onSubmit({ text, files, uploads, audience, mode }, api) → a promise; false keeps the box as it is }
// → the enhance api plus { form, text, send, note, setBusy(bool), setRunning(bool, onStop) (the send becomes Stop
-- // while a run works, as in the agents portal), values() (every chip's value by name) }.
++ // while a run works, as in the agents portal), values() (every chip's value by name),
++ // edit({ text, onSave(text) → false keeps editing, onCancel, label }) (the box takes your message's words under an
++ // "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),
++ // reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),
++ // clearBar(), editing() }.
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
const PRESET = {
@@@ -797,21 +813,83 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
if (on) { send.innerHTML = svg(IC.stop); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Stop'); send.title = 'Stop'; send.disabled = false; }
else { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; api.refresh(); }
};
++ // One bar above the box at a time: "Editing your message" or "Replying to …" (openvibe.design rule 138: a
++ // message's actions are a cluster at its corner, and what they start is said here, in the box).
++ let bar = null, barClose = null, editing = null, replying = null;
++ const clearBar = () => {
++ const f = barClose; barClose = null;
++ if (bar) { bar.remove(); bar = null; }
++ form.classList.remove('is-editing', 'is-replying');
++ if (f) f();
++ };
++ const showBar = (icon, title, detail, onClose) => {
++ clearBar();
++ bar = el('div', 'ovc-bar', svg(icon));
++ const t = el('b'); t.textContent = title;
++ const d = el('span'); d.textContent = detail || '';
++ const x = el('button', 'ovc-bar-x', svg(IC.x)); x.type = 'button'; x.title = 'Cancel'; x.setAttribute('aria-label', `Cancel: ${title}`);
++ x.addEventListener('click', () => { clearBar(); ta.focus(); });
++ bar.append(t, d, x);
++ body.insertBefore(bar, head.parentNode === body ? head.nextSibling : body.firstChild);
++ barClose = onClose;
++ };
++ const sendFaceOf = () => { send.innerHTML = sendFace; send.classList.toggle('ovt-send--round', !p.send); send.setAttribute('aria-label', sendLabel); send.title = ''; };
++ const edit = (spec = {}) => {
++ const draft = editing ? editing.draft : ta.value;
++ if (replying) clearBar();
++ showBar(IC.pen, spec.label || 'Editing your message', 'Enter saves · Esc cancels', () => {
++ const was = editing; editing = null;
++ sendFaceOf();
++ if (was && !was.saved) { ta.value = was.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); if (typeof was.onCancel === 'function') was.onCancel(); }
++ api.refresh();
++ });
++ editing = { draft, onSave: spec.onSave, onCancel: spec.onCancel, saved: false };
++ form.classList.add('is-editing');
++ ta.value = String(spec.text || '');
++ ta.dispatchEvent(new Event('input', { bubbles: true }));
++ send.innerHTML = svg(IC.check); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', 'Save'); send.title = 'Save';
++ api.refresh();
++ ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length;
++ };
++ const reply = (spec = {}) => {
++ if (editing) clearBar();
++ const who = String(spec.name || 'someone');
++ showBar(IC.reply, `Replying to ${who}`, String(spec.text || '').slice(0, 140), () => { const was = replying; replying = null; if (was && typeof was.onClear === 'function') was.onClear(); });
++ replying = { data: spec.data !== undefined ? spec.data : { name: who, text: spec.text }, onClear: spec.onClear };
++ form.classList.add('is-replying');
++ ta.focus();
++ };
++ ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });
++ // Saving an edit is not a send: it runs first and nothing else hears the submit.
++ form.addEventListener('submit', async (e) => {
++ if (!editing) return;
++ e.preventDefault(); e.stopImmediatePropagation();
++ if (form.classList.contains('is-busy')) return;
++ const text = ta.value.trim(), ed = editing;
++ if (!text) { api.ctx.note('A message can\'t be empty.'); return; }
++ setBusy(true);
++ let keep = false;
++ try { keep = typeof ed.onSave === 'function' ? (await ed.onSave(text)) === false : false; }
++ catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
++ setBusy(false);
++ if (!keep && editing === ed) { ed.saved = true; ta.value = ed.draft; ta.dispatchEvent(new Event('input', { bubbles: true })); clearBar(); }
++ api.refresh();
++ }, true);
if (typeof o.onSubmit === 'function') {
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (form.classList.contains('is-busy')) return;
-- const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values() };
++ const value = { text: ta.value.trim(), files: api.files(), uploads: api.uploads(), audience: aud ? aud.querySelector('select').value : null, mode: md ? md.querySelector('select').value : null, chips: values(), reply: replying ? replying.data : null };
setBusy(true);
let keep = false;
try { keep = (await o.onSubmit(value, api)) === false; }
catch (err) { keep = true; api.ctx.note(String((err && err.message) || err)); }
setBusy(false);
-- if (!keep) api.clear();
++ if (!keep) { api.clear(); if (replying) clearBar(); }
api.refresh();
});
}
-- return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values });
++ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing });
}
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 06314fc..21ae0b7 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': '60c9858ff204', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': '96e59dd5193b', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/package-lock.json b/package-lock.json
iindex b08c0f3..e7bc680 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.215.0",
++ "version": "4.216.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.215.0",
++ "version": "4.216.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 3136814..4adb1f1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.215.0",
++ "version": "4.216.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -150,6 +150,7 @@
"./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
++ "./ov-capture": "./ov-capture.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-wallet": "./ov-wallet.js",
ddiff --git a/test/capture-core.test.js b/test/capture-core.test.js
nnew file mode 100644
iindex 0000000..2eee4f3
---- /dev/null
++++ b/test/capture-core.test.js
@@@ -0,0 +1,235 @@
++'use strict';
++/**
++ * ov-capture.js (window.OVCapture), the capture core Live, Cam and the composer share, run against a fake browser:
++ * - the strategies in order, the first that opens wins; a refusal stops at once; a hung strategy is timed out;
++ * - every part can be replaced (and the replaced one called through); strategies added, removed, replaced, reset;
++ * - the screen, a photo and a recording;
++ * - the person's settings: this device first; the portable ones follow them through settings.sync(), while camera and
++ * microphone ids stay on the device;
++ * - plain words for every failure, with the site's own last step;
++ * - one copy wins (a newer one is never replaced by an older), and the module fetches nothing from anywhere.
++ * node test/capture-core.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const source = fs.readFileSync(path.join(__dirname, '..', 'ov-capture.js'), 'utf8');
++const code = source.replace(/^\s*(\/\/|\*|\/\*).*$/gm, '');
++assert.ok(!/https?:\/\//.test(code), 'the module names no URL');
++assert.ok(!/\bfetch\s*\(|XMLHttpRequest|WebSocket|importScripts|createElement\(['"]script/.test(code), 'the module fetches nothing and loads nothing');
++
++class FakeTrack { constructor(kind) { this.kind = kind; this.stopped = false; } stop() { this.stopped = true; } }
++class FakeStream {
++ constructor(tracks = []) { this.tracks = tracks.slice(); }
++ getTracks() { return this.tracks; }
++ getVideoTracks() { return this.tracks.filter((t) => t.kind === 'video'); }
++ getAudioTracks() { return this.tracks.filter((t) => t.kind === 'audio'); }
++ addTrack(t) { this.tracks.push(t); }
++}
++class FakeDOMException extends Error { constructor(message, name) { super(message); this.name = name; } }
++class FakeBlob { constructor(parts, o = {}) { this.parts = parts; this.type = o.type || ''; this.size = parts.length; } }
++class FakeRecorder {
++ constructor(stream, options) { this.stream = stream; this.mimeType = (options && options.mimeType) || 'video/webm'; this.state = 'inactive'; FakeRecorder.last = this; }
++ static isTypeSupported(t) { return /webm/.test(t); }
++ start() { this.state = 'recording'; }
++ stop() { this.state = 'inactive'; this.ondataavailable({ data: { size: 3 } }); this.onstop(); }
++}
++
++function load({ gum, display, ua, extra = {} } = {}) {
++ const store = new Map();
++ const calls = [];
++ const ctx = vm.createContext({
++ navigator: {
++ userAgent: ua || 'Mozilla/5.0 (Linux; Android 14) Chrome/130.0 Mobile Safari/537.36', maxTouchPoints: 5, onLine: true,
++ mediaDevices: {
++ getUserMedia: gum ? (c) => { calls.push(c); return gum(c, calls.length); } : undefined,
++ getDisplayMedia: display ? (c) => { calls.push(c); return display(c); } : undefined,
++ enumerateDevices: async () => [{ kind: 'videoinput', deviceId: 'a', label: 'Front' }],
++ },
++ },
++ MediaStream: FakeStream, DOMException: FakeDOMException, Blob: FakeBlob, MediaRecorder: FakeRecorder,
++ setTimeout, clearTimeout, setInterval, clearInterval, console, isSecureContext: true,
++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
++ document: { addEventListener() {} },
++ ...extra,
++ });
++ ctx.window = ctx;
++ vm.runInContext(source, ctx);
++ return { capture: ctx.OVCapture, calls, store, ctx };
++}
++// Values made inside the vm come from another realm: compare them as JSON.
++const same = (a, b, msg) => assert.strictEqual(JSON.stringify(a), JSON.stringify(b), msg);
++const both = (c) => new FakeStream([...(c.video ? [new FakeTrack('video')] : []), ...(c.audio ? [new FakeTrack('audio')] : [])]);
++const tick = () => new Promise((r) => setImmediate(r));
++
++(async () => {
++ // ── Opening a camera ──
++ let t = load({ gum: async (c) => both(c) });
++ assert.strictEqual(t.capture.version, 2);
++ let s = await t.capture.open({ mode: 'camera', facing: 'environment', resolution: '1080' });
++ assert.strictEqual(s.getVideoTracks().length, 1);
++ assert.strictEqual(t.calls.length, 1, 'the preferred way opened it in one call');
++ same(t.calls[0].video.facingMode, { ideal: 'environment' });
++ assert.strictEqual(t.calls[0].video.height.ideal, 1080);
++
++ // Both at once refused by the hardware (an Android phone): the camera, then the microphone, each on its own.
++ t = load({ gum: async (c) => { if (c.video && c.audio) throw new FakeDOMException('busy', 'OverconstrainedError'); return both(c); } });
++ const seen = [];
++ t.capture.on('failed', (e) => seen.push(`failed:${e.strategy}`));
++ t.capture.on('opened', (e) => seen.push(`opened:${e.strategy}`));
++ s = await t.capture.open({ mode: 'camera' });
++ assert.strictEqual(s.getVideoTracks().length + s.getAudioTracks().length, 2);
++ same(seen, ['failed:preferred', 'opened:separately']);
++
++ // A refusal is the person's answer: no second way of asking.
++ t = load({ gum: async () => { throw new FakeDOMException('denied', 'NotAllowedError'); } });
++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotAllowedError');
++ assert.strictEqual(t.calls.length, 1, 'stopped after the refusal');
++
++ // Several failures: the one that says most about what to do is the one reported.
++ t = load({ gum: async (c, n) => { throw new FakeDOMException('x', n === 1 ? 'NotFoundError' : 'NotReadableError'); } });
++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotReadableError');
++
++ // A strategy that never answers is timed out, and the next one runs.
++ t = load({ gum: async (c, n) => (n === 1 ? new Promise(() => {}) : both(c)) });
++ t.capture.use('timeoutMs', 30);
++ s = await t.capture.open({ mode: 'camera' });
++ assert.ok(s.getVideoTracks().length, 'opened after the timed-out strategy');
++
++ // Asked for a camera and given none: not opened, and its tracks are stopped.
++ const lone = new FakeTrack('audio');
++ t = load({ gum: async () => new FakeStream([lone]) });
++ await assert.rejects(t.capture.open({ mode: 'camera' }), (e) => e.name === 'NotFoundError');
++ assert.ok(lone.stopped, 'the half-open stream was stopped');
++
++ // ── Parts and strategies ──
++ t = load({ gum: async (c) => both(c) });
++ const before = t.capture.use('constraints', (o) => ({ ...before(o), audio: { echoCancellation: false } }));
++ await t.capture.open({ mode: 'camera' });
++ same(t.calls[0].audio, { echoCancellation: false });
++ assert.throws(() => t.capture.part('nope'), /no capture part "nope"/);
++ for (const p of ['constraints', 'getUserMedia', 'getDisplayMedia', 'enumerate', 'meter', 'network', 'still', 'recorder', 'mimeTypes', 'store', 'portable', 'explain', 'timeoutMs']) {
++ assert.ok(t.capture.parts().includes(p), `the ${p} part can be replaced`);
++ }
++ t = load({ gum: async (c) => both(c) });
++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);
++ let ran = '';
++ t.capture.strategies.add({ name: 'mine', open: async () => { ran = 'mine'; return both({ video: true, audio: true }); } }, { before: 'preferred' });
++ same(t.capture.strategies.list().map((x) => x.name), ['mine', 'preferred', 'separately', 'basic']);
++ await t.capture.open({ mode: 'camera' });
++ assert.strictEqual(ran, 'mine');
++ t.capture.strategies.remove('mine');
++ t.capture.strategies.add({ name: 'last', open: async () => both({ video: true, audio: true }) }, { after: 'basic' });
++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic', 'last']);
++ t.capture.strategies.reset();
++ same(t.capture.strategies.list().map((x) => x.name), ['preferred', 'separately', 'basic']);
++ assert.throws(() => t.capture.strategies.add({ name: 'x' }), /a strategy is/);
++
++ // ── The screen ──
++ t = load({ gum: async (c) => both(c), display: async (c) => both({ video: true, audio: c.audio }) });
++ s = await t.capture.screen({ frameRate: 15 });
++ assert.strictEqual(s.getVideoTracks().length, 1);
++ assert.strictEqual(t.calls[0].video.frameRate.ideal, 15);
++ same(t.capture.can(), { camera: true, screen: true, record: true, secure: true });
++ t = load({ gum: async (c) => both(c) });
++ await assert.rejects(t.capture.screen(), (e) => e.name === 'NoApi');
++ assert.strictEqual(t.capture.can().screen, false, 'a page can hide "share your screen" where it cannot work');
++
++ // ── A photo, through the replaceable still part ──
++ t = load({ gum: async (c) => both(c) });
++ let asked = null;
++ t.capture.use('still', async (stream, o) => { asked = o; return new FakeBlob(['jpg'], { type: o.type || 'image/jpeg' }); });
++ const photo = await t.capture.photo(await t.capture.open({ mode: 'camera_only' }), { mirror: true });
++ assert.strictEqual(photo.type, 'image/jpeg');
++ assert.strictEqual(asked.mirror, true);
++ // The real still part says so plainly when there is no picture to take.
++ t = load({ gum: async (c) => both(c) });
++ await assert.rejects(t.capture.photo(new FakeStream([new FakeTrack('audio')])), (e) => e.name === 'NotFoundError');
++
++ // ── A recording: the first supported format, events, a stop that gives a Blob ──
++ t = load({ gum: async (c) => both(c) });
++ const got = [];
++ t.capture.on('recorded', (e) => got.push(e.blob.type));
++ const rec = t.capture.record(await t.capture.open({ mode: 'camera' }), { maxSeconds: 5 });
++ assert.strictEqual(rec.mimeType, 'video/webm;codecs=vp9,opus', 'mp4 is not supported by the fake browser: the first webm is used');
++ const clip = await rec.stop();
++ assert.strictEqual(clip.type, 'video/webm');
++ same(got, ['video/webm']);
++ assert.strictEqual(await rec.stop(), clip, 'stop twice gives the same clip');
++ // Cancelled: nothing comes back.
++ const rec2 = t.capture.record(new FakeStream([new FakeTrack('audio')]));
++ assert.strictEqual(rec2.mimeType, 'audio/webm;codecs=opus', 'an audio-only stream records audio');
++ rec2.cancel();
++
++ // ── Settings: this device, then the person ──
++ t = load({ gum: async (c) => both(c) });
++ let saved = null;
++ t.capture.on('settings', (e) => { saved = e.settings; });
++ t.capture.settings.save({ mirror: false, resolution: '1080', lens: 'cam-back-1' });
++ assert.strictEqual(t.capture.settings.load().resolution, '1080');
++ assert.strictEqual(saved.mirror, false);
++ assert.ok(t.store.get('ov_capture_settings').includes('1080'), 'kept on this device');
++ // A remote store (OpenVibe.Cam's camera settings, for example): its portable choices arrive; device ids stay here.
++ const remote = { v: { mirror: true, resolution: '480', effects: 'warm', lens: 'someone-elses-camera' }, saves: [] };
++ const synced = [];
++ t.capture.on('synced', (e) => synced.push(e.settings));
++ const unsync = t.capture.settings.sync({ load: async () => remote.v, save: async (v) => { remote.saves.push(v); } });
++ await tick(); await tick();
++ const now = t.capture.settings.load();
++ assert.strictEqual(now.resolution, '480', 'the person\'s resolution followed them');
++ assert.strictEqual(now.effects, 'warm');
++ assert.strictEqual(now.lens, 'cam-back-1', 'a camera id from another device is never taken');
++ assert.strictEqual(synced.length, 1);
++ // A change here goes to the remote store, without device ids.
++ t.capture.settings.save({ mirror: false });
++ await tick();
++ assert.strictEqual(remote.saves.length, 1);
++ assert.strictEqual(remote.saves[0].mirror, false);
++ assert.ok(!('lens' in remote.saves[0]) && !('mic' in remote.saves[0]), 'device ids never leave the device');
++ // A device-only change is not sent.
++ t.capture.settings.save({ mic: 'usb-mic' });
++ await tick();
++ assert.strictEqual(remote.saves.length, 1);
++ // A remote that fails leaves this device working, and says so.
++ unsync();
++ let failed = null;
++ t.capture.on('sync-failed', (e) => { failed = e.error; });
++ t.capture.settings.sync({ load: async () => { throw new Error('offline'); }, save: async () => {} });
++ await tick(); await tick();
++ assert.ok(failed && /offline/.test(failed.message));
++ assert.strictEqual(t.capture.settings.load().mic, 'usb-mic');
++ assert.throws(() => t.capture.settings.sync({}), /settings\.sync takes/);
++ // A replaced store is used.
++ const mem = {};
++ t.capture.use('store', { load: () => mem.v || {}, save: (v) => { mem.v = v; } });
++ t.capture.settings.save({ lens: 'back-wide' });
++ assert.strictEqual(mem.v.lens, 'back-wide');
++
++ // ── Plain words ──
++ t = load({ gum: async (c) => both(c) });
++ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'camera').title, /said no to the camera and microphone/);
++ const found = t.capture.explain({ name: 'NotFoundError' }, 'camera', { instead: 'Or go live with your screen instead.' });
++ assert.strictEqual(found.steps[found.steps.length - 1], 'Or go live with your screen instead.', 'the site adds its own last step');
++ assert.match(t.capture.explain({ name: 'NotAllowedError' }, 'screen').title, /Screen sharing was stopped/);
++ assert.match(t.capture.explain({ name: 'NoApi' }, 'screen').steps[0], /Phones can't share their screen/);
++ assert.ok(!/openvibe\.live/.test(JSON.stringify(t.capture.explain({ name: 'NoApi' }, 'camera'))), 'no site\'s name in the shared words');
++ const mac = load({ gum: async (c) => both(c), ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15', extra: {} });
++ mac.ctx.navigator.maxTouchPoints = 0;
++ assert.match(mac.capture.explain({ name: 'NotAllowedError' }, 'screen').steps.join(' '), /Screen Recording/);
++ const inApp = load({ ua: 'Instagram 350 (iPhone)' });
++ await assert.rejects(inApp.capture.open({}), (e) => e.name === 'NoApi');
++ assert.match(inApp.capture.explain({ name: 'NoApi' }, 'camera').steps.join(' '), /inside another app/);
++
++ // ── One copy wins: an older or equal copy loaded later changes nothing ──
++ t = load({ gum: async (c) => both(c) });
++ const first = t.ctx.OVCapture;
++ vm.runInContext(source, t.ctx);
++ assert.strictEqual(t.ctx.OVCapture, first, 'a second copy of the same version keeps the first');
++ t.ctx.OVCapture = { version: 1 };
++ vm.runInContext(source, t.ctx);
++ assert.strictEqual(t.ctx.OVCapture.version, 2, 'a newer copy replaces an older one (Live\'s own, version 1)');
++
++ console.log('capture-core: strategies, parts, screen, photo, recording, settings that follow the person, plain words, one copy wins');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/composer-v2-chrome.test.js b/test/composer-v2-chrome.test.js
iindex d58b4ee..9fbece7 100644
---- a/test/composer-v2-chrome.test.js
++++ b/test/composer-v2-chrome.test.js
@@@ -27,7 +27,8 @@ window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1',
tools: ['photo', 'file', 'emoji', 'poll', 'voice'],
audience: { name: 'audience', label: 'Who sees it', options: [['public', 'Everyone'], ['followers', 'Followers']], value: 'public' },
onSubmit: async (v) => { window.got.push({ text: v.text, files: v.files.map((f) => f.name), audience: v.audience }); return window.keepNext ? false : true; } });
--window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async () => true });
++window.chatSent = []; window.saved = [];
++window.chat = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', onSubmit: async (v) => { window.chatSent.push({ text: v.text, reply: v.reply }); return true; } });
window.agent = C.mount(document.getElementById('c'), { preset: 'agent', id: 'g1', mode: { name: 'mode', label: 'Mode', options: [['balanced', 'Balanced'], ['best', 'Best']], value: 'balanced' },
chips: [{ name: 'effort', label: 'Reasoning', icon: 'gauge', options: [['medium', 'Medium'], ['high', 'High']], value: 'high' }],
onSubmit: async (v) => { window.got.push({ mode: v.mode, effort: v.chips.effort }); } });
@@@ -104,10 +105,33 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
await page.evaluate(`agent.setRunning(false)`);
assert.equal(await page.evaluate(`document.querySelector('#g1 [data-ovt-send]').getAttribute('aria-label')`), 'Send');
++ // Editing your message in the box (openvibe.design rule 138): a bar says so, the send is Save, Enter saves
++ // through onSave (not onSubmit), Esc puts back what was in the box.
++ await page.evaluate(`(() => { const t = chat.text; t.value = 'half a draft'; t.dispatchEvent(new Event('input', { bubbles: true })); chat.edit({ text: 'Yo', onSave: async (x) => { window.saved.push(x); } }); })()`);
++ const ed = await page.evaluate(`(() => { const f = document.getElementById('c1'); return { bar: (f.querySelector('.ovc-bar b') || {}).textContent, text: f.querySelector('textarea').value, send: f.querySelector('[data-ovt-send]').getAttribute('aria-label'), editing: chat.editing(), first: f.querySelector('.ovc-body').firstElementChild.className }; })()`);
++ assert.deepEqual(ed, { bar: 'Editing your message', text: 'Yo', send: 'Save', editing: true, first: 'ovc-bar' });
++ await page.evaluate(`(() => { const t = chat.text; t.value += ' there'; t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
++ await page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);
++ await until(`window.saved.length === 1`);
++ assert.deepEqual(await page.evaluate(`window.saved`), ['Yo there'], 'Save sends the edit to onSave');
++ assert.equal(await page.evaluate(`window.chatSent.length`), 0, 'an edit is not a new message');
++ assert.deepEqual(await page.evaluate(`({ bar: !!document.querySelector('#c1 .ovc-bar'), text: chat.text.value, send: document.querySelector('#c1 [data-ovt-send]').getAttribute('aria-label') })`),
++ { bar: false, text: 'half a draft', send: 'Send' }, 'after saving: the draft is back and the send is Send');
++ await page.evaluate(`chat.edit({ text: 'Yo', onSave: async () => {} })`);
++ await page.evaluate(`chat.text.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))`);
++ assert.deepEqual(await page.evaluate(`({ bar: !!document.querySelector('#c1 .ovc-bar'), text: chat.text.value, editing: chat.editing() })`), { bar: false, text: 'half a draft', editing: false }, 'Esc cancels');
++ // Replying: a bar names who, and the send carries the reply.
++ await page.evaluate(`chat.reply({ name: 'goosely', text: 'Yo', data: { id: 7 } })`);
++ assert.equal(await page.evaluate(`document.querySelector('#c1 .ovc-bar b').textContent`), 'Replying to goosely');
++ await page.evaluate(`document.querySelector('#c1 [data-ovt-send]').click()`);
++ await until(`window.chatSent.length === 1`);
++ assert.deepEqual(await page.evaluate(`window.chatSent[0]`), { text: 'half a draft', reply: { id: 7 } });
++ assert.equal(await page.evaluate(`!!document.querySelector('#c1 .ovc-bar')`), false, 'the reply bar goes with the send');
++
// Nothing is wider than the phone.
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
await page.close();
-- console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, 390 px');
++ console.log('composer-v2-chrome: four presets, a post\'s chip above and pictures under, onSubmit and false, the ring, emoji, a site\'s tool, editing and replying bars, 390 px');
} finally {
await browser.close();
server.close();
ddiff --git a/test/messages.test.js b/test/messages.test.js
iindex 247af40..b154fd5 100644
---- a/test/messages.test.js
++++ b/test/messages.test.js
@@@ -17,4 +17,31 @@ assert.strictEqual(root.querySelector('.ovm-link').getAttribute('href'), 'https:
assert.strictEqual(root.querySelector('.ovm-group[data-mine="true"]').dataset.tail, 'true');
render(root, { view: 'room', messages: [{ id: 4, username: 'bob', message: 'room line' }] });
assert.strictEqual(root.querySelector('.ovm-room-text').textContent, 'room line');
--console.log('messages: room and bubbles, groups, dates, links and profile picture ok');
++// Actions (openvibe.design rule 138): one cluster per message at its corner; edit and delete only on your own;
++// "edited" is one word after the words.
++const pressed = [];
++render(root, { view: 'room', me: 1, actions: ['reply', 'edit', 'delete'], onAction: (id, m) => pressed.push(`${id}:${m.id}`), messages: [
++ { id: 5, sender_id: 2, username: 'bob', message: 'theirs' },
++ { id: 6, sender_id: 1, username: 'alice', message: 'mine', edited_at: '2026-11-12 02:49:00' },
++] });
++const rows = root.querySelectorAll('.ovm-room');
++assert.strictEqual(rows[0].querySelectorAll('.ovm-actions .ovm-act').length, 1, 'someone else\'s message: reply only');
++assert.deepStrictEqual([...rows[1].querySelectorAll('.ovm-act')].map((b) => b.dataset.action), ['reply', 'edit', 'delete']);
++assert.strictEqual(rows[1].querySelectorAll('.ovm-actions').length, 1, 'one cluster');
++assert.ok(!rows[1].querySelector('.ovm-room-text .ovm-act'), 'no action inside the words');
++assert.strictEqual(rows[1].querySelector('.ovm-room-text').nextElementSibling.className, 'ovm-edited', '"edited" right after the words');
++assert.strictEqual(rows[1].querySelector('.ovm-edited').textContent, 'edited');
++assert.ok(/^Edited/.test(rows[1].querySelector('.ovm-edited').title));
++rows[1].querySelector('.ovm-act-edit').dispatchEvent(new window.Event('click', { bubbles: true }));
++assert.deepStrictEqual(pressed, ['edit:6']);
++assert.ok(rows[1].querySelector('.ovm-act-edit').getAttribute('aria-label'), 'each action is labelled');
++// A site's own row gets the same cluster.
++const own = document.createElement('div'); own.className = 'chat-msg'; root.appendChild(own);
++const { actions } = require('../messages');
++assert.strictEqual(actions(own, ['reply', 'edit'], { mine: false }).children.length, 1);
++assert.strictEqual(actions(own, ['edit'], { mine: false }), null, 'nothing to offer: no cluster');
++// The CSS: absolute, hidden until asked for, thumb-sized without hover.
++const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'messages.css'), 'utf8');
++assert.match(css, /\.ovm-actions \{ position: absolute;[^}]*display: none;/);
++assert.match(css, /@media \(hover: none\) \{[\s\S]{0,300}\.ovm-act \{ width: 44px; height: 40px;/);
++console.log('messages: room and bubbles, groups, dates, links and profile picture ok; actions in one cluster; edited after the words');