const box = document.getElementById('box'); const fromEl = document.getElementById('from'); const textEl = document.getElementById('text'); const token = location.pathname.split('/').filter(Boolean).pop(); const socket = io({ query: { role: 'overlay', token } }); const FADE_MS = 400; // matches the CSS transition duration on #box const queue = []; let current = null; // { id, text, from, durationMs, audio } 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); } } function playSpeech(base64Audio, mimeType, onEnded) { const audio = new Audio(`data:${mimeType};base64,${base64Audio}`); const done = () => { if (onEnded) onEnded(); }; audio.addEventListener('ended', done); audio.addEventListener('error', done); audio.play().catch((err) => { console.error('Could not play TTS audio:', err); done(); }); } function advanceQueue() { if (current || queue.length === 0) return; 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 void box.offsetWidth; box.classList.add('visible'); playNotificationSound(); // The message stays up for at least durationMs (streamer's configured // display time), but if its TTS audio is still playing (or hasn't even // arrived yet — synthesis is async and can take a few seconds, longer // than a short configured duration) when that timer fires, we must not // start the next message's audio on top of it. So we only advance once // BOTH the minimum time has passed AND we know the audio is actually // done — "not arrived yet" is treated as "still waiting", not "done", // unlike an earlier version of this code that got that backwards. let minTimeElapsed = false; // 'unknown' (no audio yet, may still arrive), 'playing', or 'done'. let audioState = current.audio ? 'playing' : 'unknown'; const maybeAdvance = () => { if (!minTimeElapsed || audioState !== 'done') return; box.classList.remove('visible'); current = null; setTimeout(advanceQueue, FADE_MS); }; current.startAudio = (audio) => { audioState = 'playing'; playSpeech(audio.data, audio.mimeType, () => { audioState = 'done'; maybeAdvance(); }); }; if (current.audio) { current.startAudio(current.audio); } setTimeout(() => { minTimeElapsed = true; maybeAdvance(); }, current.durationMs || 10000); // Safety net: if TTS synthesis fails server-side, no display_audio ever // arrives for this id — don't wait forever for audio that's never coming. setTimeout(() => { if (audioState === 'unknown') { audioState = 'done'; maybeAdvance(); } }, (current.durationMs || 10000) + 8000); } socket.on('display_message', (payload) => { queue.push({ id: payload.id, text: payload.text, from: payload.from, durationMs: payload.durationMs, audio: null, }); advanceQueue(); }); socket.on('display_audio', (payload) => { const audio = { data: payload.audio, mimeType: payload.mimeType }; if (current && current.id === payload.id) { current.startAudio(audio); return; } const queued = queue.find((item) => item.id === payload.id); if (queued) queued.audio = audio; }); socket.on('invalid_link', () => { console.error('Invalid overlay link — check the URL from your dashboard.'); });