Compare commits

...
73 Commits
Author SHA1 Message Date
Henry_Du 3c69740470 fix(audio,schedule): 音乐只扫 audio/music 避免错播短提示音假失败 + 远端主机完全离线时通过 HTTP REST + WS 主动推送拿到时刻表
- listMusicTracks/getPlaybackAudioByBaseName 彻底去掉 audio/beep fallback:空目录就空,提示音 wav(fc1/ksts) 不再出现在音乐列表,杜绝 API 返回 sent=1 但实际播放 0.25s 导致用户以为失败
- music enqueue 优先级从 0 下调到 -1,approaching/alert/arrival 永远干净抢占,equal-prio 不会把整首歌切成碎段
- 新增 4 套取时刻表的兜底方法(最远端主机完全加载不到也能用):
  1. POST/GET /api/stations/:name/schedule — 管理员或远端屏幕 HTTP 拉/手动注入 upcomingTrains+currentTrain,完全不依赖本地 Lua block-reader
  2. GET /api/stations/schedule(?line=CE) — 批量按线路拉取全量时刻表摘要
  3. 同站点 schedule TTL 10min 鲜度字段 scheduleIsStale / scheduleFreshForMs 返回给客户端自行判断
  4. 每次 heartbeat/station_schedule_sync 触发 WS 广播 station_schedule_available + same_cable_group_schedule 推送到同站/同线缆分组全部连接(不只 master)
- normalizeStationRecord + POST schedule 新站自动补 lastScheduleSyncedAt=0 避免 500
2026-08-17 20:10:54 +08:00
Henry_Du 9c5d6e9659 fix(audio): 修复线缆 token 死锁(holder=CE-01) + 同组 approaching 等优先级饿死 + 线缆太长前端主机听不到
1. releaseCableToken(L688): 去掉 audioId 严格匹配, 只要 stationName 对就释放
   修复: CE-01 因 audioId 在 setTimeout/release 分支里 miss-match, token 永远不释放,
   导致同组其它站 approaching 100% 被 deny reason=lower_priority holder=CE-01

2. acquireCableToken(L549): 新增 equal-prio-timeout 抢占, 同一线缆分组多站连续
   approaching(p=2) 时不会再出现 holder 永远持住的死锁: 旧 token 持有超过
   max(30s, durHint+30s) 后, 等优先级新请求可强制切换 (日志 WARN: equal-prio switch)

3. Cable token watchdog(每20s扫描): holder 站点所有连接断开立刻强制释放
   (holder_offline); 最长持有 10min 兜底强制释放 (max_hold_timeout). 彻底避免
   音乐循环/循环临时广播 崩溃后 token 泄漏永远释放不了的情况

4. 线缆太长前端主机听不到 (架构修复):
   旧方案: sendAudioBufferToStation / sendLiveWavChunksToStation 里全部 WS 消息
   (audio_start + audio_chunk + audio_end) 只发给站点唯一 masterWs 连接, 若
   master 物理位置在线缆尾部, 中间节点断链, 线缆最前面主机就完全收不到音频
   新方案: 新增 sendWsToAllConnectionsOfStation + sendBinaryToAllConnectionsOfStation
   (L503-L520), 所有 (audio_start/end, cable_token_granted, audio_preempt) JSON 消息
   及所有 binary WAV 分块, 广播到该站点 getAllStationConnections 返回的 *所有*
   已连接 WS node, 每台主机独立从云端服务器拿一份流, 不再依赖线缆上 master 转发
   对 10 站长线缆场景, 只要 node 还连着云端, 就肯定能收到音频, 彻底避免前端
   主机因线缆中段节点断网/掉电听不到音频的故障

端到端单元测试 5/5 PASS:
  A releaseCableToken audioId 不严格匹配 PASS
  B p=2 approaching dur=8s age=70s 时 equal-prio 切换 PASS
  B2 p=2 age=5s 时不抢占 PASS (防抖动)
  C watchdog holder_offline 检测后立刻释放 PASS
  D 同站 4 个 node 同时都收到 audio_start 消息 PASS (前端主机不漏消息)

v1.0.114
2026-08-17 19:48:36 +08:00
Henry_Du dc8f94ce22 fix(broadcast): 修复播放音乐失败 + 临时广播失败 (group.memberNames nodeId/stationName 语义混淆)
根因: getStationGroups (L881) + resolveStationGroup 单站分支 (L942) 错误地把
  memberNames = nodes.map(n => n.nodeId || '')
填成了 nodeId 数组 (n_msx5ieg9_9iy1dw 这类), 但下游 4 处消费方
  /api/music/start, /api/music/stop, /api/temp-broadcast/publish
全部把 memberNames[i] 当 stationName 用:
  getStationWs(memberName) → stations[n_xxx] = undefined → null
  stopStationMusic(memberName) → 按错 key 清队列
  stationMusicStates.set(memberName) → 按错 key 存状态
  enqueueStationAudioTask(memberName) → 按错 station 名入队

后果: 所有成员永远被判为 offline (reason=offline), 所以 API 返回 sent=0 failed=1,
音乐和临时广播 100% 无法触发推流, 端到端链路完全中断.

修法: 对 CC station group 语义来说 memberNames = 该 group 包含的每个子站的
stationName 数组 (同 cable group 的 stationNames 语义). 单站 selector 只包含
一个成员 [st.name], 无论这个站挂了多少个 node 连接, 最终 enqueue 都是按
stationName 维度合并到同一个队列, 不会重复推多次.

端到端验证通过:
  MUSIC START: sent=1 failed=0 ✓
  TEMP PUBLISH: success=true sent=1 failed=0 ✓
  Live WAV chunk stream start/end 两条日志完整闭合, 说明 ffmpeg 分块
  → WS 推送 → 结束 全链路走通.
2026-08-17 19:34:10 +08:00
Henry_Du 8c9c72fcba feat(display): 优化外接显示器小屏自适应显示方案
- 新增 textScale 自动分级:根据显示器实际尺寸在 1.0/0.75/0.5 间自适应选择
- 实现 L/M/S 三档布局模式:L(大屏双栏完整)、M(中屏单栏精简)、S(小屏极简核心信息)
- 小屏模式隐藏日志、即将到达列车等次要信息,优先展示当前列车和发车倒计时
- 音频状态标签在小屏下进一步压缩缩写 (Sta Ann / Sp Ann)
- 版本升级至 v1.0.113
2026-08-17 19:17:40 +08:00
Henry_Du 0335e245e1 fix(audio): 修复终点站/下一站漏播 + 广播卡顿跳音 + 音频wav多变体查找
- 新增 findAudioFileSmart 多变体模糊匹配: ch-/ja- 语言前缀剥离, CE- 线路前缀剥离,
  连字符/下划线/空格互转, station-/line-/voice-/stn- 通用前缀补全, 每 token 生成
  16+ 文件名变体匹配, 彻底解决 ch-ce-13 命不中 13.wav/station-ch-ce13.wav 漏播
- 新增 getAudioDirIndex 目录文件索引缓存 (mtime+size 失效), 避免每次查找递归读盘
- 补全 buildConcatListFile 致命缺失函数, 之前 local HLS 模式直接 ReferenceError
- 重写 clearLocalHlsStreamDir: 只清理 30min 前的旧 TS, 新广播使用唯一前缀
  b_<ts>_<rand>-seg-<n>.ts, 新旧广播段永不重名, 修复新广播删/覆盖旧段导致的卡/跳音
- server.listen 启动时预执行 ensureSilenceWav, 避免第一次广播阻塞生成静音段开头卡
- handleApproaching/Arrival/Departure 全链路结构化 DEBUG 日志: Station-Audio-Code
  (destCode/nextCode 提取链) -> audio tokens 完整序列 -> Sequence resolve 清单
  (searched 目录+missing 列表), 以后再漏播四行日志 100% 定位根因
- resolveStationAudioCode 3 级兜底链不变, 确保 destCode/nextCode 正确进入 audio seq
2026-08-17 18:34:15 +08:00
Henry_Du 1179130656 fix(cablemanager): 修复线缆分组详情编辑时成员被自动刷新打回且保存无效的问题
根因:
- cablemanager 每 900ms scheduleAutoReload() 通过 WS state_update 触发 reloadAll({silent:true}),之前 reloadAll 在 detail-view 场景下每次都会无条件把 detailTempMembers 从服务器读回、selectedAvailable/selectedMembers 清空,所以用户刚把某站台 » 加进成员,下一次被动刷新到了就立刻还原(视觉上就是「点过去又跳回来」)
- 保存 saveDetail() PUT 本身是能成功的(server PUT /api/audio-cable-groups/:id 的 stationNames 接口契约没问题),但保存之后 loadGroups() 拉完数据又立刻触发 broadcastUpdate()→WS state_update→scheduleAutoReload→又一次 reloadAll()→把刚保存的 detailTempMembers 用服务器值覆盖掉,用户看起来就是「保存无效」
修复 (cablemanager.html reloadAll):
1. 保存 refresh 前后的 prevGroups/prevStations 快照,只有 name/color/description 或站台列表真的变了(groupListChanged/ stationListChanged)才重绘列表和快选,避免心跳、lastSeen、tokenHolder 这些纯瞬时字段反复重绘
2. 区分 silent (被动自动刷) vs 非 silent (用户手动点刷新):
   - 非 silent:保持原语义,完全用服务器值覆盖所有本地编辑(用户主动点刷新就是要回到当前后台状态)
   - silent:
     * 只在 metaChanged 时同步 name/description/color(并且不抢焦点的 input,避免正在打字被重置)
     * 只在 membersChanged(后台 stationNames 真的跟上次不一样)时才同步成员到 detailTempMembers,否则保持用户未保存的成员编辑不变;同时清理 selected 中已不存在的名字
     * renderDualLists 仅在 membersChanged || stationListChanged || metaChanged 才调用,其余时间完全不重绘详情
3. 服务器 PUT 端再验证过 stationNames 接口契约没问题:前端 saveDetail 发 {name,color,description,stationNames} 对得上 server PUT handler,保存完 /api/audio-cable-groups 读回 stationNames 正确
APP_VERSION_TEXT bump 到 v1.0.111
2026-08-17 18:11:57 +08:00
Henry_Du a58632272e fix(announce): 修复进站预告音频终点站名字被跳过的问题
根因链:
- station_broadcast.lua 通过 BlockReader / CreateTarget 两个分支触发 train_approaching 发送时,只带 destination (中文名/站名),未带 destCode;server 端只能走 getStationCodeFromName(destination) 正则解析,正则没匹配到就会得到 UNK → ch-UNK.wav / ja-UNK.wav 不存在 → 整条目的地段音频被 ffmpeg concat 跳过
- 对比 train_arrival (Peripherals.station 分支) 是有 destCode 字段的(由 Utils.getStationCode + schedule 解析得到),所以到站广播终点站名能正常播出,预告(走 Display/BlockReader 分支)不能
修复:
1. station_broadcast.lua 四处 train_approaching / train_alert Network.send 全部补 destCode = Utils.getStationCode(destination):
   - L4053 (alert, blockreader) / L4091 (approach, blockreader)
   - L4221 (alert, createTarget) / L4257 (approach, createTarget)
2. server.js 新增 resolveStationAudioCode(rawCode, rawName) 三级兜底解析:
   - L1: 直接用 raw destCode(优先级最高,客户端算好的)
   - L2: 正则解析 destination 名字抽取 code(parens/dash/下划线模式)
   - L3: 把 destination 名拿去 findStationByFlexibleName 已注册站台表里 fuzzy 匹配,取其 code
   - 解析不到就打 WARN 日志,提示会跳过这一段
3. handleTrainApproaching / handleTrainArrival / handleTrainDeparture 三个 handler 的 destCode / nextCode 全部切到 resolveStationAudioCode 统一入口,不再各写一遍正则,所有预告/到站/发车音频用同一条解析链,保证结果一致
APP_VERSION_TEXT bump 到 v1.0.110
2026-08-17 18:05:21 +08:00
Henry_Du fb17de6493 fix(cablemanager): 修复 cablemanager 无法操作 + 一直刷新的问题
- 根因1: GET /api/audio-cable-groups 服务端返回 {groups: list},但前端 loadGroups() 读 data.audioCableGroups → 永远读到 undefined → 始终空列表
  修复: 服务端 L3447 同时返回 groups 和 audioCableGroups 两个别名;前端 loadGroups() 优先读 audioCableGroups,不存在则回退 groups
- 根因2: WebSocket onmessage 收到 init_state/state_update 立即 reloadAll(带 toast),若事件频繁会反复 re-render + 连弹『数据已刷新』toast → 看起来一直刷新且输入框总被重置
  修复: 新增 scheduleAutoReload() 节流(两次自动刷新之间至少间隔 900ms);且自动刷新统一走 reloadAll({silent:true}) 不弹成功/失败 toast;仅用户点刷新按钮才弹提示
- 本地实测: PORT=8999 启动 server 后 /api/stations → {success:true, stations:[]},/api/audio-cable-groups → {success:true, groups:[], audioCableGroups:[]},两个端点均 200 且响应结构含双别名 key
- APP_VERSION_TEXT bump 到 v1.0.109
2026-08-17 17:55:09 +08:00
Henry_Du a3ff8664ee fix(station_broadcast): 修复 Utils 为 nil 导致启动崩溃的前向引用问题
- 原因: Lua local 变量按声明顺序起作用域,Peripherals.init() 在 L96 定义(体内 L188 引用 Utils.log),而 local Utils={} 原本在 L195 才声明,导致函数体内引用成 _G.Utils(全局,nil)
- 修复: 在文件 L71 附近集中前向声明所有顶层模块(Peripherals / Utils / Display / Audio / Network / Main),删除各章节头部重复的 local Module = {},保证 Lua 解析时能正确捕获 local 上值
- APP_VERSION_TEXT bump 到 v1.0.108
2026-08-17 17:39:15 +08:00
Henry_Du 5e466f4553 fix(ui): 补齐 generator.html 和 listmanager.html 的问号剥离兜底
- generator.html: 新增 ds() 钩子,在 renderList / batch 预览 / showToast 三个渲染点统一问号剥离
- listmanager.html: 新增 ds() 钩子,在 renderList / renderSequence / 自定义 select 选项 / showToast 四个点统一问号剥离
- APP_VERSION_TEXT bump 到 v1.0.107
2026-08-17 17:32:01 +08:00
Henry_Du 4c049050ce feat(ui): 显示层去问号化 + 网页统一 CableManager 风格 + 补齐 select 下拉样式
需求:
1. 客户端与网页端所有展示到屏幕上的内容,去掉 ???、Unknown、未知版本、未选择、未合成、暂无在线 等占位问号/疑问式字符;
2. 网页端所有页面统一 cablemanager.html 那套风格(色板、硬边边框、圆角 6/8、顶栏渐变字/下边框分隔、shadow、body 径向渐变);
3. 补齐 <select>/option 下拉样式(appearance none、内联 SVG 箭头、padding-right 留位、ring focus、option 背景与主色一致),不再沿用系统默认。

改动:
1. station_broadcast.lua:
   - 新增 Utils.sanitizeDisplay:统一把任意显示字符串内的 ? 全部剥离并 trim 空白,空串返回 '-'
   - Display.render 里 truncL / truncR 从直接 tostring 改为走 sanitizeDisplay,保证所有监视器内容 0 问号
   - Display.render 底部 3 条日志(drawLeft)和 Display.renderTerminal 顶部日志全部走 sanitizeDisplay

2. CloudServer/public/:
   - index.html:
     * :root 色板对齐 cablemanager(--bg-panel #121214、--text-primary #f4f4f5、--text-muted #a1a1aa、加 --bg-input/--ring-color、radius 8px)
     * body 改 radial-gradient 背景
     * .btn / .btn-primary / .btn-danger 统一成 cablemanager 同款尺寸(10x14、radius 6、font-weight 500/600)
     * 新增 input[type=text|number|password] + textarea + select + option 完整样式组(cablemanager 同款)
     * 清理 display 层的 Unknown/未知/未发布:
       - stationVersion || '未知版本' 改为 || ''(空版本号直接省略,不写未知)
       - seqHtml fallback 从 'Unknown sequence' 改为 ''
       - localVersion 从 '未知' 改为 '-',targetVersion/publishedAt 从 '未发布' 改为 ''(空即省略)
   - infoboard.html:
     * :root 对齐 cablemanager,加 --bg-input、--ring-color、加 body radial-gradient
     * 车次目的地 fallback 从 '未知终点' 改为 ''(没终点就空白)
   - music.html:
     * :root / body / layout-wrapper / topbar / title / btn / panel / card-title / badge / input+select+textarea 全对齐 cablemanager
     * track_badge 默认占位从 '未选择' 改空(badge:empty 直接不显示)
     * 空站台列表占位从 '暂无在线/已注册站台' 改为 '-'
     * 补了完整 scrollbar 样式
     * 最外层 body 内加 .layout-wrapper(对齐 cablemanager max-width 1280 + 居中)
   - tempbroadcast.html:
     * 所有风格同步 music.html(与 cablemanager 一致)
     * build_badge 默认 '未合成' 改为空,失败分支也清空(只在合成成功/合成中写入),同时 .badge:empty {display:none}
     * 空站台列表占位 '-'
     * 补 select 下拉外观样式

客户端 bump v1.0.105 -> v1.0.106。
server.js node -c 通过;所有修改 HTML 仅改 style 段与少量占位文本,不影响 API 契约。
2026-08-17 16:17:38 +08:00
Henry_Du e0a1252db6 chore(ui): 首页导航黄色按钮从 Full Info Board 换成 Cable Manager 入口
把 index.html 文件管理器工具栏的黄色 var(--warning) 按钮:
- href: /infoboard.html -> /cablemanager.html
- 文案: Full Info Board -> Cable Manager
- 保留 target=_blank(新标签打开线缆分组管理页,避免丢当前文件视图)

客户端 bump v1.0.104 -> v1.0.105
2026-08-17 15:53:13 +08:00
Henry_Du b2c415f253 fix(server): 彻底放弃 :path(*) 等参数化通配路由,改用 app.use + req.path 手动截取,解决 path-to-regexp v8.3 启动崩溃
服务器环境 path-to-regexp 实际版本是 8.3.0(Express 5.2.1 内置),
该版本既不接受裸 '*'、也不接受 ':name(.*)' 自定义正则、也不接受
':name(*)' 单段通配修饰符,参数路由只要带修饰符就会在 new Layer
阶段直接抛 PathError。

解决方案(这次本地用真实 node_modules 实装 require 整份 server.js
模拟启动,已确认不崩溃,http.createServer 正常被调用 3 次):
- 7 条 /media/* 路由全部删除 app.get('/prefix/:path(*)', ...)
- 改为 app.use('/prefix', middleware) + 在中间件里用 req.path
  手动去掉前置 '/' 得到相对路径,再 decodeURIComponent();只处理
  GET 请求,其他方法 next()
- static 命中优先(fallthrough),未命中时继续调用原
  sendBinaryFile / sendBinaryFileFromCandidates 兜底,保持
  Content-Type / Range / Accept-Ranges / Content-Length 等头
  与旧行为完全一致
- 涉及:general / beep / music / voice / line / station / temp

客户端 bump v1.0.103 -> v1.0.104
2026-08-17 15:49:59 +08:00
Henry_Du a6d9b6c418 fix(server): 修复 serve-static 不接受数组 root、改为按请求逐个挂载,避免启动时 TypeError: root path must be a string
上一轮把 getBeepAudioDirs() 这个数组直接塞进 express.static(),
但 serve-static 签名要求 root 必须是字符串,会在 app 启动注册
中间件时直接抛错(甚至在接收请求前)。

修复方式:
- /media/audio/general 和 /media/audio/beep 改为动态中间件:
  请求进来时再依次对每个目录调用一次 express.static(root, {fallthrough})
- 单目录的 music/voice/line/station/temp 保留 express.static
  但统一包一层 String(xxx || '') 保证就算路径解析异常也是空
  字符串而不是 undefined
- 后备的 7 条 app.get(':path(*)') 路由不变,仍然负责 sendBinaryFile /
  sendBinaryFileFromCandidates 兜底(含 range、Content-Length 等头)

客户端 bump v1.0.102 -> v1.0.103
2026-08-17 15:44:38 +08:00
Henry_Du 5c462a4d09 fix(server): 改用 Express 5 + path-to-regexp v7 兼容的通配写法,避免路由启动崩溃
Express 5 内置的 path-to-regexp v7 不再支持裸 '*',也不支持
自定义正则 '.*'(会报 Missing parameter name)。Express 5
官方兼容的单段全通写是 ':name(*)',并且路由注册会在 app 启动
第一时间完成(出错会直接崩)。

同时为了兼容旧 URL 行为,保留 express.static 前置挂载 +
显式 app.get 后备两条路径,因为 Audio 模块可能需要带
Cache-Control / Range / Content-Type 等头的 custom sendBinaryFile
处理(例如 station 动态裁剪音频),所以不能直接用 static 替代。

修复 7 条路由:
  /media/audio/general/:path(*)
  /media/audio/beep/:path(*)
  /media/audio/music/:path(*)
  /media/audio/voice/:path(*)
  /media/audio/line/:path(*)
  /media/audio/station/:path(*)
  /media/temp/:path(*)

参数解码加 decodeURIComponent(),兼容中文/空格文件名。
客户端 bump v1.0.101 -> v1.0.102
2026-08-17 15:41:36 +08:00
Henry_Du fc66e43379 fix: 修复新版 path-to-regexp 不兼容 '*' 通配符导致启动崩溃
新版 express/router 依赖的 path-to-regexp 不再接受裸 '*' 路径语法,
改为命名通配参数 :relPath(.*),并同步把 req.params[0] 改为
req.params.relPath。涉及 7 条 /media/* 静态路由:
- /media/audio/general/*
- /media/audio/beep/*
- /media/audio/music/*
- /media/audio/voice/*
- /media/audio/line/*
- /media/audio/station/*
- /media/temp/*

客户端版本号 bump v1.0.100 -> v1.0.101
2026-08-17 15:38:41 +08:00
Henry_Du c9d8994942 chore: 版本号升到 v1.0.100 2026-08-17 15:33:54 +08:00
Henry_Du 4c7d94f2c9 feat(A1+B1): 引入站台多主机识别、音频线缆分组互斥令牌、本地Modem仲裁、线缆分组管理WebUI
服务端 A1 改动(CloudServer/server.js):
- stations 改为 nodes[] 多连接存储,同站台名的多台 CC 主机归入同一组(纯 stationName 精确分组)
- 新增 getStationMaster():hasStationPeripheral + lastSeen 加权选 Master,音频只下发给 Master
- 新增 audioCableGroups 数据结构(持久化到 server-data.json)
- 新增 acquireCableToken/releaseCableToken:同线缆组全局互斥,高优先级发送 audio_preempt 抢占
- 新增 7 路由 REST API:/api/audio-cable-groups CRUD + 批量/单个站台成员管理 + PUT /stations/:name/cable-group 直接改组
- sendAudioControl 给同站组所有节点全量下发(stop/状态同步)
- init_state / state_update 携带 audioCableGroups 给前端
- WS close 自动 removeStationWs 清理节点 + 释放令牌

客户端 B1 改动(station_broadcast.lua v1.0.99):
- Peripherals 新增 wiredModems 扫描(排除无线),自动 rednet.open
- 新增 LocalBus 命名空间:
  - station_hello 每 10s 广播 + 30s 超时清理邻居
  - 同站台名自动组 stationGroup,按 hasStationPeripheral + nodeId 升序选 Master
  - 新增 Utils.isLocalStationMaster(),所有列车上报(arrival/approaching/alert/departure/schedule_sync)仅 Master 执行,彻底解决重复广播
  - acquireLocalToken:audio_probe -> 80ms 收响应 -> 必要时 audio_preempt 抢占 -> token_grant
  - 令牌持有期间每 2s 广播 audio_playing 心跳,30s 超时自动判死回收
- Audio.playerLoop 在 playItem 前获取本地令牌,失败则跳过该音频
- Audio.stop() 自动释放本地令牌
- WS 新增下行处理:audio_preempt / cable_token_denied / station_group_state
- station_identity.cfg 支持 audio_cable_group= 字段,saveStationIdentity 写回
- Network.register/heartbeat 携带 cableGroup 供服务端回填分组

Web 管理端(CloudServer/public/cablemanager.html,单文件无外部依赖):
- 极简硬边风格,对齐 listmanager.html 配色和字体
- 分组网格卡片:色标/名称/描述/成员数/当前令牌持有者 + 编辑删除
- 新增/编辑 Modal:名称+描述+8色预设色板+自定义#RRGGBB
- 分组详情双栏 Dual List:左栏全部站台(搜索+在线状态+在其他组标签)/ 右栏成员,中间批量添加移除按钮
- 顶部快捷操作:站台下拉+组下拉+确认,直接 PUT /stations/:name/cable-group
- WebSocket 监听 state_update 实时刷新,Toast 提示反馈
2026-08-17 15:32:20 +08:00
Henry_Du 6ac05d3a84 feat(server): 实施 A1 方案 - 多连接存储、站台分组 Master 选举、audioCableGroup 互斥令牌机制
- stations 存储改造:stations[name] = { name, code, platform, audioCableGroup, version, lastSeen, nodes: [] } 结构,每个 node 含 ws/hasStationPeripheral/stationPeripheralSide/hasBlockReader/hasCreateTarget/identityMode/nodeId/wsId/connectedAt
- 新增 audioCableGroups:{ id, name, color, description, stationNames: [] } 分组,及 audioCableTokens 互斥令牌 Map(cableGroupId -> { holderStationName, holderAudioId, holderPriority, acquiredAt, cancelToken })
- 新增核心函数:upsertStationNode(按 wsId 去重更新 node、聚合计时表级 schedule)、removeStationWs(WS 关闭时清理 node + 释放令牌 + 下发 group_state)、getStationMaster(hasStationPeripheral=1000加权 + lastSeen小数加权 两轮选举 OPEN ws)、getAllStationConnections、getGroupKeyForStation(纯 stationName 分组,不再 code@@platform)、acquireCableToken(无持者直给/同 holder 更新/高优先级 audio_preempt + 200ms 延迟抢占/低优先级拒绝+denied 消息)、releaseCableToken
- 发送策略改造:sendAudioControl 遍历组内所有节点 ws 全发(同名站同步 stop 等指令);sendAudioBufferToStation/sendLiveWavChunksToStation 只发送 master ws,发送前后 acquire/release token,durationSec 后 setTimeout 延迟释放,audio_start 消息新增 cableGroupId 字段
- enqueueStationAudioQueue 增加 cableGrp 兜底释放;loadData 兼容旧 stations 单值结构导入 nodes=[] 数组新结构 + audioCableGroups 双向回填;saveData 剥离 nodes 中 ws 对象后保存,新增 audioCableGroups 字段持久化
- WS 层改造:wss close 事件调用 removeStationWs;console_connect init_state 增加 audioCableGroups 数组;handleStationRegister/handleHeartbeat/handleScheduleSync 改为调用 upsertStationNode;station_register_ack 返回 nodeId/audioCableGroup
- broadcastUpdate state_update 增加 audioCableGroups 字段;新增 WS 下行 audio_preempt / cable_token_granted / cable_token_denied / station_group_state 四种消息类型
- getStationGroups 完全重写:按 stationName 精确分组,返回 members/nodes 数组(每个 node 带 nodeId/wsId/online/isMaster/connectedAt/lastSeen/hasStationPeripheral/hasBlockReader/hasCreateTarget/identityMode/tickRate/memberTags),增加 masterNodeId、audioCableGroup{id,name,color}、cableGroupId 字段;resolveStationGroup memberNames 返回 nodeId 列表
- REST API 新增 9 个路由:GET/POST/PUT/DELETE /api/audio-cable-groups,POST /api/audio-cable-groups/:id/stations(批量加站台,含 prev cableGroup 自动清理)、DELETE /api/audio-cable-groups/:id/stations/:stationName、PUT /api/stations/:name/cable-group(stations.audioCableGroup 与 group.stationNames 双向同步),stations DELETE 自动同步 cable group 清理
- 兼容性:旧 stations[name].ws 统一改为 getStationMaster(name).ws;TTS、HLS、temp-broadcast、音乐播放、version 管理等功能不受破坏;Lua 客户端版本号提升至 v1.0.99
2026-08-17 15:15:23 +08:00
Henry_Du 55f0312314 feat(audio): 支持斜杠格式的车站编号和模糊搜索匹配
- 修改TTS音频导入逻辑,保留编号中的斜杠(/)格式(如 CH-01/CE-12),自动创建子目录
- 移除generator.html中单文件和批量导入时的斜杠转横杠逻辑
- 新增sanitizeStationCode和ensureDirForFile辅助函数,确保路径安全
- 重写findFileCaseInsensitive,支持递归子目录搜索与末尾段模糊匹配
- 新增listAllFilesRecursive和resolveAudioPathWithFuzzy工具函数
- 修改getPlaybackAudioByBaseName/findWavByBaseName支持模糊搜索查找
- 修改resolveManagedPath允许子路径访问,getManagedFiles递归扫描子目录
- 媒体路由从:file参数改为通配符/*形式,safeRelativeToBase确保路径安全
- 升级版本号至v1.0.98
2026-08-17 14:54:21 +08:00
Henry_Du 849b43af39 fix: 修复音乐和临时广播无法播放(AUDIO FREE)的问题
- 重写 waitForInterruptibleDelay/waitForNativeSpeakersEmpty 使用 os.clock()
  轮询,替换会抢占 websocket_message 事件的 os.pullEvent()
- 重写 queueSamplesSynchronized/playChunkOnSpeakers 重试循环使用
  os.sleep() 轮询,不再调用 pullEvent 吞掉音频分块事件
- 重写 Audio.createStream().read() 与 Audio.playerLoop 使用 sleep 轮询,
  不再使用 os.pullEvent('audio_chunk')/'audio_queue' 抢事件
- 修复 Audio.playItem 将 wav-stream/wav-stream-live 错误路由到
  playPcmS16LEItem,改为路由到 playWavStreamItem(服务端分块每块
  带完整 44 字节 WAV 头,需 decodeWavChunkToSamples)
- Network.messageLoop audio_start 分支将 wav-stream-live 加入流式
  接收判定,避免走 chunks 缓冲逻辑
- 版本号升级至 v1.0.97
2026-08-17 14:30:54 +08:00
Henry_Du e9819d4894 fix(multiplayer): normalize CRN BlockReader NBT structure with unwrapNbtObject helper to handle SP vs MP differences (single-player wraps every nested object as { [1] = obj } one-element arrays, while multiplayer uses direct maps without [1] wrapper)
- New unwrapNbtObject(obj): detects whether the table actually has a [1] wrapper AND outer table has exactly 1 key AND the inner [1] is useful, then returns inner object; otherwise returns obj unchanged - works transparently for both SP and MP structures
- Replace ALL hard-coded [1] dereferences in BlockReader parsing:
  - stop.Station -> unwrapNbtObject(stop.Station)
  - st.RealTimeStation -> unwrapNbtObject(st.RealTimeStation)
  - st.ScheduledStation -> unwrapNbtObject(st.ScheduledStation)
  - rtStation.StationInfo -> unwrapNbtObject(rtStation.StationInfo)
  - schStation.StationInfo -> unwrapNbtObject(schStation.StationInfo)
  - stop.Train -> unwrapNbtObject(stop.Train)
  - stop.Stopovers[i] entries: deep-unwrap if table, try StationName/Name fields to get string destination from potential NBT compound stopovers
  - tr.Status entries: unwrap each element before reading as string
- This eliminates the MP-only failure where st=nil (because SP-style Station[1] didn't exist) causing the whole TrainStop loop to skip inserting anything into newUpcoming and render UPCOMING as ???

Bump to v1.0.76
2026-08-16 14:35:18 +08:00
Henry_Du 66683f6d13 fix(upcoming): prevent UPCOMING freeze caused by NBT parsing errors in CC environment
- Eliminate empty-then block in Stopovers fallback logic: rewrite nested if/else with empty body to elseif chain to avoid parser error in reduced CC Lua dialect (was silently caught by pcall causing newUpcoming to remain empty)
- Add pcall protection to the standalone CreateTarget.dump() path (previously unprotected), with fallback to empty lines table on failure
- Add explicit WARN log on BlockReader.getBlockData pcall failure so user can diagnose errors via terminal output instead of silent data=nil
- Wrap each TrainStop iteration inside its own pcall so a malformed entry cannot abort the whole loop; failures are logged with stop index

Bump to v1.0.75
2026-08-16 14:23:43 +08:00
Henry_Du 50746ce766 fix(nbt): unwrap single-element arrays in CRN BlockReader NBT parsing (Station[1]/RealTimeStation[1]/ScheduledStation[1]/StationInfo[1]/Train[1]) to get real TrainName/Destination/StationName/Platform; fallback destination to precise StationName code (e.g. CE-03 2) when Destination is invalid or is CRN aggregated tag name. Bump to v1.0.74 2026-08-16 14:15:24 +08:00
Henry_Du 0de59531f9 test(br): diagnose block_reader instead of bailing
When no block_reader is found by exact type name, list all connected
peripherals (getType/getMethods/getName) into the output txt so the real
peripheral name can be identified on the target machine.
2026-08-16 14:07:13 +08:00
Henry_Du 926db04a3e test: add block_reader NBT dumper for advanced display
Reads the adjacent block (Advanced Display) via Advanced Peripherals block_reader
and writes registry name, block state, and NBT data as structured text to
blockreader_display_nbt.txt.
2026-08-16 14:02:38 +08:00
Henry_Du ae213a84a1 feat: 从机站台吸附识别与时刻表自动同步 v1.0.73
- Lua端: 新增定期外设重扫描(PERIPHERAL_SCAN_INTERVAL=15s),支持热插拔站台方块
- Lua端: 新增定期身份同步,站台外设热插/热拔时自动刷新身份并重新register
- Lua端: 新增独立的schedule sync coroutine与station_schedule_sync消息类型(SCHEDULE_SYNC_INTERVAL=10s)
- Lua端: 扩展heartbeat携带hasStationPeripheral/stationPeripheralSide/hasBlockReader/hasCreateTarget/identityMode/upcomingTrains/currentTrain等字段
- 从机若吸附有站台方块(train_station外设),则自动进入主机逻辑: 识别到发、停靠列车检测,数据通过WS推送到服务器
- server端: 新增assignDeviceFields工具函数统一写入设备字段与时刻表
- server端: 新增handleScheduleSync处理station_schedule_sync消息,数据写入stations[name]并broadcast
- server端: 扩展handleHeartbeat与handleStationRegister,均调用assignDeviceFields存储设备状态和时刻表摘要
- server端: 扩展getStationGroups分组视图,新增hostCount/slaveCount/hasAnyStationPeripheral/upcomingTrains/currentTrain/members详细信息
- 时刻表合并: 同一站台组多节点的upcomingTrains按trainName@@dest@@platform去重合并; currentTrain优先主机数据
- 版本升级: v1.0.72 -> v1.0.73
2026-08-16 13:33:10 +08:00
Henry_Du cd5b1d47b0 fix(crn): 修复CRN聚合站(如「千叶」)在CC中文环境下变????导致校正匹配失败引发终点站广播漏播
- 新增 extractAlphaNumCodes / hasSharedAlphaNumCode: 提取并比对字母数字代码(CB-03,CE-09等), 在中文变问号乱码环境下仍可通过代码完成跨源匹配
- looksLikeValidDestinationText: 放宽校验, 若问号超过50%但仍存在有效字母数字代码(如「????CB-03 6」)时不直接判为无效——保留代码供后续 hasSharedAlphaNumCode 匹配
- tryCorrectUpcomingEntryWithCurrentTrain:
  1) 新增 etaSeconds 形参用于识别在站列车(eta≤0.5s), 据此触发校正
  2) 新增 nearbyPlatform 条件: 站台号数字差≤1 时也进入校正候选
  3) 新增 sharedCodeDest / sharedCodeTrain: 即使车次/站台全不匹配, 只要 dest 或 id 含有与权威值相同的字母数字代码即可触发
  4) 新增兜底触发: (dest或id无效且eta=0)  |  (双无效且相邻站台)  —— 专门覆盖CRN聚合名????这种彻底无代码的场景
  5) 新增校正分支: 只要 sharedCodeTrain/sharedCodeDest 命中且 (etaAtStation|samePlatform|invalid) 即直接覆盖原值, 无需 fuzzy match 再次通过
- checkIsAtStationWithCurrentTrain:
  1) 无 sameTrain 时用 hasSharedAlphaNumCode 二次匹配车次
  2) 若车次/目的地双乱码 + 站台相邻, 也视为同车
  3) 乱码目的地分支新增 hasSharedAlphaNumCode 成功即判停靠成功
- BlockReader/CreateTarget 两处预校正均移至 etaSeconds 计算之后, 并将 eta 传入函数, 确保看门条件里的 etaAtStation 可生效
- 全局校正循环也传入 entry.eta

版本升级至 v1.0.72
2026-08-16 13:25:39 +08:00
Henry_Du 716a23cad5 fix(data): 修复权威时刻表与NBT显示粒度不一致导致终点站漏播(「千叶」聚合名 vs「千叶CB-03 6」具体站台)
- tryCorrectUpcomingEntryWithCurrentTrain: 校正逻辑从『仅无效乱码才覆盖』改为『同站fuzzy匹配且字符串不等即覆盖』,使schedule中的具体站台名优先于NBT中的粗糙聚合名
- 新增trainName的同粒度校正:车次信息不一致时,同车次fuzzy匹配成功即采用权威id
- BlockReader/CreateTarget两处预校正:解除『looksLikeValidDestinationText为false才替换』的限制,只要tryCorrect...返回的值与原值不同即替换——确保聚合名「千叶」这类『有效但粗糙』的文本也会被升级为具体站台名
- announceKey生成前的预校正与upcomingTrains赋值前的全局校正双保险,使冷却去重key与显示/广播目的地均完全对齐权威时刻表

版本升级至 v1.0.71
2026-08-16 13:05:57 +08:00
Henry_Du ae96c86288 feat: departure广播新增下一站播报 + 客户端预解析station code 2026-08-16 13:01:32 +08:00
Henry_Du 17b03ce0f7 fix(data): 修复NBT(BlockReader/CreateTarget)与车站时刻表数据源不一致导致终点站广播漏播的问题
- 新增 looksLikeValidDestinationText: 检测目的地/车次名是否有效(过滤Unknown和高比例问号乱码)
- 新增 tryCorrectUpcomingEntryWithCurrentTrain: 用权威State.currentTrain数据校正 upcoming 条目中的乱码车次/目的地
- 新增 checkIsAtStationWithCurrentTrain: 增强 isAtStation 匹配鲁棒性, 优先站台号+车次ID匹配, 乱码时降级到目的地模糊匹配
- 提前预校正: 在生成 announceKey 前就校正乱码字段, 确保 approaching/alarm 冷却去重 key 一致
- 后置全局校正: 在赋值 State.upcomingTrains 前再次对所有条目做权威校正, 保证显示和广播使用的终点站信息一致

版本升级至 v1.0.70
2026-08-16 12:44:09 +08:00
Henry_Du 32b2c8ce9d fix: 终点站到达时下一站广播播放异常 + getNextStation 不回绕起点
1. Utils.getNextStation: 移除终点站回绕到起点的逻辑(非环线)
   - 此前当前站是 END 时会从 schedule.entries 开头再找,把起点当下一站
   - 修复后在终点站返回 nil,明确表示无后续站

2. 服务端 handleTrainArrival: 智能区分普通站/终点站广播序列
   - 新增 hasNextStation / finalNextCode 判断:只有当下一站有效时
     才在 seq 中插入 ch-nextsta + ch- 片段
   - 终点站(finalNextCode 为 null):直接删除 nextsta 和下一站代码,
     仅保留 站台-站台-线路-方向-终点站-警示 片段
   - 避免 ch-UNK 等无效资源或回绕起点站名被播放

3. 日志增强:arrival 处理时额外打印 nextStation 和 nextCode,
   便于快速确认服务端是否收到正确的下一站信息

版本号 v1.0.69 -> v1.0.70
2026-08-16 12:41:19 +08:00
Henry_Du 34d9ed00d6 fix: 解决自动更新无限重启循环 + 终点站列车不显示在预测列表
1. CloudServer: publishStationUpdate 发布时同步 Gitea 最新脚本到本地副本
   - 新增 fetchGiteaScript() 一次下载全文
   - 发布更新时将 Gitea 脚本内容写入 stationScriptPath
   - 保证 targetVersion 与 /downloads/station_broadcast.lua 内容版本一致

2. 客户端 AutoUpdate.apply: 下载后校验版本一致性
   - 从下载内容正则提取 APP_VERSION_TEXT
   - 若 downloadedVersion ~= targetVersion 则中止更新,防止 reboot loop
   - 写入错误日志便于排查

3. 预测显示: 在站列车(尤其终点站)强制插入 upcomingTrains 列表头部
   - CRN 可能从 TrainStops 提前移除终点站列车
   - 使用 normalizeUpcomingKey 去重避免重复条目
   - 固定 ETA=0,确保 CURREN T TRAIN 对应列车能在 UPCOMING 中可见

版本号 v1.0.68 -> v1.0.69
2026-08-16 12:32:11 +08:00
Henry_Du 82a4ecd3e8 fix: 确保在站列车(包括终点站列车)始终显示在预测列表中
- 当 State.currentTrain 存在时,手动将其信息插入 upcomingTrains 列表头部
- 解决终点站列车被 CRN 导航系统从 TrainStops 提前移除导致预测区无显示的问题
- 使用 normalizeUpcomingKey 去重,避免重复条目
- 版本号 v1.0.68 -> v1.0.69
2026-08-16 12:22:50 +08:00
Henry_Du 1b958c25df feat(display): 标注终点站[END]与下一站[NEXT]并修复多项显示bug
- 修复所有站误标[END]:原来用instruction.id判断是错误的,Create中每个停靠站都是create:destination;现在改为用Utils.getDestination()匹配或取列表最后一个站作为终点站
- 过滤包含NaN的无效站点名
- 当前站匹配:先尝试『站名+站台号』严格匹配(避免CB-03 5和CB-03 6同时高亮,匹配不到再退化到站名匹配
- 新增『下一站』标识:~前缀 + 橙色 + [NEXT]后缀
- 新增『终点站』标识:黄色 + [END]后缀
- 当前站:>前缀 + 绿色 + << 标记
- 未到达站:*前缀 + 白色
- 已过站:浅灰色
- 版本号升级至 v1.0.68
2026-08-16 12:01:04 +08:00
Henry_Du 1d99634957 fix(audio): 修复导致完全没有声音的回归错误,添加超时防止挂起,修复等待缓冲区清空的逻辑 2026-08-16 11:58:31 +08:00
Henry_Du af00d57e68 feat: 发布更新时优先使用远程 Gitea 仓库的版本号 2026-08-16 11:54:29 +08:00
Henry_Du 4b972c3ae6 fix(display): 修复时刻表不显示的多个问题
- 将宽度阈值从 50 降低到 30,适配更小的 monitor
- 重构 schedule.entries 解析逻辑:兼容非纯数字索引(用 pairs 收集后排序)
- 修复视觉逻辑 bug:已到达站(浅灰) vs 未到达站(白) 颜色搞反
- 增加 schedule 为 nil/invalid 时的错误提示显示
- 增加调试日志输出 monitor 尺寸和提取到的站点数
- 版本号升级至 v1.0.65
2026-08-16 11:50:44 +08:00
Henry_Du 71f6800a2a feat(display): 列车进站后在屏幕右侧显示时刻表信息
- 新增 Utils.extractAllStationsFromSchedule() 从时刻表提取所有站点
- 重构 Display.render() 实现左右分栏布局
- 列车存在且屏幕宽度>=50时启用右侧时刻表面板
- 使用颜色区分:已过站(浅灰)/当前站(绿色高亮+>)/未到站(白)/终点站(黄色+[END])
- 版本号升级至 v1.0.64
2026-08-16 11:45:37 +08:00
Henry_Du b74eb252e3 feat: 在后端增加解析远程 Gitea 最新版本号的接口并展示在前端 2026-08-16 11:40:47 +08:00
Henry_Du 298308b7f3 fix(audio): 修复非高音质环境下 PCM 音频分片播放时的爆音问题
1. 移除 server.js 生成的每个音频分片携带的 44 字节 WAV 头,防止将其作为音频样本解码导致每 0.25 秒出现点击声 ('哒')。 2. 优化 playAudio 队列满时的轮询机制,从原先阻塞等待 speaker_audio_empty(会导致扬声器饥饿)改为使用 0.05 秒的定时器定期尝试,以保持缓冲区处于满载状态。 3. 同步修复了 station_broadcast-warning.lua 中潜在的相同轮询饥饿问题。 4. 版本号递增至 v1.0.62。
2026-08-16 11:32:00 +08:00
Henry_Du 6b1a531b77 fix(audio): 修复在没有高音质模块时,回退播放实时音频流产生刺耳噪音及可能导致卡死的问题 2026-08-15 23:30:09 +08:00
Henry_Du 2eddc9fefe 1.0.61 2026-08-15 23:13:10 +08:00
Henry_Du 51968cc825 fix(audio): 当扬声器不支持高音质时退回使用默认原版解码播放 2026-08-15 23:11:25 +08:00
Henry_Du 8f70c1f1d3 fix: 发车音乐列表过长时自动换行显示,修复 UI 溢出重叠问题 2026-08-04 13:13:03 +08:00
Henry_Du d8eee041c8 feat: 明确音频优先级,优化发车音频触发时机
- 到达与发车最高优先级(3),其次是进站预告(2),然后是延误警告(1),最低为背景音乐(0)

- 将发车音乐的触发时机从固定时间改为动态计算(音频时长+1.5秒补偿),确保音频结束时列车刚好发车

- 拆分 handleTrainApproaching 与 handleTrainAlert 逻辑,解耦进站与延误的优先级

- 版本号提升至 v1.0.59
2026-08-04 13:00:04 +08:00
Henry_Du 01ac488aef fix(client): 解决终端配置界面文字重叠与停站状态进站预告误报问题
1. 修复配置界面 UI 布局:当发车音乐可用列表过长时导致与后续输入提示重叠的问题,通过拆分显示并换行解决。
2. 彻底解决停站时误报进站预告:当 CC 电脑检测到当前站台已有列车停靠(`State.currentTrain ~= nil`)时,无论显示器上显示的是该站台的当前列车发车倒计时,还是被阻塞的下一班列车的到达倒计时,均强制抑制针对该站台的“进站预告”触发,直至站台被清空。
2026-08-04 11:29:45 +08:00
Henry_Du c86fca8449 feat(client): 全面增强站名模糊匹配逻辑,支持中英文混排关键词提取
1. 新增 `Utils.getKeywords(text)`:自动提取所有关键信息:
   - 包含中划线的字母数字组合(如 CE-03)
   - 连续的字母数字(如 CRH01)
   - 中文字符串,并在提取时自动过滤掉“站台”、“轨道”、“号”、“线”等噪音词。
2. 将 `Utils.getKeywords` 的匹配逻辑集成至 `Utils.isFuzzyMatch` 的最终校验中,即使在 `Utils.getBaseName` 无法精确比较的情况下,也能通过核心关键字(特别是纯中文关键字)判断两者是否为同一站台。
3. 将列车在站判定(`isAtStation`)对未知列车的目的站比较逻辑统一切换为使用强化的 `Utils.isFuzzyMatch`,保证时刻表、列车站名、NBT、显示器等来源的中英文站名信息均能互相精准匹配。
2026-08-04 07:42:57 +08:00
Henry_Du 6398146ee6 fix(client): 优化停站预告判断条件,彻底解决停站倒秒重复播报
修复由于列车时刻表中的目的站字符串与 NBT 或显示器获取到的目的地字符串不完全匹配,导致 `isAtStation` 判断失败的问题。
现在判断列车是否在站(`isAtStation`)主要依据 `trainName` 与 `State.currentTrain.id` 是否匹配。
对于未命名列车(如 ??? 或 Unknown),会额外通过模糊匹配或基础名称匹配目的地,从而保证当任何一列车在站台停留期间,系统正确识别它已停站,并忽略它因为离站倒数带来的 `etaSeconds > 0` 从而错误触发进站预告。
2026-08-04 07:37:11 +08:00
Henry_Du 86e58a5c59 feat(蜂鸣音频资源): 添加 fc2 至 fc14 共13个蜂鸣音效文件 2026-08-03 19:45:57 +08:00
Henry_Du aa10f0ebe1 chore(server): 屏蔽 HLS RTMP 推流失败的错误日志
由于当前环境未配置 RTMP 服务器,当 ffmpeg 尝试推送音频流到 127.0.0.1:1935 时会一直报错 `Connection refused` 从而导致控制台刷屏。
现将该错误日志注释屏蔽,使控制台保持清爽。
2026-08-03 12:35:16 +08:00
Henry_Du effcd30291 1 2026-08-03 12:33:59 +08:00
Henry_Du 3c25040a84 chore(client): 优化日志输出并清理测试代码
1. 引入全局 `CONFIG.DEBUG` 配置项(默认为 `false`),将所有 `Utils.log(DEBUG, ...)` 的大量扫描和推算日志默认隐藏,避免刷屏 CC 终端,需要时可通过配置文件开启。
2. 移除了排查 NBT 结构时添加的 `stop_dump.json` 导出逻辑,保持主机系统文件整洁。
2026-08-03 12:27:37 +08:00
Henry_Du fdbcfa3b9b fix(client): 优化停站时间获取,并在列车停站时禁用进站预告
1. 修复 `Utils.getStopTime` 和 `Utils.getNextStation` 会误匹配“非车站指令”(如修改最高速度等指令)中的文本的问题,现在仅在 `instruction.id == create:station`
或 `create:destination` 的条目中扫描,保证了 CC 电脑从 `create:track_station`
获取到的时刻表信息(包括计算出的停站时间)100% 准确。
2. 修复显示器(Display Board / NBT)在列车停站后依然显示“离站倒计时 ETA”,导致触发 `train_approaching` 的问题:如果当前列车已经在站内(`State.currentTrain` 存在且匹配),将强制忽略该列车的 `train_approaching` 触发逻辑,彻底解决列车停站时倒秒导致的重复播报。
2026-08-03 12:20:54 +08:00
Henry_Du 87fda31485 fix(client): 在 NBT 分支中也应用非车站指令过滤
将 `isStationDestination` 的过滤逻辑同时应用于 Block Reader(读取车站方块 NBT)的分支,
确保无论是通过显示屏还是 NBT 读取,都不会把“设置最高速度”等非停站指令当作列车进站播报。
同时已包含 `etaSeconds > 0` 修复,解决完全停站后重复播报的问题。
2026-08-03 12:09:08 +08:00
Henry_Du f846004091 fix(client): 过滤非车站指令并修复列车停站后重复播报进站预告的问题
1. 新增 `isStationDestination` 过滤器,在通过显示屏(Create Target)读取时刻表时,
   自动忽略包含“速度”、“重命名”、“时刻表”、“等待”、“卸载”等关键字的非车站指令,
   防止将这些指令当作车站并错误触发进站预告。
2. 修复显示屏读取到的负数 ETA 被错误解析为正数的问题(增加可选负号匹配)。
3. 将触发进站预告的条件从 `etaSeconds >= 0` 修改为 `etaSeconds > 0`。
   这样当列车完全停站(ETA=0或负数)时,将不再触发进站预告,彻底解决停站时间
   超过冷却期后导致的重复播报“进站预告”的问题。
2026-08-03 12:04:13 +08:00
Henry_Du 439ed9ea3c chore(client): 增加调试日志以导出 NBT stop 结构
用于排查非停站指令(如设置最高速度、新建时刻表区段)的 NBT 结构,
以便后续在代码中精准过滤这些非车站指令。
2026-08-03 11:28:32 +08:00
Henry_Du eb018dbae9 fix(client): 修复多个同名同目的地的列车互相清除 pending 状态的问题
当存在多个列车(如无名列车 ??? 开往同一目的地)时:
- 第二个 ETA 较远的列车会因为 `etaSeconds > 45` 走进 `else` 分支
- 原逻辑在 `else` 分支会直接清空该 key 的 pending 状态
- 导致排在前面的列车永远无法积累 5 秒的稳定期

修改:
1. 移除 `else` 分支,统一交由末尾的清理循环处理
2. 使用 `if not seenApproachKeys[announceKey]` 确保同一个 tick 只有 ETA 最近(排最前)的列车更新状态
2026-08-03 11:14:40 +08:00
Henry_Du 619d4e9a20 杂项(station_broadcast): 更新版本至v1.0.50并添加列车ETA调试日志
该调试日志会打印列车名称、ETA秒数、计时tick数及服务器tick率,仅在列车ETA不超过60秒且未触发过接近播报时输出,用于辅助排查列车接近播报的逻辑问题
2026-08-03 11:09:44 +08:00
Henry_Du 3c71d64f79 chore(station_broadcast): 升级版本至v1.0.49并调整ETA触发阈值
调整列车接近播报的ETA更新阈值,从3秒改为15秒以减少频繁触发
2026-08-03 10:40:12 +08:00
Henry_Du a36b21cfaf fix(server): 完善列车接近音频的处理与日志记录
新增列车接近音频入队和构建失败的日志输出
调整音频任务优先级为1,中断模式改为ifLower
修复音频构建为空时的静默失败问题
2026-08-03 10:22:04 +08:00
Henry_Du 101d392840 fix(CloudServer/server): 修复列车接近处理的ch序列遗漏的站台编号参数 2026-08-03 09:59:58 +08:00
Henry_Du c8681417a8 refactor(音频模块): 重构wav音频播放与解码逻辑
移除冗余的流式wav解码工具函数,复用现有decodeWavChunkToSamples
简化playChunkOnSpeakers实现,移除固定帧拆分逻辑与无用常量
优化音频日志与错误处理,解码失败时回退至HQ播放路径
调整应用版本号从v1.0.50回退至v1.0.47
2026-08-03 09:51:33 +08:00
Henry_Du bf1895ebc5 perf(audio): 优化音频播放帧处理逻辑,升级版本至v1.0.50
- 为test.lua添加标准文件头部注释
- 将station_broadcast.lua版本从v1.0.49更新至v1.0.50
- 新增NATIVE_AUDIO_FRAME_SAMPLES常量,将音频处理拆分为128样本每帧
- 修复空音频样本输入的边界异常问题
- 优化原生扬声器播放的循环逻辑,提升兼容性与执行效率
2026-08-03 09:49:01 +08:00
Henry_Du 394f81b9fa 1 2026-08-03 09:39:00 +08:00
Henry_Du 5fb81e3998 feat(audio): 实现流式WAV解码器并优化音频播放逻辑
将应用版本更新至v1.0.48;新增WAV头部解析与流式解码逻辑,替换原有的单块解码方案;添加扬声器speakWav兼容性检测,无支持设备时输出错误日志;优化WAV解码错误处理与日志输出,仅首次打印音频格式信息。
2026-08-03 09:37:00 +08:00
Henry_Du e89b82a8b4 1 2026-08-03 09:27:51 +08:00
Henry_Du 5dbd3d667f fix: restore speaker peripheral detection
Re-add speaker discovery during peripheral init so installed speakers are available for playback and log the detected count at startup.
2026-08-03 09:24:58 +08:00
Henry_Du 6964e7a47d refactor(station_broadcast): 清理冗余代码与注释,移除无用文件并更新gitignore
- 清理station_broadcast.lua中的冗余注释、废弃代码块,移除无用的节分隔注释
- 删除不再使用的austream.lua文件
- 更新.gitignore,新增临时文件与转储文件的忽略规则
2026-08-02 22:03:06 +08:00
Henry_Du 59865138c7 11 2026-08-02 21:26:41 +08:00
Henry_Du b2888c6c88 chore(安装脚本与文档): 修改安装脚本下载源并新增教程视频
将install_station_broadcast.lua中的旧下载地址替换为gitea仓库的https新源,同时新增FSE铁路广播系统教程视频文件
2026-08-02 21:24:57 +08:00
Henry_Du aef63ca444 fix: install updater alongside startup script
Download the update helper during install and expose it from the cloud server so new hosts can run manual updates immediately.
2026-08-02 21:20:28 +08:00
Henry_Du 10d15dfddc feat: add station auto update publishing
Publish the latest station script from the console so online hosts download and reboot immediately while offline hosts update on their next load.
2026-08-02 20:04:55 +08:00
37 changed files with 7233 additions and 5011 deletions
+5
View File
@@ -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.
+1 -8
View File
@@ -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
+19 -18
View File
@@ -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);
@@ -940,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>
@@ -1085,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)';
@@ -1190,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;
@@ -1318,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>
+216 -55
View File
@@ -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,13 +990,16 @@
<!-- 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">
<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>
<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');
+31 -12
View File
@@ -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>
+16 -6
View File
@@ -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>
+148 -56
View File
@@ -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,21 +237,29 @@
@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="topbar">
<div class="title">音乐播放</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
<div class="layout-wrapper">
<div class="topbar">
<div class="title">音乐播放</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
</div>
</div>
</div>
<div class="grid">
<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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
@@ -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() {
+10 -1
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
+10 -1
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
+146 -56
View File
@@ -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,93 +146,129 @@
}
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="topbar">
<div class="title">临时广播</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
<div class="layout-wrapper">
<div class="topbar">
<div class="title">临时广播</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
</div>
</div>
</div>
<div class="grid">
<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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
@@ -383,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;
}
+2054 -398
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
-3620
View File
File diff suppressed because it is too large Load Diff
-255
View File
@@ -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
+265
View File
@@ -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
}
+4 -1
View File
@@ -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.")
+24 -2
View File
@@ -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
+2175 -505
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@ while true do
local success, data = pcall(reader.getBlockData)
if success and data and data.TrainStops then
local now = os.epoch("utc") / 50 -- Minecraft ticks (approx)
local now = os.epoch("utc") / 50
for i, stop in ipairs(data.TrainStops) do
local st = stop.Station
+370
View File
@@ -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)
+49
View File
@@ -0,0 +1,49 @@
function getKeywords(text)
if not text then return {} end
local s = tostring(text):lower()
local keywords = {}
for code in s:gmatch("[%a%d]+%-[%a%d]+") do
keywords[code] = true
end
for code in s:gmatch("%a+%d+") do
keywords[code] = true
end
for zh in s:gmatch("[\128-\255]+") do
local cleanZh = zh:gsub("站台", ""):gsub("轨道", ""):gsub("号", ""):gsub("线", "")
if cleanZh and cleanZh ~= "" then
keywords[cleanZh] = true
end
end
local list = {}
for k, _ in pairs(keywords) do
table.insert(list, k)
end
return list
end
local function test(a, b)
local k1 = getKeywords(a)
local k2 = getKeywords(b)
local match = false
for _, w1 in ipairs(k1) do
for _, w2 in ipairs(k2) do
if w1 == w2 or w1:find(w2, 1, true) or w2:find(w1, 1, true) then
match = true
break
end
end
end
print(a .. " vs " .. b .. " -> " .. tostring(match))
print(" K1: " .. table.concat(k1, ", "))
print(" K2: " .. table.concat(k2, ", "))
end
test("CE-03 (桃园)", "桃园")
test("CE-04", "CE-04 [西门]")
test("CRH-01", "CRH-01 南山线")
test("南山线", "南山")
test("???", "Unknown")
+1
View File
@@ -1,3 +1,4 @@
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
local function fetch(url)