// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here. (() => { if (location.hostname !== 'camera.localhost') return; let cameras = ['back'], devices = new Map(), current = null, generation = 0; let cameraError = null; function recordError(error) { cameraError = { name: error.name || 'Error', message: error.message || '' }; console.warn('Vulpes Camera:', cameraError.name, cameraError.message); } function renderError() { const node = document.querySelector('.overlay[data-type="request-fail"] #overlay-text'); if (!node || !cameraError) return; const fr = (document.documentElement.lang || navigator.language).startsWith('fr'); const messages = { NotAllowedError: [ 'Accès à la caméra refusé. Autorisez Photo à utiliser la caméra dans les permissions du système ou du navigateur.', 'Camera access denied. Allow Camera to use the device in system or browser permissions.', ], NotSupportedError: [ 'L’accès au capteur photo n’est pas encore disponible sur ce modèle.', 'Access to the camera sensor is not yet available on this model.', ], NotFoundError: [ 'Aucune caméra détectée. Vérifiez son branchement.', 'No camera detected. Check its connection.', ], NotReadableError: [ 'Impossible de démarrer la caméra. Elle peut être occupée, ou son accès matériel a échoué.', 'Unable to start the camera. It may be busy, or hardware access has failed.', ], OverconstrainedError: [ 'Le format demandé n’est pas disponible sur cette caméra.', 'The requested format is not available on this camera.', ], AbortError: [ 'L’ouverture de la caméra a été interrompue. Revenez dans Photo pour réessayer.', 'Camera startup was interrupted. Return to Camera to try again.', ], }; const text = (messages[cameraError.name] || [ 'Impossible d’ouvrir la caméra.', 'Unable to open the camera.', ])[fr ? 0 : 1]; node.removeAttribute('data-l10n-id'); if (node.textContent !== text) node.textContent = text; } new MutationObserver(renderError).observe(document.documentElement, { subtree: true, childList: true, }); addEventListener('localized', renderError); const nativeHidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden').get; let frameVisible = true; Object.defineProperty(document, 'hidden', { get: () => !frameVisible || nativeHidden.call(document), }); Object.defineProperty(document, 'visibilityState', { get: () => (document.hidden ? 'hidden' : 'visible'), }); addEventListener('message', (event) => { if ( event.source !== parent || event.origin !== 'http://system.localhost:' + location.port || event.data?.type !== 'vulpes-camera-visibility' ) return; const visible = event.data.visible === true; if (visible === frameVisible) return; frameVisible = visible; document.dispatchEvent(new Event('visibilitychange', { bubbles: true })); }); const nativeSource = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject'); Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', { configurable: true, get() { return nativeSource.get.call(this); }, set(stream) { nativeSource.set.call(this, stream); }, }); function emit(target, type, detail = {}) { const event = new Event(type); Object.assign(event, detail); target.dispatchEvent(event); } async function discover(stream) { if (stream.vulpesNative) { cameras = ['back', 'front']; return; } // Device enumeration may wait indefinitely for focus in embedded desktop contexts. // The already-open stream remains usable even when no device list is exposed. let timeout; const entries = await Promise.race([ navigator.mediaDevices.enumerateDevices(), new Promise((resolve) => (timeout = setTimeout(() => resolve([]), 1500))), ]).finally(() => clearTimeout(timeout)); const list = entries.filter((d) => d.kind === 'videoinput'); const settings = stream.getVideoTracks()[0].getSettings(); const active = list.find((d) => d.deviceId === settings.deviceId) || list[0]; const back = list.find((d) => /back|rear|environment/i.test(d.label)); const front = list.find((d) => /front|user|face/i.test(d.label)); const first = back || (settings.facingMode === 'user' && list.length > 1 ? list.find((d) => d !== active) : active); devices = new Map(); if (first) devices.set('back', first.deviceId); const second = front && front !== first ? front : list.find((d) => d !== first); if (second) devices.set('front', second.deviceId); cameras = devices.size ? [...devices.keys()] : ['back']; } async function nativeStream(name) { let session; const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session}); const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'}); session = meta.session; const canvas = document.createElement('canvas'); const sideways = meta.orientation % 180 !== 0; canvas.width = sideways ? meta.height : meta.width; canvas.height = sideways ? meta.width : meta.height; // Convert NV21 in a shader instead of blocking Gaia's UI with one JS loop // per pixel. The two textures contain Y and interleaved V/U respectively. const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true}); if (!gl) { await call({action:'stop'}); throw new DOMException('Camera preview requires WebGL', 'NotSupportedError'); } function shader(type, source) { const value = gl.createShader(type); gl.shaderSource(value, source); gl.compileShader(value); if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed'); return value; } const program = gl.createProgram(); gl.attachShader(program, shader(gl.VERTEX_SHADER, ` attribute vec2 position; varying vec2 coord; void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); } `)); gl.attachShader(program, shader(gl.FRAGMENT_SHADER, ` precision mediump float; varying vec2 coord; uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation; void main() { vec2 p=coord; if(rotation==90.) p=vec2(coord.y,1.-coord.x); else if(rotation==270.) p=vec2(1.-coord.y,coord.x); else if(rotation==180.) p=1.-coord; float y=1.16438356*(texture2D(luma,p).r-16./255.); vec4 vu=texture2D(chroma,p); float v=vu.r-128./255.; float u=vu.a-128./255.; gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.); } `)); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed'); gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW); const position=gl.getAttribLocation(program,'position'); gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0); gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation); const textures = ['luma','chroma'].map((name,index) => { const texture=gl.createTexture();gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); gl.uniform1i(gl.getUniformLocation(program,name),index);return texture; }); const timing = {frames:0, startedAt:performance.now(), conversionMs:0}; let stopped = false, timer, stream, sequence = -1, closing; const stop = () => { if (closing) return closing; stopped = true; clearTimeout(timer); closing = call({action:'stop'}).finally(() => { textures.forEach(texture => gl.deleteTexture(texture)); gl.deleteBuffer(buffer); gl.deleteProgram(program); gl.getExtension('WEBGL_lose_context')?.loseContext(); }); return closing; }; const draw = async () => { const frame = await call({action:'frame'}); if (stopped || !frame.data || frame.sequence === sequence) return false; const data = new Uint8Array(frame.data), w = meta.width, h = meta.height; if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame'); sequence = frame.sequence; const start=performance.now(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]); gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,w,h,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,w*h)); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]); gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,w/2,h/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(w*h)); gl.drawArrays(gl.TRIANGLE_STRIP,0,4); timing.frames++;timing.conversionMs+=performance.now()-start; return true; }; try { const deadline = Date.now() + 6000; while (!(await draw())) { if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError'); await new Promise(resolve => setTimeout(resolve, 50)); } stream = canvas.captureStream(24); stream.vulpesTiming = timing; stream.vulpesNative = true; stream.vulpesCameraMetadata = meta; stream.vulpesCameraControl = call; stream.vulpesCameraClose = stop; for (const track of stream.getTracks()) { const originalStop = track.stop.bind(track); track.stop = () => { stop().catch(recordError); originalStop(); }; } const next = async () => { if (stopped) return; const started = performance.now(); try { await draw(); } catch (error) { if (!stopped) { recordError(error); stream.getTracks().forEach(track => track.stop()); } return; } if (!stopped) timer = setTimeout(next, Math.max(0, 1000/24-(performance.now()-started))); }; timer = setTimeout(next, 1000/24); return stream; } catch (error) { await stop().catch(() => {}); throw error; } } async function getCamera(name, config = {}) { if (config.mode && config.mode !== 'picture') throw new DOMException('Video capture is not ported yet', 'NotSupportedError'); const ticket = ++generation; await current?.release?.(); const selected = devices.get(name); cameraError = null; let stream; try { const platform = await VulpesCompat.call('platform.capabilities', {}); if (platform.platform === 'tundra' && !platform.camera) throw new DOMException('Native camera adapter unavailable', 'NotSupportedError'); stream = platform.platform === 'tundra' ? await nativeStream(name) : await navigator.mediaDevices.getUserMedia({ audio: false, video: { ...(selected ? { deviceId: { exact: selected } } : { facingMode: { ideal: 'environment' } }), width: { ideal: 1920 }, height: { ideal: 1080 }, }, }); } catch (error) { recordError(error); throw error; } if (document.hidden || ticket !== generation) { stream.getTracks().forEach((t) => t.stop()); const error = new DOMException('Camera hidden', 'AbortError'); recordError(error); throw error; } current = stream; let preview; try { await discover(stream); if (document.hidden || ticket !== generation) throw new DOMException('Camera hidden', 'AbortError'); const track = stream.getVideoTracks()[0]; const settings = track.getSettings(); const size = { width: settings.width, height: settings.height }; preview = document.createElement('video'); preview.muted = true; preview.playsInline = true; preview.srcObject = stream; preview.dataset.vulpesCapture = ''; preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none'; document.body.append(preview); let previewTimeout; await Promise.race([ preview.play(), new Promise((_, reject) => { previewTimeout = setTimeout( () => reject(new DOMException('Camera preview timed out', 'NotReadableError')), 10000, ); }), ]).finally(() => clearTimeout(previewTimeout)); if (document.hidden || ticket !== generation) throw new DOMException('Camera hidden', 'AbortError'); const native = stream.vulpesCameraMetadata; const pictureSizes = native?.pictureSizes || [size]; let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b); let focusArea = null, zoom = 1, releasing; const configuration = () => ({ mode: 'picture', previewSize: size, pictureSize, recorderProfile: null, }); Object.assign(stream, { capabilities: { pictureSizes, previewSizes: [size], thumbnailSizes: [], recorderProfiles: {}, flashModes: ['off'], focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'], sceneModes: ['auto'], whiteBalanceModes: ['auto'], isoModes: ['auto'], zoomRatios: native?.zoomRatios || [1], maxFocusAreas: native?.maxFocusAreas || 0, maxMeteringAreas: 0, maxDetectedFaces: 0, }, resumeContinuousFocus() {}, sensorAngle: 0, focusMode: 'fixed', flashMode: 'off', zoom: 1, async setConfiguration(value) { if (value.pictureSize) stream.setPictureSize(value.pictureSize); if (value.mode && value.mode !== 'picture') throw new DOMException('Video unavailable', 'NotSupportedError'); queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' })); return configuration(); }, setPictureSize(value) { const selected = pictureSizes.find(s=>s.width===value.width && s.height===value.height); if (!selected) throw new DOMException('Unsupported picture size','NotSupportedError'); pictureSize = selected; }, getPictureSize: () => pictureSize, setFocusAreas(areas) { focusArea = areas?.[0] || null; }, setMeteringAreas() {}, async autoFocus() { if (!native?.focusModes.includes('auto')) return; emit(stream,'focus',{newState:'focusing'}); let area = null; if (focusArea) { const rotate = (x,y) => native.orientation===90 ? [y,-x] : native.orientation===270 ? [-y,x] : native.orientation===180 ? [-x,-y] : [x,y]; const points = [rotate(focusArea.left,focusArea.top),rotate(focusArea.right,focusArea.bottom)]; const clamp = n=>Math.max(-1000,Math.min(1000,Math.round(n))); area={left:clamp(Math.min(...points.map(p=>p[0]))),right:clamp(Math.max(...points.map(p=>p[0]))), top:clamp(Math.min(...points.map(p=>p[1]))),bottom:clamp(Math.max(...points.map(p=>p[1])))}; } try { await stream.vulpesCameraControl({action:'focus',area}); emit(stream,'focus',{newState:'focused'}); } catch(error) {emit(stream,'focus',{newState:'failed'});throw error;} }, setThumbnailSize() {}, async takePicture() { if (native) { const result=await stream.vulpesCameraControl({action:'picture',size:pictureSize}); emit(stream,'shutter'); return new Blob([result.data],{type:'image/jpeg'}); } if (track.readyState !== 'live' || !preview.videoWidth) throw new DOMException('Camera inactive', 'InvalidStateError'); const canvas = document.createElement('canvas'); canvas.width = preview.videoWidth; canvas.height = preview.videoHeight; canvas.getContext('2d').drawImage(preview, 0, 0); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92)); if (!blob) throw new Error('JPEG capture failed'); emit(stream, 'shutter'); return blob; }, resumePreview() { emit(stream, 'previewstatechange', { newState: 'started' }); }, release() { if (releasing) return releasing; // Detach first: visibility changes and Gaia may release the same stream // again while its hardware worker is still shutting down. if (current === stream) current = null; releasing = Promise.resolve().then(async () => { for (const video of document.querySelectorAll('video')) { if (video.srcObject === stream) { video.pause(); video.srcObject = null; } } preview.remove(); await stream.vulpesCameraClose?.(); stream.getTracks().forEach(t => t.stop()); }); return releasing; }, async startRecording() { throw new DOMException('Video unavailable', 'NotSupportedError'); }, stopRecording() {}, }); if (native) Object.defineProperty(stream,'zoom',{ configurable:true, get:()=>zoom, set(value) { if (!Number.isFinite(value)) return; const ratios=native.zoomRatios; const nearest=ratios.reduce((a,b)=>Math.abs(a-value){zoom=result.zoom;}) .catch(recordError); } }); dispatchEvent( new CustomEvent('vulpes-camera-capabilities', { detail: { cameras: [...cameras], flash: false, video: false }, }), ); return { camera: stream, configuration: configuration() }; } catch (error) { recordError(error); stream.getTracks().forEach((t) => t.stop()); if (preview) { preview.srcObject = null; preview.remove(); } if (current === stream) current = null; throw error; } } Object.defineProperty(navigator, 'mozCameras', { value: { getListOfCameras: () => [...cameras], getCamera }, }); const stop = () => { // A permission dialog can hide the document before any stream exists. // Keep that request valid; its completion still checks visibility. if (!current) return; ++generation; if (current?.release) current.release().catch(recordError); else current?.getTracks().forEach((t) => t.stop()); }; addEventListener('pagehide', stop); addEventListener('visibilitychange', () => { if (document.hidden) stop(); }); // Preserve familiar positions. Unavailable controls remain visible, labelled and inert. function updateControls() { const app = window.app; if (!app || !document.querySelector('.hud')) return; app.camera.cameraList = [...cameras]; app.settings.cameras.filterOptions(cameras); document.querySelector('.hud').setAttribute('camera-enabled', String(cameras.length > 1)); const controls = [ [ '.js-flash', true, 'Flash indisponible dans cette Preview', 'Flash unavailable in this Preview', 'flash-off', ], [ '.js-camera', cameras.length < 2, 'Une seule caméra disponible', 'Only one camera available', 'toggle-camera-rear', ], ['.mode-switch', true, 'Vidéo pas encore disponible', 'Video is not available yet', null], ]; const fr = (document.documentElement.lang || navigator.language).startsWith('fr'); for (const [selector, disabled, french, english, icon] of controls) { const e = document.querySelector(selector); if (!e) continue; e.classList.toggle('vulpes-camera-unavailable', disabled); e.setAttribute('aria-disabled', String(disabled)); if (!disabled) { e.removeAttribute('title'); e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera'); } if (disabled) { e.removeAttribute('data-l10n-id'); e.setAttribute('aria-label', fr ? french : english); e.title = fr ? french : english; for (const child of e.querySelectorAll('[role=button]')) { child.setAttribute('aria-disabled', 'true'); child.removeAttribute('data-l10n-id'); child.setAttribute('aria-label', e.title); } if (icon) e.dataset.icon = icon; } } } let bound = false; const binding = setInterval(() => { if (!window.app || !document.querySelector('.hud') || bound) return; bound = true; clearInterval(binding); app.on('settings:configured', updateControls); app.on('localized', updateControls); updateControls(); }, 100); addEventListener('pagehide', () => clearInterval(binding)); addEventListener('vulpes-camera-capabilities', updateControls); // A pinch belongs to the viewfinder, never to the surrounding Gaia document. addEventListener('touchmove', event => { if (event.touches.length > 1) event.preventDefault(); }, {passive:false}); addEventListener( 'click', (event) => { if (event.target.closest?.('.vulpes-camera-unavailable')) { event.preventDefault(); event.stopImmediatePropagation(); } }, true, ); for (const name of ['touchstart', 'touchmove', 'touchend', 'pointerdown']) addEventListener( name, (event) => { if (event.target.closest?.('.vulpes-camera-unavailable')) { event.preventDefault(); event.stopImmediatePropagation(); } }, { capture: true, passive: false }, ); })();