2026-07-01 15:17:25 +00:00
|
|
|
const box = document.getElementById('box');
|
|
|
|
|
const fromEl = document.getElementById('from');
|
|
|
|
|
const textEl = document.getElementById('text');
|
2026-07-01 15:53:07 +00:00
|
|
|
|
|
|
|
|
const token = location.pathname.split('/').filter(Boolean).pop();
|
|
|
|
|
const socket = io({ query: { role: 'overlay', token } });
|
2026-07-01 15:17:25 +00:00
|
|
|
|
2026-07-01 17:05:01 +00:00
|
|
|
const FADE_MS = 400; // matches the CSS transition duration on #box
|
|
|
|
|
|
|
|
|
|
const queue = [];
|
|
|
|
|
let current = null; // { id, text, from, durationMs, audio }
|
2026-07-01 15:17:25 +00:00
|
|
|
let audioCtx = null;
|
|
|
|
|
|
|
|
|
|
function getAudioCtx() {
|
|
|
|
|
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
return audioCtx;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Browsers suspend AudioContext until a user gesture happens on the page.
|
|
|
|
|
// OBS's Browser Source doesn't enforce this, but for testing in a normal
|
|
|
|
|
// tab, unlock it on the first click/keypress anywhere on the page.
|
|
|
|
|
['click', 'keydown'].forEach((eventName) => {
|
|
|
|
|
document.addEventListener(eventName, () => {
|
|
|
|
|
const ctx = getAudioCtx();
|
|
|
|
|
if (ctx.state === 'suspended') ctx.resume();
|
|
|
|
|
}, { once: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function playNotificationSound() {
|
|
|
|
|
try {
|
|
|
|
|
const ctx = getAudioCtx();
|
|
|
|
|
const osc = ctx.createOscillator();
|
|
|
|
|
const gain = ctx.createGain();
|
|
|
|
|
osc.type = 'sine';
|
|
|
|
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
|
|
|
osc.frequency.setValueAtTime(1320, ctx.currentTime + 0.1);
|
|
|
|
|
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
|
|
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
|
|
|
|
|
osc.connect(gain);
|
|
|
|
|
gain.connect(ctx.destination);
|
|
|
|
|
osc.start();
|
|
|
|
|
osc.stop(ctx.currentTime + 0.35);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Could not play notification sound:', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 18:21:57 +00:00
|
|
|
function playSpeech(base64Audio, mimeType) {
|
2026-07-01 17:05:01 +00:00
|
|
|
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
2026-07-08 18:21:57 +00:00
|
|
|
// Fire-and-forget: we don't gate queue timing on this element's 'ended'
|
|
|
|
|
// event. edge-tts streams MP3 without a duration header (no Xing/VBRI
|
|
|
|
|
// frame), and OBS's embedded CEF browser has been observed firing 'ended'
|
|
|
|
|
// unreliably (sometimes early) for such streams — see durationMs below.
|
|
|
|
|
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
|
2026-07-01 17:05:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function advanceQueue() {
|
|
|
|
|
if (current || queue.length === 0) return;
|
2026-07-01 15:17:25 +00:00
|
|
|
|
2026-07-01 17:05:01 +00:00
|
|
|
current = queue.shift();
|
|
|
|
|
fromEl.textContent = current.from || '';
|
|
|
|
|
fromEl.style.display = current.from ? 'block' : 'none';
|
|
|
|
|
textEl.textContent = current.text;
|
|
|
|
|
// force reflow so the transition re-triggers even if a message was already visible
|
2026-07-01 15:17:25 +00:00
|
|
|
void box.offsetWidth;
|
|
|
|
|
box.classList.add('visible');
|
|
|
|
|
|
|
|
|
|
playNotificationSound();
|
2026-07-08 17:28:36 +00:00
|
|
|
if (current.audio) {
|
2026-07-08 18:21:57 +00:00
|
|
|
playSpeech(current.audio.data, current.audio.mimeType);
|
2026-07-08 17:28:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 18:21:57 +00:00
|
|
|
// durationMs is computed server-side as max(streamer's configured display
|
|
|
|
|
// time, estimated reading time for the TTS text) — see estimateSpeechMs in
|
|
|
|
|
// server.js — so it's long enough to cover the actual audio playback
|
|
|
|
|
// without relying on the browser telling us when the audio finished.
|
2026-07-08 17:28:36 +00:00
|
|
|
setTimeout(() => {
|
2026-07-08 18:21:57 +00:00
|
|
|
box.classList.remove('visible');
|
|
|
|
|
current = null;
|
|
|
|
|
setTimeout(advanceQueue, FADE_MS);
|
2026-07-01 17:05:01 +00:00
|
|
|
}, current.durationMs || 10000);
|
2026-07-01 15:17:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket.on('display_message', (payload) => {
|
2026-07-01 17:05:01 +00:00
|
|
|
queue.push({
|
|
|
|
|
id: payload.id,
|
|
|
|
|
text: payload.text,
|
|
|
|
|
from: payload.from,
|
|
|
|
|
durationMs: payload.durationMs,
|
|
|
|
|
audio: null,
|
|
|
|
|
});
|
|
|
|
|
advanceQueue();
|
2026-07-01 15:17:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on('display_audio', (payload) => {
|
2026-07-01 17:05:01 +00:00
|
|
|
const audio = { data: payload.audio, mimeType: payload.mimeType };
|
|
|
|
|
if (current && current.id === payload.id) {
|
2026-07-08 18:21:57 +00:00
|
|
|
playSpeech(audio.data, audio.mimeType);
|
2026-07-01 17:05:01 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const queued = queue.find((item) => item.id === payload.id);
|
|
|
|
|
if (queued) queued.audio = audio;
|
2026-07-01 15:17:25 +00:00
|
|
|
});
|
2026-07-01 15:53:07 +00:00
|
|
|
|
|
|
|
|
socket.on('invalid_link', () => {
|
|
|
|
|
console.error('Invalid overlay link — check the URL from your dashboard.');
|
|
|
|
|
});
|