Re: ANIM ORAVOX
Posted: Mon Sep 01, 2025 1:31 pm
Voici le sprite sheet du timeline en 10x10 images de 610x987 pixels, ainsi que le CSS généré par texturepacker.
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);