Files
bililive-touhou-tts/static/index.html
T
chun_qiu fd5c732d8a feat: web UI + QR login + portable build
- Add web UI panel (dark theme) at / with REST API
- Add Bilibili QR code scan login with browser header emulation
- Add wanakana for English -> katakana conversion
- Add volume control (0%-200%)
- Add message format with '说、' pause marker
- PyInstaller --onedir build for portable exe
- Rate-limit danmaku logging (1/20)
- Fix aiohttp.access log spam
- Add README.md
2026-08-08 13:22:58 +08:00

537 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bilibili Live → Yukkuri TTS</title>
<style>
:root {
--bg: #1a1a2e;
--panel: #16213e;
--border: #0f3460;
--accent: #e94560;
--accent-hover: #ff6b81;
--green: #4ade80;
--text: #e2e8f0;
--text-dim: #94a3b8;
--input-bg: #0f1629;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex; justify-content: center; min-height: 100vh;
padding: 24px;
}
.container {
width: 100%; max-width: 680px;
display: flex; flex-direction: column; gap: 16px;
}
h1 {
font-size: 1.4rem; font-weight: 700;
text-align: center; color: var(--accent);
padding-bottom: 8px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.card h2 {
font-size: 0.95rem; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 1px;
margin-bottom: 14px;
}
.form-row {
display: flex; gap: 12px; margin-bottom: 12px;
flex-wrap: wrap;
}
.form-group {
display: flex; flex-direction: column; gap: 4px;
flex: 1; min-width: 140px;
}
.form-group label {
font-size: 0.8rem; color: var(--text-dim);
}
.form-group input, .form-group select {
padding: 8px 12px; border-radius: 6px;
border: 1px solid var(--border);
background: var(--input-bg); color: var(--text);
font-size: 0.9rem; outline: none;
}
.form-group input:focus, .form-group select:focus {
border-color: var(--accent);
}
input[type="range"] {
accent-color: var(--accent);
border: none; background: transparent; padding: 0;
}
.btn-row {
display: flex; gap: 10px; margin-top: 4px;
}
.btn {
padding: 10px 24px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-start {
background: var(--green); color: #1a1a2e;
flex: 1;
}
.btn-start:hover { opacity: 0.9; }
.btn-stop {
background: var(--accent); color: white;
flex: 1;
}
.btn-stop:hover { background: var(--accent-hover); }
.btn:disabled {
opacity: 0.4; cursor: not-allowed;
}
.btn-login {
background: transparent; color: var(--text);
border: 1px solid var(--border);
padding: 6px 16px; font-size: 0.85rem; cursor: pointer;
border-radius: 6px; transition: background 0.15s;
}
.btn-login:hover { background: var(--input-bg); }
.btn-login.ready {
border-color: var(--green); color: var(--green);
}
/* QR Container */
.login-area {
display: flex; align-items: flex-start; gap: 20px;
margin-top: 4px; flex-wrap: wrap;
}
.qr-box {
background: white; border-radius: 8px;
padding: 12px; width: 180px; height: 180px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
position: relative; overflow: hidden;
}
.qr-box img {
width: 156px; height: 156px; image-rendering: pixelated;
}
.qr-box .qr-placeholder {
display: flex; flex-direction: column; align-items: center;
justify-content: center; color: #94a3b8; font-size: 0.82rem;
text-align: center; gap: 8px;
}
.qr-box .qr-placeholder svg {
width: 48px; height: 48px; opacity: 0.3;
}
.qr-overlay {
position: absolute; inset: 0;
background: rgba(15,22,41,0.92);
display: flex; align-items: center; justify-content: center;
border-radius: 8px; flex-direction: column; gap: 6px;
font-size: 0.85rem; text-align: center;
}
.qr-overlay .btn-refresh {
margin-top: 8px; padding: 6px 18px; border-radius: 6px;
border: 1px solid var(--accent); background: transparent;
color: var(--accent); font-size: 0.82rem; cursor: pointer;
transition: background 0.15s;
}
.qr-overlay .btn-refresh:hover { background: var(--accent); color: white; }
.qr-info {
display: flex; flex-direction: column; gap: 6px;
flex: 1; min-width: 180px;
}
.qr-info .status-text {
font-size: 0.88rem; line-height: 1.5;
}
.qr-info .user-info {
display: flex; align-items: center; gap: 10px;
font-size: 0.9rem; color: var(--green);
}
.qr-info .user-info .avatar {
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--green);
}
.logged-in-badge {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; font-size: 0.85rem;
}
.logged-in-badge .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--green); flex-shrink: 0;
}
/* Status bar */
.status-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; font-size: 0.85rem;
}
.status-dot {
width: 10px; height: 10px; border-radius: 50%;
display: inline-block;
}
.dot-off { background: #64748b; }
.dot-on { background: var(--green); animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.stats {
display: flex; gap: 20px; flex-wrap: wrap;
font-size: 0.82rem; color: var(--text-dim);
}
.stats span { color: var(--text); font-weight: 600; }
#log {
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
height: 320px; overflow-y: auto;
padding: 12px;
font-family: "Consolas", "Courier New", monospace;
font-size: 0.82rem; line-height: 1.5;
white-space: pre-wrap; word-break: break-all;
}
#log .msg { color: var(--text-dim); }
#log .msg:last-child { color: var(--text); }
/* spinner */
.spinner {
width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.2);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.8s linear infinite; margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<h1>Bilibili Live &rarr; Yukkuri TTS</h1>
<!-- Login Card -->
<div class="card" id="login-card">
<h2>Login</h2>
<div class="login-area">
<div class="qr-box" id="qr-box">
<div class="qr-placeholder" id="qr-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<rect x="6" y="6" width="2" height="2"/><rect x="10" y="6" width="2" height="2"/>
<rect x="14" y="6" width="2" height="2"/><rect x="16" y="6" width="2" height="2"/>
<rect x="6" y="10" width="2" height="2"/>
<rect x="16" y="10" width="2" height="2"/>
<rect x="6" y="14" width="2" height="2"/>
<rect x="10" y="14" width="2" height="2"/>
<rect x="6" y="16" width="2" height="2"/>
<rect x="14" y="16" width="2" height="2"/>
</svg>
<span>Click "Login"<br/>to get QR code</span>
</div>
<img id="qr-img" src="" alt="QR Code" style="display:none">
<div id="qr-overlay" style="display:none"></div>
</div>
<div class="qr-info">
<div id="login-status" class="status-text">
Not logged in — anonymous danmaku may have censored usernames.
</div>
<div>
<button class="btn btn-login" id="btn-login" onclick="startLogin()">Login with QR</button>
<button class="btn btn-login" id="btn-logout" onclick="doLogout()" style="display:none">Logout</button>
</div>
</div>
</div>
</div>
<!-- Config Card -->
<div class="card">
<h2>Configuration</h2>
<div class="form-row">
<div class="form-group">
<label for="room-id">Room ID</label>
<input type="number" id="room-id" placeholder="e.g. 12235923" value="">
</div>
<div class="form-group" style="max-width:120px">
<label for="voice">Voice</label>
<select id="voice">
<option value="f1">f1 (Reimu)</option>
<option value="f2">f2 (Marisa)</option>
<option value="m1">m1 (Male 1)</option>
<option value="m2">m2 (Male 2)</option>
<option value="r1">r1 (Robot)</option>
<option value="dvd">dvd</option>
<option value="imd1">imd1</option>
<option value="jgr">jgr</option>
</select>
</div>
<div class="form-group" style="max-width:140px">
<label for="speed">Speed (<span id="speed-val">100</span>)</label>
<input type="range" id="speed" min="50" max="300" value="100"
oninput="document.getElementById('speed-val').textContent=this.value">
</div>
<div class="form-group" style="max-width:140px">
<label for="volume">Volume (<span id="volume-val">100</span>%)</label>
<input type="range" id="volume" min="0" max="200" value="100"
oninput="document.getElementById('volume-val').textContent=this.value">
</div>
</div>
<div class="btn-row">
<button class="btn btn-start" id="btn-start" onclick="startTTS()">Start</button>
<button class="btn btn-stop" id="btn-stop" onclick="stopTTS()" disabled>Stop</button>
</div>
</div>
<!-- Status Card -->
<div class="card">
<div class="status-bar">
<span class="status-dot dot-off" id="status-dot"></span>
<span id="status-text">Stopped</span>
</div>
<div class="stats">
<div>Messages: <span id="stat-msgs">0</span></div>
<div>Uptime: <span id="stat-uptime">00:00</span></div>
</div>
<div id="log" style="margin-top:12px">
<span class="msg">Ready. Login with QR (optional), set Room ID, then click Start.</span>
</div>
</div>
</div>
<script>
let pollTimer = null;
let qrPollTimer = null;
let running = false;
let loginPollKey = null;
function fmtUptime(sec) {
const m = Math.floor(sec / 60), s = sec % 60;
return String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
// ── QR Login ────────────────────────────────────────────────────────────
async function startLogin() {
document.getElementById('btn-login').disabled = true;
document.getElementById('login-status').innerHTML = '<div class="spinner"></div> Generating...';
try {
const resp = await fetch('/api/qr/generate');
const data = await resp.json();
if (data.error) {
document.getElementById('login-status').textContent = 'Error: ' + data.error;
document.getElementById('btn-login').disabled = false;
return;
}
document.getElementById('qr-placeholder').style.display = 'none';
document.getElementById('qr-img').src = 'https://api.qrserver.com/v1/create-qr-code/?size=156x156&data=' + encodeURIComponent(data.url);
document.getElementById('qr-img').style.display = 'block';
document.getElementById('qr-overlay').style.display = 'none';
document.getElementById('login-status').textContent = '请使用Bilibili客户端扫码';
document.getElementById('btn-login').disabled = false;
document.getElementById('btn-login').textContent = 'Refresh QR';
loginPollKey = data.qrcode_key;
if (qrPollTimer) clearInterval(qrPollTimer);
qrPollTimer = setInterval(pollLoginStatus, 2000);
} catch (e) {
document.getElementById('login-status').textContent = 'Network error: ' + e.message;
document.getElementById('btn-login').disabled = false;
}
}
async function pollLoginStatus() {
if (!loginPollKey) return;
try {
const resp = await fetch('/api/qr/poll?key=' + loginPollKey);
const data = await resp.json();
const st = parseInt(data.status);
if (st === 0) {
// SUCCESS
clearInterval(qrPollTimer); qrPollTimer = null;
loginPollKey = null;
document.getElementById('qr-img').style.display = 'none';
document.getElementById('qr-placeholder').style.display = 'none';
showOverlay(false, '', '');
document.getElementById('login-status').innerHTML =
'<div class="user-info"><span class="status-dot dot-on"></span> Login: ' + escapeHtml(data.username || 'Unknown') + '</div>';
document.getElementById('btn-login').style.display = 'none';
document.getElementById('btn-logout').style.display = 'inline-block';
document.getElementById('btn-login').disabled = false;
document.getElementById('btn-login').textContent = 'Login with QR';
updateLoginStatusCard(true, data.username || '');
} else if (st === 86090) {
// Scanned, waiting
document.getElementById('login-status').textContent = '已扫码,请在手机上确认登录';
showOverlay(false, '', '');
} else if (st === 86101) {
// Not scanned
document.getElementById('login-status').textContent = '请使用Bilibili客户端扫码';
showOverlay(false, '', '');
} else if (st === 86038) {
// Expired
clearInterval(qrPollTimer); qrPollTimer = null;
loginPollKey = null;
showOverlay(true, 'expired', '二维码已过期');
document.getElementById('login-status').textContent = '二维码已过期,请点击刷新';
document.getElementById('btn-login').textContent = 'Refresh QR';
} else {
document.getElementById('login-status').textContent = data.message || ('Status: ' + st);
}
} catch (e) {
// ignore network errors during polling
}
}
function showOverlay(show, cls, text) {
const overlay = document.getElementById('qr-overlay');
if (show) {
overlay.style.display = 'flex';
overlay.innerHTML = '<span>' + text + '</span>' +
'<button class="btn-refresh" onclick="startLogin()">刷新二维码</button>';
} else {
overlay.style.display = 'none';
overlay.innerHTML = '';
}
}
async function doLogout() {
try {
await fetch('/api/logout');
} catch(e) {}
loginPollKey = null;
if (qrPollTimer) { clearInterval(qrPollTimer); qrPollTimer = null; }
document.getElementById('qr-img').style.display = 'none';
document.getElementById('qr-img').src = '';
document.getElementById('qr-placeholder').style.display = 'flex';
document.getElementById('qr-overlay').style.display = 'none';
document.getElementById('login-status').textContent = 'Not logged in — anonymous danmaku may have censored usernames.';
document.getElementById('btn-login').style.display = 'inline-block';
document.getElementById('btn-logout').style.display = 'none';
document.getElementById('btn-login').textContent = 'Login with QR';
}
function updateLoginStatusCard(loggedIn, username) {
// Visual indicator in login card already handled above.
}
// ── TTS Control ──────────────────────────────────────────────────────────
async function startTTS() {
const roomId = document.getElementById('room-id').value;
if (!roomId) { alert('Please enter a Room ID'); return; }
const config = {
room_id: parseInt(roomId),
voice: document.getElementById('voice').value,
speed: parseInt(document.getElementById('speed').value),
volume: parseInt(document.getElementById('volume').value),
};
try {
const resp = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await resp.json();
if (data.error) { alert('Error: ' + data.error); return; }
running = true;
updateButtons();
startPolling();
} catch (e) {
alert('Failed to connect: ' + e.message);
}
}
async function stopTTS() {
try {
await fetch('/api/stop', { method: 'POST' });
} catch(e) {}
running = false;
updateButtons();
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
document.getElementById('status-dot').className = 'status-dot dot-off';
document.getElementById('status-text').textContent = 'Stopped';
}
function updateButtons() {
document.getElementById('btn-start').disabled = running;
document.getElementById('btn-stop').disabled = !running;
for (const el of ['room-id', 'voice', 'speed', 'volume']) {
document.getElementById(el).disabled = running;
}
document.getElementById('btn-login').disabled = running;
document.getElementById('btn-logout').disabled = running;
}
async function pollStatus() {
try {
const resp = await fetch('/api/status');
const s = await resp.json();
if (!s.running && running) {
running = false;
updateButtons();
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
const dot = document.getElementById('status-dot');
const txt = document.getElementById('status-text');
if (s.running) {
dot.className = 'status-dot dot-on';
txt.textContent = 'Running';
} else {
dot.className = 'status-dot dot-off';
txt.textContent = 'Stopped';
}
document.getElementById('stat-msgs').textContent = s.messages_processed;
document.getElementById('stat-uptime').textContent = fmtUptime(s.uptime);
const log = document.getElementById('log');
const msgs = s.recent_messages || [];
log.innerHTML = msgs.length > 0
? msgs.map(m => '<span class="msg">' + escapeHtml(m) + '</span>').join('\n')
: '<span class="msg">Waiting for danmaku...</span>';
log.scrollTop = log.scrollHeight;
} catch(e) {
// Server may have stopped
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollStatus, 400);
pollStatus();
}
// ── Init: check existing login ──────────────────────────────────────────
async function checkExistingLogin() {
try {
const resp = await fetch('/api/login/status');
const data = await resp.json();
if (data.logged_in) {
document.getElementById('login-status').innerHTML =
'<div class="user-info"><span class="status-dot dot-on"></span> Login: ' + escapeHtml(data.username) + '</div>';
document.getElementById('btn-login').style.display = 'none';
document.getElementById('btn-logout').style.display = 'inline-block';
document.getElementById('qr-placeholder').style.display = 'none';
}
} catch(e) {}
}
checkExistingLogin();
</script>
</body>
</html>