diff --git a/public/overlay.js b/public/overlay.js index 52583b2..ae29ff8 100644 --- a/public/overlay.js +++ b/public/overlay.js @@ -5,7 +5,10 @@ const textEl = document.getElementById('text'); const token = location.pathname.split('/').filter(Boolean).pop(); const socket = io({ query: { role: 'overlay', token } }); -let hideTimer = null; +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() { @@ -42,34 +45,53 @@ function playNotificationSound() { } } -function showMessage(text, from, durationMs) { - clearTimeout(hideTimer); - - fromEl.textContent = from || ''; - fromEl.style.display = from ? 'block' : 'none'; - textEl.textContent = text; - // force reflow so the transition re-triggers even if a message is already visible - void box.offsetWidth; - box.classList.add('visible'); - - playNotificationSound(); - - hideTimer = setTimeout(() => { - box.classList.remove('visible'); - }, durationMs || 10000); -} - function playSpeech(base64Audio, mimeType) { const audio = new Audio(`data:${mimeType};base64,${base64Audio}`); audio.play().catch((err) => console.error('Could not play TTS audio:', err)); } +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(); + if (current.audio) { + playSpeech(current.audio.data, current.audio.mimeType); + } + + setTimeout(() => { + box.classList.remove('visible'); + current = null; + setTimeout(advanceQueue, FADE_MS); + }, current.durationMs || 10000); +} + socket.on('display_message', (payload) => { - showMessage(payload.text, payload.from, payload.durationMs); + queue.push({ + id: payload.id, + text: payload.text, + from: payload.from, + durationMs: payload.durationMs, + audio: null, + }); + advanceQueue(); }); socket.on('display_audio', (payload) => { - playSpeech(payload.audio, payload.mimeType); + const audio = { data: payload.audio, mimeType: payload.mimeType }; + if (current && current.id === payload.id) { + playSpeech(audio.data, audio.mimeType); + return; + } + const queued = queue.find((item) => item.id === payload.id); + if (queued) queued.audio = audio; }); socket.on('invalid_link', () => { diff --git a/server.js b/server.js index aa2eab8..ed19d9c 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,7 @@ const { createServer } = require('http'); const { Server } = require('socket.io'); const { OAuth2Client } = require('google-auth-library'); const { execFile } = require('child_process'); +const crypto = require('crypto'); const { findStreamerByOverlayToken, findStreamerBySenderToken, @@ -222,9 +223,11 @@ io.on('connection', (socket) => { const displayName = (typeof name === 'string' && name.trim().slice(0, 60)) || user.name; setViewerName(streamer.id, user.id, displayName); + const messageId = crypto.randomUUID(); const room = `streamer-${streamer.id}`; - console.log(`[broadcast] streamer=${streamer.id} from="${displayName}" "${message}"`); + console.log(`[broadcast] streamer=${streamer.id} id=${messageId} from="${displayName}" "${message}"`); io.to(room).emit('display_message', { + id: messageId, text: message, from: displayName, at: Date.now(), @@ -232,10 +235,13 @@ io.on('connection', (socket) => { }); // Voice is generated after the fact so the text shows up immediately - // instead of waiting on the round trip to the TTS service. + // instead of waiting on the round trip to the TTS service. The overlay + // queues messages and matches this to the right one by id, since it may + // arrive before that message's turn to display. synthesizeSpeech(message, streamer.tts_voice || DEFAULT_TTS_VOICE) .then((audioBuffer) => { io.to(room).emit('display_audio', { + id: messageId, audio: audioBuffer.toString('base64'), mimeType: 'audio/mpeg', });