by CPAS » Sun Aug 31, 2025 6:39 am
bon, je renonce à comprendre tes interjections, Serge,
voici le code, si tu as besoin d'essayer d'y inclure des animations,
moi je me contente de finir la page d'upload et de corriger le timeout
HTML :
Code: Select all
<div class="player">
<audio></audio>
<img id="interface" src="/components/oravox-player/interface.avif" alt="Interface"/>
<img id="background" src="/components/oravox-player/background.png" alt="Fond"/>
<img id="glass" src="/components/oravox-player/glass.png" alt="Vitre"/>
<img id="cover" src="" alt="Couverture"/>
<img id="shell" src="/components/oravox-player/shell.png" alt="Coque">
<div id="buttons">
<button type="button" id="back" title="Reculer"></button>
<button type="button" id="stop" title="Pause"></button>
<button type="button" id="play" title="Jouer"></button>
</div>
<div id="buttons2">
<button type="button" id="restart" title="Recommencer"></button>
<button type="button" id="quit" title="Quitter"></button>
</div>
<div id="bar">
<div id="fill"></div>
</div>
<div id="choices">
<button id="btnA"></button>
<button id="btnB"></button>
<button id="btnC"></button>
</div>
<div id="chrono">0:00</div>
<div id="recording"></div>
<img id="timer" src="/components/oravox-player/timer.png"></div>
<div id="synopsis"></div>
<canvas id="canvas"></canvas>
</div>
CSS:
Code: Select all
.player {
position: absolute;
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
aspect-ratio: 610 / 987;
width: min(80vw, calc(75vh * 610 / 987));
}
#background {
position: absolute;
width: 100%;
}
#shell {
position: absolute;
width: 106%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
filter: drop-shadow(0px 0px 8px black) drop-shadow(0px 0px 32px black);
pointer-events: none;
}
#cover {
position: absolute;
width: 100%;
}
#interface {
position: absolute;
width: 100%;
z-index: 1;
pointer-events: none;
}
button {
position: absolute;
background-color: transparent;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
border: none;
cursor: pointer;
z-index: 2;
}
button#back {
top: 84.7%;
left: 10.5%;
width: 18%;
aspect-ratio: 90/96;
background-image: url('/components/oravox-player/back.png');
}
button#stop {
top: 85.5%;
left: 42.5%;
width: 15%;
aspect-ratio: 74/80;
background-image: url('/components/oravox-player/stop.png');
}
button#play {
top: 84.7%;
left: 71.5%;
width: 18%;
aspect-ratio: 90/96;
background-image: url('/components/oravox-player/play.png');
}
button#restart {
top: 87.7%;
left: 67%;
width: 24%;
aspect-ratio: 147/60;
background-image: url('/components/oravox-player/restart.png');
opacity: 0.75;
}
button#quit {
top: 87.7%;
left: 8.5%;
width: 24%;
aspect-ratio: 147/60;
background-image: url('/components/oravox-player/quit.png');
opacity: 0.75;
}
#choices {
position: absolute;
top: 60%;
z-index: 2;
display: grid;
width: 80%;
left: 10%;
}
#choices button {
position: static;
color: white;
background: #963;
border: 2px solid #0008;
border-radius: 8px;
box-shadow: 2px 2px 8px black;
text-shadow: 2px 2px 8px black;
margin: 2%;
padding: 2%;
font-size: inherit;
}
#choices button:hover {
background: #C93;
color: black;
text-shadow: none;
}
#bar {
position: absolute;
background-image: url("/components/oravox-player/thumb_empty.png");
background-repeat: repeat-x;
background-position: center left;
background-size: auto 100%;
bottom: 16%;
left: 6.4%;
width: 88%;
height: 6.75%;
}
#fill {
width: 0%;
height: 100%;
background-image: url("/components/oravox-player/thumb.png");
background-repeat: repeat-x;
background-position: center left;
background-size: auto 100%;
transition: width 0.2s linear;
}
button.active {
animation: blink 1s steps(2, jump-none) infinite;
}
@keyframes blink {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
#chrono {
position: absolute;
color: #C93;
font-weight: bold;
font-family: monospace;
z-index: 1;
left: 19.5%;
top: 4.5%;
}
#recording {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
left: 50%;
top: -4%;
z-index: 9;
animation: rec-blink 1s steps(2, jump-none) infinite;
background-color: transparent;
}
@keyframes rec-blink {
from {
background-color: red;
}
to {
background-color: transparent;
}
}
#synopsis {
position: absolute;
color: #C93;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
#canvas {
position: absolute;
width: 100%;
aspect-ratio: 610 / 987;
pointer-events: none;
opacity: 0;
}
#timer {
position: absolute;
z-index: 3;
width: 50%;
left: 50%;
top: 35%;
transform: translate(-50%, -50%);
}
#glass {
position: absolute;
width: 100%;
z-index: 1;
pointer-events: none;
}
JAVASCRIPT:
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 = '';
}
});
// TEST
const path = new Path2D();
path.arc(this._canvas.width / 2, this._canvas.height / 2, 48, 0, Math.PI * 2);
this._ctx.fillStyle = 'red';
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);
bon, je renonce à comprendre tes interjections, Serge,
voici le code, si tu as besoin d'essayer d'y inclure des animations,
moi je me contente de finir la page d'upload et de corriger le timeout
HTML :
[code]<div class="player">
<audio></audio>
<img id="interface" src="/components/oravox-player/interface.avif" alt="Interface"/>
<img id="background" src="/components/oravox-player/background.png" alt="Fond"/>
<img id="glass" src="/components/oravox-player/glass.png" alt="Vitre"/>
<img id="cover" src="" alt="Couverture"/>
<img id="shell" src="/components/oravox-player/shell.png" alt="Coque">
<div id="buttons">
<button type="button" id="back" title="Reculer"></button>
<button type="button" id="stop" title="Pause"></button>
<button type="button" id="play" title="Jouer"></button>
</div>
<div id="buttons2">
<button type="button" id="restart" title="Recommencer"></button>
<button type="button" id="quit" title="Quitter"></button>
</div>
<div id="bar">
<div id="fill"></div>
</div>
<div id="choices">
<button id="btnA"></button>
<button id="btnB"></button>
<button id="btnC"></button>
</div>
<div id="chrono">0:00</div>
<div id="recording"></div>
<img id="timer" src="/components/oravox-player/timer.png"></div>
<div id="synopsis"></div>
<canvas id="canvas"></canvas>
</div>[/code]
CSS:
[code].player {
position: absolute;
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
aspect-ratio: 610 / 987;
width: min(80vw, calc(75vh * 610 / 987));
}
#background {
position: absolute;
width: 100%;
}
#shell {
position: absolute;
width: 106%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
filter: drop-shadow(0px 0px 8px black) drop-shadow(0px 0px 32px black);
pointer-events: none;
}
#cover {
position: absolute;
width: 100%;
}
#interface {
position: absolute;
width: 100%;
z-index: 1;
pointer-events: none;
}
button {
position: absolute;
background-color: transparent;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
border: none;
cursor: pointer;
z-index: 2;
}
button#back {
top: 84.7%;
left: 10.5%;
width: 18%;
aspect-ratio: 90/96;
background-image: url('/components/oravox-player/back.png');
}
button#stop {
top: 85.5%;
left: 42.5%;
width: 15%;
aspect-ratio: 74/80;
background-image: url('/components/oravox-player/stop.png');
}
button#play {
top: 84.7%;
left: 71.5%;
width: 18%;
aspect-ratio: 90/96;
background-image: url('/components/oravox-player/play.png');
}
button#restart {
top: 87.7%;
left: 67%;
width: 24%;
aspect-ratio: 147/60;
background-image: url('/components/oravox-player/restart.png');
opacity: 0.75;
}
button#quit {
top: 87.7%;
left: 8.5%;
width: 24%;
aspect-ratio: 147/60;
background-image: url('/components/oravox-player/quit.png');
opacity: 0.75;
}
#choices {
position: absolute;
top: 60%;
z-index: 2;
display: grid;
width: 80%;
left: 10%;
}
#choices button {
position: static;
color: white;
background: #963;
border: 2px solid #0008;
border-radius: 8px;
box-shadow: 2px 2px 8px black;
text-shadow: 2px 2px 8px black;
margin: 2%;
padding: 2%;
font-size: inherit;
}
#choices button:hover {
background: #C93;
color: black;
text-shadow: none;
}
#bar {
position: absolute;
background-image: url("/components/oravox-player/thumb_empty.png");
background-repeat: repeat-x;
background-position: center left;
background-size: auto 100%;
bottom: 16%;
left: 6.4%;
width: 88%;
height: 6.75%;
}
#fill {
width: 0%;
height: 100%;
background-image: url("/components/oravox-player/thumb.png");
background-repeat: repeat-x;
background-position: center left;
background-size: auto 100%;
transition: width 0.2s linear;
}
button.active {
animation: blink 1s steps(2, jump-none) infinite;
}
@keyframes blink {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
#chrono {
position: absolute;
color: #C93;
font-weight: bold;
font-family: monospace;
z-index: 1;
left: 19.5%;
top: 4.5%;
}
#recording {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
left: 50%;
top: -4%;
z-index: 9;
animation: rec-blink 1s steps(2, jump-none) infinite;
background-color: transparent;
}
@keyframes rec-blink {
from {
background-color: red;
}
to {
background-color: transparent;
}
}
#synopsis {
position: absolute;
color: #C93;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
#canvas {
position: absolute;
width: 100%;
aspect-ratio: 610 / 987;
pointer-events: none;
opacity: 0;
}
#timer {
position: absolute;
z-index: 3;
width: 50%;
left: 50%;
top: 35%;
transform: translate(-50%, -50%);
}
#glass {
position: absolute;
width: 100%;
z-index: 1;
pointer-events: none;
}[/code]
JAVASCRIPT:
[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 = '';
}
});
// TEST
const path = new Path2D();
path.arc(this._canvas.width / 2, this._canvas.height / 2, 48, 0, Math.PI * 2);
this._ctx.fillStyle = 'red';
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]