2026-05-27 19:09:51 +08:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<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">
|
2026-05-27 19:09:51 +08:00
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<div class="shell">
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<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>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<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>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<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>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<button class="broadcast" id="go" type="button">开始广播</button>
|
|
|
|
|
|
</main>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<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>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<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>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
</div>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
'use strict';
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
var $ = function (id) { return document.getElementById(id); };
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
var THEME_KEY = 'broadcast-theme';
|
|
|
|
|
|
var themeMeta = document.querySelector('meta[name="theme-color"]');
|
|
|
|
|
|
var COLORS = { light: '#F0F1EE', dark: '#121614' };
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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');
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
/* ---------- 主题 ---------- */
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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) {}
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
$('theme-btn').addEventListener('click', function () {
|
|
|
|
|
|
applyTheme(document.documentElement.classList.contains('theme-light') ? 'dark' : 'light');
|
|
|
|
|
|
});
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
/* ---------- 日志 ---------- */
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
function stamp() {
|
|
|
|
|
|
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
$('clear-logs').addEventListener('click', function () { elLogs.innerHTML = ''; });
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
/* ---------- 抽屉 ---------- */
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
var history = drawer('history');
|
|
|
|
|
|
var status = drawer('status');
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
/* ---------- 广播状态 ---------- */
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
var live = false; // 喇叭是否正在响
|
|
|
|
|
|
var busy = false; // 是否正在合成
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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);
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
if (on) {
|
|
|
|
|
|
elStatusText.textContent = '正在播报';
|
|
|
|
|
|
elStatusMeta.textContent = describe(info);
|
|
|
|
|
|
} else if (!busy) {
|
|
|
|
|
|
elStatusText.textContent = '待机';
|
|
|
|
|
|
elStatusMeta.textContent = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
function describe(info) {
|
|
|
|
|
|
if (!info || !info.playing) return '';
|
|
|
|
|
|
var who = info.display_name ? ',' + info.display_name : '';
|
|
|
|
|
|
return info.mode === 'loop'
|
|
|
|
|
|
? '循环播报,每 ' + info.interval + ' 秒一次' + who
|
|
|
|
|
|
: '播一遍' + who;
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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 = '等几秒'; }
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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) { /* 网络抖动就跳过这一轮 */ }
|
|
|
|
|
|
}
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
/* ---------- 内容与音色 ---------- */
|
2026-05-27 19:09:51 +08:00
|
|
|
|
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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');
|
2026-05-27 19:09:51 +08:00
|
|
|
|
loadFiles();
|
2026-10-13 18:13:47 +08:00
|
|
|
|
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, '&').replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-05-27 19:09:51 +08:00
|
|
|
|
</body>
|
2026-10-13 18:13:47 +08:00
|
|
|
|
</html>
|