Queue overlay messages instead of interrupting the current one
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
Messages now play one at a time for their full configured duration before the next one appears, instead of a new message immediately replacing whatever was on screen. TTS audio is tagged with the same message id as its text so it stays in sync even though it's generated and delivered asynchronously after the text broadcast. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f4d023c695
commit
03a3a4c4af
2 changed files with 50 additions and 22 deletions
|
|
@ -5,7 +5,10 @@ const textEl = document.getElementById('text');
|
||||||
const token = location.pathname.split('/').filter(Boolean).pop();
|
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||||
const socket = io({ query: { role: 'overlay', token } });
|
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;
|
let audioCtx = null;
|
||||||
|
|
||||||
function getAudioCtx() {
|
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) {
|
function playSpeech(base64Audio, mimeType) {
|
||||||
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
||||||
audio.play().catch((err) => console.error('Could not play TTS audio:', err));
|
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) => {
|
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) => {
|
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', () => {
|
socket.on('invalid_link', () => {
|
||||||
|
|
|
||||||
10
server.js
10
server.js
|
|
@ -7,6 +7,7 @@ const { createServer } = require('http');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
const { OAuth2Client } = require('google-auth-library');
|
const { OAuth2Client } = require('google-auth-library');
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
const {
|
const {
|
||||||
findStreamerByOverlayToken,
|
findStreamerByOverlayToken,
|
||||||
findStreamerBySenderToken,
|
findStreamerBySenderToken,
|
||||||
|
|
@ -222,9 +223,11 @@ io.on('connection', (socket) => {
|
||||||
const displayName = (typeof name === 'string' && name.trim().slice(0, 60)) || user.name;
|
const displayName = (typeof name === 'string' && name.trim().slice(0, 60)) || user.name;
|
||||||
setViewerName(streamer.id, user.id, displayName);
|
setViewerName(streamer.id, user.id, displayName);
|
||||||
|
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
const room = `streamer-${streamer.id}`;
|
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', {
|
io.to(room).emit('display_message', {
|
||||||
|
id: messageId,
|
||||||
text: message,
|
text: message,
|
||||||
from: displayName,
|
from: displayName,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
|
|
@ -232,10 +235,13 @@ io.on('connection', (socket) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Voice is generated after the fact so the text shows up immediately
|
// 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)
|
synthesizeSpeech(message, streamer.tts_voice || DEFAULT_TTS_VOICE)
|
||||||
.then((audioBuffer) => {
|
.then((audioBuffer) => {
|
||||||
io.to(room).emit('display_audio', {
|
io.to(room).emit('display_audio', {
|
||||||
|
id: messageId,
|
||||||
audio: audioBuffer.toString('base64'),
|
audio: audioBuffer.toString('base64'),
|
||||||
mimeType: 'audio/mpeg',
|
mimeType: 'audio/mpeg',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue