feat: 实现动态插件系统 (6阶段完成)

- 阶段1: 消息类型序列化 (Serialize/Deserialize, &'static str → String)
- 阶段2: FFI 边界类型 + Plugin SDK (plugin_abi, showen-plugin-sdk crate)
- 阶段3: PluginLoader + DynamicPlugin (libloading 动态加载 .so)
- 阶段4: 版本管理 + 错误策略 (VersionManager, PluginState, 自动回退)
- 阶段5: 远程仓库客户端 (HTTP 下载 + tar.gz 安装)
- 阶段6: 示例插件 + HTTP 管理 API + 全目录 README 文档

54/54 测试通过,0 warnings。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-13 03:38:08 +08:00
parent 5dcc1ad98e
commit 7135f28545
62 changed files with 3501 additions and 299 deletions

View File

@@ -0,0 +1,48 @@
# HttpPlugin — Web UI + REST API
基于 warp 的 HTTP 服务插件,提供完整的控制 API 和实时 WebSocket 事件。
## 模块
| 文件 | 说明 |
|------|------|
| `mod.rs` | HttpPlugin 实现、HttpState 共享状态、WebSocket 事件编码 |
| `routes.rs` | 全部 HTTP 路由定义、请求处理、内嵌 Web UI HTML |
## API 端点
### 播放控制
- `GET /api/status` — 播放状态
- `POST /api/play` / `pause` / `next` / `previous`
- `POST /api/goto` — 跳转到指定索引
- `GET /api/playlist` — 播放列表
- `POST /api/scene` — 切换场景
- `POST /api/trigger` — 发送触发器
### 配置管理
- `GET /api/config` — 完整配置
- `GET /api/config/display` — 显示配置
- `POST /api/config` — 更新配置(热重载)
### 媒体管理
- `GET /api/videos` — 视频文件列表
- `POST /api/videos/upload` — 上传视频
- `DELETE /api/videos/:name` — 删除视频
### WiFi / BLE
- `GET /api/wifi/status` / `scan` / `connect` / `ap/start` / `ap/stop`
- `POST /api/ble/start` / `stop` / `GET /api/ble/status`
### 插件管理 (动态插件)
- `GET /api/plugins` — 列出所有插件状态
- `GET /api/plugins/:id` — 插件详情
- `POST /api/plugins/:id/enable` / `disable` / `rollback` / `switch`
- `POST /api/plugins/install` — 远程安装
- `POST /api/plugins/check-updates` — 检查更新
### WebSocket
- `ws://host:port/ws` — 实时事件推送
## 依赖
- 依赖 VideoPlugin启动顺序

View File

@@ -43,6 +43,8 @@ pub(crate) struct HttpState {
player_status: Mutex<crate::core::message::PlayerStatusData>,
ble_ready: AtomicBool,
ws_events: broadcast::Sender<String>,
/// 动态插件管理状态(由 Custom 消息更新)
plugin_states: Mutex<Vec<crate::core::service_manager::PluginStateInfo>>,
}
impl HttpState {
@@ -68,6 +70,7 @@ impl HttpState {
player_status: Mutex::new(player_status),
ble_ready: AtomicBool::new(false),
ws_events,
plugin_states: Mutex::new(Vec::new()),
}
}
@@ -179,6 +182,21 @@ impl HttpState {
self.publish_ws(payload);
}
}
pub(crate) fn plugin_states(&self) -> Vec<crate::core::service_manager::PluginStateInfo> {
self.plugin_states
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
fn update_plugin_states(&self, json: &str) {
if let Ok(states) = serde_json::from_str::<Vec<crate::core::service_manager::PluginStateInfo>>(json) {
if let Ok(mut current) = self.plugin_states.lock() {
*current = states;
}
}
}
}
pub struct HttpPlugin {
@@ -202,21 +220,21 @@ impl Default for HttpPlugin {
}
impl Plugin for HttpPlugin {
fn id(&self) -> &'static str {
fn id(&self) -> &str {
"http"
}
fn info(&self) -> PluginInfo {
PluginInfo {
name: "HTTP API",
version: "0.2.0",
description: "Web UI + REST API (warp)",
name: "HTTP API".to_string(),
version: "0.2.0".to_string(),
description: "Web UI + REST API (warp)".to_string(),
platform: Platform::Any,
}
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["video"]
fn dependencies(&self) -> Vec<String> {
vec!["video".to_string()]
}
fn init(&mut self, ctx: PluginContext) -> Result<()> {
@@ -268,9 +286,9 @@ impl Plugin for HttpPlugin {
};
if let Err(error) = tx.send(Envelope {
from: "http",
from: "http".to_string(),
to: crate::core::message::Destination::Manager,
message: Message::PluginReady("http"),
message: Message::PluginReady("http".to_string()),
}) {
eprintln!("[HttpPlugin] failed to report ready state: {error}");
}
@@ -314,8 +332,11 @@ impl Plugin for HttpPlugin {
state.publish_ws(payload);
}
}
Message::PluginReady("ble") => state.set_ble_ready(true),
Message::PluginReady(ref id) if id == "ble" => state.set_ble_ready(true),
Message::Shutdown => state.set_ble_ready(false),
Message::Custom { ref kind, ref payload } if kind == "plugin_states" => {
state.update_plugin_states(payload);
}
_ => {}
}

View File

@@ -1,5 +1,6 @@
use super::HttpState;
use crate::core::config::{self, AppConfig};
use crate::core::dispatch;
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
use bytes::Buf;
use futures_util::{SinkExt, StreamExt, TryStreamExt};
@@ -67,7 +68,8 @@ pub(crate) fn build_routes(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
let api = status_route(Arc::clone(&state))
// 使用 boxed() 分段避免 warp 递归类型溢出
let core_api = status_route(Arc::clone(&state))
.or(play_route(tx.clone()))
.or(pause_route(tx.clone()))
.or(next_route(tx.clone()))
@@ -79,7 +81,9 @@ pub(crate) fn build_routes(
.or(config_get_route(Arc::clone(&state)))
.or(config_display_route(Arc::clone(&state)))
.or(config_update_route(tx.clone(), Arc::clone(&state)))
.or(video_list_route(Arc::clone(&state)))
.boxed();
let media_api = video_list_route(Arc::clone(&state))
.or(video_upload_route(Arc::clone(&state)))
.or(video_delete_route(Arc::clone(&state)))
.or(wifi_status_route(tx.clone(), Arc::clone(&state)))
@@ -89,9 +93,22 @@ pub(crate) fn build_routes(
.or(wifi_ap_stop_route(tx.clone(), Arc::clone(&state)))
.or(ble_start_route(Arc::clone(&state)))
.or(ble_stop_route())
.or(ble_status_route(Arc::clone(&state)));
.or(ble_status_route(Arc::clone(&state)))
.boxed();
root_route().or(ws_route(Arc::clone(&state))).or(api).with(
let plugin_api = plugins_list_route(Arc::clone(&state))
.or(plugin_detail_route(Arc::clone(&state)))
.or(plugin_enable_route(tx.clone()))
.or(plugin_disable_route(tx.clone()))
.or(plugin_rollback_route(tx.clone()))
.or(plugin_switch_route(tx.clone()))
.or(plugin_install_route(tx.clone()))
.or(plugin_check_updates_route(tx.clone()))
.boxed();
let api = core_api.or(media_api).or(plugin_api);
root_route().or(ws_route(tx.clone(), Arc::clone(&state))).or(api).with(
warp::cors()
.allow_any_origin()
.allow_headers(["content-type"])
@@ -112,14 +129,16 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
}
fn ws_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path("ws")
.and(warp::path::end())
.and(warp::ws())
.and(with_tx(tx))
.and(with_state(state))
.map(|ws: warp::ws::Ws, state: Arc<HttpState>| {
ws.on_upgrade(move |socket| websocket_session(socket, state))
.map(|ws: warp::ws::Ws, tx: mpsc::Sender<Envelope>, state: Arc<HttpState>| {
ws.on_upgrade(move |socket| websocket_session(socket, tx, state))
})
}
@@ -570,7 +589,7 @@ async fn handle_config_update(
}
if let Err(error) = tx.send(Envelope {
from: "http",
from: "http".to_string(),
to: Destination::Manager,
message: Message::ConfigReloadRequest,
}) {
@@ -733,8 +752,8 @@ async fn send_video_command(
success_message: impl Into<String>,
) -> Result<warp::reply::Response, Infallible> {
match tx.send(Envelope {
from: "http",
to: Destination::Plugin("video"),
from: "http".to_string(),
to: Destination::Plugin("video".to_string()),
message,
}) {
Ok(()) => Ok(success_json(success_message.into())),
@@ -778,8 +797,8 @@ async fn wifi_request(
};
if let Err(error) = tx.send(Envelope {
from: "http",
to: Destination::Plugin("wifi"),
from: "http".to_string(),
to: Destination::Plugin("wifi".to_string()),
message: Message::WifiCommand(command),
}) {
return Err(error_json(
@@ -852,7 +871,11 @@ async fn wifi_request(
Ok(payload)
}
async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
async fn websocket_session(
ws: warp::ws::WebSocket,
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) {
let (mut sender, mut receiver) = ws.split();
let mut events = state.ws_subscribe();
@@ -884,6 +907,11 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
}
} else if message.is_close() {
break;
} else if message.is_text() {
let reply = handle_ws_command(message.to_str().unwrap_or(""), &tx);
if sender.send(warp::ws::Message::text(reply)).await.is_err() {
break;
}
}
}
Some(Err(_)) | None => break,
@@ -893,6 +921,104 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
}
}
/// 解析 WebSocket 收到的 JSON 命令,返回 JSON 响应字符串。
///
/// 输入格式: `{"cmd":"play"}` 或 `{"cmd":"goto","index":3}` 或
/// `{"cmd":"connect","ssid":"x","password":"y"}` 等
fn handle_ws_command(text: &str, tx: &mpsc::Sender<Envelope>) -> String {
let json: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return r#"{"ok":false,"error":"invalid JSON"}"#.to_string(),
};
let cmd = match json.get("cmd").and_then(|v| v.as_str()) {
Some(c) => c,
None => return r#"{"ok":false,"error":"missing cmd field"}"#.to_string(),
};
// 将 JSON 字段组合为文本命令字符串
let command_str = build_command_string(cmd, &json);
// 从 JSON 中提取 ssid/password 作为 hint用于无参数的 connect/ap_start
let ssid_hint = json
.get("ssid")
.and_then(|v| v.as_str())
.unwrap_or("");
let password_hint = json
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("");
match dispatch::parse_command(&command_str, "ws", ssid_hint, password_hint) {
Ok(result) => {
if tx.send(result.envelope).is_ok() {
format!(r#"{{"ok":true,"cmd":"{}"}}"#, cmd)
} else {
r#"{"ok":false,"error":"channel closed"}"#.to_string()
}
}
Err(error) => {
format!(
r#"{{"ok":false,"cmd":"{}","error":"{}"}}"#,
cmd,
error.replace('"', "\\\"")
)
}
}
}
/// 从 JSON 对象组装文本命令字符串。
/// 例: `{"cmd":"goto","index":3}` -> `"goto:3"`
/// `{"cmd":"scene","name":"idle"}` -> `"scene:idle"`
/// `{"cmd":"trigger","name":"voice","value":"hi"}` -> `"trigger:voice:hi"`
/// `{"cmd":"connect","ssid":"x","password":"y"}` -> `"connect:x:y"`
fn build_command_string(cmd: &str, json: &serde_json::Value) -> String {
match cmd {
"goto" => {
if let Some(index) = json.get("index").and_then(|v| v.as_u64()) {
format!("goto:{index}")
} else {
"goto".to_string()
}
}
"scene" => {
if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
format!("scene:{name}")
} else {
"scene".to_string()
}
}
"trigger" => {
let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = json.get("value").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() {
"trigger".to_string()
} else {
format!("trigger:{name}:{value}")
}
}
"connect" => {
let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or("");
let password = json.get("password").and_then(|v| v.as_str()).unwrap_or("");
if ssid.is_empty() {
"connect".to_string()
} else {
format!("connect:{ssid}:{password}")
}
}
"ap_start" => {
let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or("");
let password = json.get("password").and_then(|v| v.as_str()).unwrap_or("");
if ssid.is_empty() {
"ap_start".to_string()
} else {
format!("ap_start:{ssid}:{password}")
}
}
_ => cmd.to_string(),
}
}
fn parse_optional_json<T>(body: &bytes::Bytes) -> Result<T, Box<warp::reply::Response>>
where
T: DeserializeOwned + Default,
@@ -1000,6 +1126,181 @@ fn error_json(status: StatusCode, message: &str) -> warp::reply::Response {
)
}
// ── 插件管理 API ──
fn plugins_list_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
Ok::<_, Infallible>(json_response(StatusCode::OK, &state.plugin_states()))
})
}
fn plugin_detail_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String)
.and(warp::get())
.and(with_state(state))
.and_then(|id: String, state: Arc<HttpState>| async move {
let plugins = state.plugin_states();
match plugins.iter().find(|p| p.id == id) {
Some(info) => Ok::<_, Infallible>(json_response(StatusCode::OK, info)),
None => Ok(error_json(StatusCode::NOT_FOUND, &format!("plugin '{}' not found", id))),
}
})
}
#[derive(Deserialize)]
struct PluginSwitchRequest {
version: String,
}
#[derive(Deserialize)]
struct PluginInstallRequest {
id: String,
#[serde(default)]
version: Option<String>,
}
fn plugin_enable_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "enable")
.and(warp::post())
.and(with_tx(tx))
.and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_enable", &id).await
})
}
fn plugin_disable_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "disable")
.and(warp::post())
.and(with_tx(tx))
.and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_disable", &id).await
})
}
fn plugin_rollback_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "rollback")
.and(warp::post())
.and(with_tx(tx))
.and_then(|id: String, tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_rollback", &id).await
})
}
fn plugin_switch_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / String / "switch")
.and(warp::post())
.and(warp::body::json::<PluginSwitchRequest>())
.and(with_tx(tx))
.and_then(
|id: String, body: PluginSwitchRequest, tx: mpsc::Sender<Envelope>| async move {
let payload = serde_json::json!({
"id": id,
"version": body.version,
})
.to_string();
match tx.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "plugin_switch".to_string(),
payload,
},
}) {
Ok(()) => Ok::<_, Infallible>(success_json(
format!("版本切换请求已发送: {} -> v{}", id, body.version),
)),
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送失败: {e}"),
)),
}
},
)
}
fn plugin_install_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / "install")
.and(warp::post())
.and(warp::body::json::<PluginInstallRequest>())
.and(with_tx(tx))
.and_then(
|body: PluginInstallRequest, tx: mpsc::Sender<Envelope>| async move {
let payload = serde_json::json!({
"id": body.id,
"version": body.version,
})
.to_string();
match tx.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "plugin_install".to_string(),
payload,
},
}) {
Ok(()) => Ok::<_, Infallible>(success_json(
format!("安装请求已发送: {}", body.id),
)),
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送失败: {e}"),
)),
}
},
)
}
fn plugin_check_updates_route(
tx: mpsc::Sender<Envelope>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "plugins" / "check-updates")
.and(warp::post())
.and(with_tx(tx))
.and_then(|tx: mpsc::Sender<Envelope>| async move {
send_plugin_command(tx, "plugin_check_updates", "").await
})
}
async fn send_plugin_command(
tx: mpsc::Sender<Envelope>,
kind: &str,
plugin_id: &str,
) -> Result<warp::reply::Response, Infallible> {
match tx.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: kind.to_string(),
payload: plugin_id.to_string(),
},
}) {
Ok(()) => Ok(success_json(format!("{kind} 命令已发送"))),
Err(e) => Ok(error_json(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("发送失败: {e}"),
)),
}
}
fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::Response {
warp::reply::with_status(warp::reply::json(payload), status).into_response()
}
@@ -1042,7 +1343,7 @@ const WEB_UI_HTML: &str = r#"<!doctype html>
<section class="panel active" id="panel-control">
<div class="grid">
<div class="card"><h2>播放状态</h2><div class="status"><div><span class="label">状态</span><span id="st-state" class="val">--</span></div><div><span class="label">当前视频</span><span id="st-video" class="val">--</span></div><div><span class="label">索引</span><span id="st-index" class="val">--</span></div><div><span class="label">列表长度</span><span id="st-len" class="val">--</span></div></div></div>
<div class="card"><h2>播放控制</h2><div class="btns"><button class="secondary" onclick="api('POST','/api/previous')">上一个</button><button onclick="api('POST','/api/play')">播放</button><button class="secondary" onclick="api('POST','/api/pause')">暂停</button><button onclick="api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:10px"><input id="goto-idx" type="number" min="0" placeholder="输入视频索引"><button onclick="gotoVideo()">跳转</button></div></div>
<div class="card"><h2>播放控制</h2><div class="btns"><button class="secondary" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button><button onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button><button class="secondary" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button><button onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:10px"><input id="goto-idx" type="number" min="0" placeholder="输入视频索引"><button onclick="gotoVideo()">跳转</button></div></div>
<div class="card"><h2>触发器</h2><div class="btns"><button class="secondary" onclick="triggerPreset('voice','name')">语音唤醒</button><button class="secondary" onclick="triggerPreset('button','button1')">按钮1</button><button class="secondary" onclick="triggerPreset('button','button2')">按钮2</button><button class="secondary" onclick="triggerPreset('sensor','touch')">触摸</button></div><label>名称</label><input id="tr-name" type="text" placeholder="voice"><label>值</label><input id="tr-value" type="text" placeholder="name"><div class="btns" style="margin-top:10px"><button onclick="triggerCustom()">发送触发器</button></div></div>
</div>
</section>
@@ -1055,24 +1356,29 @@ const WEB_UI_HTML: &str = r#"<!doctype html>
</div>
<script>
var cachedConfig=null;
var cachedConfig=null;var ws=null;var wsReady=false;
function $(id){return document.getElementById(id)}
function toast(msg,err){var el=$('toast');el.textContent=msg;el.style.display='block';el.style.background=err?'#7f1d1d':'#1f2937';clearTimeout(el._timer);el._timer=setTimeout(function(){el.style.display='none'},3000)}
document.querySelectorAll('.tab').forEach(function(tab){tab.onclick=function(){document.querySelectorAll('.tab').forEach(function(el){el.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(el){el.classList.remove('active')});tab.classList.add('active');$('panel-'+tab.dataset.tab).classList.add('active');if(tab.dataset.tab==='videos')loadVideoList();if(tab.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab.dataset.tab==='settings'&&!cachedConfig)loadConfig()}})
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');refreshStatus();return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(function(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val paused'}else if(d.paused){el.textContent='已暂停';el.className='val paused'}else{el.textContent='播放中';el.className='val'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}).catch(function(){})}
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}api('POST','/api/goto/'+idx)}
function triggerPreset(name,value){api('POST','/api/trigger/'+encodeURIComponent(name)+'/'+encodeURIComponent(value||''))}
function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';var url=proto+'//'+location.host+'/ws';ws=new WebSocket(url);ws.onopen=function(){wsReady=true};ws.onclose=function(){wsReady=false;setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update'){toast('场景切换: '+msg.data.old_state+' → '+msg.data.new_state)}else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update'){cachedConfig=msg.data}}catch(e){}}}
function wsCmd(obj){if(wsReady){ws.send(JSON.stringify(obj))}else{toast('WebSocket 未连接',true)}}
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val paused'}else if(d.paused){el.textContent='已暂停';el.className='val paused'}else{el.textContent='播放中';el.className='val'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val':'val paused';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val':'val paused'}}
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady){wsCmd({cmd:'goto',index:parseInt(idx,10)})}else{api('POST','/api/goto/'+idx)}}
function triggerPreset(name,value){if(wsReady){wsCmd({cmd:'trigger',name:name,value:value||''})}else{api('POST','/api/trigger/'+encodeURIComponent(name)+'/'+encodeURIComponent(value||''))}}
function triggerCustom(){var name=$('tr-name').value;var value=$('tr-value').value;if(!name){toast('请输入触发器名',true);return}triggerPreset(name,value)}
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item">目录中没有视频文件</div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span>'+escapeHtml(f.name)+' ('+sz+')</span><button class="danger" onclick="deleteVideo(\''+jsString(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(function(d){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val':'val paused';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}).catch(function(){})}
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item">扫描中...</div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item">未发现 WiFi 网络</div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span>'+escapeHtml(n.ssid||'隐藏网络')+' / '+escapeHtml(String(n.signal||0))+' / '+escapeHtml(n.security||'OPEN')+'</span><button class="secondary" onclick="selectWifi(\''+jsString(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
function connectWifi(){var ssid=$('wifi-ssid-input').value;var password=$('wifi-pass-input').value;if(!ssid){toast('请输入 WiFi 名称',true);return}api('POST','/api/wifi/connect',{ssid:ssid,password:password}).then(function(){setTimeout(loadWifiStatus,1500)})}
function startAP(){var ssid=$('ap-ssid').value||'showen';var password=$('ap-pass').value||'12345678';if(password.length<8){toast('热点密码至少 8 位',true);return}api('POST','/api/wifi/ap/start',{ssid:ssid,password:password})}
function stopAP(){api('POST','/api/wifi/ap/stop')}
function connectWifi(){var ssid=$('wifi-ssid-input').value;var password=$('wifi-pass-input').value;if(!ssid){toast('请输入 WiFi 名称',true);return}if(wsReady){wsCmd({cmd:'connect',ssid:ssid,password:password})}else{api('POST','/api/wifi/connect',{ssid:ssid,password:password}).then(function(){setTimeout(loadWifiStatus,1500)})}}
function startAP(){var ssid=$('ap-ssid').value||'showen';var password=$('ap-pass').value||'12345678';if(password.length<8){toast('热点密码至少 8 位',true);return}if(wsReady){wsCmd({cmd:'ap_start',ssid:ssid,password:password})}else{api('POST','/api/wifi/ap/start',{ssid:ssid,password:password})}}
function stopAP(){if(wsReady){wsCmd({cmd:'ap_stop'})}else{api('POST','/api/wifi/ap/stop')}}
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val'}else{el.textContent='未就绪';el.className='val paused'}}).catch(function(){})}
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
@@ -1084,7 +1390,7 @@ function saveDisplay(){if(!cachedConfig){loadConfig();return}var next=JSON.parse
function escapeHtml(v){return String(v).replace(/[&<>\"]/g,function(ch){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[ch]})}
function escapeAttr(v){return escapeHtml(v).replace(/'/g,'&#39;')}
function jsString(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
refreshStatus();setInterval(refreshStatus,3000);
connectWS();refreshStatus();
</script>
</body>
</html>"#;