Compare commits
149
Commits
5e600bb75d
...
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 |
@@ -7,3 +7,8 @@ CloudServer/server-data.json
|
||||
Audio/general/
|
||||
Audio/music/
|
||||
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.
@@ -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 = {
|
||||
apps : [{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -725,6 +725,14 @@
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<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 ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
|
||||
const JA_REFERENCE_ID = "38985e545cff41b79c11c32b0196c9a0";
|
||||
@@ -826,8 +834,8 @@
|
||||
toast.className = 'toast';
|
||||
toast.innerHTML = `
|
||||
<div>
|
||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
|
||||
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</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)">${ds(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
@@ -858,44 +866,24 @@
|
||||
|
||||
async function _generateSingleTTS(text, reference_id) {
|
||||
try {
|
||||
// Client-side fetch to use local browser's proxy
|
||||
const fishRes = await fetch('https://api.fish.audio/v1/tts', {
|
||||
const res = await fetch('/api/tts/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
reference_id: reference_id,
|
||||
model: TTS_MODEL,
|
||||
format: "wav"
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, reference_id, model: TTS_MODEL })
|
||||
});
|
||||
|
||||
if (!fishRes.ok) {
|
||||
let msg = 'Fish Audio API Error';
|
||||
try { msg = await fishRes.text(); } catch(e){}
|
||||
throw new Error(`[${fishRes.status}] ${msg}`);
|
||||
if (!res.ok) {
|
||||
let msg = '服务器返回错误';
|
||||
try { const errData = await res.json(); msg = errData.message || msg; } catch(e){}
|
||||
throw new Error(`[${res.status}] ${msg}`);
|
||||
}
|
||||
|
||||
const audioBlob = await fishRes.blob();
|
||||
|
||||
// Upload the blob to the server as a temporary file
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'temp.wav');
|
||||
|
||||
const uploadRes = await fetch('/api/tts/upload_temp', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await uploadRes.json();
|
||||
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('网络请求失败或跨域被拦截(CORS)。如果启用了本地代理,请确保浏览器能够通过代理正常访问外网接口。');
|
||||
throw new Error('网络连接失败。请确保你的服务器代理正常!');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -960,11 +948,15 @@
|
||||
item.style.alignItems = 'stretch';
|
||||
item.style.gap = '8px';
|
||||
|
||||
const dsCode = ds(task.code);
|
||||
const dsZh = ds(task.zh || '');
|
||||
const dsJa = ds(task.en || '');
|
||||
|
||||
item.innerHTML = `
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||
<div style="display:flex; flex-direction:column; gap:2px;">
|
||||
<span style="font-weight:600; color:var(--accent)">${task.code}</span>
|
||||
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${task.zh} | JA: ${task.en}</span>
|
||||
<span style="font-weight:600; color:var(--accent)">${dsCode}</span>
|
||||
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${dsZh} | JA: ${dsJa}</span>
|
||||
</div>
|
||||
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
|
||||
</div>
|
||||
@@ -1034,9 +1026,9 @@
|
||||
const jaData = await _generateSingleTTS(task.en, JA_REFERENCE_ID);
|
||||
|
||||
task.zhTempName = zhData.tempName;
|
||||
task.jaTempName = jaData.jaTempName || jaData.enTempName || jaData.tempName;
|
||||
task.jaTempName = jaData.tempName;
|
||||
task.zhUrl = zhData.url;
|
||||
task.jaUrl = jaData.jaUrl || jaData.enUrl || jaData.url;
|
||||
task.jaUrl = jaData.url;
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
@@ -1105,12 +1097,6 @@
|
||||
const task = batchTasks[i];
|
||||
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}`);
|
||||
statusSpan.textContent = 'Importing...';
|
||||
statusSpan.style.color = 'var(--warning)';
|
||||
@@ -1210,12 +1196,6 @@
|
||||
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) {
|
||||
showToast("Error", "No audio generated to import");
|
||||
return;
|
||||
@@ -1338,10 +1318,11 @@
|
||||
div.className = 'list-item';
|
||||
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 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 style="display:flex; gap:8px;">
|
||||
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
|
||||
|
||||
+214
-53
@@ -10,33 +10,25 @@
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
/* Ultra-Dark / Minimalist Theme */
|
||||
/* Ultra-Dark / Minimalist Theme (aligned with cablemanager) */
|
||||
--bg-body: #09090b;
|
||||
/* Zinc-950 */
|
||||
--bg-panel: #18181b;
|
||||
/* Zinc-900 */
|
||||
--bg-panel: #121214;
|
||||
--bg-input: #09090b;
|
||||
--bg-hover: #27272a;
|
||||
/* Zinc-800 */
|
||||
--bg-border: #27272a;
|
||||
/* Zinc-800 */
|
||||
|
||||
--text-primary: #e4e4e7;
|
||||
/* Zinc-200 */
|
||||
--text-muted: #71717a;
|
||||
/* Zinc-500 */
|
||||
--text-primary: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
|
||||
--accent: #fafafa;
|
||||
/* Zinc-50 */
|
||||
--danger: #ef4444;
|
||||
/* Red-500 */
|
||||
--success: #10b981;
|
||||
/* Emerald-500 */
|
||||
--warning: #f59e0b;
|
||||
/* Amber-500 */
|
||||
|
||||
--radius: 6px;
|
||||
--radius: 8px;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
||||
--ring-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -45,7 +37,7 @@
|
||||
|
||||
body {
|
||||
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);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -299,14 +291,18 @@
|
||||
background: transparent;
|
||||
border: 1px solid var(--bg-border);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8rem;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
@@ -322,15 +318,25 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #fff;
|
||||
border-color: #fff;
|
||||
background: #d4d4d8;
|
||||
border-color: #d4d4d8;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.3;
|
||||
opacity: 0.5;
|
||||
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 {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
@@ -699,6 +705,61 @@
|
||||
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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -929,14 +990,17 @@
|
||||
|
||||
<!-- Sidebar: Status -->
|
||||
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
||||
<!-- Version Control (Hidden since we don't use it anymore) -->
|
||||
<div class="version-panel" style="display:none;">
|
||||
<!-- Station Update -->
|
||||
<div class="version-panel">
|
||||
<div class="version-panel-header">
|
||||
<span>Audio Version</span>
|
||||
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
|
||||
<span>Station Update</span>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn" id="publish-update-btn" onclick="publishStationUpdate()" title="Publish Station Update">发布更新</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 class="version-display" id="current-version">Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -977,7 +1041,7 @@
|
||||
<span id="selection-count"
|
||||
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>
|
||||
<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="/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>
|
||||
@@ -1055,6 +1119,15 @@
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script>
|
||||
// --- Display sanitizer: 强制剥除所有 ? 类字符,保证 UI 零问号 ---
|
||||
function ds(x) {
|
||||
let v = String(x == null ? '' : x)
|
||||
.replace(/[?¿?]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return v === '' ? '-' : v;
|
||||
}
|
||||
|
||||
// --- Icons (SVG) ---
|
||||
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>`,
|
||||
@@ -1072,6 +1145,7 @@
|
||||
let currentPath = 'root';
|
||||
let stationCodeMap = {};
|
||||
let broadcastingStations = new Set();
|
||||
let currentStationUpdate = null;
|
||||
|
||||
// --- WebSocket ---
|
||||
const connStatus = document.getElementById('conn-status');
|
||||
@@ -1118,6 +1192,11 @@
|
||||
|
||||
// --- Renderers ---
|
||||
function updateState(state) {
|
||||
if (state.stationUpdate) {
|
||||
currentStationUpdate = state.stationUpdate;
|
||||
renderStationUpdate(state.stationUpdate);
|
||||
}
|
||||
|
||||
if (state.stations) {
|
||||
const list = document.getElementById('station-list');
|
||||
list.innerHTML = '';
|
||||
@@ -1136,20 +1215,31 @@
|
||||
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
||||
const stationTarget = st.target || st.name;
|
||||
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');
|
||||
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 class="status-main">
|
||||
<div class="item-info">
|
||||
<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>' : ''}
|
||||
${updateBadge}
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1168,12 +1258,15 @@
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status-item';
|
||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||
const dsTrainId = ds(tr.trainId);
|
||||
const dsStatus = ds(tr.status || '');
|
||||
const dsCurCode = ds(currentStCode);
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="status-main">
|
||||
<div class="item-info">
|
||||
<div class="item-title">${tr.trainId}</div>
|
||||
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</span></div>
|
||||
<div class="item-title">${dsTrainId}</div>
|
||||
<div class="item-sub">${dsCurCode} <span style="margin-left:8px;color:var(--text-muted)">${dsStatus}</span></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||
@@ -1200,7 +1293,9 @@
|
||||
|
||||
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
|
||||
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) {
|
||||
// Clear empty state
|
||||
@@ -1216,15 +1311,20 @@
|
||||
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 = `
|
||||
<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="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>
|
||||
${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 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;">
|
||||
@@ -1288,8 +1388,10 @@
|
||||
else typeClass = 'type-info';
|
||||
|
||||
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);
|
||||
if (container.children.length > 100) container.lastChild.remove();
|
||||
}
|
||||
@@ -1299,6 +1401,9 @@
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
|
||||
const dsTitle = ds(title || '');
|
||||
const dsMsg = ds(message || '');
|
||||
|
||||
// Icon based on title/message
|
||||
let iconColor = 'var(--text-primary)';
|
||||
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>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<div class="toast-title">${title}</div>
|
||||
<div class="toast-msg">${message}</div>
|
||||
<div class="toast-title">${dsTitle}</div>
|
||||
<div class="toast-msg">${dsMsg}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1360,7 +1465,7 @@
|
||||
beep: 'Beep',
|
||||
station: 'Station'
|
||||
};
|
||||
const name = nameMap[currentPath] || currentPath;
|
||||
const name = ds(nameMap[currentPath] || currentPath);
|
||||
breadcrumb.innerHTML = `
|
||||
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
|
||||
<div class="breadcrumb-sep">/</div>
|
||||
@@ -1383,10 +1488,12 @@
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-row';
|
||||
row.onclick = () => navigate(f.id);
|
||||
const dsFolderName = ds(f.name);
|
||||
const dsCount = String(f.count || 0);
|
||||
row.innerHTML = `
|
||||
<div></div>
|
||||
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${f.name}</span></div>
|
||||
<div class="file-meta">${f.count} items</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${dsFolderName}</span></div>
|
||||
<div class="file-meta">${dsCount} items</div>
|
||||
<div></div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
@@ -1408,6 +1515,8 @@
|
||||
};
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-row';
|
||||
const dsFileName = ds(f.name);
|
||||
const dsTypeLabel = ds(typeLabelMap[f.type] || f.type || '');
|
||||
// Click to preview
|
||||
row.onclick = (e) => {
|
||||
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
|
||||
@@ -1416,8 +1525,8 @@
|
||||
};
|
||||
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;align-items:center;gap:10px">${f.name.endsWith('.txt') ? ICONS.file : ICONS.audio}<span class="file-name">${f.name}</span></div>
|
||||
<div class="file-meta">${typeLabelMap[f.type] || f.type}</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">${dsTypeLabel}</div>
|
||||
<div style="text-align:right">
|
||||
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
|
||||
</div>
|
||||
@@ -1688,20 +1797,72 @@
|
||||
|
||||
// Init
|
||||
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 {
|
||||
const res = await fetch('/api/version');
|
||||
const res = await fetch('/api/station-update');
|
||||
const data = await res.json();
|
||||
if (data.version) {
|
||||
document.getElementById('current-version').textContent = data.version;
|
||||
currentStationUpdate = data;
|
||||
|
||||
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) {
|
||||
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 ---
|
||||
const resizerV = document.getElementById('resizer-v');
|
||||
const resizerH = document.getElementById('resizer-h');
|
||||
|
||||
@@ -10,18 +10,20 @@
|
||||
<style>
|
||||
:root {
|
||||
--bg-body: #09090b;
|
||||
--bg-panel: #18181b;
|
||||
--bg-panel: #121214;
|
||||
--bg-input: #09090b;
|
||||
--bg-hover: #27272a;
|
||||
--bg-border: #27272a;
|
||||
--text-primary: #e4e4e7;
|
||||
--text-muted: #71717a;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #fafafa;
|
||||
--danger: #ef4444;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--radius: 8px;
|
||||
--font-ui: 'Inter', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--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);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -30,7 +32,7 @@
|
||||
|
||||
body {
|
||||
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);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
@@ -356,6 +358,14 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ds(x) {
|
||||
let v = String(x == null ? '' : x)
|
||||
.replace(/[?¿?]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return v === '' ? '-' : v;
|
||||
}
|
||||
|
||||
// --- Icons (SVG) ---
|
||||
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>`,
|
||||
@@ -441,7 +451,7 @@
|
||||
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();
|
||||
renderMessages();
|
||||
}
|
||||
@@ -484,18 +494,22 @@
|
||||
const isBroadcasting = broadcastingStations.has(st.name);
|
||||
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
|
||||
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');
|
||||
div.className = 'status-item';
|
||||
div.innerHTML = `
|
||||
<div class="item-info">
|
||||
<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>' : ''}
|
||||
</div>
|
||||
<div class="item-sub">${st.code}${st.platform ? ` - 站台 ${st.platform}` : ''}</div>
|
||||
<div class="item-sub">${dsCode}${dsPlatform && dsPlatform !== '-' ? ` - 站台 ${dsPlatform}` : ''}</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);
|
||||
});
|
||||
@@ -517,13 +531,18 @@
|
||||
|
||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||
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 style="width:100%">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<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-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中')}</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">${dsCurCode} <span style="color:${statusColor}; margin-left:8px;">${dsStatus}</span></div>
|
||||
</div>
|
||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||
</div>
|
||||
|
||||
@@ -452,6 +452,14 @@
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script>
|
||||
function ds(x) {
|
||||
let v = String(x == null ? '' : x)
|
||||
.replace(/[?¿?]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return v === '' ? '-' : v;
|
||||
}
|
||||
|
||||
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>`,
|
||||
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.innerHTML = `
|
||||
<div>
|
||||
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
|
||||
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</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)">${ds(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
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>`;
|
||||
availableFiles.forEach(f => {
|
||||
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;
|
||||
}
|
||||
@@ -576,9 +585,10 @@
|
||||
const div = document.createElement('div');
|
||||
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 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 style="display:flex; gap:6px;">
|
||||
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
|
||||
@@ -636,7 +646,7 @@
|
||||
const div = document.createElement('div');
|
||||
div.className = 'edit-file-item';
|
||||
div.innerHTML = `
|
||||
<div class="edit-file-name">${file}</div>
|
||||
<div class="edit-file-name">${ds(file)}</div>
|
||||
<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 === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
|
||||
|
||||
+143
-51
@@ -8,58 +8,95 @@
|
||||
<style>
|
||||
:root {
|
||||
--bg-body: #09090b;
|
||||
--bg-panel: #18181b;
|
||||
--bg-panel: #121214;
|
||||
--bg-input: #09090b;
|
||||
--bg-hover: #27272a;
|
||||
--bg-border: #27272a;
|
||||
--text-primary: #e4e4e7;
|
||||
--text-muted: #71717a;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #fafafa;
|
||||
--danger: #ef4444;
|
||||
--radius: 6px;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--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; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: var(--bg-body);
|
||||
padding: 30px 20px;
|
||||
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--bg-border);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
background: linear-gradient(to right, #fff, #a1a1aa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--bg-border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border-color: var(--accent);
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-body);
|
||||
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 {
|
||||
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);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
@@ -69,7 +106,11 @@
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-border);
|
||||
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;
|
||||
}
|
||||
.card-title {
|
||||
@@ -77,10 +118,12 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bg-border);
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
@@ -90,71 +133,102 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-border);
|
||||
background: #0f0f12;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select, input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--bg-border);
|
||||
background: #0f0f12;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
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;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-border);
|
||||
background: #0f0f12;
|
||||
background: var(--bg-input);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-primary);
|
||||
min-height: 2.4em;
|
||||
}
|
||||
.stations {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--bg-border);
|
||||
border-radius: var(--radius);
|
||||
background: #0f0f12;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.station-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.station-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--bg-border);
|
||||
align-items: center;
|
||||
}
|
||||
.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 {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
@@ -163,9 +237,17 @@
|
||||
@media (max-width: 980px) {
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout-wrapper">
|
||||
<div class="topbar">
|
||||
<div class="title">音乐播放</div>
|
||||
<div class="row" style="margin-top:0">
|
||||
@@ -177,7 +259,7 @@
|
||||
<div class="panel">
|
||||
<div class="card-title">
|
||||
<h3>控制</h3>
|
||||
<span class="badge" id="track_badge">未选择</span>
|
||||
<span class="badge" id="track_badge"></span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setStatus(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) {
|
||||
return String(str || "")
|
||||
let v = sanitizeDisplay(str);
|
||||
return v
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
@@ -273,7 +365,7 @@
|
||||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||||
const stations = Array.isArray(data.stations) ? data.stations : [];
|
||||
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("站台列表为空");
|
||||
return;
|
||||
}
|
||||
@@ -303,7 +395,7 @@
|
||||
function onTrackChange() {
|
||||
const sel = document.getElementById("track_select");
|
||||
const v = String(sel.value || "").trim();
|
||||
document.getElementById("track_badge").textContent = v ? v : "未选择";
|
||||
document.getElementById("track_badge").textContent = v ? v : "";
|
||||
}
|
||||
|
||||
async function loadTracks() {
|
||||
|
||||
@@ -189,8 +189,17 @@
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function sanitizeDisplay(s) {
|
||||
let v = String(s ?? '')
|
||||
.replace(/[?¿?]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return v === '' ? '-' : v;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
let v = sanitizeDisplay(s);
|
||||
return v
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
|
||||
@@ -256,8 +256,17 @@
|
||||
let editKey = '';
|
||||
let lastData = null;
|
||||
|
||||
function sanitizeDisplay(s) {
|
||||
let v = String(s ?? '')
|
||||
.replace(/[?¿?]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return v === '' ? '-' : v;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
let v = sanitizeDisplay(s);
|
||||
return v
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
|
||||
@@ -8,60 +8,95 @@
|
||||
<style>
|
||||
:root {
|
||||
--bg-body: #09090b;
|
||||
--bg-panel: #18181b;
|
||||
--bg-panel: #121214;
|
||||
--bg-input: #09090b;
|
||||
--bg-hover: #27272a;
|
||||
--bg-border: #27272a;
|
||||
--text-primary: #e4e4e7;
|
||||
--text-muted: #71717a;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #fafafa;
|
||||
--danger: #ef4444;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--radius: 6px;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--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; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: var(--bg-body);
|
||||
padding: 30px 20px;
|
||||
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--bg-border);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
background: linear-gradient(to right, #fff, #a1a1aa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--bg-border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border-color: var(--accent);
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-body);
|
||||
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 {
|
||||
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);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
@@ -72,29 +107,38 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bg-border);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-border);
|
||||
background: #0f0f12;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
}
|
||||
.badge:empty { display: none; }
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-border);
|
||||
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;
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
@@ -102,81 +146,117 @@
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
textarea, select, input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-border);
|
||||
background: #0f0f12;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
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: 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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.row .btn { flex: 0 0 auto; }
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--bg-border);
|
||||
background: #0f0f12;
|
||||
background: var(--bg-input);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-primary);
|
||||
min-height: 2.4em;
|
||||
}
|
||||
.stations {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--bg-border);
|
||||
border-radius: var(--radius);
|
||||
background: #0f0f12;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.station-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.station-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--bg-border);
|
||||
align-items: center;
|
||||
}
|
||||
.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 {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
audio { width: 100%; margin-top: 8px; }
|
||||
audio { width: 100%; }
|
||||
@media (max-width: 980px) {
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout-wrapper">
|
||||
<div class="topbar">
|
||||
<div class="title">临时广播</div>
|
||||
<div class="row" style="margin-top:0">
|
||||
@@ -188,7 +268,7 @@
|
||||
<div class="panel">
|
||||
<div class="card-title">
|
||||
<h3 style="margin:0;">内容</h3>
|
||||
<span class="badge" id="build_badge">未合成</span>
|
||||
<span class="badge" id="build_badge"></span>
|
||||
</div>
|
||||
|
||||
<label for="tb_text">广播文本(中文)</label>
|
||||
@@ -233,6 +313,7 @@
|
||||
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TTS_MODEL = "s2.1-pro-free";
|
||||
@@ -269,7 +350,7 @@
|
||||
if (!data || !data.success) throw new Error(data?.message || "加载失败");
|
||||
const stations = Array.isArray(data.stations) ? data.stations : [];
|
||||
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("站台列表为空");
|
||||
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) {
|
||||
return String(str || "")
|
||||
let v = sanitizeDisplay(str);
|
||||
return v
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
@@ -324,44 +414,24 @@
|
||||
|
||||
async function _generateTTSLocal(text, reference_id) {
|
||||
try {
|
||||
// Client-side fetch to use local browser's proxy
|
||||
const fishRes = await fetch('https://api.fish.audio/v1/tts', {
|
||||
const res = await fetch('/api/tts/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
reference_id: reference_id,
|
||||
model: TTS_MODEL,
|
||||
format: "wav"
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, reference_id, model: TTS_MODEL })
|
||||
});
|
||||
|
||||
if (!fishRes.ok) {
|
||||
let msg = 'Fish Audio API Error';
|
||||
try { msg = await fishRes.text(); } catch(e){}
|
||||
throw new Error(`[${fishRes.status}] ${msg}`);
|
||||
if (!res.ok) {
|
||||
let msg = '服务器返回错误';
|
||||
try { const errData = await res.json(); msg = errData.message || msg; } catch(e){}
|
||||
throw new Error(`[${res.status}] ${msg}`);
|
||||
}
|
||||
|
||||
const audioBlob = await fishRes.blob();
|
||||
|
||||
// Upload the blob to the server as a temporary file
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'temp.wav');
|
||||
|
||||
const uploadRes = await fetch('/api/tts/upload_temp', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await uploadRes.json();
|
||||
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 new Error('网络连接失败。请确保你的服务器代理正常!');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -393,7 +463,6 @@
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data || !data.success) throw new Error(data?.message || "合成失败");
|
||||
|
||||
const preview = document.getElementById("preview_area");
|
||||
preview.style.display = "block";
|
||||
state.broadcastId = data.broadcastId;
|
||||
@@ -404,7 +473,7 @@
|
||||
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
|
||||
} catch (e) {
|
||||
setStatus("合成失败: " + (e?.message || String(e)));
|
||||
document.getElementById("build_badge").textContent = "未合成";
|
||||
document.getElementById("build_badge").textContent = "";
|
||||
} finally {
|
||||
document.getElementById("btn_compose").disabled = false;
|
||||
}
|
||||
|
||||
+2168
-355
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 UPDATE_SCRIPT_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/update_station_broadcast.lua"
|
||||
|
||||
local function fetch(url)
|
||||
if not http then
|
||||
@@ -64,7 +65,8 @@ local function downloadTo(url, path)
|
||||
end
|
||||
|
||||
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
|
||||
@@ -76,4 +78,5 @@ for _, t in ipairs(targets) do
|
||||
end
|
||||
|
||||
print("Install complete.")
|
||||
print("Update helper installed as /update_station_broadcast.lua")
|
||||
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
|
||||
-- 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
|
||||
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
|
||||
else
|
||||
local pending = {}
|
||||
for _, sp in ipairs(speakers) do
|
||||
pending[peripheral.getName(sp)] = sp
|
||||
end
|
||||
local maxAttempts = 20
|
||||
while next(pending) do
|
||||
for name, sp in pairs(pending) do
|
||||
if sp.playAudio(samples, volume) then
|
||||
@@ -59,7 +69,19 @@ local function playDfpwmFile(path, volume)
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
+2496
-218
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
|
||||
@@ -0,0 +1,370 @@
|
||||
-- BlockReader NBT Dumper for Advanced Display Block
|
||||
-- Reads block NBT via Advanced Peripherals block_reader and writes to txt
|
||||
|
||||
local COLORS = {
|
||||
INFO = colors.white,
|
||||
WARN = colors.yellow,
|
||||
ERROR = colors.red,
|
||||
SUCCESS = colors.green
|
||||
}
|
||||
|
||||
local OUTPUT_FILE = "blockreader_display_nbt.txt"
|
||||
|
||||
local function log(level, message)
|
||||
local timestamp = os.date("%H:%M:%S")
|
||||
local logMessage = string.format("[%s][%s] %s", timestamp, level, message)
|
||||
local color = COLORS[level] or colors.white
|
||||
local oldColor = term.getTextColor()
|
||||
term.setTextColor(color)
|
||||
print(logMessage)
|
||||
term.setTextColor(oldColor)
|
||||
end
|
||||
|
||||
local function serializeValue(value, indent)
|
||||
indent = indent or 0
|
||||
local pad = string.rep(" ", indent)
|
||||
|
||||
local t = type(value)
|
||||
if t == "nil" then
|
||||
return "nil"
|
||||
elseif t == "boolean" then
|
||||
return value and "true" or "false"
|
||||
elseif t == "number" then
|
||||
return tostring(value)
|
||||
elseif t == "string" then
|
||||
local escaped = value:gsub("\\", "\\\\"):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
|
||||
return string.format("%q", escaped)
|
||||
elseif t == "table" then
|
||||
local lines = {}
|
||||
local isArray = true
|
||||
local maxIndex = 0
|
||||
for k, v in pairs(value) do
|
||||
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
|
||||
isArray = false
|
||||
break
|
||||
end
|
||||
if k > maxIndex then maxIndex = k end
|
||||
end
|
||||
if isArray then
|
||||
for i = 1, maxIndex do
|
||||
if value[i] ~= nil then
|
||||
table.insert(lines, pad .. " [" .. tostring(i) .. "] = " .. serializeValue(value[i], indent + 1))
|
||||
end
|
||||
end
|
||||
else
|
||||
local keys = {}
|
||||
for k, _ in pairs(value) do
|
||||
table.insert(keys, k)
|
||||
end
|
||||
table.sort(keys, function(a, b)
|
||||
local ta, tb = type(a), type(b)
|
||||
if ta == tb then
|
||||
if ta == "number" then return a < b end
|
||||
return tostring(a) < tostring(b)
|
||||
end
|
||||
return ta < tb
|
||||
end)
|
||||
for _, k in ipairs(keys) do
|
||||
local keyStr
|
||||
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
|
||||
keyStr = k
|
||||
else
|
||||
keyStr = "[" .. serializeValue(k, 0) .. "]"
|
||||
end
|
||||
local v = value[k]
|
||||
if type(v) == "table" and next(v) ~= nil then
|
||||
table.insert(lines, pad .. " " .. keyStr .. " = {")
|
||||
table.insert(lines, serializeValue(v, indent + 1))
|
||||
table.insert(lines, pad .. " }")
|
||||
else
|
||||
table.insert(lines, pad .. " " .. keyStr .. " = " .. serializeValue(v, indent + 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
if #lines == 0 then
|
||||
return "{}"
|
||||
end
|
||||
if isArray then
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
table.insert(lines, 1, pad .. "{")
|
||||
table.insert(lines, pad .. "}")
|
||||
return table.concat(lines, "\n")
|
||||
else
|
||||
return "<" .. t .. ">"
|
||||
end
|
||||
end
|
||||
|
||||
local PERIPHERAL_TYPE_CANDIDATES = {
|
||||
"blockReader",
|
||||
"block_reader",
|
||||
"blockreader",
|
||||
"BlockReader",
|
||||
"advancedperipherals:block_reader",
|
||||
"advancedperipherals:blockReader"
|
||||
}
|
||||
|
||||
local function enumeratePeripherals()
|
||||
local list = {}
|
||||
local names = peripheral.getNames() or {}
|
||||
for _, name in ipairs(names) do
|
||||
local entry = {
|
||||
name = name,
|
||||
getType = nil,
|
||||
methodsSorted = nil,
|
||||
getNameCall = nil
|
||||
}
|
||||
local ok, tp = pcall(function() return peripheral.getType(name) end)
|
||||
if ok then entry.getType = tp end
|
||||
if type(entry.getType) == "string" and entry.getType:lower():find("block") and entry.getType:lower():find("reader") then
|
||||
entry.matchHint = "type-name contains block+reader"
|
||||
end
|
||||
local ok2, methods = pcall(function() return peripheral.getMethods(name) end)
|
||||
if ok2 and type(methods) == "table" then
|
||||
local m = {}
|
||||
for _, v in ipairs(methods) do table.insert(m, tostring(v)) end
|
||||
table.sort(m)
|
||||
entry.methodsSorted = m
|
||||
local hasKey = false
|
||||
for _, v in ipairs(m) do
|
||||
if v == "getBlockName" or v == "getBlockState" or v == "getBlockData" or v == "isBlockPresent" then
|
||||
hasKey = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if hasKey then
|
||||
entry.methodsHint = "contains block-reader methods"
|
||||
end
|
||||
end
|
||||
local ok3, p = pcall(function() return peripheral.wrap(name) end)
|
||||
if ok3 and type(p) == "table" and type(p.getName) == "function" then
|
||||
local ok4, res = pcall(function() return p.getName() end)
|
||||
if ok4 then entry.getNameCall = res end
|
||||
end
|
||||
table.insert(list, entry)
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function findBlockReader()
|
||||
for _, candidateType in ipairs(PERIPHERAL_TYPE_CANDIDATES) do
|
||||
local ok, found = pcall(function() return { peripheral.find(candidateType) } end)
|
||||
if ok and type(found) == "table" and #found > 0 then
|
||||
for _, dev in ipairs(found) do
|
||||
local ok2, nm = pcall(function() return peripheral.getName(dev) end)
|
||||
if not ok2 then nm = candidateType end
|
||||
if type(dev) == "table" and (dev.getBlockData or dev.getBlockName or dev.getBlockState or dev.isBlockPresent) then
|
||||
return dev, nm
|
||||
end
|
||||
end
|
||||
return found[1], candidateType
|
||||
end
|
||||
end
|
||||
for _, side in ipairs(rs.getSides()) do
|
||||
if peripheral.isPresent(side) then
|
||||
local ok, tp = pcall(function() return peripheral.getType(side) end)
|
||||
local wrapped = nil
|
||||
local ok2, w = pcall(function() return peripheral.wrap(side) end)
|
||||
if ok2 then wrapped = w end
|
||||
local match = false
|
||||
if ok and type(tp) == "string" then
|
||||
local tpl = tp:lower()
|
||||
for _, cand in ipairs(PERIPHERAL_TYPE_CANDIDATES) do
|
||||
if tpl == cand:lower() then match = true; break end
|
||||
end
|
||||
if not match and tpl:find("block") and tpl:find("reader") then match = true end
|
||||
end
|
||||
if not match and type(wrapped) == "table" and (wrapped.getBlockData or wrapped.getBlockName or wrapped.getBlockState or wrapped.isBlockPresent) then
|
||||
match = true
|
||||
end
|
||||
if match then
|
||||
return wrapped, side
|
||||
end
|
||||
end
|
||||
end
|
||||
local names = peripheral.getNames() or {}
|
||||
for _, name in ipairs(names) do
|
||||
local ok, tp = pcall(function() return peripheral.getType(name) end)
|
||||
local ok2, wrapped = pcall(function() return peripheral.wrap(name) end)
|
||||
if not ok2 then wrapped = nil end
|
||||
local match = false
|
||||
if ok and type(tp) == "string" then
|
||||
local tpl = tp:lower()
|
||||
for _, cand in ipairs(PERIPHERAL_TYPE_CANDIDATES) do
|
||||
if tpl == cand:lower() then match = true; break end
|
||||
end
|
||||
if not match and tpl:find("block") and tpl:find("reader") then match = true end
|
||||
end
|
||||
if not match and type(wrapped) == "table" and (wrapped.getBlockData or wrapped.getBlockName or wrapped.getBlockState or wrapped.isBlockPresent) then
|
||||
match = true
|
||||
end
|
||||
if match then
|
||||
return wrapped, name
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local function writeToFile(content)
|
||||
local handle = fs.open(OUTPUT_FILE, "w")
|
||||
if not handle then
|
||||
return false, "Cannot open output file"
|
||||
end
|
||||
handle.write(content)
|
||||
handle.close()
|
||||
return true, nil
|
||||
end
|
||||
|
||||
local function main()
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("=== BlockReader Advanced Display NBT Dumper ===")
|
||||
print("Output file: " .. OUTPUT_FILE)
|
||||
print()
|
||||
|
||||
local output = {}
|
||||
table.insert(output, "Advanced Display Block NBT Dump")
|
||||
table.insert(output, "Generated: " .. os.date("%Y-%m-%d %H:%M:%S"))
|
||||
table.insert(output, string.rep("=", 60))
|
||||
table.insert(output, "")
|
||||
|
||||
log("INFO", "Looking for block_reader peripheral...")
|
||||
local reader, readerName = findBlockReader()
|
||||
if not reader then
|
||||
log("WARN", "block_reader not found by exact type, dumping peripheral diagnostics...")
|
||||
local periList = enumeratePeripherals()
|
||||
table.insert(output, "[Peripheral Diagnostics]")
|
||||
table.insert(output, " Connected peripherals count: " .. tostring(#periList))
|
||||
table.insert(output, "")
|
||||
if #periList == 0 then
|
||||
table.insert(output, " (no peripherals found via peripheral.getNames())")
|
||||
table.insert(output, " Ensure modems/cables connect the block_reader to this computer.")
|
||||
else
|
||||
for i, entry in ipairs(periList) do
|
||||
table.insert(output, string.format(" [%d] name = %s", i, tostring(entry.name)))
|
||||
table.insert(output, string.format(" getType = %s", tostring(entry.getType)))
|
||||
if entry.matchHint then
|
||||
table.insert(output, string.format(" [HINT] %s", tostring(entry.matchHint)))
|
||||
end
|
||||
if entry.getNameCall ~= nil then
|
||||
table.insert(output, string.format(" getName() = %s", tostring(entry.getNameCall)))
|
||||
end
|
||||
if entry.methodsSorted then
|
||||
table.insert(output, string.format(" getMethods() = [ %s ]", table.concat(entry.methodsSorted, ", ")))
|
||||
end
|
||||
if entry.methodsHint then
|
||||
table.insert(output, string.format(" [HINT] %s", tostring(entry.methodsHint)))
|
||||
end
|
||||
table.insert(output, "")
|
||||
end
|
||||
end
|
||||
table.insert(output, "[Troubleshooting Tips]")
|
||||
table.insert(output, " 1. Verify the block_reader is adjacent/connected via modem.")
|
||||
table.insert(output, " 2. In CC, run: lua -e \"p=peripheral.getNames(); for _,n in ipairs(p) do print(n, peripheral.getType(n)) end\"")
|
||||
local fullContent = table.concat(output, "\n")
|
||||
local ok, err = writeToFile(fullContent)
|
||||
if not ok then
|
||||
log("ERROR", "Failed to write file: " .. tostring(err))
|
||||
return 3
|
||||
end
|
||||
log("SUCCESS", "Peripheral diagnostics written to " .. OUTPUT_FILE)
|
||||
local size = fs.getSize(OUTPUT_FILE)
|
||||
log("INFO", "File size: " .. tostring(size) .. " bytes")
|
||||
print()
|
||||
print("Press any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return 1
|
||||
end
|
||||
log("SUCCESS", "block_reader found: " .. tostring(readerName))
|
||||
table.insert(output, "BlockReader: " .. tostring(readerName))
|
||||
table.insert(output, "")
|
||||
|
||||
if type(reader.isBlockPresent) ~= "function" then
|
||||
log("WARN", "isBlockPresent() not available on this block_reader, trying direct read...")
|
||||
else
|
||||
local ok, present = pcall(function() return reader.isBlockPresent() end)
|
||||
if ok and not present then
|
||||
log("ERROR", "No block in front of block_reader. Place the Advanced Display block adjacent to the block_reader.")
|
||||
table.insert(output, "[Block Present Check]")
|
||||
table.insert(output, " isBlockPresent() = false")
|
||||
local fullContent = table.concat(output, "\n")
|
||||
local ok2, err = writeToFile(fullContent)
|
||||
if not ok2 then
|
||||
log("ERROR", "Failed to write file: " .. tostring(err))
|
||||
return 3
|
||||
end
|
||||
log("SUCCESS", "Result written to " .. OUTPUT_FILE)
|
||||
return 2
|
||||
end
|
||||
end
|
||||
|
||||
if type(reader.getBlockName) == "function" then
|
||||
local ok, name = pcall(function() return reader.getBlockName() end)
|
||||
if ok and name then
|
||||
table.insert(output, "[Block Registry Name]")
|
||||
table.insert(output, " " .. tostring(name))
|
||||
table.insert(output, "")
|
||||
end
|
||||
end
|
||||
|
||||
if type(reader.getBlockState) == "function" then
|
||||
local ok, state = pcall(function() return reader.getBlockState() end)
|
||||
if ok and state then
|
||||
table.insert(output, "[Block State]")
|
||||
if type(state) == "table" and next(state) == nil then
|
||||
table.insert(output, " (empty)")
|
||||
else
|
||||
table.insert(output, serializeValue(state, 0))
|
||||
end
|
||||
table.insert(output, "")
|
||||
end
|
||||
end
|
||||
|
||||
if type(reader.getBlockData) == "function" then
|
||||
local ok, data = pcall(function() return reader.getBlockData() end)
|
||||
if ok and data then
|
||||
table.insert(output, "[Block NBT Data]")
|
||||
if type(data) == "table" and next(data) == nil then
|
||||
table.insert(output, " (empty)")
|
||||
else
|
||||
table.insert(output, serializeValue(data, 0))
|
||||
end
|
||||
table.insert(output, "")
|
||||
else
|
||||
log("WARN", "getBlockData() failed or returned nothing: " .. tostring(data))
|
||||
end
|
||||
else
|
||||
log("WARN", "getBlockData() not found on this block_reader")
|
||||
end
|
||||
|
||||
local fullContent = table.concat(output, "\n")
|
||||
local ok, err = writeToFile(fullContent)
|
||||
if not ok then
|
||||
log("ERROR", "Failed to write file: " .. tostring(err))
|
||||
return 3
|
||||
end
|
||||
|
||||
log("SUCCESS", "NBT data written to " .. OUTPUT_FILE)
|
||||
local size = fs.getSize(OUTPUT_FILE)
|
||||
log("INFO", "File size: " .. tostring(size) .. " bytes")
|
||||
|
||||
print()
|
||||
print("Press any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return 0
|
||||
end
|
||||
|
||||
local exitCode = 0
|
||||
local ok, err = pcall(main)
|
||||
if not ok then
|
||||
log("ERROR", "Program crashed: " .. tostring(err))
|
||||
exitCode = 99
|
||||
end
|
||||
|
||||
if exitCode ~= 0 then
|
||||
print()
|
||||
print("Press any key to exit...")
|
||||
os.pullEvent("key")
|
||||
end
|
||||
|
||||
error("", exitCode)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user