Let streamers set how long messages stay on screen
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 29s
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 29s
Adds display_duration_seconds to the streamers table (default 10s, with an idempotent migration for existing rows), a dashboard control to change it, and wires the value through to the overlay via the display_message payload instead of a hardcoded 10s. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fab55cde1d
commit
f4d023c695
4 changed files with 87 additions and 4 deletions
12
db.js
12
db.js
|
|
@ -18,6 +18,7 @@ db.exec(`
|
||||||
overlay_token TEXT UNIQUE NOT NULL,
|
overlay_token TEXT UNIQUE NOT NULL,
|
||||||
sender_token TEXT UNIQUE NOT NULL,
|
sender_token TEXT UNIQUE NOT NULL,
|
||||||
tts_voice TEXT NOT NULL DEFAULT 'ru-RU-DmitryNeural',
|
tts_voice TEXT NOT NULL DEFAULT 'ru-RU-DmitryNeural',
|
||||||
|
display_duration_seconds INTEGER NOT NULL DEFAULT 10,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -30,6 +31,12 @@ db.exec(`
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Idempotent migration for databases created before display_duration_seconds existed.
|
||||||
|
const existingColumns = db.prepare('PRAGMA table_info(streamers)').all().map((c) => c.name);
|
||||||
|
if (!existingColumns.includes('display_duration_seconds')) {
|
||||||
|
db.exec('ALTER TABLE streamers ADD COLUMN display_duration_seconds INTEGER NOT NULL DEFAULT 10');
|
||||||
|
}
|
||||||
|
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
return crypto.randomBytes(16).toString('hex');
|
return crypto.randomBytes(16).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
@ -75,10 +82,15 @@ function setViewerName(streamerId, googleId, displayName) {
|
||||||
).run(streamerId, googleId, displayName, Date.now());
|
).run(streamerId, googleId, displayName, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDisplayDuration(streamerId, seconds) {
|
||||||
|
db.prepare('UPDATE streamers SET display_duration_seconds = ? WHERE id = ?').run(seconds, streamerId);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findStreamerByOverlayToken,
|
findStreamerByOverlayToken,
|
||||||
findStreamerBySenderToken,
|
findStreamerBySenderToken,
|
||||||
getOrCreateStreamerByGoogle,
|
getOrCreateStreamerByGoogle,
|
||||||
getViewerName,
|
getViewerName,
|
||||||
setViewerName,
|
setViewerName,
|
||||||
|
updateDisplayDuration,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,28 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.settings-row input {
|
||||||
|
width: 80px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.settings-row button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#settings-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -73,6 +95,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Длительность показа сообщения</h2>
|
||||||
|
<p>Сколько секунд сообщение зрителя остаётся на экране.</p>
|
||||||
|
<div class="settings-row">
|
||||||
|
<input id="display-duration" type="number" min="1" max="120" />
|
||||||
|
<span>сек.</span>
|
||||||
|
<button id="save-duration">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div id="settings-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="logout">выйти</p>
|
<p id="logout">выйти</p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -92,8 +125,26 @@
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
document.getElementById('overlay-url').value = data.overlayUrl;
|
document.getElementById('overlay-url').value = data.overlayUrl;
|
||||||
document.getElementById('sender-url').value = data.senderUrl;
|
document.getElementById('sender-url').value = data.senderUrl;
|
||||||
|
document.getElementById('display-duration').value = data.displayDurationSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('save-duration').addEventListener('click', async () => {
|
||||||
|
const statusEl = document.getElementById('settings-status');
|
||||||
|
const seconds = Number(document.getElementById('display-duration').value);
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ displayDurationSeconds: seconds }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
statusEl.textContent = 'Сохранено';
|
||||||
|
} else {
|
||||||
|
const { error } = await res.json();
|
||||||
|
statusEl.textContent = 'Ошибка: ' + error;
|
||||||
|
}
|
||||||
|
setTimeout(() => { statusEl.textContent = ''; }, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('[data-copy]').forEach((btn) => {
|
document.querySelectorAll('[data-copy]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const input = document.getElementById(btn.dataset.copy);
|
const input = document.getElementById(btn.dataset.copy);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ function playNotificationSound() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text, from) {
|
function showMessage(text, from, durationMs) {
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
|
|
||||||
fromEl.textContent = from || '';
|
fromEl.textContent = from || '';
|
||||||
|
|
@ -54,10 +54,9 @@ function showMessage(text, from) {
|
||||||
|
|
||||||
playNotificationSound();
|
playNotificationSound();
|
||||||
|
|
||||||
const durationMs = 10000;
|
|
||||||
hideTimer = setTimeout(() => {
|
hideTimer = setTimeout(() => {
|
||||||
box.classList.remove('visible');
|
box.classList.remove('visible');
|
||||||
}, durationMs);
|
}, durationMs || 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playSpeech(base64Audio, mimeType) {
|
function playSpeech(base64Audio, mimeType) {
|
||||||
|
|
@ -66,7 +65,7 @@ function playSpeech(base64Audio, mimeType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('display_message', (payload) => {
|
socket.on('display_message', (payload) => {
|
||||||
showMessage(payload.text, payload.from);
|
showMessage(payload.text, payload.from, payload.durationMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('display_audio', (payload) => {
|
socket.on('display_audio', (payload) => {
|
||||||
|
|
|
||||||
21
server.js
21
server.js
|
|
@ -13,6 +13,7 @@ const {
|
||||||
getOrCreateStreamerByGoogle,
|
getOrCreateStreamerByGoogle,
|
||||||
getViewerName,
|
getViewerName,
|
||||||
setViewerName,
|
setViewerName,
|
||||||
|
updateDisplayDuration,
|
||||||
} = require('./db');
|
} = require('./db');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -116,9 +117,28 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||||
overlayUrl: `${origin}/overlay/${streamer.overlay_token}`,
|
overlayUrl: `${origin}/overlay/${streamer.overlay_token}`,
|
||||||
senderUrl: `${origin}/s/${streamer.sender_token}`,
|
senderUrl: `${origin}/s/${streamer.sender_token}`,
|
||||||
ttsVoice: streamer.tts_voice,
|
ttsVoice: streamer.tts_voice,
|
||||||
|
displayDurationSeconds: streamer.display_duration_seconds,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/settings', requireAuth, (req, res) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
const streamer = getOrCreateStreamerByGoogle({
|
||||||
|
googleId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
picture: user.picture,
|
||||||
|
});
|
||||||
|
|
||||||
|
const seconds = Number(req.body.displayDurationSeconds);
|
||||||
|
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 120) {
|
||||||
|
return res.status(400).json({ error: 'displayDurationSeconds must be an integer between 1 and 120' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDisplayDuration(streamer.id, seconds);
|
||||||
|
res.json({ displayDurationSeconds: seconds });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/viewer-name', requireAuth, (req, res) => {
|
app.get('/api/viewer-name', requireAuth, (req, res) => {
|
||||||
const streamer = findStreamerBySenderToken(req.query.token);
|
const streamer = findStreamerBySenderToken(req.query.token);
|
||||||
if (!streamer) {
|
if (!streamer) {
|
||||||
|
|
@ -208,6 +228,7 @@ io.on('connection', (socket) => {
|
||||||
text: message,
|
text: message,
|
||||||
from: displayName,
|
from: displayName,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
|
durationMs: (streamer.display_duration_seconds || 10) * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Voice is generated after the fact so the text shows up immediately
|
// Voice is generated after the fact so the text shows up immediately
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue