Init commit

This commit is contained in:
pporcheret
2026-10-24 17:55:05 +02:00
parent 7152b10695
commit c9c3d5f223
122 changed files with 7154 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */
(() => {
'use strict';
const button = document.getElementById('cameraToggle');
const image = document.getElementById('cameraFeed');
const placeholder = document.getElementById('cameraPlaceholder');
const message = document.getElementById('cameraMessage');
const panel = document.querySelector('.camera-panel');
let active = false;
function stop() {
active = false;
image.removeAttribute('src');
image.hidden = true;
placeholder.hidden = false;
message.textContent = 'Caméra désactivée';
button.textContent = 'Activer';
button.setAttribute('aria-pressed', 'false');
panel.dataset.active = 'false';
}
function start() {
active = true;
placeholder.hidden = false;
message.textContent = 'Connexion à la caméra…';
image.hidden = false;
image.src = `/api/camera/stream?t=${Date.now()}`;
button.textContent = 'Arrêter';
button.setAttribute('aria-pressed', 'true');
}
button.addEventListener('click', () => active ? stop() : start());
image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } });
image.addEventListener('error', () => {
if (!active) return;
stop();
message.textContent = 'Caméra indisponible ou déjà utilisée';
});
// Si l'onglet n'est pas visible, relâcher le périphérique USB.
document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); });
window.addEventListener('pagehide', stop);
stop();
})();
+93
View File
@@ -0,0 +1,93 @@
/* Contrôles audio depuis un pop-up + raccourci mute haut-parleur. */
(() => {
'use strict';
const panel = document.getElementById('audioSettingsPanel');
const button = document.getElementById('audioSettingsButton');
const close = document.getElementById('closeAudioSettings');
const speaker = document.getElementById('speakerMuteQuick');
const mic = document.getElementById('micMute');
const volume = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const gain = document.getElementById('micGainSlider');
const gainValue = document.getElementById('micGainValue');
const message = document.getElementById('audioSettingsMessage');
let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false };
let volumeTimer, gainTimer;
function render() {
volume.value = settings.volume;
volumeValue.textContent = `${settings.volume} %`;
gain.disabled = !Number.isFinite(settings.mic_gain);
if (!gain.disabled) gain.value = settings.mic_gain;
gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`;
speaker.setAttribute('aria-pressed', String(settings.speaker_muted));
speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son');
speaker.title = speaker.getAttribute('aria-label');
mic.setAttribute('aria-pressed', String(settings.mic_muted));
mic.textContent = settings.mic_muted ? 'Coupé' : 'Actif';
mic.title = settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone';
}
async function update(changes) {
message.textContent = '';
try {
const response = await fetch('/api/audio/settings', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(changes)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
settings = { ...settings, ...data };
render();
} catch (err) {
message.textContent = `Impossible de modifier le réglage : ${err.message}`;
console.warn('[ALFRED AUDIO]', err);
await refresh();
}
}
async function refresh() {
try {
const response = await fetch('/api/audio/settings', {cache:'no-store'});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
settings = { ...settings, ...await response.json() };
render();
} catch (err) {
message.textContent = 'Réglages indisponibles.';
console.warn('[ALFRED AUDIO]', err);
}
}
function setOpen(open) {
panel.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) { refresh(); close.focus(); } else { button.focus(); }
}
button.addEventListener('click', () => setOpen(panel.hidden));
close.addEventListener('click', () => setOpen(false));
document.addEventListener('pointerdown', e => {
if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) {
panel.hidden = true; button.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); });
volume.addEventListener('input', () => {
settings.volume = Number(volume.value); render();
clearTimeout(volumeTimer);
volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220);
});
gain.addEventListener('input', () => {
settings.mic_gain = Number(gain.value); render();
clearTimeout(gainTimer);
gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250);
});
speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted}));
mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted}));
if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => {
settings = {...settings, ...data}; render();
});
render(); refresh();
})();
+35
View File
@@ -0,0 +1,35 @@
/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */
(() => {
'use strict';
const el = document.getElementById('mode');
if (!el) return;
const icons = {
idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'],
listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'],
speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur']
};
let current = 'idle';
function apply(raw) {
const key = String(raw || '').trim().toLowerCase();
if (!icons[key]) return;
current = key;
const [icon, label] = icons[key];
if (el.textContent !== icon) el.textContent = icon;
el.title = label;
el.setAttribute('aria-label', label);
el.dataset.mode = key;
}
// L'ancien alfred.js écrit le nom du mode dans #mode.
// L'observateur le convertit immédiatement en icône, sans modifier ce script.
const observer = new MutationObserver(() => {
const value = el.textContent.trim();
if (icons[value.toLowerCase()]) apply(value);
});
observer.observe(el, { childList: true, characterData: true, subtree: true });
apply(el.textContent);
// Récupère également l'état actuel après un rafraîchissement de page.
fetch('/api/state', { cache: 'no-store' })
.then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); })
.then(data => { if (data && data.mode) apply(data.mode); })
.catch(() => {});
})();
+121
View File
@@ -0,0 +1,121 @@
/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */
(() => {
'use strict';
const socket = (typeof io === 'function') ? io() : null;
window.alfredSocket = socket;
const content = document.getElementById('content');
const modeEl = document.getElementById('mode');
const statusDot = document.getElementById('statusDot');
const transcriptText = document.getElementById('transcriptText');
const transcriptState = document.getElementById('transcriptState');
const transcriptPanel = document.querySelector('.transcript-panel');
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');
let mode = 'idle', level = 0.02, phase = 0, previousContent = '';
const labels = {
idle:'En attente', waiting:'En attente', listening:'À l’écoute',
thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur'
};
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
}
window.addEventListener('resize', resize);
resize();
function draw() {
const dpr = window.devicePixelRatio || 1;
phase += 0.035;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(232,241,243,.76)';
ctx.lineWidth = 1.6 * dpr;
ctx.beginPath();
const mid = canvas.height / 2;
const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr;
for (let x = 0; x < canvas.width; x += 4 * dpr) {
const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp *
(0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI));
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
function makeText(tag, value, className) {
const el = document.createElement(tag);
if (className) el.className = className;
el.textContent = String(value ?? '');
return el;
}
function render(data) {
if (!data || typeof data !== 'object') return;
const key = JSON.stringify(data);
if (key === previousContent) return;
previousContent = key;
const fragment = document.createDocumentFragment();
if (data.type === 'image' && typeof data.url === 'string') {
// Refuser les URL javascript: et les schémas non prévus.
if (/^(https?:\/\/|\/[^/])/i.test(data.url)) {
const img = document.createElement('img');
img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred');
fragment.appendChild(img);
}
if (data.caption) fragment.appendChild(makeText('p', data.caption));
} else if (data.type === 'cards' && Array.isArray(data.items)) {
const cards = document.createElement('div'); cards.className = 'cards';
data.items.forEach(item => {
const card = makeText('article', '', 'card');
card.append(makeText('h2', item.title || ''), makeText('p', item.text || ''));
cards.appendChild(card);
});
fragment.appendChild(cards);
} else {
if (data.title) fragment.appendChild(makeText('h1', data.title));
fragment.appendChild(makeText('p', data.text || ''));
}
content.replaceChildren(fragment);
}
function updateTranscript(s) {
// Une phrase finale reste visible lors du retour en veille.
const finalText = String(s.transcript || '').trim();
const partial = String(s.partial || '').trim();
if (finalText) {
transcriptText.textContent = finalText;
transcriptText.classList.remove('is-placeholder');
transcriptState.textContent = 'Reconnu';
} else if (s.mode === 'listening') {
transcriptText.textContent = partial || 'Je vous écoute…';
transcriptText.classList.toggle('is-placeholder', !partial);
transcriptState.textContent = partial ? 'En cours' : 'Écoute';
} else if (s.mode === 'error') {
transcriptState.textContent = 'Erreur';
}
transcriptPanel.dataset.active = String(s.mode === 'listening');
}
function updateState(s) {
if (!s || typeof s !== 'object') return;
mode = String(s.mode || 'idle').toLowerCase();
modeEl.textContent = labels[mode] || mode;
modeEl.dataset.mode = mode;
statusDot.dataset.mode = mode;
updateTranscript(s);
if (s.content) render(s.content);
}
if (socket) {
socket.on('alfred_state', updateState);
socket.on('alfred_render', render);
socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); });
socket.on('connect', () => {
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
});
}
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
})();