by CPAS » Tue Sep 02, 2025 9:57 am
Salut — la migration vers Canvas demandera une quantité de code que je ne peux pas prendre en charge. À ma connaissance, il n’existe pas de mécanisme permettant de « déposer » du code qui s’activerait automatiquement comme un plugin dans l’API Canvas.
Je peux toutefois préparer une partie du travail : je mettrai en place un contexte Canvas aux dimensions du lecteur, prêt à être utilisé pour le dessin. Ensuite, la personne qui voudra (et saura) écrire les instructions de dessin en JavaScript pourra réimplémenter le lecteur en langage bas niveau.
EDIT. voilà c'est intégré et aux bonnes dimensions, un espace de 610 par 987 pixels est disponible pour dessiner dans l'espace intérieur de l'écran Oravox (les bords bruns).
Code: Select all
'use strict';
const TEMPLATE_URL = new URL('./oravox-player.html', import.meta.url).href;
const STYLE_URL = new URL('./oravox-player.css', import.meta.url).href;
export default class OravoxPlayer extends HTMLElement {
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
this.state = { chapter: '001', currentTime: 0, objects: [] };
this._lastSave = Date.now();
this._rec = { recorder: null, stream: null, chunks: [], aborted: false, active: false };
this._sheetIndex = 0;
}
_norm(s) {
return String(s || '').toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async connectedCallback() {
const params = new URLSearchParams(location.search);
this._storyId = params.get('story') || 'empty';
const resp = await fetch(`/api/progress.php?story=${encodeURIComponent(this._storyId)}`, { credentials: 'include' });
if (resp.status === 401) return void location.replace('/login.html');
if (resp.ok) {
const data = await resp.json();
if (data.state_json) this._initialState = JSON.parse(data.state_json);
}
try {
const [html, css] = await Promise.all([
fetch(TEMPLATE_URL).then(r => r.text()),
fetch(STYLE_URL).then(r => r.text())
]);
this._shadow.innerHTML = `<style>${css}</style>${html}`;
const s = this._shadow;
this._audio = s.querySelector('audio');
this._audio.preload = 'metadata';
this._buttons = s.getElementById('buttons');
this._buttons2 = s.getElementById('buttons2');
this._interface = s.getElementById('interface');
this._synopsis = s.getElementById('synopsis');
this._play = s.getElementById('play');
this._stop = s.getElementById('stop');
this._back = s.getElementById('back');
this._restart = s.getElementById('restart');
this._quit = s.getElementById('quit');
this._bar = s.getElementById('bar');
this._fill = s.getElementById('fill');
this._cover = s.getElementById('cover');
this._choices = s.getElementById('choices');
this._btnA = s.getElementById('btnA');
this._btnB = s.getElementById('btnB');
this._btnC = s.getElementById('btnC');
this._chrono = s.getElementById('chrono');
this._recording = s.getElementById('recording');
this._timer = s.getElementById('timer');
this._canvas = s.getElementById("canvas");
this._ctx = this._canvas.getContext("2d");
this._audio.addEventListener('ended', async () => {
this._choices.style.display = '';
this._recording.style.display = '';
this._timer.style.display = '';
this._chrono.textContent = '0:00';
new Audio('/assets/audio/jingle.mp3').play().catch(() => {});
this._rec.aborted = false;
this._rec.active = false;
this._rec.chunks = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
this._rec.stream = stream;
this._rec.recorder = mediaRecorder;
this._rec.active = true;
console.log('[ enregistrement en cours ]');
mediaRecorder.addEventListener('dataavailable', e => {
if (e.data && e.data.size > 0) this._rec.chunks.push(e.data);
});
mediaRecorder.addEventListener('stop', async () => {
const aborted = this._rec.aborted;
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._rec.active = false;
this._rec.stream?.getTracks().forEach(t => t.stop());
this._rec.stream = null;
if (aborted) {
this._rec.chunks = [];
return;
}
const audioBlob = new Blob(this._rec.chunks, { type: 'audio/webm' });
this._rec.chunks = [];
console.log('[ envoi du blob au serveur ]');
try {
const resp = await fetch('/api/whisper.php', {
method: 'POST',
headers: { 'Content-Type': 'audio/webm' },
body: audioBlob
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const { text = '' } = await resp.json();
console.log(text || '[ aucune transcription reçue ]');
const heard = this._norm(text);
const heardSet = new Set(heard ? heard.split(' ') : []);
const currentId = this.state.chapter;
const nextIds = this.storyMeta.chapters[currentId]?.choices || [];
const matchedId = nextIds.find(id => {
const cmds = this.storyMeta.chapters[id]?.commands || [];
return cmds.some(cmd => {
const c = this._norm(cmd);
return c && c.split(' ').some(tok => heardSet.has(tok));
});
});
if (matchedId) {
const chosenIndex = nextIds.indexOf(matchedId);
await this._choose(chosenIndex);
} else {
console.log(this.storyMeta.chapters[currentId]?.label || 'Label manquant');
}
} catch (err) {
console.error('whisper.php error:', err);
}
});
mediaRecorder.start();
setTimeout(() => { mediaRecorder.stop(); }, 3000);
} catch (err) {
console.error('getUserMedia/MediaRecorder error:', err);
this._recording.style.display = 'none';
this._timer.style.display = 'none';
}
});
this._audio.addEventListener('timeupdate', () => {
const cur = this._audio.currentTime || 0;
const dur = this._audio.duration || 0;
if (Number.isFinite(dur) && dur > 0) {
this._fill.style.width = ((cur / dur) * 100) + '%';
const rem = Math.max(0, dur - cur);
const minutes = Math.floor(rem / 60);
const seconds = String(Math.floor(rem % 60)).padStart(2, '0');
this._chrono.textContent = `${minutes}:${seconds}`;
} else {
this._fill.style.width = '0%';
this._chrono.textContent = '--:--';
}
if (Date.now() - this._lastSave > 2000) {
this._lastSave = Date.now();
this._saveState();
}
this.state.currentTime = cur;
});
this._audio.addEventListener('play', () => {
this._play.classList.add('active');
this._stop.classList.remove('active');
});
this._audio.addEventListener('pause', () => {
this._play.classList.remove('active');
this._stop.classList.add('active');
});
this._audio.addEventListener('error', e => console.error('Audio playback error:', e));
this._play.addEventListener('click', () => {
if (!this._audio.ended) {
if (this._audio.paused) this._audio.play();
else this._audio.currentTime = Math.min(this._audio.duration, this._audio.currentTime + 5);
}
});
this._stop.addEventListener('click', () => this._audio.pause());
this._back.addEventListener('click', () => {
this._abortRecordingIfAny();
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._choices.style.display = 'none';
this._audio.currentTime = Math.max(0, this._audio.currentTime - 5);
});
[this._btnA, this._btnB, this._btnC].forEach((btn, idx) => {
btn.addEventListener('click', async () => {
this._abortRecordingIfAny();
await this._choose(idx);
});
});
this._restart.addEventListener('click', async () => {
this._abortRecordingIfAny();
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._choices.style.display = 'none';
this.state.chapter = '001';
this.state.currentTime = 0;
await this._loadChapter(this.state.chapter, 0);
this._audio.play();
});
this._quit.addEventListener('click', () => {
this._abortRecordingIfAny();
this._saveState();
location.assign('/');
});
window.addEventListener('beforeunload', () => {
const payload = { story: this._storyId, state_json: JSON.stringify(this.state) };
navigator.sendBeacon('/api/progress.php', new Blob([JSON.stringify(payload)], { type: 'application/json' }));
}, { once: true });
const meta = await fetch(`/assets/stories/${this._storyId}/story.json`);
this.storyMeta = await meta.json();
this._cover.src = `/assets/stories/${this._storyId}/cover.avif`;
if (this._initialState) Object.assign(this.state, this._initialState);
await this._loadChapter(this.state.chapter, this.state.currentTime);
} catch (err) {
console.error('Init OravoxPlayer error:', err);
}
this._buttons2.style.display = 'none';
this._cover.addEventListener('click', () => {
this._sheetIndex = (this._sheetIndex + 1) % 2;
console.log('sheet:', this._sheetIndex);
if (this._sheetIndex == 0) {
this._cover.src = `/assets/stories/${this._storyId}/cover.avif`;
this._interface.src = `/components/oravox-player/interface.avif`;
this._buttons.style.display = '';
this._synopsis.textContent = '';
this._bar.style.backgroundImage = 'url("/components/oravox-player/thumb_empty.png")';
this._buttons2.style.display = 'none';
} else if (this._sheetIndex == 1) {
this._cover.src = `/components/oravox-player/cover2.png`;
this._buttons.style.display = 'none';
this._synopsis.textContent = 'SYNOPSIS';
this._interface.src = `/components/oravox-player/interface2.avif`;
this._bar.style.backgroundImage = 'none';
this._buttons2.style.display = '';
}
});
// CANVAS - C'EST ICI QUE CA SE PASSE
const path = new Path2D();
path.arc(this._canvas.width / 2, this._canvas.height / 2, 256, 0, Math.PI * 2);
this._ctx.fillStyle = '#C938';
this._ctx.fill(path);
}
async _choose(choiceIndex) {
const chap = this.storyMeta.chapters[this.state.chapter];
const next = chap.choices?.[choiceIndex];
if (!next) return;
this.state.chapter = next;
this.state.currentTime = 0;
await this._loadChapter(next, 0);
this._audio.play();
}
async _loadChapter(chapterId, startTime = 0) {
console.log("LOG: loadChapter()")
this._abortRecordingIfAny();
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._choices.style.display = 'none';
const chap = this.storyMeta.chapters[chapterId];
if (!chap) return console.warn('Chapitre introuvable :', chapterId);
this._audio.src = `/assets/stories/${this._storyId}/${chap.audio}`;
await new Promise(res => {
if (Number.isFinite(this._audio.duration)) return res();
const onMeta = () => { this._audio.removeEventListener('loadedmetadata', onMeta); res(); };
this._audio.addEventListener('loadedmetadata', onMeta, { once: true });
});
try { this._audio.currentTime = startTime; } catch {}
console.log(chap.label || 'Label manquant');
this._renderChoice(this._btnA, chap.choices?.[0]);
this._renderChoice(this._btnB, chap.choices?.[1]);
this._renderChoice(this._btnC, chap.choices?.[2]);
this.state.chapter = chapterId;
this._audio.dispatchEvent(new Event('pause'));
this._saveState();
}
_renderChoice(button, nextId) {
if (!nextId) {
button.style.display = 'none';
return;
}
const target = this.storyMeta.chapters[nextId];
button.textContent = target?.label ?? nextId;
button.style.display = '';
}
_saveState() {
console.log("LOG: saveState()")
fetch('/api/progress.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ story: this._storyId, state_json: JSON.stringify(this.state) })
}).catch(err => console.error('Erreur _saveState :', err));
}
_abortRecordingIfAny() {
console.log("LOG: abortRecordingIfAny()")
if (this._rec.active) {
this._rec.aborted = true;
this._rec.recorder?.stop();
this._rec.stream?.getTracks().forEach(t => t.stop());
this._rec.active = false;
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._rec.chunks = [];
}
}
}
customElements.define('oravox-player', OravoxPlayer);
Salut — la migration vers Canvas demandera une quantité de code que je ne peux pas prendre en charge. À ma connaissance, il n’existe pas de mécanisme permettant de « déposer » du code qui s’activerait automatiquement comme un plugin dans l’API Canvas.
Je peux toutefois préparer une partie du travail : je mettrai en place un contexte Canvas aux dimensions du lecteur, prêt à être utilisé pour le dessin. Ensuite, la personne qui voudra (et saura) écrire les instructions de dessin en JavaScript pourra réimplémenter le lecteur en langage bas niveau.
EDIT. voilà c'est intégré et aux bonnes dimensions, un espace de 610 par 987 pixels est disponible pour dessiner dans l'espace intérieur de l'écran Oravox (les bords bruns).
[code]'use strict';
const TEMPLATE_URL = new URL('./oravox-player.html', import.meta.url).href;
const STYLE_URL = new URL('./oravox-player.css', import.meta.url).href;
export default class OravoxPlayer extends HTMLElement {
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
this.state = { chapter: '001', currentTime: 0, objects: [] };
this._lastSave = Date.now();
this._rec = { recorder: null, stream: null, chunks: [], aborted: false, active: false };
this._sheetIndex = 0;
}
_norm(s) {
return String(s || '').toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async connectedCallback() {
const params = new URLSearchParams(location.search);
this._storyId = params.get('story') || 'empty';
const resp = await fetch(`/api/progress.php?story=${encodeURIComponent(this._storyId)}`, { credentials: 'include' });
if (resp.status === 401) return void location.replace('/login.html');
if (resp.ok) {
const data = await resp.json();
if (data.state_json) this._initialState = JSON.parse(data.state_json);
}
try {
const [html, css] = await Promise.all([
fetch(TEMPLATE_URL).then(r => r.text()),
fetch(STYLE_URL).then(r => r.text())
]);
this._shadow.innerHTML = `<style>${css}</style>${html}`;
const s = this._shadow;
this._audio = s.querySelector('audio');
this._audio.preload = 'metadata';
this._buttons = s.getElementById('buttons');
this._buttons2 = s.getElementById('buttons2');
this._interface = s.getElementById('interface');
this._synopsis = s.getElementById('synopsis');
this._play = s.getElementById('play');
this._stop = s.getElementById('stop');
this._back = s.getElementById('back');
this._restart = s.getElementById('restart');
this._quit = s.getElementById('quit');
this._bar = s.getElementById('bar');
this._fill = s.getElementById('fill');
this._cover = s.getElementById('cover');
this._choices = s.getElementById('choices');
this._btnA = s.getElementById('btnA');
this._btnB = s.getElementById('btnB');
this._btnC = s.getElementById('btnC');
this._chrono = s.getElementById('chrono');
this._recording = s.getElementById('recording');
this._timer = s.getElementById('timer');
this._canvas = s.getElementById("canvas");
this._ctx = this._canvas.getContext("2d");
this._audio.addEventListener('ended', async () => {
this._choices.style.display = '';
this._recording.style.display = '';
this._timer.style.display = '';
this._chrono.textContent = '0:00';
new Audio('/assets/audio/jingle.mp3').play().catch(() => {});
this._rec.aborted = false;
this._rec.active = false;
this._rec.chunks = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
this._rec.stream = stream;
this._rec.recorder = mediaRecorder;
this._rec.active = true;
console.log('[ enregistrement en cours ]');
mediaRecorder.addEventListener('dataavailable', e => {
if (e.data && e.data.size > 0) this._rec.chunks.push(e.data);
});
mediaRecorder.addEventListener('stop', async () => {
const aborted = this._rec.aborted;
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._rec.active = false;
this._rec.stream?.getTracks().forEach(t => t.stop());
this._rec.stream = null;
if (aborted) {
this._rec.chunks = [];
return;
}
const audioBlob = new Blob(this._rec.chunks, { type: 'audio/webm' });
this._rec.chunks = [];
console.log('[ envoi du blob au serveur ]');
try {
const resp = await fetch('/api/whisper.php', {
method: 'POST',
headers: { 'Content-Type': 'audio/webm' },
body: audioBlob
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const { text = '' } = await resp.json();
console.log(text || '[ aucune transcription reçue ]');
const heard = this._norm(text);
const heardSet = new Set(heard ? heard.split(' ') : []);
const currentId = this.state.chapter;
const nextIds = this.storyMeta.chapters[currentId]?.choices || [];
const matchedId = nextIds.find(id => {
const cmds = this.storyMeta.chapters[id]?.commands || [];
return cmds.some(cmd => {
const c = this._norm(cmd);
return c && c.split(' ').some(tok => heardSet.has(tok));
});
});
if (matchedId) {
const chosenIndex = nextIds.indexOf(matchedId);
await this._choose(chosenIndex);
} else {
console.log(this.storyMeta.chapters[currentId]?.label || 'Label manquant');
}
} catch (err) {
console.error('whisper.php error:', err);
}
});
mediaRecorder.start();
setTimeout(() => { mediaRecorder.stop(); }, 3000);
} catch (err) {
console.error('getUserMedia/MediaRecorder error:', err);
this._recording.style.display = 'none';
this._timer.style.display = 'none';
}
});
this._audio.addEventListener('timeupdate', () => {
const cur = this._audio.currentTime || 0;
const dur = this._audio.duration || 0;
if (Number.isFinite(dur) && dur > 0) {
this._fill.style.width = ((cur / dur) * 100) + '%';
const rem = Math.max(0, dur - cur);
const minutes = Math.floor(rem / 60);
const seconds = String(Math.floor(rem % 60)).padStart(2, '0');
this._chrono.textContent = `${minutes}:${seconds}`;
} else {
this._fill.style.width = '0%';
this._chrono.textContent = '--:--';
}
if (Date.now() - this._lastSave > 2000) {
this._lastSave = Date.now();
this._saveState();
}
this.state.currentTime = cur;
});
this._audio.addEventListener('play', () => {
this._play.classList.add('active');
this._stop.classList.remove('active');
});
this._audio.addEventListener('pause', () => {
this._play.classList.remove('active');
this._stop.classList.add('active');
});
this._audio.addEventListener('error', e => console.error('Audio playback error:', e));
this._play.addEventListener('click', () => {
if (!this._audio.ended) {
if (this._audio.paused) this._audio.play();
else this._audio.currentTime = Math.min(this._audio.duration, this._audio.currentTime + 5);
}
});
this._stop.addEventListener('click', () => this._audio.pause());
this._back.addEventListener('click', () => {
this._abortRecordingIfAny();
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._choices.style.display = 'none';
this._audio.currentTime = Math.max(0, this._audio.currentTime - 5);
});
[this._btnA, this._btnB, this._btnC].forEach((btn, idx) => {
btn.addEventListener('click', async () => {
this._abortRecordingIfAny();
await this._choose(idx);
});
});
this._restart.addEventListener('click', async () => {
this._abortRecordingIfAny();
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._choices.style.display = 'none';
this.state.chapter = '001';
this.state.currentTime = 0;
await this._loadChapter(this.state.chapter, 0);
this._audio.play();
});
this._quit.addEventListener('click', () => {
this._abortRecordingIfAny();
this._saveState();
location.assign('/');
});
window.addEventListener('beforeunload', () => {
const payload = { story: this._storyId, state_json: JSON.stringify(this.state) };
navigator.sendBeacon('/api/progress.php', new Blob([JSON.stringify(payload)], { type: 'application/json' }));
}, { once: true });
const meta = await fetch(`/assets/stories/${this._storyId}/story.json`);
this.storyMeta = await meta.json();
this._cover.src = `/assets/stories/${this._storyId}/cover.avif`;
if (this._initialState) Object.assign(this.state, this._initialState);
await this._loadChapter(this.state.chapter, this.state.currentTime);
} catch (err) {
console.error('Init OravoxPlayer error:', err);
}
this._buttons2.style.display = 'none';
this._cover.addEventListener('click', () => {
this._sheetIndex = (this._sheetIndex + 1) % 2;
console.log('sheet:', this._sheetIndex);
if (this._sheetIndex == 0) {
this._cover.src = `/assets/stories/${this._storyId}/cover.avif`;
this._interface.src = `/components/oravox-player/interface.avif`;
this._buttons.style.display = '';
this._synopsis.textContent = '';
this._bar.style.backgroundImage = 'url("/components/oravox-player/thumb_empty.png")';
this._buttons2.style.display = 'none';
} else if (this._sheetIndex == 1) {
this._cover.src = `/components/oravox-player/cover2.png`;
this._buttons.style.display = 'none';
this._synopsis.textContent = 'SYNOPSIS';
this._interface.src = `/components/oravox-player/interface2.avif`;
this._bar.style.backgroundImage = 'none';
this._buttons2.style.display = '';
}
});
// CANVAS - C'EST ICI QUE CA SE PASSE
const path = new Path2D();
path.arc(this._canvas.width / 2, this._canvas.height / 2, 256, 0, Math.PI * 2);
this._ctx.fillStyle = '#C938';
this._ctx.fill(path);
}
async _choose(choiceIndex) {
const chap = this.storyMeta.chapters[this.state.chapter];
const next = chap.choices?.[choiceIndex];
if (!next) return;
this.state.chapter = next;
this.state.currentTime = 0;
await this._loadChapter(next, 0);
this._audio.play();
}
async _loadChapter(chapterId, startTime = 0) {
console.log("LOG: loadChapter()")
this._abortRecordingIfAny();
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._choices.style.display = 'none';
const chap = this.storyMeta.chapters[chapterId];
if (!chap) return console.warn('Chapitre introuvable :', chapterId);
this._audio.src = `/assets/stories/${this._storyId}/${chap.audio}`;
await new Promise(res => {
if (Number.isFinite(this._audio.duration)) return res();
const onMeta = () => { this._audio.removeEventListener('loadedmetadata', onMeta); res(); };
this._audio.addEventListener('loadedmetadata', onMeta, { once: true });
});
try { this._audio.currentTime = startTime; } catch {}
console.log(chap.label || 'Label manquant');
this._renderChoice(this._btnA, chap.choices?.[0]);
this._renderChoice(this._btnB, chap.choices?.[1]);
this._renderChoice(this._btnC, chap.choices?.[2]);
this.state.chapter = chapterId;
this._audio.dispatchEvent(new Event('pause'));
this._saveState();
}
_renderChoice(button, nextId) {
if (!nextId) {
button.style.display = 'none';
return;
}
const target = this.storyMeta.chapters[nextId];
button.textContent = target?.label ?? nextId;
button.style.display = '';
}
_saveState() {
console.log("LOG: saveState()")
fetch('/api/progress.php', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ story: this._storyId, state_json: JSON.stringify(this.state) })
}).catch(err => console.error('Erreur _saveState :', err));
}
_abortRecordingIfAny() {
console.log("LOG: abortRecordingIfAny()")
if (this._rec.active) {
this._rec.aborted = true;
this._rec.recorder?.stop();
this._rec.stream?.getTracks().forEach(t => t.stop());
this._rec.active = false;
this._recording.style.display = 'none';
this._timer.style.display = 'none';
this._rec.chunks = [];
}
}
}
customElements.define('oravox-player', OravoxPlayer);
[/code]