Files
PA_horn_speaker/templates/index.html
T

486 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#F0F1EE">
<title>喇叭</title>
<link rel="stylesheet" href="/static/style.css?v=2">
</head>
<body>
<div class="shell">
<header class="top">
<h1 class="wordmark">喇叭</h1>
<div class="top-actions">
<span class="live-dot" aria-hidden="true"></span>
<button class="ghost-btn" id="theme-btn" type="button">夜间</button>
</div>
</header>
<main class="composer">
<label class="sr-only" for="text">广播内容</label>
<div class="slab">
<textarea id="text" rows="4" maxlength="500"
placeholder="输入今天要吆喝的内容…"></textarea>
<div class="slab-foot">
<span class="meta" id="count">0 / 500</span>
<label class="voice-pick">
<span class="sr-only">选择声音</span>
<select id="voice">
{% for k,v in voices.items() %}<option>{{ k }}</option>{% endfor %}
</select>
</label>
</div>
</div>
<div class="loop-row">
<label class="switch">
<input type="checkbox" id="loop">
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">循环播报</span>
</label>
<div class="interval" id="interval-wrap" hidden>
<label class="sr-only" for="interval">循环间隔秒数</label>
<input type="range" id="interval" min="1" max="60" value="5">
<output class="meta" id="interval-out" for="interval">每 5 秒</output>
</div>
</div>
<button class="broadcast" id="go" type="button">开始广播</button>
</main>
<section class="drawer" id="history">
<button class="drawer-head" type="button" aria-expanded="false" aria-controls="history-body">
<span class="drawer-title">之前播过的</span>
<span class="drawer-meta" id="history-count">0 条</span>
<span class="caret" aria-hidden="true"></span>
</button>
<div class="drawer-body" id="history-body">
<div class="rows" id="files-list"></div>
</div>
</section>
<section class="drawer" id="status">
<button class="drawer-head" type="button" aria-expanded="false" aria-controls="status-body">
<span class="drawer-title" id="status-text">待机</span>
<span class="drawer-meta" id="status-meta"></span>
<span class="caret" aria-hidden="true"></span>
</button>
<div class="drawer-body" id="status-body">
<div class="logs" id="logs"></div>
<button class="ghost-btn small" id="clear-logs" type="button">清空日志</button>
</div>
</section>
</div>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var THEME_KEY = 'broadcast-theme';
var themeMeta = document.querySelector('meta[name="theme-color"]');
var COLORS = { light: '#F0F1EE', dark: '#121614' };
var elText = $('text'), elVoice = $('voice'), elLoop = $('loop'),
elInterval = $('interval'), elIntervalWrap = $('interval-wrap'),
elIntervalOut = $('interval-out'), elCount = $('count'),
elGo = $('go'), elFiles = $('files-list'), elHistoryCount = $('history-count'),
elStatusText = $('status-text'), elStatusMeta = $('status-meta'),
elLogs = $('logs');
/* ---------- 主题 ---------- */
function applyTheme(name) {
var light = name === 'light';
document.documentElement.classList.toggle('theme-light', light);
$('theme-btn').textContent = light ? '夜间' : '日间';
if (themeMeta) themeMeta.setAttribute('content', light ? COLORS.light : COLORS.dark);
try { localStorage.setItem(THEME_KEY, name); } catch (e) {}
}
function initTheme() {
var saved = null;
try { saved = localStorage.getItem(THEME_KEY); } catch (e) {}
if (saved !== 'light' && saved !== 'dark') {
saved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
applyTheme(saved);
}
$('theme-btn').addEventListener('click', function () {
applyTheme(document.documentElement.classList.contains('theme-light') ? 'dark' : 'light');
});
/* ---------- 日志 ---------- */
function stamp() {
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
function log(message, kind) {
var line = document.createElement('div');
line.className = 'log' + (kind ? ' log-' + kind : '');
var time = document.createElement('span');
time.className = 'log-time';
time.textContent = stamp();
var body = document.createElement('span');
body.textContent = message;
line.appendChild(time);
line.appendChild(body);
elLogs.appendChild(line);
elLogs.scrollTop = elLogs.scrollHeight;
while (elLogs.childElementCount > 80) elLogs.removeChild(elLogs.firstChild);
}
$('clear-logs').addEventListener('click', function () { elLogs.innerHTML = ''; });
/* ---------- 抽屉 ---------- */
function drawer(id) {
var box = $(id);
var head = box.querySelector('.drawer-head');
var body = box.querySelector('.drawer-body');
head.addEventListener('click', function () {
var open = body.classList.toggle('open');
head.setAttribute('aria-expanded', open ? 'true' : 'false');
});
return {
open: function () { body.classList.add('open'); head.setAttribute('aria-expanded', 'true'); },
body: body
};
}
var history = drawer('history');
var status = drawer('status');
/* ---------- 广播状态 ---------- */
var live = false; // 喇叭是否正在响
var busy = false; // 是否正在合成
function setLive(on, info) {
live = on;
document.body.classList.toggle('is-live', on);
elGo.textContent = busy ? '正在合成…' : (on ? '停止广播' : '开始广播');
elGo.disabled = busy;
elGo.classList.toggle('is-stop', on && !busy);
if (on) {
elStatusText.textContent = '正在播报';
elStatusMeta.textContent = describe(info);
} else if (!busy) {
elStatusText.textContent = '待机';
elStatusMeta.textContent = '';
}
}
function describe(info) {
if (!info || !info.playing) return '';
var who = info.display_name ? ',' + info.display_name : '';
return info.mode === 'loop'
? '循环播报,每 ' + info.interval + ' 秒一次' + who
: '播一遍' + who;
}
function setBusy(on) {
busy = on;
elGo.disabled = on;
elGo.textContent = on ? '正在合成…' : (live ? '停止广播' : '开始广播');
elGo.classList.toggle('is-stop', !on && live);
elGo.classList.toggle('is-busy', on);
if (on) { elStatusText.textContent = '正在合成'; elStatusMeta.textContent = '等几秒'; }
}
async function poll() {
try {
var r = await fetch('/status');
var info = await r.json();
if (info.playing !== live && !busy) setLive(info.playing, info);
else if (info.playing) elStatusMeta.textContent = describe(info);
} catch (e) { /* 网络抖动就跳过这一轮 */ }
}
/* ---------- 内容与音色 ---------- */
function autoGrow() {
elText.style.height = 'auto';
elText.style.height = Math.min(elText.scrollHeight, 420) + 'px';
elCount.textContent = elText.value.length + ' / 500';
}
elText.addEventListener('input', autoGrow);
elLoop.addEventListener('change', function () {
elIntervalWrap.hidden = !elLoop.checked;
});
elInterval.addEventListener('input', function () {
elIntervalOut.textContent = '每 ' + elInterval.value + ' 秒';
});
/* ---------- 广播 / 停止 ---------- */
elGo.addEventListener('click', function () {
if (live) stop(); else speak();
});
async function speak() {
var text = elText.value.trim();
if (!text) { flash('先写一句要播的话'); elText.focus(); return; }
setBusy(true);
log('开始合成,音色:' + elVoice.value);
if (elLoop.checked) log('循环播报,每 ' + elInterval.value + ' 秒一次');
var form = new FormData();
form.append('text', text);
form.append('voice', elVoice.value);
form.append('loop', elLoop.checked);
form.append('interval', elInterval.value);
try {
var r = await fetch('/speak', { method: 'POST', body: form });
var result = await r.json();
setBusy(false);
if (result.status === 'ok') {
log('已开始广播', 'ok');
loadFiles();
poll();
} else {
fail(result.message || '合成失败');
}
} catch (e) {
setBusy(false);
fail(e.message);
}
}
async function stop() {
try {
await fetch('/stop');
log('已停止播报');
setLive(false);
} catch (e) { fail(e.message); }
}
function fail(message) {
log('没成功:' + message, 'err');
elStatusText.textContent = '上次没成功';
elStatusMeta.textContent = message;
status.open();
}
var flashTimer = null;
function flash(message) {
log(message, 'err');
elText.classList.add('nudge');
clearTimeout(flashTimer);
flashTimer = setTimeout(function () { elText.classList.remove('nudge'); }, 600);
}
/* ---------- 语音列表 ---------- */
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function when(ts) {
var d = new Date(ts * 1000);
var now = new Date();
var sameDay = d.toDateString() === now.toDateString();
var hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
return sameDay ? hm : (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hm;
}
async function loadFiles() {
try {
var r = await fetch('/files');
var result = await r.json();
renderFiles(result.files || []);
} catch (e) {
log('读不到语音列表:' + e.message, 'err');
}
}
function renderFiles(files) {
elHistoryCount.textContent = files.length + ' 条';
elFiles.innerHTML = '';
if (!files.length) {
var empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = '还没有播过的内容。上面写一句,点「开始广播」就有了。';
elFiles.appendChild(empty);
return;
}
files.forEach(function (file) { elFiles.appendChild(fileRow(file)); });
}
function fileRow(file) {
var row = document.createElement('div');
row.className = 'row' + (file.is_favorite ? ' is-fav' : '');
row.dataset.filename = file.filename;
var name = document.createElement('input');
name.className = 'row-name';
name.type = 'text';
name.value = file.display_name;
name.dataset.filename = file.filename;
name.dataset.old = file.display_name;
name.setAttribute('aria-label', '这一条的名字,可以直接改');
var meta = document.createElement('div');
meta.className = 'row-meta';
var voice = document.createElement('span');
voice.textContent = file.voice || '未知声音';
var time = document.createElement('span');
time.textContent = when(file.created_at);
meta.appendChild(voice);
meta.appendChild(time);
var actions = document.createElement('div');
actions.className = 'row-actions';
actions.appendChild(action('播一遍', 'play'));
actions.appendChild(action('循环', 'loop'));
actions.appendChild(action('删除', 'del'));
actions.appendChild(favButton(file.is_favorite));
row.appendChild(name);
row.appendChild(meta);
row.appendChild(actions);
name.addEventListener('blur', function () { rename(name); });
name.addEventListener('keydown', function (e) {
if (e.key === 'Enter') name.blur();
if (e.key === 'Escape') { name.value = name.dataset.old; name.blur(); }
});
actions.querySelector('[data-act="play"]').addEventListener('click', function () { play(file.filename, false); });
actions.querySelector('[data-act="loop"]').addEventListener('click', function () { play(file.filename, true); });
actions.querySelector('.row-fav').addEventListener('click', function () { favorite(file.filename); });
actions.querySelector('[data-act="del"]').addEventListener('click', function () { confirmDelete(row, file); });
return row;
}
function action(label, act) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'act';
b.dataset.act = act;
b.textContent = label;
return b;
}
function favButton(isFav) {
var b = action(isFav ? '已收藏' : '收藏', 'fav');
b.classList.add('row-fav');
if (isFav) b.classList.add('on');
return b;
}
async function play(filename, loop) {
var form = new FormData();
form.append('filename', filename);
form.append('loop', loop);
form.append('interval', elInterval.value);
try {
var r = await fetch('/play-file', { method: 'POST', body: form });
var result = await r.json();
if (result.status === 'ok') {
log(result.message);
poll();
} else {
fail(result.message || '播不了这一条');
}
} catch (e) { fail(e.message); }
}
async function rename(input) {
var next = input.value.trim();
if (!next || next === input.dataset.old) { input.value = input.dataset.old; return; }
var form = new FormData();
form.append('filename', input.dataset.filename);
form.append('new_name', next);
try {
var r = await fetch('/rename-file', { method: 'POST', body: form });
var result = await r.json();
if (result.status === 'ok') { input.dataset.old = next; log('改名成功'); }
else { input.value = input.dataset.old; fail(result.message); }
} catch (e) { input.value = input.dataset.old; fail(e.message); }
}
async function favorite(filename) {
var form = new FormData();
form.append('filename', filename);
try {
var r = await fetch('/toggle-favorite', { method: 'POST', body: form });
var result = await r.json();
if (result.status === 'ok') { log(result.message); loadFiles(); }
else { fail(result.message); }
} catch (e) { fail(e.message); }
}
function confirmDelete(row, file) {
var actions = row.querySelector('.row-actions');
if (row.dataset.confirming) return;
row.dataset.confirming = '1';
var kept = actions.innerHTML;
actions.innerHTML = '';
var ask = document.createElement('span');
ask.className = 'confirm-text';
ask.textContent = '删掉这条?';
var yes = action('删除', 'yes');
yes.classList.add('danger');
var no = action('取消', 'no');
yes.addEventListener('click', function () { remove(file); });
no.addEventListener('click', function () {
delete row.dataset.confirming;
actions.innerHTML = kept;
rebind(actions, row, file);
});
actions.appendChild(ask);
actions.appendChild(yes);
actions.appendChild(no);
yes.focus();
}
function rebind(actions, row, file) {
actions.querySelector('[data-act="play"]').addEventListener('click', function () { play(file.filename, false); });
actions.querySelector('[data-act="loop"]').addEventListener('click', function () { play(file.filename, true); });
actions.querySelector('.row-fav').addEventListener('click', function () { favorite(file.filename); });
actions.querySelector('[data-act="del"]').addEventListener('click', function () { confirmDelete(row, file); });
}
async function remove(file) {
var form = new FormData();
form.append('filename', file.filename);
try {
var r = await fetch('/delete-file', { method: 'POST', body: form });
var result = await r.json();
if (result.status === 'ok') { log('已删除'); loadFiles(); poll(); }
else { fail(result.message); }
} catch (e) { fail(e.message); }
}
/* ---------- 启动 ---------- */
initTheme();
autoGrow();
loadFiles();
poll();
setInterval(poll, 3000);
log('就绪');
})();
</script>
</body>
</html>