openvibers/openvibe.shared ov-capture.js
ov-capture.js
| 1 | /* |
| 2 | * ov-capture.js: OpenVibe's capture core, window.OVCapture. One way to open a camera, a microphone or a screen, take a |
| 3 | * photo and record a clip, on every OpenVibe site: Live's go-live page, OpenVibe.Cam's recorder and <ov-camera>, and the |
| 4 | * composer's camera button. Built from parts that can each be replaced, and served by each site from its own copy |
| 5 | * (/shared/ov-capture.js), so a camera never waits on another site, a content policy or a cache. |
| 6 | * |
| 7 | * The owner, 2026-11-12: Live's capture must be "completely customize[able] … implemented in a highly over engineered |
| 8 | * modular editable way", and then "OpenVibe.cam is actually fine … maybe we should be using that for live … Harmony". |
| 9 | * 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 |
| 10 | * 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 |
| 11 | * copy of this file cannot be refused that way. |
| 12 | * |
| 13 | * Using it: |
| 14 | * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. mode: 'camera' (both), 'camera_only', |
| 15 | * 'mic_only'. Runs the strategies in order, each with its |
| 16 | * own time limit; the first that opens wins. |
| 17 | * OVCapture.screen({ audio, frameRate }) → a MediaStream of a screen, a window or a tab. |
| 18 | * OVCapture.photo(stream, { type, quality, mirror }) → a Blob: the camera's picture now. |
| 19 | * OVCapture.record(stream, { maxSeconds, mimeType }) → { stop() → Promise<Blob>, cancel(), seconds(), mimeType }. |
| 20 | * OVCapture.devices() → the browser's cameras and microphones. |
| 21 | * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame. |
| 22 | * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown). |
| 23 | * OVCapture.can() → { camera, screen, record, secure }: what this browser |
| 24 | * allows here, so a page can hide what it cannot do. |
| 25 | * OVCapture.stop(stream) |
| 26 | * OVCapture.explain(error, mode, { instead }) → { title, steps }: what happened and what to tap, for |
| 27 | * this browser. `instead` adds a last step of the site's own |
| 28 | * (Live: "go live with your screen instead"). |
| 29 | * OVCapture.settings.load() / .save(partial) → the person's choices: facing, lens, mic, resolution, |
| 30 | * mirror, effects (the 'store' part; this browser first). |
| 31 | * OVCapture.settings.sync({ load, save }) → unsync(): the choices that are the same on every device |
| 32 | * (the 'portable' part: facing, resolution, mirror, |
| 33 | * effects) follow the person through a remote store, such |
| 34 | * as OpenVibe.Cam's camera settings. Camera and microphone |
| 35 | * ids stay on the device that has them. |
| 36 | * |
| 37 | * Changing it: |
| 38 | * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through: |
| 39 | * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … })); |
| 40 | * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, getDisplayMedia, enumerate, |
| 41 | * meter, network, still, recorder, mimeTypes, store, portable, explain, timeoutMs. |
| 42 | * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }), |
| 43 | * .remove(name), .replace(name, strategy), .reset(). A strategy is |
| 44 | * { name, note?, timeoutMs?, open(options, capture) }. |
| 45 | * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } · |
| 46 | * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings } · |
| 47 | * 'synced' { settings } · 'sync-failed' { error } · 'recording' { seconds } · |
| 48 | * 'recorded' { blob, seconds }. Returns off(). |
| 49 | * Every name and every default is in this file. It fetches nothing; a page that wants the person's settings to follow |
| 50 | * them passes its own remote store to settings.sync(). |
| 51 | */ |
| 52 | (function (root) { |
| 53 | ; |
| 54 | const VERSION = 2; |
| 55 | if (!root || (root.OVCapture && Number(root.OVCapture.version) >= VERSION)) return; |
| 56 | const nav = () => root.navigator || {}; |
| 57 | const doc = () => root.document || null; |
| 58 | |
| 59 | // ── Parts: each one plain, each one replaceable with use(). ───────────────────────────────────────────────────── |
| 60 | const parts = { |
| 61 | /** What to ask the browser for, from the options (facing, lens, mic, resolution). */ |
| 62 | constraints(o = {}) { |
| 63 | const height = Number(o.resolution) || 720; |
| 64 | const video = o.video === false ? false : { |
| 65 | facingMode: { ideal: o.facing || 'user' }, |
| 66 | ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}), |
| 67 | width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height }, |
| 68 | }; |
| 69 | const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true); |
| 70 | return { video, audio }; |
| 71 | }, |
| 72 | getUserMedia: (constraints) => nav().mediaDevices.getUserMedia(constraints), |
| 73 | getDisplayMedia: (constraints) => nav().mediaDevices.getDisplayMedia(constraints), |
| 74 | enumerate: () => nav().mediaDevices.enumerateDevices(), |
| 75 | meter(stream, onLevel) { |
| 76 | const AC = root.AudioContext || root.webkitAudioContext; |
| 77 | if (!AC || !stream || !stream.getAudioTracks().length) return () => {}; |
| 78 | const ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512); |
| 79 | let frame = 0, closed = false; |
| 80 | analyser.fftSize = 512; |
| 81 | ctx.createMediaStreamSource(stream).connect(analyser); |
| 82 | if (ctx.state === 'suspended') { |
| 83 | ctx.resume().catch(() => {}); |
| 84 | const d = doc(); |
| 85 | if (d) d.addEventListener('pointerdown', () => { ctx.resume().catch(() => {}); }, { once: true, capture: true }); |
| 86 | } |
| 87 | const tick = () => { |
| 88 | if (closed) return; |
| 89 | analyser.getByteTimeDomainData(data); |
| 90 | let sum = 0; |
| 91 | for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; } |
| 92 | onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4)); |
| 93 | frame = root.requestAnimationFrame(tick); |
| 94 | }; |
| 95 | frame = root.requestAnimationFrame(tick); |
| 96 | return () => { closed = true; root.cancelAnimationFrame(frame); ctx.close().catch(() => {}); }; |
| 97 | }, |
| 98 | network() { |
| 99 | const n = nav(); |
| 100 | if (n.onLine === false) return 'offline'; |
| 101 | const c = n.connection || n.mozConnection || n.webkitConnection; |
| 102 | if (!c || !(c.effectiveType || c.downlink)) return ''; |
| 103 | return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good'; |
| 104 | }, |
| 105 | /** The camera's picture now, as a Blob: the video track drawn once into a canvas. */ |
| 106 | async still(stream, o = {}) { |
| 107 | const d = doc(); |
| 108 | const track = stream && stream.getVideoTracks()[0]; |
| 109 | if (!d || !track) throw Object.assign(new Error('No camera picture to take'), { name: 'NotFoundError' }); |
| 110 | const video = d.createElement('video'); |
| 111 | video.muted = true; video.playsInline = true; video.srcObject = new root.MediaStream([track]); |
| 112 | await video.play().catch(() => {}); |
| 113 | if (!video.videoWidth) await new Promise((r) => { video.addEventListener('loadeddata', r, { once: true }); root.setTimeout(r, 1500); }); |
| 114 | const w = video.videoWidth || 1280, h = video.videoHeight || 720; |
| 115 | const canvas = d.createElement('canvas'); |
| 116 | canvas.width = w; canvas.height = h; |
| 117 | const g = canvas.getContext('2d'); |
| 118 | if (o.mirror) { g.translate(w, 0); g.scale(-1, 1); } |
| 119 | g.drawImage(video, 0, 0, w, h); |
| 120 | video.srcObject = null; |
| 121 | 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)); |
| 122 | }, |
| 123 | /** A MediaRecorder for the stream. */ |
| 124 | recorder: (stream, options) => new root.MediaRecorder(stream, options), |
| 125 | /** The recording formats to try, best first; the first this browser supports is used. */ |
| 126 | mimeTypes: { |
| 127 | video: ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'], |
| 128 | audio: ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'], |
| 129 | }, |
| 130 | /** Where the person's choices live on this device. Device ids are this browser's own, so this store is local. */ |
| 131 | store: { |
| 132 | key: 'ov_capture_settings', |
| 133 | load() { try { return JSON.parse(root.localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } }, |
| 134 | save(settings) { try { root.localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } }, |
| 135 | }, |
| 136 | /** The choices that mean the same on any device, so they may follow the person (settings.sync). */ |
| 137 | portable: ['facing', 'resolution', 'mirror', 'effects'], |
| 138 | explain: null, // set below (explainFailure): the plain words for a failure, by browser |
| 139 | timeoutMs: 10000, |
| 140 | }; |
| 141 | |
| 142 | // ── Events ────────────────────────────────────────────────────────────────────────────────────────────────── |
| 143 | const listeners = new Map(); |
| 144 | function on(event, fn) { |
| 145 | if (!listeners.has(event)) listeners.set(event, new Set()); |
| 146 | listeners.get(event).add(fn); |
| 147 | return () => listeners.get(event).delete(fn); |
| 148 | } |
| 149 | function emit(event, detail) { |
| 150 | (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } }); |
| 151 | } |
| 152 | |
| 153 | // ── Strategies: the ways to open a camera, tried in order. ────────────────────────────────────────────────────── |
| 154 | const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false }); |
| 155 | const merge = (...streams) => { |
| 156 | const out = new root.MediaStream(); |
| 157 | streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t))); |
| 158 | return out; |
| 159 | }; |
| 160 | const defaults = [ |
| 161 | { |
| 162 | name: 'preferred', |
| 163 | note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).', |
| 164 | open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })), |
| 165 | }, |
| 166 | { |
| 167 | name: 'separately', |
| 168 | note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).', |
| 169 | async open(o, c) { |
| 170 | const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints'); |
| 171 | let video = null, audio = null, last = null; |
| 172 | if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } } |
| 173 | if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } } |
| 174 | if (!video && !audio) throw last || new root.DOMException('No camera or microphone', 'NotFoundError'); |
| 175 | return merge(video, audio); |
| 176 | }, |
| 177 | }, |
| 178 | { |
| 179 | name: 'basic', |
| 180 | note: 'Any camera and any microphone, nothing asked of them.', |
| 181 | open: (o, c) => c.part('getUserMedia')(wants(o)), |
| 182 | }, |
| 183 | ]; |
| 184 | let order = defaults.slice(); |
| 185 | const strategies = { |
| 186 | list: () => order.map((s) => ({ name: s.name, note: s.note || '' })), |
| 187 | add(strategy, at = {}) { |
| 188 | if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }'); |
| 189 | order = order.filter((s) => s.name !== strategy.name); |
| 190 | const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1; |
| 191 | if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy); |
| 192 | }, |
| 193 | remove(name) { order = order.filter((s) => s.name !== name); }, |
| 194 | replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); }, |
| 195 | reset() { order = defaults.slice(); }, |
| 196 | }; |
| 197 | |
| 198 | // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently. |
| 199 | const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 }; |
| 200 | const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a); |
| 201 | const refused = (e) => !!e && (e.name === 'NotAllowedError' || e.name === 'PermissionDeniedError' || e.name === 'SecurityError'); |
| 202 | function within(promise, ms, name) { |
| 203 | let timer; |
| 204 | return Promise.race([promise, new Promise((_, reject) => { timer = root.setTimeout(() => reject(new root.DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })]) |
| 205 | .finally(() => root.clearTimeout(timer)); |
| 206 | } |
| 207 | const noApi = (what) => Object.assign(new Error(`This browser has no ${what} here`), { name: 'NoApi' }); |
| 208 | |
| 209 | async function open(options = {}) { |
| 210 | const md = nav().mediaDevices; |
| 211 | if (!md || !md.getUserMedia) throw noApi('camera'); |
| 212 | const o = { ...capture.settings.load(), ...options }; |
| 213 | let worst = null; |
| 214 | for (const s of order) { |
| 215 | emit('opening', { strategy: s.name, options: o }); |
| 216 | try { |
| 217 | const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name); |
| 218 | const w = wants(o); |
| 219 | // A strategy that opened less than was asked for has not opened it: say so and stop the tracks. |
| 220 | if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) { |
| 221 | stream.getTracks().forEach((t) => t.stop()); |
| 222 | throw new root.DOMException('The requested device was not available', 'NotFoundError'); |
| 223 | } |
| 224 | emit('opened', { strategy: s.name, stream }); |
| 225 | return stream; |
| 226 | } catch (error) { |
| 227 | worst = heavier(worst, error); |
| 228 | emit('failed', { strategy: s.name, error }); |
| 229 | // A refusal is the person's answer: asking again in another way would only ask again. |
| 230 | if (refused(error)) break; |
| 231 | } |
| 232 | } |
| 233 | emit('gave-up', { error: worst }); |
| 234 | throw worst || new Error('The camera did not open'); |
| 235 | } |
| 236 | |
| 237 | async function screen(options = {}) { |
| 238 | const md = nav().mediaDevices; |
| 239 | if (!md || !md.getDisplayMedia) throw noApi('screen sharing'); |
| 240 | const constraints = { video: { frameRate: { ideal: Number(options.frameRate) || 30 } }, audio: options.audio !== false }; |
| 241 | emit('opening', { strategy: 'screen', options }); |
| 242 | try { |
| 243 | const stream = await parts.getDisplayMedia(constraints); |
| 244 | emit('opened', { strategy: 'screen', stream }); |
| 245 | return stream; |
| 246 | } catch (error) { |
| 247 | emit('failed', { strategy: 'screen', error }); |
| 248 | emit('gave-up', { error }); |
| 249 | throw error; |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // ── Recording ───────────────────────────────────────────────────────────────────────────────────────────── |
| 254 | function pickMime(kind, wanted) { |
| 255 | const MR = root.MediaRecorder; |
| 256 | const list = wanted ? [wanted] : (parts.mimeTypes[kind] || []); |
| 257 | if (!MR || typeof MR.isTypeSupported !== 'function') return ''; |
| 258 | return list.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || ''; |
| 259 | } |
| 260 | function record(stream, o = {}) { |
| 261 | if (!root.MediaRecorder) throw noApi('recorder'); |
| 262 | const kind = stream.getVideoTracks().length ? 'video' : 'audio'; |
| 263 | const mimeType = pickMime(kind, o.mimeType); |
| 264 | const rec = parts.recorder(stream, mimeType ? { mimeType } : undefined); |
| 265 | const chunks = []; |
| 266 | const max = Math.max(1, Number(o.maxSeconds) || 60); |
| 267 | const started = Date.now(); |
| 268 | let ticker = 0, limit = 0, done = null, cancelled = false; |
| 269 | const seconds = () => Math.min(max, Math.round((Date.now() - started) / 100) / 10); |
| 270 | const finished = new Promise((resolve, reject) => { |
| 271 | rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; |
| 272 | rec.onerror = (e) => reject((e && e.error) || new Error('The recording failed')); |
| 273 | rec.onstop = () => { |
| 274 | root.clearInterval(ticker); root.clearTimeout(limit); |
| 275 | if (cancelled) { resolve(null); return; } |
| 276 | const blob = new root.Blob(chunks, { type: (mimeType || rec.mimeType || `${kind}/webm`).split(';')[0] }); |
| 277 | emit('recorded', { blob, seconds: seconds() }); |
| 278 | resolve(blob); |
| 279 | }; |
| 280 | }); |
| 281 | rec.start(1000); |
| 282 | ticker = root.setInterval(() => emit('recording', { seconds: seconds() }), 500); |
| 283 | limit = root.setTimeout(() => { if (rec.state !== 'inactive') rec.stop(); }, max * 1000); |
| 284 | return { |
| 285 | mimeType: mimeType || rec.mimeType || '', |
| 286 | seconds, |
| 287 | stop() { if (!done) { done = finished; if (rec.state !== 'inactive') rec.stop(); } return done; }, |
| 288 | cancel() { cancelled = true; if (rec.state !== 'inactive') rec.stop(); }, |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | // ── Plain words for a failure, by browser. ─────────────────────────────────────────────────────────────── |
| 293 | function explainFailure(err, mode, extra = {}) { |
| 294 | const n = nav(), ua = n.userAgent || ''; |
| 295 | const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && n.maxTouchPoints > 1); |
| 296 | const mac = /Macintosh/.test(ua) && !ios; |
| 297 | const android = /Android/.test(ua); |
| 298 | const firefox = /Firefox|FxiOS/.test(ua); |
| 299 | const safari = /Safari/.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua); |
| 300 | const inApp = /FBAN|FBAV|Instagram|Line\/|Twitter|Discord|TikTok|musical_ly|Snapchat|GSA\//.test(ua) || /; wv\)/.test(ua); |
| 301 | const what = mode === 'mic_only' ? 'microphone' : mode === 'camera_only' ? 'camera' : mode === 'screen' ? 'screen' : 'camera and microphone'; |
| 302 | const both = mode === 'camera_only' ? '' : ' and Microphone'; |
| 303 | const name = err && err.name; |
| 304 | const instead = extra && extra.instead ? [extra.instead] : []; |
| 305 | if (name === 'NoApi' || (inApp && name !== 'NotReadableError')) { |
| 306 | return { title: `This browser can't use your ${mode === 'screen' ? 'screen' : 'camera'} here`, steps: inApp |
| 307 | ? ['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.'] |
| 308 | : mode === 'screen' && (ios || android) ? ['Phones can\'t share their screen from a browser. Use a computer, or your camera instead.', ...instead] |
| 309 | : ['Open this page in Chrome, Safari, Edge or Firefox, then try again.', ...instead] }; |
| 310 | } |
| 311 | if (mode === 'screen' && refused(err)) { |
| 312 | return { title: 'Screen sharing was stopped', steps: mac |
| 313 | ? ['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.'] |
| 314 | : ['Try again and pick a screen, a window or a tab, then press Share.'] }; |
| 315 | } |
| 316 | if (refused(err)) { |
| 317 | 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.`] |
| 318 | : ios ? ['Open the iPhone Settings app, find this browser, and turn on Camera and Microphone.', 'Come back here.'] |
| 319 | : 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.`] |
| 320 | : firefox ? ['Click the camera icon with a line through it, at the left of the address bar.', 'Remove "Blocked temporarily" (the ×).'] |
| 321 | : safari ? ['Open Safari\'s menu → Settings for this website.', `Set Camera${both} to Allow.`] |
| 322 | : ['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.`]; |
| 323 | return { title: `Your browser said no to the ${what}`, steps: [...steps, 'Then press Try again.'] }; |
| 324 | } |
| 325 | if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') { |
| 326 | 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.'] }; |
| 327 | } |
| 328 | if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { |
| 329 | 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] }; |
| 330 | } |
| 331 | if (name === 'OverconstrainedError') { |
| 332 | 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.'] }; |
| 333 | } |
| 334 | if (name === 'TimeoutError') { |
| 335 | 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.'] }; |
| 336 | } |
| 337 | 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.'] }; |
| 338 | } |
| 339 | parts.explain = explainFailure; |
| 340 | |
| 341 | // ── Settings: this device first; the portable ones may follow the person. ────────────────────────────────────── |
| 342 | const DEFAULTS = { facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, effects: 'none' }; |
| 343 | const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o && o[k] !== undefined).map((k) => [k, o[k]])); |
| 344 | let remote = null; |
| 345 | const settings = { |
| 346 | load: () => ({ ...DEFAULTS, ...parts.store.load() }), |
| 347 | save(partial) { |
| 348 | const next = { ...settings.load(), ...(partial || {}) }; |
| 349 | parts.store.save(next); |
| 350 | emit('settings', { settings: next }); |
| 351 | const r = remote; |
| 352 | const portable = pick(partial || {}, parts.portable); |
| 353 | if (r && Object.keys(portable).length) { |
| 354 | Promise.resolve().then(() => r.save(pick(next, parts.portable))).catch((error) => emit('sync-failed', { error })); |
| 355 | } |
| 356 | return next; |
| 357 | }, |
| 358 | /** Follow the person: take the remote store's portable choices now, and send each later change to it. */ |
| 359 | sync(store) { |
| 360 | if (!store || typeof store.load !== 'function' || typeof store.save !== 'function') throw new TypeError('settings.sync takes { load(), save(settings) }'); |
| 361 | remote = store; |
| 362 | const mine = store; |
| 363 | Promise.resolve().then(() => store.load()).then((theirs) => { |
| 364 | if (remote !== mine || !theirs || typeof theirs !== 'object') return; |
| 365 | const next = { ...settings.load(), ...pick(theirs, parts.portable) }; |
| 366 | parts.store.save(next); |
| 367 | emit('settings', { settings: next }); |
| 368 | emit('synced', { settings: next }); |
| 369 | }).catch((error) => emit('sync-failed', { error })); |
| 370 | return () => { if (remote === mine) remote = null; }; |
| 371 | }, |
| 372 | }; |
| 373 | |
| 374 | // ── The API ───────────────────────────────────────────────────────────────────────────────────────────────── |
| 375 | const capture = { |
| 376 | version: VERSION, |
| 377 | part(name) { |
| 378 | if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`); |
| 379 | return parts[name]; |
| 380 | }, |
| 381 | use(name, impl) { |
| 382 | const before = capture.part(name); |
| 383 | parts[name] = impl; |
| 384 | return before; |
| 385 | }, |
| 386 | parts: () => Object.keys(parts), |
| 387 | strategies, |
| 388 | on, |
| 389 | open, |
| 390 | screen, |
| 391 | photo: (stream, o = {}) => parts.still(stream, { mirror: false, ...o }), |
| 392 | record, |
| 393 | devices: () => parts.enumerate(), |
| 394 | meter: (stream, onLevel) => parts.meter(stream, onLevel), |
| 395 | network: () => parts.network(), |
| 396 | can() { |
| 397 | const md = nav().mediaDevices || {}; |
| 398 | return { camera: typeof md.getUserMedia === 'function', screen: typeof md.getDisplayMedia === 'function', record: typeof root.MediaRecorder === 'function', secure: root.isSecureContext !== false }; |
| 399 | }, |
| 400 | stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } }, |
| 401 | explain: (error, mode, extra) => parts.explain(error, mode, extra), |
| 402 | settings, |
| 403 | }; |
| 404 | root.OVCapture = capture; |
| 405 | if (typeof module === 'object' && module && module.exports) module.exports = capture; |
| 406 | })(typeof window !== 'undefined' ? window : globalThis); |
| 407 |