Compare commits
153
Commits
86b6ba180e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c69740470 | ||
|
|
9c5d6e9659 | ||
|
|
dc8f94ce22 | ||
|
|
8c9c72fcba | ||
|
|
0335e245e1 | ||
|
|
1179130656 | ||
|
|
a58632272e | ||
|
|
fb17de6493 | ||
|
|
a3ff8664ee | ||
|
|
5e466f4553 | ||
|
|
4c049050ce | ||
|
|
e0a1252db6 | ||
|
|
b2c415f253 | ||
|
|
a6d9b6c418 | ||
|
|
5c462a4d09 | ||
|
|
fc66e43379 | ||
|
|
c9d8994942 | ||
|
|
4c7d94f2c9 | ||
|
|
6ac05d3a84 | ||
|
|
55f0312314 | ||
|
|
849b43af39 | ||
|
|
e9819d4894 | ||
|
|
66683f6d13 | ||
|
|
50746ce766 | ||
|
|
0de59531f9 | ||
|
|
926db04a3e | ||
|
|
ae213a84a1 | ||
|
|
cd5b1d47b0 | ||
|
|
716a23cad5 | ||
|
|
ae96c86288 | ||
|
|
17b03ce0f7 | ||
|
|
32b2c8ce9d | ||
|
|
34d9ed00d6 | ||
|
|
82a4ecd3e8 | ||
|
|
1b958c25df | ||
|
|
1d99634957 | ||
|
|
af00d57e68 | ||
|
|
4b972c3ae6 | ||
|
|
71f6800a2a | ||
|
|
b74eb252e3 | ||
|
|
298308b7f3 | ||
|
|
6b1a531b77 | ||
|
|
2eddc9fefe | ||
|
|
51968cc825 | ||
|
|
8f70c1f1d3 | ||
|
|
d8eee041c8 | ||
|
|
01ac488aef | ||
|
|
c86fca8449 | ||
|
|
6398146ee6 | ||
|
|
86e58a5c59 | ||
|
|
aa10f0ebe1 | ||
|
|
effcd30291 | ||
|
|
3c25040a84 | ||
|
|
fdbcfa3b9b | ||
|
|
87fda31485 | ||
|
|
f846004091 | ||
|
|
439ed9ea3c | ||
|
|
eb018dbae9 | ||
|
|
619d4e9a20 | ||
|
|
3c71d64f79 | ||
|
|
a36b21cfaf | ||
|
|
101d392840 | ||
|
|
c8681417a8 | ||
|
|
bf1895ebc5 | ||
|
|
394f81b9fa | ||
|
|
5fb81e3998 | ||
|
|
e89b82a8b4 | ||
|
|
5dbd3d667f | ||
|
|
6964e7a47d | ||
|
|
59865138c7 | ||
|
|
b2888c6c88 | ||
|
|
aef63ca444 | ||
|
|
10d15dfddc | ||
|
|
52a643a868 | ||
|
|
e29cc0c2ba | ||
|
|
73f0ba8e69 | ||
|
|
22ea43c1c1 | ||
|
|
fb1256fbcc | ||
|
|
04591fc0c8 | ||
|
|
906594008a | ||
|
|
126aaaf02f | ||
|
|
278de7de6b | ||
|
|
ea22c79008 | ||
|
|
39dc446958 | ||
|
|
f922ace154 | ||
|
|
b7be4cd00d | ||
|
|
9060e79006 | ||
|
|
426e55a7f0 | ||
|
|
db818209f2 | ||
|
|
a1db54b295 | ||
|
|
96222ebf06 | ||
|
|
9f2dc5bb3d | ||
|
|
4b95c86af6 | ||
|
|
432b82eae9 | ||
|
|
23196cd59d | ||
|
|
ea288b6572 | ||
|
|
61576a7d9e | ||
|
|
b725c74e1d | ||
|
|
c13a0c9dcc | ||
|
|
5134a4ebf0 | ||
|
|
544179758e | ||
|
|
95e550441e | ||
|
|
8a431147ec | ||
|
|
d28b0eec30 | ||
|
|
ad70f4adbc | ||
|
|
e92348b4db | ||
|
|
13dc054a01 | ||
|
|
c573260e5d | ||
|
|
dbf259efe5 | ||
|
|
5c899b30bb | ||
|
|
53b4d1b296 | ||
|
|
b52776fe09 | ||
|
|
43a2a4deaf | ||
|
|
712d8bb84d | ||
|
|
26003844b9 | ||
|
|
ef75671527 | ||
|
|
805aba9f37 | ||
|
|
faa25bbb3c | ||
|
|
ac8405e300 | ||
|
|
832658ad58 | ||
|
|
8aec4c6a84 | ||
|
|
fb49925a9b | ||
|
|
66ce214f51 | ||
|
|
944f00f521 | ||
|
|
90c108b1d7 | ||
|
|
01b367fa8b | ||
|
|
4b3bd91023 | ||
|
|
2fae7305f6 | ||
|
|
e009f05a61 | ||
|
|
81f58b58bb | ||
|
|
5c78b20163 | ||
|
|
1043d46fb8 | ||
|
|
440a8366ca | ||
|
|
495b3cedb9 | ||
|
|
2741b2d321 | ||
|
|
c76538e4bd | ||
|
|
a516976e35 | ||
|
|
3ecae7b0bc | ||
|
|
b8aee4d766 | ||
|
|
038662c15e | ||
|
|
dedb37e22b | ||
|
|
1b6a2b7e33 | ||
|
|
7bfff51919 | ||
|
|
f4c22cb713 | ||
|
|
52ecdc8a16 | ||
|
|
62ecf5642b | ||
|
|
6150050f69 | ||
|
|
6f122c7a2c | ||
|
|
e3a88acf9b | ||
|
|
5e600bb75d | ||
|
|
1d0c0e0703 | ||
|
|
c0f100a833 | ||
|
|
545c19335f |
@@ -7,3 +7,8 @@ CloudServer/server-data.json
|
|||||||
Audio/general/
|
Audio/general/
|
||||||
Audio/music/
|
Audio/music/
|
||||||
Audio/station/
|
Audio/station/
|
||||||
|
tmp_vercel_proxy/
|
||||||
|
nbt_dump.txt
|
||||||
|
test.lua
|
||||||
|
temp-broadcast/
|
||||||
|
debug-upcoming-data-missing.md
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,4 @@
|
|||||||
/*
|
|
||||||
* @Author: HenryDu8133 813367384@qq.com
|
|
||||||
* @Date: 2026-02-24 14:42:13
|
|
||||||
* @LastEditors: HenryDu8133 813367384@qq.com
|
|
||||||
* @LastEditTime: 2026-06-07 02:05:41
|
|
||||||
* @FilePath: \StationBroadcastDisplayCN\CloudServer\ecosystem.config.js
|
|
||||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps : [{
|
apps : [{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -725,6 +725,14 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
const TTS_MODEL = "s2.1-pro-free";
|
const TTS_MODEL = "s2.1-pro-free";
|
||||||
const ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
|
const ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
|
||||||
const JA_REFERENCE_ID = "38985e545cff41b79c11c32b0196c9a0";
|
const JA_REFERENCE_ID = "38985e545cff41b79c11c32b0196c9a0";
|
||||||
@@ -826,8 +834,8 @@
|
|||||||
toast.className = 'toast';
|
toast.className = 'toast';
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
|
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${ds(title)}</div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
|
<div style="font-size:0.8rem; color:var(--text-muted)">${ds(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
@@ -857,14 +865,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _generateSingleTTS(text, reference_id) {
|
async function _generateSingleTTS(text, reference_id) {
|
||||||
const res = await fetch('/api/tts/generate', {
|
try {
|
||||||
method: 'POST',
|
const res = await fetch('/api/tts/generate', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ text, reference_id, model: TTS_MODEL })
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
body: JSON.stringify({ text, reference_id, model: TTS_MODEL })
|
||||||
const data = await res.json();
|
});
|
||||||
if (!data.success) throw new Error(data.message);
|
|
||||||
return data;
|
if (!res.ok) {
|
||||||
|
let msg = '服务器返回错误';
|
||||||
|
try { const errData = await res.json(); msg = errData.message || msg; } catch(e){}
|
||||||
|
throw new Error(`[${res.status}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.message);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
|
||||||
|
throw new Error('网络连接失败。请确保你的服务器代理正常!');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _importSingleAudio(code, zhTemp, jaTemp) {
|
async function _importSingleAudio(code, zhTemp, jaTemp) {
|
||||||
@@ -926,11 +948,15 @@
|
|||||||
item.style.alignItems = 'stretch';
|
item.style.alignItems = 'stretch';
|
||||||
item.style.gap = '8px';
|
item.style.gap = '8px';
|
||||||
|
|
||||||
|
const dsCode = ds(task.code);
|
||||||
|
const dsZh = ds(task.zh || '');
|
||||||
|
const dsJa = ds(task.en || '');
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||||
<div style="display:flex; flex-direction:column; gap:2px;">
|
<div style="display:flex; flex-direction:column; gap:2px;">
|
||||||
<span style="font-weight:600; color:var(--accent)">${task.code}</span>
|
<span style="font-weight:600; color:var(--accent)">${dsCode}</span>
|
||||||
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${task.zh} | JA: ${task.en}</span>
|
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${dsZh} | JA: ${dsJa}</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
|
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -996,13 +1022,13 @@
|
|||||||
try {
|
try {
|
||||||
// 1. Generate ZH
|
// 1. Generate ZH
|
||||||
const zhData = await _generateSingleTTS(task.zh, ZH_REFERENCE_ID);
|
const zhData = await _generateSingleTTS(task.zh, ZH_REFERENCE_ID);
|
||||||
// 2. Generate JA
|
// 2. Generate JA
|
||||||
const jaData = await _generateSingleTTS(task.en, JA_REFERENCE_ID);
|
const jaData = await _generateSingleTTS(task.en, JA_REFERENCE_ID);
|
||||||
|
|
||||||
task.zhTempName = zhData.tempName;
|
task.zhTempName = zhData.tempName;
|
||||||
task.jaTempName = jaData.jaTempName || jaData.enTempName || jaData.tempName;
|
task.jaTempName = jaData.tempName;
|
||||||
task.zhUrl = zhData.url;
|
task.zhUrl = zhData.url;
|
||||||
task.jaUrl = jaData.jaUrl || jaData.enUrl || jaData.url;
|
task.jaUrl = jaData.url;
|
||||||
|
|
||||||
success = true;
|
success = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1071,12 +1097,6 @@
|
|||||||
const task = batchTasks[i];
|
const task = batchTasks[i];
|
||||||
if (!task.zhTempName || !task.jaTempName) continue; // Skip failed ones
|
if (!task.zhTempName || !task.jaTempName) continue; // Skip failed ones
|
||||||
|
|
||||||
// Replace slashes with dashes to ensure filesystem compatibility
|
|
||||||
if (task.code.includes('/')) {
|
|
||||||
console.log(`Replacing slashes in batch code: ${task.code}`);
|
|
||||||
task.code = task.code.replace(/\//g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusSpan = document.getElementById(`batch-status-span-${i}`);
|
const statusSpan = document.getElementById(`batch-status-span-${i}`);
|
||||||
statusSpan.textContent = 'Importing...';
|
statusSpan.textContent = 'Importing...';
|
||||||
statusSpan.style.color = 'var(--warning)';
|
statusSpan.style.color = 'var(--warning)';
|
||||||
@@ -1141,46 +1161,20 @@
|
|||||||
status.textContent = "Generating Chinese audio...";
|
status.textContent = "Generating Chinese audio...";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate Chinese via Server-Side Proxy
|
// Generate Chinese via Client-Side Fetch
|
||||||
const resZh = await fetch('/api/tts/generate', {
|
const dataZh = await _generateSingleTTS(zhText, ZH_REFERENCE_ID);
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: zhText,
|
|
||||||
reference_id: ZH_REFERENCE_ID,
|
|
||||||
model: TTS_MODEL
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataZh = await resZh.json();
|
|
||||||
if (!dataZh.success) throw new Error("ZH TTS failed: " + dataZh.message);
|
|
||||||
|
|
||||||
status.textContent = "Generating Japanese audio...";
|
status.textContent = "Generating Japanese audio...";
|
||||||
|
|
||||||
// Generate Japanese via Server-Side Proxy
|
// Generate Japanese via Client-Side Fetch
|
||||||
const resJa = await fetch('/api/tts/generate', {
|
const dataJa = await _generateSingleTTS(jaText, JA_REFERENCE_ID);
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: jaText,
|
|
||||||
reference_id: JA_REFERENCE_ID,
|
|
||||||
model: TTS_MODEL
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataJa = await resJa.json();
|
|
||||||
if (!dataJa.success) throw new Error("JA TTS failed: " + dataJa.message);
|
|
||||||
|
|
||||||
// Success
|
// Success
|
||||||
zhTempFile = dataZh.tempName;
|
zhTempFile = dataZh.tempName;
|
||||||
jaTempFile = dataJa.jaTempName || dataJa.enTempName || dataJa.tempName;
|
jaTempFile = dataJa.tempName;
|
||||||
|
|
||||||
document.getElementById('zh-audio').src = dataZh.url;
|
document.getElementById('zh-audio').src = dataZh.url;
|
||||||
document.getElementById('en-audio').src = dataJa.jaUrl || dataJa.enUrl || dataJa.url;
|
document.getElementById('en-audio').src = dataJa.url;
|
||||||
|
|
||||||
document.getElementById('preview-section').style.display = 'block';
|
document.getElementById('preview-section').style.display = 'block';
|
||||||
status.textContent = "Generation complete!";
|
status.textContent = "Generation complete!";
|
||||||
@@ -1202,12 +1196,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace slashes with dashes to ensure filesystem compatibility
|
|
||||||
if (code.includes('/')) {
|
|
||||||
console.log(`Replacing slashes in code: ${code}`);
|
|
||||||
code = code.replace(/\//g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zhTempFile || !jaTempFile) {
|
if (!zhTempFile || !jaTempFile) {
|
||||||
showToast("Error", "No audio generated to import");
|
showToast("Error", "No audio generated to import");
|
||||||
return;
|
return;
|
||||||
@@ -1330,10 +1318,11 @@
|
|||||||
div.className = 'list-item';
|
div.className = 'list-item';
|
||||||
div.style.gridTemplateColumns = '100px 1fr auto';
|
div.style.gridTemplateColumns = '100px 1fr auto';
|
||||||
|
|
||||||
const tagsHtml = item.files.map(f => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.8rem; margin-right:4px;">${f}</span>`).join('');
|
const dsCode = ds(item.code);
|
||||||
|
const tagsHtml = (item.files || []).map(f => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.8rem; margin-right:4px;">${ds(f)}</span>`).join('');
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div style="font-family:var(--font-mono); font-weight:600;">${item.code}</div>
|
<div style="font-family:var(--font-mono); font-weight:600;">${dsCode}</div>
|
||||||
<div>${tagsHtml || '<span style="color:var(--text-muted);font-style:italic">No files</span>'}</div>
|
<div>${tagsHtml || '<span style="color:var(--text-muted);font-style:italic">No files</span>'}</div>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
|
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
|
||||||
|
|||||||
+218
-55
@@ -10,33 +10,25 @@
|
|||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Ultra-Dark / Minimalist Theme */
|
/* Ultra-Dark / Minimalist Theme (aligned with cablemanager) */
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
/* Zinc-950 */
|
--bg-panel: #121214;
|
||||||
--bg-panel: #18181b;
|
--bg-input: #09090b;
|
||||||
/* Zinc-900 */
|
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
/* Zinc-800 */
|
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
/* Zinc-800 */
|
|
||||||
|
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
/* Zinc-200 */
|
--text-muted: #a1a1aa;
|
||||||
--text-muted: #71717a;
|
|
||||||
/* Zinc-500 */
|
|
||||||
|
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
/* Zinc-50 */
|
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
/* Red-500 */
|
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
/* Emerald-500 */
|
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
/* Amber-500 */
|
|
||||||
|
|
||||||
--radius: 6px;
|
--radius: 8px;
|
||||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -45,7 +37,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
background-color: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -299,14 +291,18 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 6px 12px;
|
padding: 10px 14px;
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover:not(:disabled) {
|
.btn:hover:not(:disabled) {
|
||||||
@@ -322,15 +318,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: #fff;
|
background: #d4d4d8;
|
||||||
border-color: #fff;
|
border-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.3;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -699,6 +705,61 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="number"], input[type="password"], textarea, select {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'><path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
padding-right: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select > option {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: #52525b;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -929,13 +990,16 @@
|
|||||||
|
|
||||||
<!-- Sidebar: Status -->
|
<!-- Sidebar: Status -->
|
||||||
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
||||||
<!-- Version Control (Hidden since we don't use it anymore) -->
|
<!-- Station Update -->
|
||||||
<div class="version-panel" style="display:none;">
|
<div class="version-panel">
|
||||||
<div class="version-panel-header">
|
<div class="version-panel-header">
|
||||||
<span>Audio Version</span>
|
<span>Station Update</span>
|
||||||
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
|
<div style="display:flex; gap:8px;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
|
<button class="btn" id="publish-update-btn" onclick="publishStationUpdate()" title="Publish Station Update">发布更新</button>
|
||||||
</button>
|
<button class="btn-icon" onclick="fetchStationUpdate()" title="Refresh Update Status">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-display" id="current-version">Loading...</div>
|
<div class="version-display" id="current-version">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -977,7 +1041,7 @@
|
|||||||
<span id="selection-count"
|
<span id="selection-count"
|
||||||
style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center;"></span>
|
style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center;"></span>
|
||||||
<button class="btn btn-primary" onclick="showRadioStream()" title="View Radio Streams">Radio Stream</button>
|
<button class="btn btn-primary" onclick="showRadioStream()" title="View Radio Streams">Radio Stream</button>
|
||||||
<a href="/infoboard.html" target="_blank" class="btn btn-primary" style="text-decoration:none; background: var(--warning); color: black; border-color: var(--warning);">Full Info Board</a>
|
<a href="/cablemanager.html" target="_blank" class="btn btn-primary" style="text-decoration:none; background: var(--warning); color: black; border-color: var(--warning);">Cable Manager</a>
|
||||||
<a href="/generator.html" class="btn btn-primary" style="text-decoration:none">Broadcast Generator</a>
|
<a href="/generator.html" class="btn btn-primary" style="text-decoration:none">Broadcast Generator</a>
|
||||||
<a href="/tempbroadcast.html" class="btn btn-primary" style="text-decoration:none">Temp Broadcast</a>
|
<a href="/tempbroadcast.html" class="btn btn-primary" style="text-decoration:none">Temp Broadcast</a>
|
||||||
<a href="/music.html" class="btn btn-primary" style="text-decoration:none">Music</a>
|
<a href="/music.html" class="btn btn-primary" style="text-decoration:none">Music</a>
|
||||||
@@ -1055,6 +1119,15 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// --- Display sanitizer: 强制剥除所有 ? 类字符,保证 UI 零问号 ---
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Icons (SVG) ---
|
// --- Icons (SVG) ---
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
folder: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#fbbf24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
|
folder: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#fbbf24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
|
||||||
@@ -1072,6 +1145,7 @@
|
|||||||
let currentPath = 'root';
|
let currentPath = 'root';
|
||||||
let stationCodeMap = {};
|
let stationCodeMap = {};
|
||||||
let broadcastingStations = new Set();
|
let broadcastingStations = new Set();
|
||||||
|
let currentStationUpdate = null;
|
||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
const connStatus = document.getElementById('conn-status');
|
const connStatus = document.getElementById('conn-status');
|
||||||
@@ -1118,6 +1192,11 @@
|
|||||||
|
|
||||||
// --- Renderers ---
|
// --- Renderers ---
|
||||||
function updateState(state) {
|
function updateState(state) {
|
||||||
|
if (state.stationUpdate) {
|
||||||
|
currentStationUpdate = state.stationUpdate;
|
||||||
|
renderStationUpdate(state.stationUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.stations) {
|
if (state.stations) {
|
||||||
const list = document.getElementById('station-list');
|
const list = document.getElementById('station-list');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
@@ -1136,20 +1215,31 @@
|
|||||||
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
||||||
const stationTarget = st.target || st.name;
|
const stationTarget = st.target || st.name;
|
||||||
const memberCount = Number(st.memberCount || 1);
|
const memberCount = Number(st.memberCount || 1);
|
||||||
|
const stationVersion = ds(st.version || '');
|
||||||
|
const targetVersion = ds(currentStationUpdate?.targetVersion || '');
|
||||||
|
const needsUpdate = !!targetVersion && stationVersion !== targetVersion && stationVersion !== '-' && targetVersion !== '-';
|
||||||
|
const updateBadge = needsUpdate
|
||||||
|
? `<span style="font-size:0.65rem; background:rgba(245, 158, 11, 0.12); color:#fbbf24; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(245, 158, 11, 0.25)">待更新 ${targetVersion}</span>`
|
||||||
|
: '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
div.id = `station-item-${String(st.name || '').replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||||
|
const dsName = ds(st.name);
|
||||||
|
const dsCode = ds(st.code || '');
|
||||||
|
const dsPlatform = st.platform ? ds(st.platform) : '';
|
||||||
|
const dsVersion = stationVersion === '-' ? '' : stationVersion;
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="status-main">
|
<div class="status-main">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">
|
<div class="item-title">
|
||||||
${st.name}
|
${dsName}
|
||||||
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
|
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
|
||||||
|
${updateBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}</div>
|
<div class="item-sub">${dsCode}${dsPlatform && dsPlatform !== '-' ? ` - Platform ${dsPlatform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}${dsVersion ? ' · ' + dsVersion : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
|
<div class="status-dot ${dotClass}" id="station-dot-${String(st.name || '').replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${dsName}"></div>
|
||||||
<button class="btn-icon" onclick="deleteStation('${stationTarget}')">${ICONS.trash}</button>
|
<button class="btn-icon" onclick="deleteStation('${stationTarget}')">${ICONS.trash}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1168,12 +1258,15 @@
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||||
|
const dsTrainId = ds(tr.trainId);
|
||||||
|
const dsStatus = ds(tr.status || '');
|
||||||
|
const dsCurCode = ds(currentStCode);
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="status-main">
|
<div class="status-main">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">${tr.trainId}</div>
|
<div class="item-title">${dsTrainId}</div>
|
||||||
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</span></div>
|
<div class="item-sub">${dsCurCode} <span style="margin-left:8px;color:var(--text-muted)">${dsStatus}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||||
@@ -1200,7 +1293,9 @@
|
|||||||
|
|
||||||
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
|
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
|
||||||
const container = document.getElementById('radio-streams-container');
|
const container = document.getElementById('radio-streams-container');
|
||||||
const cardId = `stream-card-${station.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
const dsStation = ds(station || '');
|
||||||
|
const stationKey = String(station || '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
const cardId = `stream-card-${stationKey}`;
|
||||||
|
|
||||||
if (isStarting) {
|
if (isStarting) {
|
||||||
// Clear empty state
|
// Clear empty state
|
||||||
@@ -1216,15 +1311,20 @@
|
|||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seqHtml = seq ? seq.map(s => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.75rem; font-family:var(--font-mono); margin:2px; display:inline-block;">${s}</span>`).join('') : 'Unknown sequence';
|
const seqHtml = Array.isArray(seq) && seq.length > 0
|
||||||
|
? seq.map(s => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.75rem; font-family:var(--font-mono); margin:2px; display:inline-block;">${ds(s)}</span>`).join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const dsHlsUrl = ds(hlsUrl || '');
|
||||||
|
const showHls = hlsUrl && dsHlsUrl !== '-';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:#3b82f6; animation:pulse 1s infinite;"></div>
|
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:#3b82f6; animation:pulse 1s infinite;"></div>
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div style="font-weight:600; font-size:1.1rem;">${station}</div>
|
<div style="font-weight:600; font-size:1.1rem;">${dsStation}</div>
|
||||||
<div class="status-dot dot-broadcasting"></div>
|
<div class="status-dot dot-broadcasting"></div>
|
||||||
</div>
|
</div>
|
||||||
${hlsUrl ? `<div style="font-size:0.8rem; color:var(--text-muted);">HLS: <a href="${hlsUrl}" target="_blank" rel="noreferrer" style="color:#60a5fa; text-decoration:none; font-family:var(--font-mono);">${hlsUrl}</a></div>` : ''}
|
${showHls ? `<div style="font-size:0.8rem; color:var(--text-muted);">HLS: <a href="${String(hlsUrl || '')}" target="_blank" rel="noreferrer" style="color:#60a5fa; text-decoration:none; font-family:var(--font-mono);">${dsHlsUrl}</a></div>` : ''}
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:8px;">PLAYING SEQUENCE:</div>
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:8px;">PLAYING SEQUENCE:</div>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:4px; max-height:100px; overflow-y:auto; padding-right:4px;">
|
<div style="display:flex; flex-wrap:wrap; gap:4px; max-height:100px; overflow-y:auto; padding-right:4px;">
|
||||||
@@ -1288,8 +1388,10 @@
|
|||||||
else typeClass = 'type-info';
|
else typeClass = 'type-info';
|
||||||
|
|
||||||
content = content.replace(/^\[.*?\]\s*/, '');
|
content = content.replace(/^\[.*?\]\s*/, '');
|
||||||
|
const dsTime = ds(time);
|
||||||
|
const dsContent = ds(content);
|
||||||
|
|
||||||
div.innerHTML = `<div class="log-time">${time}</div><div class="log-msg"><span class="${typeClass}">></span> ${content}</div>`;
|
div.innerHTML = `<div class="log-time">${dsTime}</div><div class="log-msg"><span class="${typeClass}">></span> ${dsContent}</div>`;
|
||||||
container.prepend(div);
|
container.prepend(div);
|
||||||
if (container.children.length > 100) container.lastChild.remove();
|
if (container.children.length > 100) container.lastChild.remove();
|
||||||
}
|
}
|
||||||
@@ -1299,6 +1401,9 @@
|
|||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'toast';
|
toast.className = 'toast';
|
||||||
|
|
||||||
|
const dsTitle = ds(title || '');
|
||||||
|
const dsMsg = ds(message || '');
|
||||||
|
|
||||||
// Icon based on title/message
|
// Icon based on title/message
|
||||||
let iconColor = 'var(--text-primary)';
|
let iconColor = 'var(--text-primary)';
|
||||||
if (title.includes('Arrival')) iconColor = 'var(--success)';
|
if (title.includes('Arrival')) iconColor = 'var(--success)';
|
||||||
@@ -1309,8 +1414,8 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast-content">
|
<div class="toast-content">
|
||||||
<div class="toast-title">${title}</div>
|
<div class="toast-title">${dsTitle}</div>
|
||||||
<div class="toast-msg">${message}</div>
|
<div class="toast-msg">${dsMsg}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -1360,7 +1465,7 @@
|
|||||||
beep: 'Beep',
|
beep: 'Beep',
|
||||||
station: 'Station'
|
station: 'Station'
|
||||||
};
|
};
|
||||||
const name = nameMap[currentPath] || currentPath;
|
const name = ds(nameMap[currentPath] || currentPath);
|
||||||
breadcrumb.innerHTML = `
|
breadcrumb.innerHTML = `
|
||||||
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
|
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
|
||||||
<div class="breadcrumb-sep">/</div>
|
<div class="breadcrumb-sep">/</div>
|
||||||
@@ -1383,10 +1488,12 @@
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'file-row';
|
row.className = 'file-row';
|
||||||
row.onclick = () => navigate(f.id);
|
row.onclick = () => navigate(f.id);
|
||||||
|
const dsFolderName = ds(f.name);
|
||||||
|
const dsCount = String(f.count || 0);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div></div>
|
<div></div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${f.name}</span></div>
|
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${dsFolderName}</span></div>
|
||||||
<div class="file-meta">${f.count} items</div>
|
<div class="file-meta">${dsCount} items</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
@@ -1408,6 +1515,8 @@
|
|||||||
};
|
};
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'file-row';
|
row.className = 'file-row';
|
||||||
|
const dsFileName = ds(f.name);
|
||||||
|
const dsTypeLabel = ds(typeLabelMap[f.type] || f.type || '');
|
||||||
// Click to preview
|
// Click to preview
|
||||||
row.onclick = (e) => {
|
row.onclick = (e) => {
|
||||||
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
|
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
|
||||||
@@ -1416,8 +1525,8 @@
|
|||||||
};
|
};
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div style="display:flex;justify-content:center"><input type="checkbox" class="file-check" value="${f.path}" onchange="updateSelection()"></div>
|
<div style="display:flex;justify-content:center"><input type="checkbox" class="file-check" value="${f.path}" onchange="updateSelection()"></div>
|
||||||
<div style="display:flex;align-items:center;gap:10px">${f.name.endsWith('.txt') ? ICONS.file : ICONS.audio}<span class="file-name">${f.name}</span></div>
|
<div style="display:flex;align-items:center;gap:10px">${f.name.endsWith('.txt') ? ICONS.file : ICONS.audio}<span class="file-name">${dsFileName}</span></div>
|
||||||
<div class="file-meta">${typeLabelMap[f.type] || f.type}</div>
|
<div class="file-meta">${dsTypeLabel}</div>
|
||||||
<div style="text-align:right">
|
<div style="text-align:right">
|
||||||
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
|
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1585,6 +1694,8 @@
|
|||||||
source = `/media/audio/music/${encodeURIComponent(path.slice('Audio/music/'.length))}`;
|
source = `/media/audio/music/${encodeURIComponent(path.slice('Audio/music/'.length))}`;
|
||||||
} else if (path.startsWith('Audio/voice/')) {
|
} else if (path.startsWith('Audio/voice/')) {
|
||||||
source = `/media/audio/voice/${encodeURIComponent(path.slice('Audio/voice/'.length))}`;
|
source = `/media/audio/voice/${encodeURIComponent(path.slice('Audio/voice/'.length))}`;
|
||||||
|
} else if (path.startsWith('Audio/beep/')) {
|
||||||
|
source = `/media/audio/beep/${encodeURIComponent(path.slice('Audio/beep/'.length))}`;
|
||||||
} else if (path.startsWith('Audio/general/')) {
|
} else if (path.startsWith('Audio/general/')) {
|
||||||
source = `/media/audio/general/${encodeURIComponent(path.slice('Audio/general/'.length))}`;
|
source = `/media/audio/general/${encodeURIComponent(path.slice('Audio/general/'.length))}`;
|
||||||
} else if (path.startsWith('Audio/station/')) {
|
} else if (path.startsWith('Audio/station/')) {
|
||||||
@@ -1686,20 +1797,72 @@
|
|||||||
|
|
||||||
// Init
|
// Init
|
||||||
refreshFiles();
|
refreshFiles();
|
||||||
fetchVersion();
|
fetchStationUpdate();
|
||||||
|
|
||||||
async function fetchVersion() {
|
function renderStationUpdate(info) {
|
||||||
|
const el = document.getElementById('current-version');
|
||||||
|
const localVersion = ds(info?.localVersion || '-');
|
||||||
|
const targetVersion = ds(info?.targetVersion || '');
|
||||||
|
const publishedAt = info?.publishedAt ? ds(new Date(info.publishedAt).toLocaleString()) : '';
|
||||||
|
const remoteVersionHtml = info?.remoteVersion
|
||||||
|
? `<div>Gitea最新:${ds(info.remoteVersion)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div>服务器脚本:${localVersion}</div>
|
||||||
|
${remoteVersionHtml}
|
||||||
|
<div>已发布版本:${targetVersion}</div>
|
||||||
|
<div style="margin-top:4px;color:var(--text-muted)">发布时间:${publishedAt}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStationUpdate() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/version');
|
const res = await fetch('/api/station-update');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.version) {
|
currentStationUpdate = data;
|
||||||
document.getElementById('current-version').textContent = data.version;
|
|
||||||
|
try {
|
||||||
|
const remoteRes = await fetch('/api/remote-version');
|
||||||
|
const remoteData = await remoteRes.json();
|
||||||
|
if (remoteData.success) {
|
||||||
|
data.remoteVersion = String(remoteData.version || '');
|
||||||
|
} else {
|
||||||
|
data.remoteVersion = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
data.remoteVersion = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderStationUpdate(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('current-version').textContent = 'Error';
|
document.getElementById('current-version').textContent = 'Error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishStationUpdate() {
|
||||||
|
if (!confirm('发布后,所有在线主机会立即下载最新 startup 并自动重启;离线主机会在下次加载时立即更新。确定继续吗?')) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('publish-update-btn');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '发布中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/station-updates/publish', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.message || 'Publish failed');
|
||||||
|
currentStationUpdate = data;
|
||||||
|
renderStationUpdate(data);
|
||||||
|
showToast('更新已发布', `目标版本 ${data.targetVersion || data.localVersion},已通知 ${data.notifiedStations || 0}/${data.onlineStations || 0} 台在线主机。`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`发布失败:${e.message || e}`);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Resizers ---
|
// --- Resizers ---
|
||||||
const resizerV = document.getElementById('resizer-v');
|
const resizerV = document.getElementById('resizer-v');
|
||||||
const resizerH = document.getElementById('resizer-h');
|
const resizerH = document.getElementById('resizer-h');
|
||||||
|
|||||||
@@ -10,18 +10,20 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
--bg-panel: #18181b;
|
--bg-panel: #121214;
|
||||||
|
--bg-input: #09090b;
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
--text-muted: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--font-ui: 'Inter', -apple-system, sans-serif;
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
background-color: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -356,6 +358,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Icons (SVG) ---
|
// --- Icons (SVG) ---
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
|
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
|
||||||
@@ -441,7 +451,7 @@
|
|||||||
icon = ICONS.broadcast;
|
icon = ICONS.broadcast;
|
||||||
}
|
}
|
||||||
|
|
||||||
messageLog.unshift({ time, content: msg, type, icon, important });
|
messageLog.unshift({ time: ds(time), content: ds(msg), type, icon, important });
|
||||||
if (messageLog.length > MAX_MESSAGES) messageLog.pop();
|
if (messageLog.length > MAX_MESSAGES) messageLog.pop();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
}
|
}
|
||||||
@@ -484,18 +494,22 @@
|
|||||||
const isBroadcasting = broadcastingStations.has(st.name);
|
const isBroadcasting = broadcastingStations.has(st.name);
|
||||||
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
|
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
|
||||||
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
||||||
|
const dsName = ds(st.name);
|
||||||
|
const dsCode = ds(st.code || '');
|
||||||
|
const dsPlatform = st.platform ? ds(st.platform) : '';
|
||||||
|
const stationKey = String(st.name || '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">
|
<div class="item-title">
|
||||||
${st.name}
|
${dsName}
|
||||||
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
|
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-sub">${st.code}${st.platform ? ` - 站台 ${st.platform}` : ''}</div>
|
<div class="item-sub">${dsCode}${dsPlatform && dsPlatform !== '-' ? ` - 站台 ${dsPlatform}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
|
<div class="status-dot ${dotClass}" id="station-dot-${stationKey}" data-online="${isOnline}" data-name="${dsName}"></div>
|
||||||
`;
|
`;
|
||||||
list.appendChild(div);
|
list.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -517,13 +531,18 @@
|
|||||||
|
|
||||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||||
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
||||||
|
const dsTrainId = ds(tr.trainId);
|
||||||
|
const dsDest = ds(tr.destination || '');
|
||||||
|
const dsCurCode = ds(currentStCode);
|
||||||
|
const rawStatus = tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中');
|
||||||
|
const dsStatus = ds(rawStatus);
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div style="width:100%">
|
<div style="width:100%">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
|
<div class="item-title">${dsTrainId}${dsDest && dsDest !== '-' ? ' <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">' + dsDest + '</span>' : ''}</div>
|
||||||
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中')}</span></div>
|
<div class="item-sub">${dsCurCode} <span style="color:${statusColor}; margin-left:8px;">${dsStatus}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -452,6 +452,14 @@
|
|||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function ds(x) {
|
||||||
|
let v = String(x == null ? '' : x)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
|
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
|
||||||
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
|
||||||
@@ -471,8 +479,8 @@
|
|||||||
toast.className = 'toast';
|
toast.className = 'toast';
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
|
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${ds(title)}</div>
|
||||||
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
|
<div style="font-size:0.8rem; color:var(--text-muted)">${ds(message)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
@@ -539,7 +547,8 @@
|
|||||||
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
|
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
|
||||||
availableFiles.forEach(f => {
|
availableFiles.forEach(f => {
|
||||||
const isSelected = selectedFileValue === f ? 'selected' : '';
|
const isSelected = selectedFileValue === f ? 'selected' : '';
|
||||||
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${f}')">${f}</div>`;
|
const dsF = ds(f);
|
||||||
|
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${dsF}')">${dsF}</div>`;
|
||||||
});
|
});
|
||||||
optionsContainer.innerHTML = html;
|
optionsContainer.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -576,9 +585,10 @@
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'list-item';
|
div.className = 'list-item';
|
||||||
|
|
||||||
const tagsHtml = (item.files || []).map(f => `<span class="tag">${f}</span>`).join(' ');
|
const dsCode = ds(item.code);
|
||||||
|
const tagsHtml = (item.files || []).map(f => `<span class="tag">${ds(f)}</span>`).join(' ');
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="code">${item.code}</div>
|
<div class="code">${dsCode}</div>
|
||||||
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
|
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
|
||||||
<div style="display:flex; gap:6px;">
|
<div style="display:flex; gap:6px;">
|
||||||
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
|
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
|
||||||
@@ -636,7 +646,7 @@
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'edit-file-item';
|
div.className = 'edit-file-item';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="edit-file-name">${file}</div>
|
<div class="edit-file-name">${ds(file)}</div>
|
||||||
<div class="edit-file-actions">
|
<div class="edit-file-actions">
|
||||||
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
|
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
|
||||||
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
|
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
|
||||||
|
|||||||
+148
-56
@@ -8,58 +8,95 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
--bg-panel: #18181b;
|
--bg-panel: #121214;
|
||||||
|
--bg-input: #09090b;
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
--text-muted: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--radius: 6px;
|
--success: #10b981;
|
||||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--warning: #f59e0b;
|
||||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
--radius: 8px;
|
||||||
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 30px 20px;
|
||||||
background: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.layout-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
background: linear-gradient(to right, #fff, #a1a1aa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 8px 10px;
|
padding: 10px 14px;
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--bg-hover); }
|
.btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--text-primary);
|
||||||
color: #09090b;
|
color: var(--bg-body);
|
||||||
border-color: var(--accent);
|
border-color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #d4d4d8;
|
||||||
|
border-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger);
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 380px;
|
grid-template-columns: 1fr 380px;
|
||||||
@@ -69,7 +106,11 @@
|
|||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -77,10 +118,12 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
}
|
}
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -90,71 +133,102 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 999px;
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
color: var(--text-primary);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 10px 0 6px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted);
|
font-weight: 500;
|
||||||
}
|
|
||||||
select, input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
|
||||||
background: #0f0f12;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
}
|
||||||
|
input[type="text"], select, textarea {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'><path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
padding-right: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
select > option {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus, textarea:focus, select:focus {
|
||||||
|
border-color: #52525b;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring-color);
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
margin-top: 10px;
|
padding: 12px;
|
||||||
padding: 10px;
|
border-radius: 6px;
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 2.4em;
|
||||||
}
|
}
|
||||||
.stations {
|
.stations {
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
.station-toolbar {
|
.station-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.station-item {
|
.station-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--bg-border);
|
border-bottom: 1px solid var(--bg-border);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.station-item:last-child { border-bottom: none; }
|
.station-item:last-child { border-bottom: none; }
|
||||||
.station-name { flex: 1; }
|
.station-item:hover { background: var(--bg-hover); }
|
||||||
|
.station-name { flex: 1; color: var(--text-primary); }
|
||||||
.station-meta {
|
.station-meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -163,21 +237,29 @@
|
|||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #52525b; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="layout-wrapper">
|
||||||
<div class="title">音乐播放</div>
|
<div class="topbar">
|
||||||
<div class="row" style="margin-top:0">
|
<div class="title">音乐播放</div>
|
||||||
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
|
<div class="row" style="margin-top:0">
|
||||||
|
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h3>控制</h3>
|
<h3>控制</h3>
|
||||||
<span class="badge" id="track_badge">未选择</span>
|
<span class="badge" id="track_badge"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/music)</label>
|
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/music)</label>
|
||||||
@@ -219,14 +301,24 @@
|
|||||||
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
document.getElementById("status_box").textContent = String(msg || "");
|
document.getElementById("status_box").textContent = String(msg || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s == null ? "" : s)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === "" ? "-" : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str || "")
|
let v = sanitizeDisplay(str);
|
||||||
|
return v
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">")
|
.replaceAll(">", ">")
|
||||||
@@ -273,7 +365,7 @@
|
|||||||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||||||
const stations = Array.isArray(data.stations) ? data.stations : [];
|
const stations = Array.isArray(data.stations) ? data.stations : [];
|
||||||
if (stations.length === 0) {
|
if (stations.length === 0) {
|
||||||
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
|
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">-</div>';
|
||||||
setStatus("站台列表为空");
|
setStatus("站台列表为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,7 +395,7 @@
|
|||||||
function onTrackChange() {
|
function onTrackChange() {
|
||||||
const sel = document.getElementById("track_select");
|
const sel = document.getElementById("track_select");
|
||||||
const v = String(sel.value || "").trim();
|
const v = String(sel.value || "").trim();
|
||||||
document.getElementById("track_badge").textContent = v ? v : "未选择";
|
document.getElementById("track_badge").textContent = v ? v : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTracks() {
|
async function loadTracks() {
|
||||||
|
|||||||
@@ -189,8 +189,17 @@
|
|||||||
return 'idle';
|
return 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s ?? '')
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '')
|
let v = sanitizeDisplay(s);
|
||||||
|
return v
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
|
|||||||
@@ -256,8 +256,17 @@
|
|||||||
let editKey = '';
|
let editKey = '';
|
||||||
let lastData = null;
|
let lastData = null;
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s ?? '')
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === '' ? '-' : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '')
|
let v = sanitizeDisplay(s);
|
||||||
|
return v
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
|
|||||||
@@ -8,60 +8,95 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-body: #09090b;
|
--bg-body: #09090b;
|
||||||
--bg-panel: #18181b;
|
--bg-panel: #121214;
|
||||||
|
--bg-input: #09090b;
|
||||||
--bg-hover: #27272a;
|
--bg-hover: #27272a;
|
||||||
--bg-border: #27272a;
|
--bg-border: #27272a;
|
||||||
--text-primary: #e4e4e7;
|
--text-primary: #f4f4f5;
|
||||||
--text-muted: #71717a;
|
--text-muted: #a1a1aa;
|
||||||
--accent: #fafafa;
|
--accent: #fafafa;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--radius: 6px;
|
--radius: 8px;
|
||||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--ring-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 30px 20px;
|
||||||
background: var(--bg-body);
|
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.layout-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
background: linear-gradient(to right, #fff, #a1a1aa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 8px 10px;
|
padding: 10px 14px;
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--bg-hover); }
|
.btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--text-primary);
|
||||||
color: #09090b;
|
color: var(--bg-body);
|
||||||
border-color: var(--accent);
|
border-color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #d4d4d8;
|
||||||
|
border-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger);
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 380px;
|
grid-template-columns: 1fr 380px;
|
||||||
@@ -72,29 +107,38 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--bg-border);
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 999px;
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
color: var(--text-primary);
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.badge:empty { display: none; }
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -102,93 +146,129 @@
|
|||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 10px 0 6px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-muted);
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
textarea, select, input[type="text"] {
|
textarea, select, input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
background: var(--bg-input);
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-ui);
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 140px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'><path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
padding-right: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
select > option {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
textarea:focus, select:focus, input[type="text"]:focus {
|
||||||
|
border-color: #52525b;
|
||||||
|
box-shadow: 0 0 0 3px var(--ring-color);
|
||||||
}
|
}
|
||||||
textarea { min-height: 140px; resize: vertical; }
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.row .btn { flex: 0 0 auto; }
|
.row .btn { flex: 0 0 auto; }
|
||||||
.hint {
|
.hint {
|
||||||
margin-top: 10px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
margin-top: 10px;
|
padding: 12px;
|
||||||
padding: 10px;
|
border-radius: 6px;
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 2.4em;
|
||||||
}
|
}
|
||||||
.stations {
|
.stations {
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--bg-border);
|
border: 1px solid var(--bg-border);
|
||||||
border-radius: var(--radius);
|
border-radius: 6px;
|
||||||
background: #0f0f12;
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
.station-toolbar {
|
.station-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
.station-item {
|
.station-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--bg-border);
|
border-bottom: 1px solid var(--bg-border);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.station-item:last-child { border-bottom: none; }
|
.station-item:last-child { border-bottom: none; }
|
||||||
.station-name { flex: 1; }
|
.station-item:hover { background: var(--bg-hover); }
|
||||||
|
.station-name { flex: 1; color: var(--text-primary); }
|
||||||
.station-meta {
|
.station-meta {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
audio { width: 100%; margin-top: 8px; }
|
audio { width: 100%; }
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #52525b; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="layout-wrapper">
|
||||||
<div class="title">临时广播</div>
|
<div class="topbar">
|
||||||
<div class="row" style="margin-top:0">
|
<div class="title">临时广播</div>
|
||||||
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
|
<div class="row" style="margin-top:0">
|
||||||
|
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<h3 style="margin:0;">内容</h3>
|
<h3 style="margin:0;">内容</h3>
|
||||||
<span class="badge" id="build_badge">未合成</span>
|
<span class="badge" id="build_badge"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="tb_text">广播文本(中文)</label>
|
<label for="tb_text">广播文本(中文)</label>
|
||||||
@@ -206,7 +286,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hint" id="hint_area">
|
<div class="hint" id="hint_area">
|
||||||
需要服务端已存在 lsts.wav(建议放在 /opt/CC-StationBroadcast/Audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
需要服务端已存在 lsts.wav(建议放在 /opt/CC-StationBroadcast/Audio/beep/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="preview_area" style="margin-top:12px; display:none;">
|
<div id="preview_area" style="margin-top:12px; display:none;">
|
||||||
@@ -233,6 +313,7 @@
|
|||||||
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const TTS_MODEL = "s2.1-pro-free";
|
const TTS_MODEL = "s2.1-pro-free";
|
||||||
@@ -269,7 +350,7 @@
|
|||||||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||||||
const stations = Array.isArray(data.stations) ? data.stations : [];
|
const stations = Array.isArray(data.stations) ? data.stations : [];
|
||||||
if (stations.length === 0) {
|
if (stations.length === 0) {
|
||||||
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
|
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">-</div>';
|
||||||
setStatus("站台列表为空");
|
setStatus("站台列表为空");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,8 +394,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplay(s) {
|
||||||
|
let v = String(s == null ? "" : s)
|
||||||
|
.replace(/[?¿?]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return v === "" ? "-" : v;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str || "")
|
let v = sanitizeDisplay(str);
|
||||||
|
return v
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">")
|
.replaceAll(">", ">")
|
||||||
@@ -322,6 +412,31 @@
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _generateTTSLocal(text, reference_id) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tts/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, reference_id, model: TTS_MODEL })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = '服务器返回错误';
|
||||||
|
try { const errData = await res.json(); msg = errData.message || msg; } catch(e){}
|
||||||
|
throw new Error(`[${res.status}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.message);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
|
||||||
|
throw new Error('网络连接失败。请确保你的服务器代理正常!');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function composeTempBroadcast() {
|
async function composeTempBroadcast() {
|
||||||
const text = document.getElementById("tb_text").value.trim();
|
const text = document.getElementById("tb_text").value.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -331,15 +446,18 @@
|
|||||||
document.getElementById("btn_publish").disabled = true;
|
document.getElementById("btn_publish").disabled = true;
|
||||||
document.getElementById("btn_compose").disabled = true;
|
document.getElementById("btn_compose").disabled = true;
|
||||||
document.getElementById("build_badge").textContent = "合成中…";
|
document.getElementById("build_badge").textContent = "合成中…";
|
||||||
setStatus("合成临时广播中…");
|
setStatus("合成临时广播中(1/2: 生成语音)…");
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/temp-broadcast/compose", {
|
// 1. 本地生成 TTS 并上传获得临时文件名
|
||||||
|
const ttsData = await _generateTTSLocal(text, ZH_REFERENCE_ID);
|
||||||
|
|
||||||
|
setStatus("合成临时广播中(2/2: 服务端混音)…");
|
||||||
|
// 2. 告诉服务端进行混音合并
|
||||||
|
const resp = await fetch("/api/temp-broadcast/build", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text,
|
contentTempName: ttsData.tempName,
|
||||||
reference_id: ZH_REFERENCE_ID,
|
|
||||||
model: TTS_MODEL,
|
|
||||||
prefixName: "lsts"
|
prefixName: "lsts"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -355,7 +473,7 @@
|
|||||||
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
|
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("合成失败: " + (e?.message || String(e)));
|
setStatus("合成失败: " + (e?.message || String(e)));
|
||||||
document.getElementById("build_badge").textContent = "未合成";
|
document.getElementById("build_badge").textContent = "";
|
||||||
} finally {
|
} finally {
|
||||||
document.getElementById("btn_compose").disabled = false;
|
document.getElementById("btn_compose").disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+2267
-392
File diff suppressed because it is too large
Load Diff
Binary file not shown.
-255
@@ -1,255 +0,0 @@
|
|||||||
|
|
||||||
local aukit = require "aukit"
|
|
||||||
|
|
||||||
local speakers = {peripheral.find("speaker")}
|
|
||||||
if #speakers == 0 then error("No speaker attached") end
|
|
||||||
if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end
|
|
||||||
|
|
||||||
local path = ...
|
|
||||||
if not path then error("Usage: austream <file/URL> [arguments for PCM/DFPWM]") end
|
|
||||||
local params = {...}
|
|
||||||
table.remove(params, 1) -- Remove path
|
|
||||||
local v = {}
|
|
||||||
if #params > 0 then
|
|
||||||
-- Try to parse as Lua table string first (original behavior)
|
|
||||||
local paramStr = table.concat(params, " ")
|
|
||||||
-- Remove outer braces if they exist to match original austream format
|
|
||||||
paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "")
|
|
||||||
|
|
||||||
local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end}))
|
|
||||||
if fn then
|
|
||||||
local ok, parsed = pcall(fn)
|
|
||||||
if ok and type(parsed) == "table" then
|
|
||||||
for k, val in pairs(parsed) do v[k] = val end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- If that fails, try manual string matching
|
|
||||||
if not next(v) then
|
|
||||||
local matchStr = table.concat(params, ", ")
|
|
||||||
if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then
|
|
||||||
v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]")
|
|
||||||
end
|
|
||||||
if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end
|
|
||||||
if matchStr:match("streamData%s*=%s*true") then v.streamData = true end
|
|
||||||
if matchStr:match("silent%s*=%s*true") then v.silent = true end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if v.volume == nil then v.volume = 3 end
|
|
||||||
if type(v.volume) == "number" then
|
|
||||||
if v.volume < 0 then v.volume = 0 end
|
|
||||||
if v.volume > 3 then v.volume = 3 end
|
|
||||||
end
|
|
||||||
local mono = v.mono or #speakers == 1
|
|
||||||
aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation
|
|
||||||
local data
|
|
||||||
if path:match("^https?://") then
|
|
||||||
local handle, err = http.get(path, nil, true)
|
|
||||||
if not handle then error("Could not connect to " .. path .. ": " .. err) end
|
|
||||||
local code = handle.getResponseCode()
|
|
||||||
if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end
|
|
||||||
if v.streamData then
|
|
||||||
local closed = false
|
|
||||||
function data()
|
|
||||||
if closed then return nil end
|
|
||||||
local d = handle.read(48000)
|
|
||||||
if not d then handle.close() closed = true return nil end
|
|
||||||
return d
|
|
||||||
end
|
|
||||||
else
|
|
||||||
data = handle.readAll()
|
|
||||||
handle.close()
|
|
||||||
end
|
|
||||||
elseif path:match("^wss?://") then
|
|
||||||
local ws, err = http.websocket(path)
|
|
||||||
if not ws then error("Failed to connect to websocket: " .. tostring(err)) end
|
|
||||||
|
|
||||||
-- Buffer completely locally to avoid any network stutter
|
|
||||||
local fullBuffer = ""
|
|
||||||
local isDone = false
|
|
||||||
|
|
||||||
local function cleanup()
|
|
||||||
if not isDone then
|
|
||||||
isDone = true
|
|
||||||
if ws then ws.close() end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect)
|
|
||||||
local startWait = os.startTimer(1.0)
|
|
||||||
while true do
|
|
||||||
local event, p1, p2 = os.pullEvent()
|
|
||||||
if event == "websocket_message" and p1 == path then
|
|
||||||
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
|
||||||
-- Once we get the first message, we can break and start playing
|
|
||||||
break
|
|
||||||
elseif event == "websocket_closed" and p1 == path then
|
|
||||||
cleanup()
|
|
||||||
break
|
|
||||||
elseif event == "timer" and p1 == startWait then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Since WebSocket connection needs to run alongside aukit stream reading,
|
|
||||||
-- we will just use a non-blocking read approach in the data function itself.
|
|
||||||
|
|
||||||
function data(n)
|
|
||||||
n = n or 6000
|
|
||||||
|
|
||||||
if isDone then
|
|
||||||
if #fullBuffer == 0 then return nil end
|
|
||||||
else
|
|
||||||
-- First, read any available messages without blocking
|
|
||||||
-- ws.receive(0) returns immediately. If nil, no message is ready.
|
|
||||||
while true do
|
|
||||||
local msg = ws.receive(0)
|
|
||||||
if msg == nil then break end
|
|
||||||
if type(msg) == "string" then
|
|
||||||
fullBuffer = fullBuffer .. msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- If we still don't have enough data, wait for more
|
|
||||||
-- but only if we know more might be coming (not isDone)
|
|
||||||
if #fullBuffer < n then
|
|
||||||
-- We only wait briefly so we don't block the audio thread too long
|
|
||||||
-- If the server isn't sending data, it means silence or end of stream
|
|
||||||
local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events
|
|
||||||
while #fullBuffer < n do
|
|
||||||
local event, p1, p2 = os.pullEvent()
|
|
||||||
if event == "websocket_message" and p1 == path then
|
|
||||||
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
|
||||||
elseif event == "websocket_closed" and p1 == path then
|
|
||||||
cleanup()
|
|
||||||
break
|
|
||||||
elseif event == "timer" and p1 == timeout then
|
|
||||||
-- We hit timeout, we must return whatever we have to keep playing
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if #fullBuffer == 0 and isDone then return nil end
|
|
||||||
-- Return DFPWM silence (0xAA) if buffer is empty but stream is alive
|
|
||||||
-- This keeps the stream alive without crashing or making noise
|
|
||||||
if #fullBuffer == 0 then return string.rep("\xAA", n) end
|
|
||||||
|
|
||||||
local ret
|
|
||||||
if #fullBuffer >= n then
|
|
||||||
ret = fullBuffer:sub(1, n)
|
|
||||||
fullBuffer = fullBuffer:sub(n + 1)
|
|
||||||
else
|
|
||||||
ret = fullBuffer
|
|
||||||
-- Pad with silence if we don't have enough data to fulfill the request
|
|
||||||
ret = ret .. string.rep("\xAA", n - #ret)
|
|
||||||
fullBuffer = ""
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
-- Note: aukit.stream handles the reading internally
|
|
||||||
-- We can use a table with a read function for custom streaming
|
|
||||||
local obj = {
|
|
||||||
read = function(n)
|
|
||||||
local d = data(n)
|
|
||||||
if not d and isDone then cleanup() end
|
|
||||||
return d
|
|
||||||
end,
|
|
||||||
close = cleanup
|
|
||||||
}
|
|
||||||
return obj
|
|
||||||
elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then
|
|
||||||
peripheral.find("modem", rednet.open)
|
|
||||||
local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$")
|
|
||||||
id = tonumber(id)
|
|
||||||
if proto == "" then proto = nil end
|
|
||||||
if path ~= "" and id >= 0 then rednet.send(id, name, proto) end
|
|
||||||
function data()
|
|
||||||
local i, msg
|
|
||||||
repeat i, msg = rednet.receive(proto) until id == -1 or i == id
|
|
||||||
return msg
|
|
||||||
end
|
|
||||||
else
|
|
||||||
path = shell.resolve(...)
|
|
||||||
local file, err = fs.open(path, "rb")
|
|
||||||
if not file then error("Could not open " .. path .. ": " .. err) end
|
|
||||||
if v.streamData then
|
|
||||||
local closed = false
|
|
||||||
function data()
|
|
||||||
if closed then return nil end
|
|
||||||
local d = file.read(48000)
|
|
||||||
if not d then file.close() closed = true return nil end
|
|
||||||
return d
|
|
||||||
end
|
|
||||||
else
|
|
||||||
data = file.readAll()
|
|
||||||
file.close()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
local iter, length
|
|
||||||
if type(v.type) == "string" then v.type = v.type:lower() end
|
|
||||||
|
|
||||||
local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://")
|
|
||||||
if isDfpwm then
|
|
||||||
if type(data) == "table" and data.read then
|
|
||||||
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
|
||||||
else
|
|
||||||
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
|
||||||
end
|
|
||||||
elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono)
|
|
||||||
elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader)
|
|
||||||
elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader)
|
|
||||||
elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader)
|
|
||||||
elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono)
|
|
||||||
elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono)
|
|
||||||
elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono)
|
|
||||||
else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end
|
|
||||||
if length == nil then length = 0 end
|
|
||||||
|
|
||||||
if not v.silent then
|
|
||||||
print("Streaming...")
|
|
||||||
local w = term.getSize()
|
|
||||||
local y = select(2, term.getCursorPos())
|
|
||||||
local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor())
|
|
||||||
term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60))
|
|
||||||
aukit.play(iter, function(pos)
|
|
||||||
pos = math.min(pos, 5999)
|
|
||||||
local p = pos / length
|
|
||||||
term.setCursorPos(1, y)
|
|
||||||
|
|
||||||
-- 计算基本时间字符串
|
|
||||||
local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60)
|
|
||||||
local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60)
|
|
||||||
|
|
||||||
if p > 1 then
|
|
||||||
-- 确保空格数量正确
|
|
||||||
local spaces = (" "):rep(w - 12)
|
|
||||||
local text = timeStr .. " " .. spaces .. "--:--"
|
|
||||||
local fgColor = fg:rep(#text)
|
|
||||||
local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6)
|
|
||||||
-- 确保所有参数长度相等
|
|
||||||
if #text == #fgColor and #text == #bgColor then
|
|
||||||
term.blit(text, fgColor, bgColor)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
local barWidth = w - 12
|
|
||||||
local progress = math.floor(barWidth * p)
|
|
||||||
progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内
|
|
||||||
|
|
||||||
local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress)
|
|
||||||
local text = timeStr .. " " .. progressBar .. " " .. endTimeStr
|
|
||||||
local fgColor = fg:rep(#text)
|
|
||||||
local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6)
|
|
||||||
-- 确保所有参数长度相等
|
|
||||||
if #text == #fgColor and #text == #bgColor then
|
|
||||||
term.blit(text, fgColor, bgColor)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end, v.volume, table.unpack(speakers))
|
|
||||||
print()
|
|
||||||
else
|
|
||||||
aukit.play(iter, function() end, v.volume, table.unpack(speakers))
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
Advanced Display Block NBT Dump
|
||||||
|
Generated: 2026-08-16 14:08:16
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
BlockReader: right
|
||||||
|
|
||||||
|
[Block Registry Name]
|
||||||
|
createrailwaysnavigator:advanced_display_block
|
||||||
|
|
||||||
|
[Block NBT Data]
|
||||||
|
{
|
||||||
|
DisplayId = "createrailwaysnavigator:platform/focus"
|
||||||
|
DisplaySettings = {
|
||||||
|
{
|
||||||
|
BackColor = 0
|
||||||
|
FontColor = -1
|
||||||
|
PlatformWidth = -1
|
||||||
|
PlatformWidthNextStop = -1
|
||||||
|
ShowLineColor = 0
|
||||||
|
ShowTrainMultipleTimes = 1
|
||||||
|
TimeDisplay = 0
|
||||||
|
TrainNameWidth = 12
|
||||||
|
TrainNameWidthNextStop = -1
|
||||||
|
TrainText = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EnableCT = 1
|
||||||
|
Filter = "CE-03 2"
|
||||||
|
Glowing = 0
|
||||||
|
IsController = 1
|
||||||
|
Item = {}
|
||||||
|
LastRefreshed = 1303
|
||||||
|
Material = {
|
||||||
|
{
|
||||||
|
Name = "create:copycat_base"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Platform = "2"
|
||||||
|
TrainStops = {
|
||||||
|
[1] = {
|
||||||
|
FirstStop = "??"
|
||||||
|
IsFirst = 1
|
||||||
|
IsLast = 1
|
||||||
|
IsNextSectionExcluded = 0
|
||||||
|
IsPrevSectionExcluded = 0
|
||||||
|
State = 1
|
||||||
|
Station = {
|
||||||
|
{
|
||||||
|
Destination = "??"
|
||||||
|
Index = 2
|
||||||
|
RealTimeArrival = 2247
|
||||||
|
RealTimeDeparture = 1347
|
||||||
|
RealTimeStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScheduledArrival = 3869
|
||||||
|
ScheduledDeparture = 4769
|
||||||
|
ScheduledStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrainName = "???"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stopovers = {
|
||||||
|
[1] = "????"
|
||||||
|
[2] = "???"
|
||||||
|
}
|
||||||
|
Train = {
|
||||||
|
{
|
||||||
|
Cancelled = 0
|
||||||
|
Carriages = 1
|
||||||
|
Icon = "create:traditional"
|
||||||
|
Id = {
|
||||||
|
[1] = 1934164313
|
||||||
|
[2] = -793689251
|
||||||
|
[3] = -1147623584
|
||||||
|
[4] = 557989055
|
||||||
|
}
|
||||||
|
StateData = {
|
||||||
|
{
|
||||||
|
["0"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["1"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[2] = {
|
||||||
|
FirstStop = "??"
|
||||||
|
IsFirst = 1
|
||||||
|
IsLast = 1
|
||||||
|
IsNextSectionExcluded = 0
|
||||||
|
IsPrevSectionExcluded = 0
|
||||||
|
State = 1
|
||||||
|
Station = {
|
||||||
|
{
|
||||||
|
Destination = "??"
|
||||||
|
Index = 2
|
||||||
|
RealTimeArrival = 6965
|
||||||
|
RealTimeDeparture = 6065
|
||||||
|
RealTimeStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScheduledArrival = 9856
|
||||||
|
ScheduledDeparture = 10756
|
||||||
|
ScheduledStation = {
|
||||||
|
{
|
||||||
|
Id = {
|
||||||
|
[1] = -284816241
|
||||||
|
[2] = -119452467
|
||||||
|
[3] = -2021313202
|
||||||
|
[4] = 1655163467
|
||||||
|
}
|
||||||
|
StationInfo = {
|
||||||
|
{
|
||||||
|
Platform = "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StationName = "CE-03 2"
|
||||||
|
TagName = "??"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrainName = "???"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Stopovers = {
|
||||||
|
[1] = "????"
|
||||||
|
[2] = "???"
|
||||||
|
}
|
||||||
|
Train = {
|
||||||
|
{
|
||||||
|
Cancelled = 0
|
||||||
|
Carriages = 1
|
||||||
|
Icon = "create:traditional"
|
||||||
|
Id = {
|
||||||
|
[1] = 2056578835
|
||||||
|
[2] = 774784604
|
||||||
|
[3] = -1876554799
|
||||||
|
[4] = 2012426381
|
||||||
|
}
|
||||||
|
StateData = {
|
||||||
|
{
|
||||||
|
["0"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["1"] = {
|
||||||
|
{
|
||||||
|
CategoryId = {
|
||||||
|
[1] = 186313593
|
||||||
|
[2] = 376131208
|
||||||
|
[3] = -1142882515
|
||||||
|
[4] = 1849467770
|
||||||
|
}
|
||||||
|
Color = -14790494
|
||||||
|
DisplayName = "???"
|
||||||
|
LineId = {
|
||||||
|
[1] = -1767394825
|
||||||
|
[2] = 1017529725
|
||||||
|
[3] = -1140094085
|
||||||
|
[4] = 280637596
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XSize = 1
|
||||||
|
YSize = 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Debug Session: eta-stopover-bug
|
||||||
|
- **Status**: [OPEN]
|
||||||
|
- **Issue**: 列车进站 ETA 仍然包含停站时间;终点站显示已正确,但 ETA 仍偏大。
|
||||||
|
- **Debug Server**: N/A
|
||||||
|
- **Log File**: N/A
|
||||||
|
|
||||||
|
## Reproduction Steps
|
||||||
|
1. 在发车牌和 Block Reader 都正常连接的情况下运行 `station_broadcast.lua`
|
||||||
|
2. 观察 `UPCOMING TRAINS` 的 ETA
|
||||||
|
3. 与 CRN 发车牌原始 ETA 对比
|
||||||
|
|
||||||
|
## Hypotheses & Verification
|
||||||
|
| ID | Hypothesis | Likelihood | Effort | Evidence |
|
||||||
|
|----|------------|------------|--------|----------|
|
||||||
|
| A | NBT 侧生成的 `announceKey` 仍未与 `create_target` 文本键完全匹配,导致没有走文本 ETA 覆盖 | High | Low | Rejected |
|
||||||
|
| B | `create_target` 文本里解析出的 ETA 本身就是“到发后/含停站”的字段,而不是进站前 ETA | Medium | Low | Inconclusive |
|
||||||
|
| C | 文本解析到了正确 ETA,但后来又被 NBT 计算值覆盖,或因为键规范化不一致而未命中覆盖 | Medium | Low | In Progress |
|
||||||
|
| D | 同一班车在 NBT 与文本中的目的地/车次格式仍有额外差异(空格、平台、后缀) | High | Low | Rejected |
|
||||||
|
| E | `create_target` 文本切分错位,把 ETA 数值丢掉了,只留下中文单位(在 CC 中显示为 `??`) | High | Low | Confirmed |
|
||||||
|
| F | NBT 中 `RealTimeArrival` / `RealTimeDeparture` 对当前用途语义与预期相反,取错字段后会把停站时间算进去 | High | Low | Confirmed |
|
||||||
|
|
||||||
|
## Log Evidence
|
||||||
|
- 屏幕证据:`TK:[Local]1123_HC-02 2`
|
||||||
|
- 屏幕证据:`NK:[Local]1123_HC-02 2`
|
||||||
|
- 屏幕证据:`TXT:-1 NBT:113 USE:113`
|
||||||
|
- 屏幕证据:`ETASTR:??`
|
||||||
|
- 结论:文本键已匹配成功,但 `etaStr` 仅剩单位文本,数值部分在切分时被错误丢弃。
|
||||||
|
- 后续证据:修复切分后,`TXT:60` 但 `USE` 仍保持 NBT 值,说明文本 ETA 未稳定命中最终覆盖,需统一键规范化。
|
||||||
|
- 样本证据:`LastRefreshed=122113`,`RealTimeDeparture=122228`,`RealTimeArrival=123428`,两者相差约 1200 tick(60s 停站时间);取 `RealTimeArrival` 会天然带入停站时间。
|
||||||
|
|
||||||
|
## Verification Conclusion
|
||||||
|
- 已确认根因 1:`create_target` 文本解析错误地将第一段数字当成独立字段丢掉,导致 ETA 解析只看到中文单位。
|
||||||
|
- 新增根因 2:文本键与 NBT 键需要统一规范化(空格压缩/去首尾),否则文本 ETA 无法稳定覆盖最终值。
|
||||||
|
- 已确认根因 3:对于“进站 ETA”用途,应该取 `RealTimeArrival/RealTimeDeparture` 中更早的那个时间点,而不是固定取 `RealTimeArrival`。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[OPEN] upcoming-data-missing
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
- Monitor shows `NONE / WAITING DATA` under `UPCOMING TRAINS`.
|
||||||
|
- Expected: list of upcoming trains parsed from CRN advanced display NBT (TrainStops).
|
||||||
|
|
||||||
|
## Hypotheses (falsifiable)
|
||||||
|
1. `Peripherals.blockReader` is not detected (wrong peripheral type string / not connected to this computer).
|
||||||
|
2. `blockReader.getBlockData()` succeeds but returns a table without `TrainStops` (block reader is pointing at the wrong block or advanced display data not present).
|
||||||
|
3. `TrainStops` exists but is not an array (non-numeric keys), so `ipairs()` iteration yields 0 rows.
|
||||||
|
4. `TrainStops` exists but the code path exits before assigning `State.upcomingTrains`.
|
||||||
|
|
||||||
|
## Evidence to collect (instrumentation)
|
||||||
|
- Whether blockReader/create_target are detected.
|
||||||
|
- Whether getBlockData succeeds.
|
||||||
|
- Whether TrainStops exists and how many rows it contains (ipairs count vs pairs count).
|
||||||
|
|
||||||
|
## Next action
|
||||||
|
- Add runtime instrumentation to display these values on the monitor and in logs.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
|
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
|
||||||
|
local UPDATE_SCRIPT_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/update_station_broadcast.lua"
|
||||||
|
|
||||||
local function fetch(url)
|
local function fetch(url)
|
||||||
if not http then
|
if not http then
|
||||||
@@ -64,7 +65,8 @@ local function downloadTo(url, path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local targets = {
|
local targets = {
|
||||||
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }
|
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
|
||||||
|
{ url = UPDATE_SCRIPT_URL, path = shell.resolve("/update_station_broadcast.lua") }
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t in ipairs(targets) do
|
for _, t in ipairs(targets) do
|
||||||
@@ -76,4 +78,5 @@ for _, t in ipairs(targets) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
print("Install complete.")
|
print("Install complete.")
|
||||||
|
print("Update helper installed as /update_station_broadcast.lua")
|
||||||
print("Run 'reboot' to start StationBroadcast on boot.")
|
print("Run 'reboot' to start StationBroadcast on boot.")
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
IsController = 1,
|
||||||
|
Platform = "",
|
||||||
|
TrainStops = {
|
||||||
|
{
|
||||||
|
IsLast = 1,
|
||||||
|
State = 2,
|
||||||
|
FirstStop = "HC-01 1",
|
||||||
|
Stopovers = {
|
||||||
|
"HC-02 2",
|
||||||
|
},
|
||||||
|
Train = {
|
||||||
|
Carriages = 1,
|
||||||
|
StateData = {
|
||||||
|
[ "1" ] = {
|
||||||
|
CategoryId = {
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
Color = 0,
|
||||||
|
LineId = {
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
DisplayName = "[Local]1123",
|
||||||
|
},
|
||||||
|
[ "0" ] = {
|
||||||
|
CategoryId = {
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
Color = 0,
|
||||||
|
LineId = {
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
DisplayName = "[Local]1123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Status = {
|
||||||
|
"createrailwaysnavigator:delay_from_previous_journey",
|
||||||
|
},
|
||||||
|
Cancelled = 0,
|
||||||
|
Icon = "create:traditional",
|
||||||
|
Id = {
|
||||||
|
768288300,
|
||||||
|
-1947906833,
|
||||||
|
-1592998865,
|
||||||
|
1043383053,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IsPrevSectionExcluded = 0,
|
||||||
|
IsNextSectionExcluded = 0,
|
||||||
|
Station = {
|
||||||
|
ScheduledStation = {
|
||||||
|
Id = {
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
StationInfo = {
|
||||||
|
Platform = "",
|
||||||
|
},
|
||||||
|
StationName = "HC-01 1",
|
||||||
|
TagName = "HC-01 1",
|
||||||
|
},
|
||||||
|
RealTimeDeparture = 122228,
|
||||||
|
ScheduledArrival = 122029,
|
||||||
|
RealTimeStation = {
|
||||||
|
Id = {
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
StationInfo = {
|
||||||
|
Platform = "",
|
||||||
|
},
|
||||||
|
StationName = "HC-01 1",
|
||||||
|
TagName = "HC-01 1",
|
||||||
|
},
|
||||||
|
RealTimeArrival = 123428,
|
||||||
|
Index = 0,
|
||||||
|
ScheduledDeparture = 123229,
|
||||||
|
Destination = "HC-01 1",
|
||||||
|
TrainName = "[Local]1123",
|
||||||
|
},
|
||||||
|
IsFirst = 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DisplaySettings = {
|
||||||
|
FontColor = -1,
|
||||||
|
BackColor = 0,
|
||||||
|
PlatformWidthNextStop = -1,
|
||||||
|
TimeDisplay = 1,
|
||||||
|
TrainNameWidth = 12,
|
||||||
|
ShowLineColor = 1,
|
||||||
|
TrainNameWidthNextStop = -1,
|
||||||
|
ShowTrainMultipleTimes = 1,
|
||||||
|
TrainText = 0,
|
||||||
|
PlatformWidth = -1,
|
||||||
|
},
|
||||||
|
Glowing = 0,
|
||||||
|
LastRefreshed = 122113,
|
||||||
|
YSize = 1,
|
||||||
|
Filter = "HC-01 1",
|
||||||
|
Material = {
|
||||||
|
Name = "create:copycat_base",
|
||||||
|
},
|
||||||
|
XSize = 3,
|
||||||
|
DisplayId = "createrailwaysnavigator:platform/focus",
|
||||||
|
Item = {},
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
-- Random Audio Player
|
-- Random Audio Player
|
||||||
-- Simplified version that only plays random audio files
|
-- Simplified version that only plays random audio files
|
||||||
|
|
||||||
@@ -45,13 +46,22 @@ local function playDfpwmFile(path, volume)
|
|||||||
|
|
||||||
if controller and type(controller.playAudioAll) == "function" then
|
if controller and type(controller.playAudioAll) == "function" then
|
||||||
while not controller.playAudioAll(samples, volume) do
|
while not controller.playAudioAll(samples, volume) do
|
||||||
os.pullEvent("speaker_audio_empty")
|
local timerId = os.startTimer(0.05)
|
||||||
|
while true do
|
||||||
|
local ev = { os.pullEvent() }
|
||||||
|
if ev[1] == "timer" and ev[2] == timerId then
|
||||||
|
break
|
||||||
|
elseif ev[1] == "speaker_audio_empty" then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
local pending = {}
|
local pending = {}
|
||||||
for _, sp in ipairs(speakers) do
|
for _, sp in ipairs(speakers) do
|
||||||
pending[peripheral.getName(sp)] = sp
|
pending[peripheral.getName(sp)] = sp
|
||||||
end
|
end
|
||||||
|
local maxAttempts = 20
|
||||||
while next(pending) do
|
while next(pending) do
|
||||||
for name, sp in pairs(pending) do
|
for name, sp in pairs(pending) do
|
||||||
if sp.playAudio(samples, volume) then
|
if sp.playAudio(samples, volume) then
|
||||||
@@ -59,7 +69,19 @@ local function playDfpwmFile(path, volume)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if next(pending) then
|
if next(pending) then
|
||||||
os.pullEvent("speaker_audio_empty")
|
maxAttempts = maxAttempts - 1
|
||||||
|
if maxAttempts <= 0 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local timerId = os.startTimer(0.05)
|
||||||
|
while true do
|
||||||
|
local ev = { os.pullEvent() }
|
||||||
|
if ev[1] == "timer" and ev[2] == timerId then
|
||||||
|
break
|
||||||
|
elseif ev[1] == "speaker_audio_empty" then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+2536
-258
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
local reader = peripheral.find("blockReader")
|
||||||
|
if not reader then
|
||||||
|
print("Error: Block Reader not found!")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1,1)
|
||||||
|
print("=== CRN Advanced Display Parser ===")
|
||||||
|
|
||||||
|
local success, data = pcall(reader.getBlockData)
|
||||||
|
|
||||||
|
if success and data and data.TrainStops then
|
||||||
|
local now = os.epoch("utc") / 50
|
||||||
|
|
||||||
|
for i, stop in ipairs(data.TrainStops) do
|
||||||
|
local st = stop.Station
|
||||||
|
if st then
|
||||||
|
local trainName = st.TrainName or "Unknown"
|
||||||
|
local dest = st.Destination or "Unknown"
|
||||||
|
|
||||||
|
-- 时间计算
|
||||||
|
local scheduledArrival = st.ScheduledArrival or 0
|
||||||
|
local realArrival = st.RealTimeArrival or 0
|
||||||
|
|
||||||
|
-- 计算 ETA (秒)
|
||||||
|
local etaTicks = realArrival - data.LastRefreshed
|
||||||
|
local etaSeconds = math.floor(etaTicks / 20)
|
||||||
|
if etaSeconds < 0 then etaSeconds = 0 end
|
||||||
|
|
||||||
|
-- 计算 Delay (分钟)
|
||||||
|
local delayTicks = realArrival - scheduledArrival
|
||||||
|
local delayMinutes = math.floor(delayTicks / (20 * 60))
|
||||||
|
if delayMinutes < 0 then delayMinutes = 0 end
|
||||||
|
|
||||||
|
print(string.format("[%d] Train: %s -> %s", i, trainName, dest))
|
||||||
|
print(string.format(" ETA: %d seconds", etaSeconds))
|
||||||
|
print(string.format(" Delay: %d minutes", delayMinutes))
|
||||||
|
print("---------------------------------")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print("Waiting for CRN data...")
|
||||||
|
end
|
||||||
|
|
||||||
|
os.sleep(1)
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user