From 8eab26c1f6ff901406c234d83f15a63629b94490 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Sun, 18 Oct 2026 11:01:53 +0200 Subject: [PATCH] Feature: Web screen wall for multi-device live monitoring Add a third view to the web UI, the screen wall, which shows several devices at once in one tab with 1x1 / 2x2 / 3x3 / 4x4 / 5x5 layouts and a device sidebar on the left. Tiles are monitoring-only; clicking one enlarges that device into the existing single-screen page for control, and Back returns to the wall. The same device can be added several times: all tiles of one device share a single decoder and a single stream. The wall decodes at most 9 streams on desktop and 4 on touch devices. Tiles beyond the budget keep receiving frames but have their decoder closed and show the last frame plus a Paused badge; they are promoted automatically when a live tile goes away. Subscriptions are reference counted on the client (streams map with owner sets), so a stream is only connected on the 0 -> 1 transition and disconnected on 1 -> 0, and the wall and the single-screen page can share one device without tearing the stream down. Server side, WebClient now holds a set of watched devices instead of a single id. A new multiWatch command flag selects set semantics, while clients without it keep the old replace-all behaviour, so an old frontend against the new server is unchanged. connect and disconnect responses echo id and req so the frontend can route them, input commands carry an id and are resolved against the watch set (dropped when the set has several devices and no id), and the per-client watch set is capped at 32. For fast recovery after a pause or when joining late, the server caches each device's raw IDR payload and replays it on connect and on a new keyframe command, then asks the client to force a new IDR. The client gets COMMAND_SCREEN_KEYFRAME_REQ = 169 and an atomic request flag that is only cleared after an IDR actually came out of the encoder, so a failed capture or EAGAIN does not lose the request. The frontend retries the keyframe request up to three times; an old client that ignores the command simply recovers on the next natural GOP. Also fix the keyframe cache double wrapping: BroadcastH264Frame used to cache the full packet header and HandleConnect wrapped it again. The cache now stores data + 9, len - 9 and every sender wraps it through BuildFramePacket, and the cache is cleared on cold start and on resolution change. StopRemoteDesktop is always called outside m_ClientsMutex, with the ids collected under the lock first. Co-Authored-By: deepseek-flash --- client/ScreenCapture.h | 18 +- client/ScreenManager.cpp | 7 + common/commands.h | 1 + server/2015Remote/WebService.cpp | 442 ++++++--- server/2015Remote/WebService.h | 31 +- server/web/index.html | 1471 +++++++++++++++++++++++++++++- 6 files changed, 1790 insertions(+), 180 deletions(-) diff --git a/client/ScreenCapture.h b/client/ScreenCapture.h index 149f844..e32f1fc 100644 --- a/client/ScreenCapture.h +++ b/client/ScreenCapture.h @@ -14,6 +14,7 @@ #include #include #include +#include #include // SSE2 #include "common/config.h" #include "VideoEncoderBase.h" @@ -149,6 +150,9 @@ public: int m_FrameID; // 帧序号 int m_GOP; // 关键帧间隔 bool m_SendKeyFrame; // 发送关键帧 + // Web 端请求下一帧强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ)。只对 H.264 生效; + // 必须在成功编码出 IDR 后清除,否则 ScanNextScreen 失败/硬编 EAGAIN 时请求会丢。 + std::atomic m_bForceKeyFrame{false}; std::unique_ptr m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测 bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包; // false 时禁止 skip——避免单显示器路径 @@ -257,6 +261,11 @@ public: m_BlockBuffers[blockY] = new BYTE[m_ulFullWidth * m_ulFullHeight * 4 * 2 / m_BlockNum + 12]; } } + + // 请求下一帧强制 IDR(可跨线程调用)。只影响 H.264 编码路径; + // 实际清除发生在成功编码出 IDR 之后。 + void RequestKeyFrame() { m_bForceKeyFrame.store(true, std::memory_order_relaxed); } + virtual ~ScreenCapture() { if (m_BitmapInfor_Full != NULL) { @@ -876,7 +885,12 @@ public: { BYTE algo = m_bAlgorithm; int frameID = m_FrameID + 1; - bool keyFrame = (frameID % m_GOP == 0); + // Web 端可请求强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ),只对 H.264 有意义。 + // 标志在成功编码出 IDR 后才清除(见下方 H.264 keyframe 分支), + // 保证 ScanNextScreen 失败或硬编 EAGAIN 时请求不会丢失。 + bool forceKeyFrame = (algo == ALGORITHM_H264) && + m_bForceKeyFrame.load(std::memory_order_relaxed); + bool keyFrame = forceKeyFrame || (frameID % m_GOP == 0); m_RectBuffer[0] = keyFrame ? TOKEN_KEYFRAME : TOKEN_NEXTSCREEN; LPBYTE data = m_RectBuffer + 1; // 写入使用了哪种算法 @@ -967,6 +981,8 @@ public: *ulNextSendLength = 1 + offset + encoded_size; memcpy(data + offset, encoded_data, encoded_size); m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全 + // IDR 已成功产出,满足(可能的)强制关键帧请求。 + m_bForceKeyFrame.store(false, std::memory_order_relaxed); break; } default: diff --git a/client/ScreenManager.cpp b/client/ScreenManager.cpp index fe87d18..b3f0d9e 100644 --- a/client/ScreenManager.cpp +++ b/client/ScreenManager.cpp @@ -930,6 +930,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength) SwitchScreen(); break; } + case COMMAND_SCREEN_KEYFRAME_REQ: { + // Web 屏幕墙恢复/新加入者请求:下一帧强制 IDR,立即重建参考链。 + if (m_ScreenSpyObject) { + m_ScreenSpyObject->RequestKeyFrame(); + } + break; + } case CMD_FULL_SCREEN: { int fullScreen = szBuffer[1]; iniFile cfg(CLIENT_PATH); diff --git a/common/commands.h b/common/commands.h index 4941450..1ac7281 100644 --- a/common/commands.h +++ b/common/commands.h @@ -328,6 +328,7 @@ enum { COMMAND_TLS_ENABLE = 166, // 服务端 → 客户端:启用 TLS 传输(写注册表,下次启动生效) COMMAND_TLS_DISABLE = 167, // 服务端 → 客户端:停用 TLS 传输(写注册表,下次启动生效) COMMAND_REGEN_CLIENT_ID = 168, // 服务端 → 客户端:撞 ID 的克隆机重生成 client_id(掺随机 UUID)后重连 + COMMAND_SCREEN_KEYFRAME_REQ = 169, // 服务端 → 客户端:下一次屏幕编码强制输出 IDR(Web 屏幕墙恢复用) TOKEN_DECRYPT = 199, TOKEN_REGEDIT = 200, // 注册表 diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 1238f4f..8049b0c 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -45,6 +45,27 @@ static void ReplaceAll(std::string& s, const std::string& from, const std::strin } } +// 设备 ID 在 JSON 里以字符串传输(避免 64 位精度丢失),但兼容数字写法。 +static uint64_t ParseDeviceIdValue(const Json::Value& v) { + if (v.isString()) return strtoull(v.asString().c_str(), nullptr, 10); + if (v.isUInt64()) return v.asUInt64(); + if (v.isInt64()) return (uint64_t)v.asInt64(); + return 0; +} + +static uint64_t ParseDeviceIdField(const Json::Value& root, const char* key) { + return ParseDeviceIdValue(root[key]); +} + +// req 是前端生成的关联号;字符串/数字都接受,原样回显。 +static std::string ParseReqField(const Json::Value& root) { + const Json::Value& v = root["req"]; + if (v.isString()) return v.asString(); + if (v.isUInt64()) return std::to_string(v.asUInt64()); + if (v.isInt64()) return std::to_string(v.asInt64()); + return ""; +} + // Challenge-response nonce storage (prevents replay attacks) static std::map s_ClientNonces; static std::mutex s_NonceMutex; @@ -337,6 +358,10 @@ void CWebService::ServerThread(int port) { Json::Value challenge; challenge["cmd"] = "challenge"; challenge["nonce"] = nonce; + // 能力协商:新前端据此启用屏幕墙(多设备订阅)与 keyframe 请求。 + // 缺少这些字段的服务端(Go / 旧 C++ 二进制)会让新前端自动降级。 + challenge["caps"]["multiWatch"] = true; + challenge["caps"]["keyframeRequest"] = true; Json::StreamWriterBuilder builder; builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, challenge)); @@ -379,14 +404,13 @@ void CWebService::ServerThread(int port) { } else if (cmd == "get_devices") { HandleGetDevices(ws_ptr, token); } else if (cmd == "connect") { - // Support both string and number ID formats - std::string id_str = root.get("id", "").asString(); - uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10); - HandleConnect(ws_ptr, token, device_id); + HandleConnect(ws_ptr, token, ParseDeviceIdField(root, "id"), + ParseReqField(root), root.get("multiWatch", false).asBool()); } else if (cmd == "disconnect") { - std::string disc_id_str = root.get("id", "").asString(); - uint64_t disc_device_id = disc_id_str.empty() ? 0 : strtoull(disc_id_str.c_str(), nullptr, 10); - HandleDisconnect(ws_ptr, token, disc_device_id); + HandleDisconnect(ws_ptr, token, ParseDeviceIdField(root, "id"), + ParseReqField(root), root.get("multiWatch", false).asBool()); + } else if (cmd == "keyframe") { + HandleKeyframe(ws_ptr, token, ParseDeviceIdField(root, "id"), ParseReqField(root)); } else if (cmd == "ping") { HandlePing(ws_ptr, token); } else if (cmd == "mouse") { @@ -396,11 +420,11 @@ void CWebService::ServerThread(int port) { } else if (cmd == "clipboard_set") { HandleClipboardSet(ws_ptr, msg); } else if (cmd == "clipboard_get") { - HandleClipboardGet(ws_ptr, token); + HandleClipboardGet(ws_ptr, token, ParseDeviceIdField(root, "id")); } else if (cmd == "rdp_reset") { - HandleRdpReset(ws_ptr, token); + HandleRdpReset(ws_ptr, token, ParseDeviceIdField(root, "id")); } else if (cmd == "audio_toggle") { - HandleAudioToggle(ws_ptr, token); + HandleAudioToggle(ws_ptr, token, ParseDeviceIdField(root, "id")); } else if (cmd == "get_salt") { HandleGetSalt(ws_ptr, msg); } else if (cmd == "create_user") { @@ -464,7 +488,7 @@ void CWebService::HeartbeatThread() { std::lock_guard lock(m_ClientsMutex); for (const auto& [ws_ptr, client] : m_Clients) { // Only check clients that are watching a device (streaming video) - if (client.watch_device_id > 0 && client.last_activity > 0) { + if (!client.watch_device_ids.empty() && client.last_activity > 0) { uint64_t elapsed = now - client.last_activity; if (elapsed > HEARTBEAT_TIMEOUT_SEC) { timedOutClients.push_back(ws_ptr); @@ -625,22 +649,36 @@ void CWebService::HandleGetDevices(void* ws_ptr, const std::string& token) { SendText(ws_ptr, BuildDeviceListJson(username)); } -void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id) { +void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req, bool multi_watch) { + // 所有响应都带 id/req:屏幕墙有多个并发 connect,前端必须能按设备/请求号路由。 + auto send_error = [&](const std::string& err) { + Json::Value res; + res["cmd"] = "connect_result"; + res["ok"] = false; + res["msg"] = err; + res["id"] = std::to_string(device_id); + if (!req.empty()) res["req"] = req; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); + }; + std::string username, role; if (!ValidateToken(token, username, role)) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Invalid token")); + send_error("Invalid token"); return; } // Check device exists and is online if (!m_pParentDlg) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Service not ready")); + send_error("Service not ready"); return; } context* ctx = m_pParentDlg->FindHost(device_id); if (!ctx) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device not online")); + send_error("Device not online"); return; } @@ -665,7 +703,7 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t } } if (!hasAccess) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Permission denied")); + send_error("Permission denied"); return; } } @@ -675,30 +713,76 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t // remote_open, cleared on session teardown), so AI input cannot be injected into a // picture a human is watching/controlling. if (IsMcpTriggered(device_id)) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device busy: remote control session active")); + send_error("Device busy: remote control session active"); return; } - // Check max clients per device - int current_count = GetWebClientCount(device_id); - if (current_count >= m_nMaxClientsPerDevice) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Too many viewers")); - return; - } - - // Update client state + // 该 ws 是否已经订阅这台设备(屏幕墙点击 tile 放大时会重复 connect 以拿 + // 分辨率/关键帧)。重复 connect 必须跳过每设备 viewer 上限检查。 + bool already = false; { std::lock_guard lock(m_ClientsMutex); auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { - it->second.watch_device_id = device_id; + already = it->second.watch_device_ids.count(device_id) > 0; } } - // Start remote desktop session if this is the first web viewer - if (current_count == 0) { + // Check max clients per device + int current_count = GetWebClientCount(device_id); + if (!already && current_count >= m_nMaxClientsPerDevice) { + send_error("Too many viewers"); + return; + } + + // Update client state. 新前端(multiWatch=true)向集合里插入;旧前端保持 + // 单设备语义:connect 替换整个集合并停掉其它设备,避免集合无限增长。 + std::vector to_stop; + bool too_many_watch = false; + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end()) { + auto& watch = it->second.watch_device_ids; + if (!multi_watch) { + for (uint64_t id : watch) { + if (id != device_id) to_stop.push_back(id); + } + watch.clear(); + } + if (already) { + watch.insert(device_id); // already true 时必然在集合内,这里只是兜底 + } else if (watch.size() >= kMaxWatchPerClient) { + too_many_watch = true; + } else { + watch.insert(device_id); + } + } + } + if (too_many_watch) { + send_error("Too many devices on one connection"); + return; + } + // StopRemoteDesktop 会再次锁 m_ClientsMutex,必须在锁外调用。 + for (uint64_t id : to_stop) { + StopRemoteDesktop(id); + } + + // Start remote desktop session if this is the first web viewer. 若之前已订阅 + // 但捕获已意外结束(例如 MFC 对话框关闭),补一次冷启动。 + bool need_start = (current_count == 0) || + (already && GetScreenContext(device_id) == nullptr); + if (need_start) { if (!StartRemoteDesktop(device_id)) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Failed to start remote desktop")); + // 回滚本次插入,避免一个没有屏幕会话的设备长期占着订阅/计数。 + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end()) { + it->second.watch_device_ids.erase(device_id); + } + } + send_error("Failed to start remote desktop"); return; } } @@ -724,6 +808,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t Json::Value res; res["cmd"] = "connect_result"; res["ok"] = true; + res["id"] = std::to_string(device_id); + if (!req.empty()) res["req"] = req; // Only include dimensions if we have valid cached values // Otherwise, client will wait for resolution_changed message if (width > 0 && height > 0) { @@ -741,48 +827,72 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, res)); - // Send cached keyframe if available + // Send cached keyframe if available. 缓存里是 raw H.264 payload(见 + // BroadcastH264Frame),这里统一用 BuildFramePacket 封装。 + std::vector cached; { std::lock_guard lock(m_DeviceCacheMutex); auto it = m_DeviceCache.find(device_id); - if (it != m_DeviceCache.end() && !it->second->keyframe_cache.empty()) { + if (it != m_DeviceCache.end()) { std::lock_guard cache_lock(it->second->cache_mutex); - auto packet = BuildFramePacket(device_id, true, - it->second->keyframe_cache.data(), - it->second->keyframe_cache.size()); - SendBinary(ws_ptr, packet.data(), packet.size()); + cached = it->second->keyframe_cache; } } + if (!cached.empty()) { + auto packet = BuildFramePacket(device_id, true, cached.data(), cached.size()); + SendBinary(ws_ptr, packet.data(), packet.size()); + } } -void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { +void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id, + const std::string& req, bool multi_watch) { + auto send_reply = [&](bool ok, const std::string& err) { + Json::Value res; + res["cmd"] = "disconnect_result"; + res["ok"] = ok; + if (!err.empty()) res["msg"] = err; + if (requested_device_id > 0) res["id"] = std::to_string(requested_device_id); + if (!req.empty()) res["req"] = req; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); + }; + std::string username, role; if (!ValidateToken(token, username, role)) { - SendText(ws_ptr, BuildJsonResponse("disconnect_result", false, "Invalid token")); + send_reply(false, "Invalid token"); return; } - // Get the device_id before clearing watch state - uint64_t device_id = 0; + std::vector to_stop; { std::lock_guard lock(m_ClientsMutex); auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { - // Only disconnect if no specific device requested, or if it matches current device - // This prevents race condition when quickly switching devices - if (requested_device_id == 0 || it->second.watch_device_id == requested_device_id) { - device_id = it->second.watch_device_id; - it->second.watch_device_id = 0; + auto& watch = it->second.watch_device_ids; + if (multi_watch && requested_device_id != 0) { + // 屏幕墙:只取消这一路订阅,其它设备继续。 + if (watch.erase(requested_device_id) > 0) { + it->second.keyframe_req_at.erase(requested_device_id); + to_stop.push_back(requested_device_id); + } + } else if (requested_device_id == 0 || watch.count(requested_device_id) > 0) { + // 旧前端 / 全量断开:保持原来的“请求 id 必须匹配”的竞态保护, + // 快速切换设备时不会误清刚建立的新订阅。 + to_stop.assign(watch.begin(), watch.end()); + watch.clear(); + it->second.keyframe_req_at.clear(); } } } - // Close remote desktop if this was the last viewer - if (device_id > 0) { - StopRemoteDesktop(device_id); + // Close remote desktop if this was the last viewer. StopRemoteDesktop 会再次 + // 锁 m_ClientsMutex,必须在锁外调用。 + for (uint64_t id : to_stop) { + StopRemoteDesktop(id); } - SendText(ws_ptr, BuildJsonResponse("disconnect_result", true)); + send_reply(true, ""); } void CWebService::HandlePing(void* ws_ptr, const std::string& token) { @@ -795,6 +905,22 @@ void CWebService::HandlePing(void* ws_ptr, const std::string& token) { SendText(ws_ptr, Json::writeString(builder, res)); } +uint64_t CWebService::ResolveWatchDevice(void* ws_ptr, uint64_t requested_device_id) { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it == m_Clients.end()) return 0; + const auto& watch = it->second.watch_device_ids; + + if (requested_device_id != 0) { + // 显式 id 必须属于本连接的订阅集合,防止越权向别的设备注入输入。 + return watch.count(requested_device_id) ? requested_device_id : 0; + } + // 旧前端不带 id:只有集合恰好一台设备时才回退。多设备时绝不猜, + // 否则会变成跨设备输入注入。 + if (watch.size() == 1) return *watch.begin(); + return 0; +} + void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) { // Parse JSON Json::Value root; @@ -817,16 +943,7 @@ void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) { } // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id")); if (device_id == 0) return; // Get screen context (not main context!) @@ -904,16 +1021,7 @@ void CWebService::HandleKey(void* ws_ptr, const std::string& msg) { } // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id")); if (device_id == 0) return; // Get screen context @@ -953,16 +1061,7 @@ void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) { } // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id")); if (device_id == 0) return; // Get screen context @@ -989,24 +1088,15 @@ void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) { ctx->Send2Client(szData.data(), (ULONG)szData.size()); } -void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token) { +void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { // Validate token std::string username, role; if (!ValidateToken(token, username, role)) { return; } - // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + // Get the device being watched + uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id); if (device_id == 0) return; // Get screen context @@ -1020,7 +1110,7 @@ void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token) { ctx->Send2Client(&bToken, sizeof(BYTE)); } -void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) { +void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { std::string username, role; if (!ValidateToken(token, username, role)) { SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Invalid token")); @@ -1028,15 +1118,7 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) { } // Get the device being watched by this client - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id); if (device_id == 0) { SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "No device connected")); return; @@ -1060,19 +1142,14 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) { } } -void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) { +void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { std::string username, role; if (!ValidateToken(token, username, role)) { SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "Invalid token")); return; } - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) device_id = it->second.watch_device_id; - } + uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id); if (device_id == 0) { SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected")); return; @@ -1087,6 +1164,78 @@ void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) { SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", true)); } +void CWebService::HandleKeyframe(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req) { + Json::Value res; + res["cmd"] = "keyframe_result"; + res["id"] = std::to_string(device_id); + if (!req.empty()) res["req"] = req; + + auto send = [&](bool ok, const std::string& err) { + res["ok"] = ok; + if (!err.empty()) res["msg"] = err; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); + }; + + std::string username, role; + if (!ValidateToken(token, username, role)) { + send(false, "Invalid token"); + return; + } + if (device_id == 0) { + send(false, "No device"); + return; + } + + // 校验该 ws 确实订阅了这台设备;同一设备最多 1 次/秒,前端重试不会打爆编码器。 + bool allowed = false; + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end() && it->second.watch_device_ids.count(device_id)) { + uint64_t now = (uint64_t)time(nullptr); + uint64_t& last = it->second.keyframe_req_at[device_id]; + if (last == 0 || now > last) { + last = now; + allowed = true; + } + } + } + if (!allowed) { + send(false, "Not watching device or rate limited"); + return; + } + + CONTEXT_OBJECT* ctx = GetScreenContext(device_id); + if (!ctx) { + send(false, "No active screen session"); + return; + } + + // 先把缓存里的 raw IDR 单独发给请求方,让它立刻有帧可画;随后再让客户端 + // 强制生成新的 IDR 重置参考链(缓存 IDR 无法保证后续 P 帧的引用正确)。 + std::vector cached; + { + std::lock_guard lock(m_DeviceCacheMutex); + auto it = m_DeviceCache.find(device_id); + if (it != m_DeviceCache.end()) { + std::lock_guard cache_lock(it->second->cache_mutex); + cached = it->second->keyframe_cache; + } + } + if (!cached.empty()) { + auto packet = BuildFramePacket(device_id, true, cached.data(), cached.size()); + SendBinary(ws_ptr, packet.data(), packet.size()); + } + + BYTE bToken = COMMAND_SCREEN_KEYFRAME_REQ; + ctx->Send2Client(&bToken, sizeof(BYTE)); + + send(true, ""); +} + ////////////////////////////////////////////////////////////////////////// // User Management Handlers ////////////////////////////////////////////////////////////////////////// @@ -1280,20 +1429,21 @@ void CWebService::RegisterClient(void* ws_ptr, const std::string& client_ip) { } void CWebService::UnregisterClient(void* ws_ptr) { - uint64_t device_id = 0; + std::set watched; - // Get device_id and remove client + // Copy the watch set and remove the client under the lock. { std::lock_guard lock(m_ClientsMutex); auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; + watched = std::move(it->second.watch_device_ids); m_Clients.erase(it); } } - // Close remote desktop if this was the last viewer - if (device_id > 0) { + // Close remote desktops whose last viewer just disconnected. + // StopRemoteDesktop re-locks m_ClientsMutex, so this must run outside the lock. + for (uint64_t device_id : watched) { StopRemoteDesktop(device_id); } @@ -1317,7 +1467,7 @@ int CWebService::GetWebClientCount(uint64_t device_id) { std::lock_guard lock(m_ClientsMutex); int count = 0; for (const auto& [ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { count++; } } @@ -1664,7 +1814,7 @@ void CWebService::BroadcastFrame(uint64_t device_id, const uint8_t* data, size_t // Broadcast to all watching clients std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendBinary(ws_ptr, packet.data(), packet.size()); } } @@ -1689,15 +1839,19 @@ void CWebService::BroadcastH264Frame(uint64_t device_id, const uint8_t* data, si if (!data || len < 9 || m_bStopping) return; // Broadcast to all watching clients - std::lock_guard lock(m_ClientsMutex); - for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { - SendBinary(ws_ptr, data, len); + { + std::lock_guard lock(m_ClientsMutex); + for (auto& [ws_ptr, client] : m_Clients) { + if (client.watch_device_ids.count(device_id)) { + SendBinary(ws_ptr, data, len); + } } } - // Cache keyframe (check FrameType byte at offset 4) + // Cache keyframe (check FrameType byte at offset 4). 只缓存 raw payload: + // HandleConnect/HandleKeyframe 发送时会统一用 BuildFramePacket 封装, + // 缓存完整包会导致双重封装(历史 bug)。 if (data[4] == 1) { // IDR frame - CacheKeyframe(device_id, data, len); + CacheKeyframe(device_id, data + 9, len - 9); } } @@ -1716,12 +1870,17 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig it->second->screen_height = height; it->second->top_down = top_down; if (!client_type.empty()) it->second->client_type = client_type; + // 旧 IDR 对应旧分辨率/编码器,不能再发给新加入者。 + { + std::lock_guard cache_lock(it->second->cache_mutex); + it->second->keyframe_cache.clear(); + } } // Notify watching clients Json::Value res; res["cmd"] = "resolution_changed"; - res["id"] = device_id; + res["id"] = std::to_string(device_id); res["width"] = width; res["height"] = height; res["top_down"] = top_down; @@ -1733,7 +1892,7 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1766,7 +1925,7 @@ void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) { Json::Value res; res["cmd"] = "audio_state"; - res["id"] = device_id; + res["id"] = std::to_string(device_id); res["enabled"] = enabled; Json::StreamWriterBuilder builder; @@ -1775,7 +1934,7 @@ void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) { std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1787,6 +1946,7 @@ void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) { // Build JSON message Json::Value res; res["cmd"] = "cursor"; + res["id"] = std::to_string(device_id); res["index"] = cursor_index; Json::StreamWriterBuilder builder; @@ -1796,7 +1956,7 @@ void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) { // Send to all watching clients std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1807,7 +1967,7 @@ void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& tex Json::Value res; res["cmd"] = "clipboard_text"; - res["id"] = device_id; + res["id"] = std::to_string(device_id); res["text"] = text; Json::StreamWriterBuilder builder; @@ -1816,7 +1976,7 @@ void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& tex std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1855,6 +2015,18 @@ bool CWebService::StartRemoteDesktop(uint64_t device_id) { // Send COMMAND_SCREEN_SPY with H264 algorithm // If client is already capturing (MFC opened first), it will re-send TOKEN_BITMAPINFO // This creates a new hidden Web dialog while MFC dialog remains visible + + // Cold start: drop any stale IDR cached from a previous capture session, it + // may have a different resolution / encoder instance. + { + std::lock_guard lock(m_DeviceCacheMutex); + auto it = m_DeviceCache.find(device_id); + if (it != m_DeviceCache.end()) { + std::lock_guard cache_lock(it->second->cache_mutex); + it->second->keyframe_cache.clear(); + } + } + BYTE bToken[32] = { 0 }; bToken[0] = COMMAND_SCREEN_SPY; bToken[1] = 0; // DXGI mode: 0=GDI @@ -1872,7 +2044,7 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) { { std::lock_guard lock(m_ClientsMutex); for (const auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { watchingCount++; } } @@ -1901,7 +2073,7 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) { { std::lock_guard lock(m_ClientsMutex); for (const auto& [ws, client] : m_Clients) - if (client.watch_device_id == device_id) count++; + if (client.watch_device_ids.count(device_id)) count++; } if (count == 0 && m_pParentDlg) { ClearWebTriggered(device_id); @@ -2223,14 +2395,20 @@ void CWebService::FlushDeviceChanges() { if (!offline.empty()) { std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (offline.count(client.watch_device_id)) { + // 一个 ws 可能同时看多台设备:只移除真正离线的那些,保留其它。 + std::vector gone; + for (uint64_t id : offline) { + if (client.watch_device_ids.count(id)) gone.push_back(id); + } + for (uint64_t id : gone) { Json::Value res; res["cmd"] = "device_offline"; - res["id"] = std::to_string(client.watch_device_id); + res["id"] = std::to_string(id); Json::StreamWriterBuilder builder; builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, res)); - client.watch_device_id = 0; + client.watch_device_ids.erase(id); + client.keyframe_req_at.erase(id); } } } diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index 4488767..c1fe072 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -68,12 +68,16 @@ struct WebClient { std::string token; std::string username; std::string role; // "admin" | "viewer" - uint64_t watch_device_id; // 0 = not watching any device + // 当前这个 ws 订阅的设备集合。屏幕墙允许多路复用;旧前端仍按单设备语义使用 + // (connect 替换整个集合)。 + std::set watch_device_ids; + // 每台设备的 keyframe 请求节流时间戳(秒),防止前端重试打爆服务端/客户端。 + std::map keyframe_req_at; uint64_t connected_at; uint64_t last_activity; // Last message/pong received (for heartbeat timeout) std::string client_ip; - WebClient() : watch_device_id(0), connected_at(0), last_activity(0) {} + WebClient() : connected_at(0), last_activity(0) {} }; // Login attempt tracking for rate limiting @@ -185,15 +189,24 @@ private: void HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip); void HandleGetSalt(void* ws_ptr, const std::string& msg); void HandleGetDevices(void* ws_ptr, const std::string& token); - void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id); - void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0); + void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req = "", bool multi_watch = false); + void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0, + const std::string& req = "", bool multi_watch = false); + void HandleKeyframe(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req); void HandlePing(void* ws_ptr, const std::string& token); void HandleMouse(void* ws_ptr, const std::string& msg); void HandleKey(void* ws_ptr, const std::string& msg); void HandleClipboardSet(void* ws_ptr, const std::string& msg); - void HandleClipboardGet(void* ws_ptr, const std::string& token); - void HandleRdpReset(void* ws_ptr, const std::string& token); - void HandleAudioToggle(void* ws_ptr, const std::string& token); + void HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t device_id = 0); + void HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t device_id = 0); + void HandleAudioToggle(void* ws_ptr, const std::string& token, uint64_t device_id = 0); + + // 输入类命令的设备解析:优先用显式 id(必须在 watch 集合内);否则当集合恰好 + // 只有一台设备时回退到它(兼容不带 id 的旧前端)。多设备且无 id 时返回 0(丢弃), + // 绝不能回退到任意集合成员,否则会变成跨设备输入注入。 + uint64_t ResolveWatchDevice(void* ws_ptr, uint64_t requested_device_id); // Token management std::string GenerateToken(const std::string& username, const std::string& role); @@ -246,6 +259,10 @@ private: static const int HEARTBEAT_INTERVAL_SEC = 30; // Send ping every 30 seconds static const int HEARTBEAT_TIMEOUT_SEC = 90; // Disconnect if no activity for 90 seconds + // 每个 ws 最多订阅的设备数(屏幕墙最多 25 个 tile,留余量)。防止异常客户端 + // 订阅数百台设备拖慢每一帧的广播循环。 + static constexpr size_t kMaxWatchPerClient = 32; + // Heartbeat thread function void HeartbeatThread(); diff --git a/server/web/index.html b/server/web/index.html index 62eeb99..363d97e 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -1198,6 +1198,213 @@ .toolbar-btn-bar { display: none; } .toolbar-toggle { display: flex; } } + /* ===== Screen Wall ===== */ + #view-wall { display: none; } + #view-wall.available { display: inline-block; } + .wall-btn { border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 8px; } + + #wall-page { max-width: none; padding-bottom: 16px; } + .wall-header { + display: flex; justify-content: space-between; align-items: center; + gap: 12px; flex-wrap: wrap; margin-bottom: 12px; + } + .wall-title-wrap { display: flex; align-items: center; gap: 10px; min-width: 0; } + .wall-title-wrap h2 { color: #e94560; font-size: 22px; display: flex; align-items: center; gap: 10px; margin: 0; } + .wall-back-btn { + width: 36px; height: 36px; flex: 0 0 36px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(15, 52, 96, 0.8); color: #eee; + font-size: 18px; line-height: 1; cursor: pointer; + } + .wall-back-btn:hover { background: rgba(233, 69, 96, 0.3); color: #e94560; } + .wall-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .wall-layouts { + display: flex; border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; overflow: hidden; + } + .wall-layouts .view-btn { border-right: 1px solid rgba(255, 255, 255, 0.08); border-radius: 0; } + .wall-layouts .view-btn:last-child { border-right: none; } + .wall-clear-btn { + padding: 8px 14px; border: 1px solid rgba(233, 69, 96, 0.35); + border-radius: 8px; background: rgba(233, 69, 96, 0.12); + color: #e94560; font-size: 13px; cursor: pointer; + } + .wall-clear-btn:hover { background: rgba(233, 69, 96, 0.25); } + .wall-drawer-btn { + display: none; width: 40px; height: 36px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(15, 52, 96, 0.8); color: #eee; font-size: 16px; cursor: pointer; + } + + .wall-body { + display: flex; gap: 12px; + height: calc(100vh - 120px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + min-height: 320px; + } + .wall-sidebar { + width: 236px; flex: 0 0 236px; display: flex; flex-direction: column; + background: rgba(15, 20, 40, 0.65); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 12px; overflow: hidden; + } + .wall-sidebar-head { padding: 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } + .wall-search { width: 100%; } + .wall-search input { width: 100%; padding: 8px 28px 8px 10px; font-size: 13px; } + .wall-device-list { + flex: 1; overflow-y: auto; padding: 6px; + /* 深色主题滚动条:Firefox 用标准属性,Chrome/Edge 用 ::-webkit-scrollbar */ + scrollbar-width: thin; + scrollbar-color: rgba(233, 69, 96, 0.45) rgba(255, 255, 255, 0.04); + } + .wall-device-list::-webkit-scrollbar { width: 8px; } + .wall-device-list::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); border-radius: 4px; + } + .wall-device-list::-webkit-scrollbar-thumb { + background: rgba(233, 69, 96, 0.45); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; + } + .wall-device-list::-webkit-scrollbar-thumb:hover { + background: rgba(233, 69, 96, 0.7); background-clip: padding-box; + } + .wall-device-list::-webkit-scrollbar-corner { background: transparent; } + .wall-device-row { + display: flex; align-items: center; gap: 8px; padding: 8px 10px; + border-radius: 8px; cursor: pointer; user-select: none; + touch-action: manipulation; /* 触屏双击打开时不触发缩放 */ + } + .wall-device-row:hover { background: rgba(233, 69, 96, 0.12); } + .wall-device-row.offline { opacity: 0.45; cursor: not-allowed; } + .wall-dot { width: 8px; height: 8px; border-radius: 50%; background: #4caf50; flex: 0 0 8px; } + .wall-device-row.offline .wall-dot { background: #666; } + .wall-device-name { + flex: 1; min-width: 0; font-size: 13px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .wall-device-badge { + background: rgba(233, 69, 96, 0.2); color: #e94560; + border-radius: 10px; padding: 1px 7px; font-size: 11px; flex: 0 0 auto; + } + .wall-device-info { color: #777; font-size: 11px; } + .wall-sidebar-empty { color: #777; font-size: 12px; text-align: center; padding: 24px 8px; } + + /* 侧边栏设备悬浮提示:复用 Grid 设备卡片的内容结构(.info/.meta-row/.active-window), + 这里只覆盖浮层定位与外观,让提示信息与 Grid 模式完全一致。 */ + .wall-tooltip { + position: fixed; + z-index: 300; + width: 270px; + max-width: calc(100vw - 24px); + padding: 12px 14px; + background: rgba(22, 33, 62, 0.97); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55); + cursor: default; + pointer-events: none; + display: none; + } + .wall-tooltip.visible { display: block; } + /* .device-card 的悬停位移与顶部渐变条不适用于浮层 */ + .wall-tooltip:hover { transform: none; border-color: rgba(255, 255, 255, 0.12); } + .wall-tooltip::before { display: none; } + .wall-tooltip h3 { font-size: 14px; margin-bottom: 8px; padding-right: 0; } + + .wall-grid { + flex: 1; min-width: 0; height: 100%; + display: grid; gap: 8px; grid-auto-rows: minmax(0, 1fr); + align-content: stretch; overflow: hidden; + } + .wall-empty { + grid-column: 1 / -1; grid-row: 1 / -1; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; color: #667; font-size: 14px; text-align: center; + border: 1px dashed rgba(255, 255, 255, 0.1); border-radius: 12px; + } + .wall-empty-icon { font-size: 40px; opacity: 0.5; } + .wall-tile { + position: relative; display: flex; flex-direction: column; + min-width: 0; min-height: 0; background: #070b14; + border: 1px solid rgba(255, 255, 255, 0.09); border-radius: 10px; overflow: hidden; + } + .wall-tile:hover { border-color: rgba(233, 69, 96, 0.45); } + .wall-tile.focused { border-color: #e94560; box-shadow: 0 0 0 1px rgba(233, 69, 96, 0.4); } + .wall-tile-head { + display: flex; align-items: center; gap: 6px; padding: 5px 8px; + background: rgba(15, 20, 40, 0.85); font-size: 12px; flex: 0 0 auto; + } + .wall-tile-name { + flex: 1; min-width: 0; color: #ddd; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .wall-tile-badge { + font-size: 10px; padding: 1px 6px; border-radius: 8px; + background: rgba(255, 255, 255, 0.08); color: #999; flex: 0 0 auto; + } + .wall-tile-badge.live { background: rgba(76, 175, 80, 0.18); color: #7bd88f; } + .wall-tile-badge.resuming { background: rgba(255, 193, 7, 0.18); color: #ffd54f; } + .wall-tile-badge.paused { background: rgba(255, 255, 255, 0.08); color: #aaa; } + .wall-tile-badge.offline { background: rgba(158, 158, 158, 0.18); color: #bbb; } + .wall-tile-badge.error { background: rgba(233, 69, 96, 0.22); color: #ff7b8d; } + .wall-tile-badge.waiting { background: rgba(33, 150, 243, 0.18); color: #64b5f6; } + .wall-tile-close { + border: none; background: transparent; color: #888; + font-size: 16px; line-height: 1; cursor: pointer; + padding: 2px 5px; border-radius: 4px; flex: 0 0 auto; + } + .wall-tile-close:hover { color: #e94560; background: rgba(233, 69, 96, 0.15); } + .wall-tile-body { + position: relative; flex: 1; min-height: 0; display: flex; + align-items: center; justify-content: center; + background: #000; cursor: pointer; overflow: hidden; + } + .wall-tile-body canvas { + max-width: 100%; max-height: 100%; width: auto; height: auto; + display: block; object-fit: contain; + } + .wall-tile-hover { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.35); opacity: 0; transition: opacity 0.15s; + color: #fff; font-size: 13px; pointer-events: none; text-align: center; padding: 8px; + } + .wall-tile-body:hover .wall-tile-hover { opacity: 1; } + .wall-tile-error { + position: absolute; inset: 0; display: none; align-items: center; justify-content: center; + background: rgba(20, 5, 10, 0.85); color: #ff7b8d; font-size: 12px; + text-align: center; padding: 12px; line-height: 1.5; + } + .wall-tile.has-error .wall-tile-error { display: flex; } + + #wall-sidebar-backdrop { + display: none; position: fixed; inset: 0; z-index: 55; + background: rgba(0, 0, 0, 0.5); + } + @media (max-width: 768px) { + #wall-page { + padding: calc(10px + env(safe-area-inset-top)) + calc(10px + env(safe-area-inset-right)) + calc(10px + env(safe-area-inset-bottom)) + calc(10px + env(safe-area-inset-left)); + } + .wall-title-wrap h2 { font-size: 18px; } + .wall-body { height: calc(100vh - 96px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); gap: 0; } + .wall-sidebar { + position: fixed; left: 0; top: 0; bottom: 0; z-index: 60; + width: 78vw; max-width: 300px; flex: none; + border-radius: 0 12px 12px 0; background: #0d1226; + transform: translateX(-105%); transition: transform 0.2s ease; + } + .wall-sidebar.open { transform: translateX(0); box-shadow: 8px 0 30px rgba(0, 0, 0, 0.6); } + .wall-drawer-btn { display: inline-flex; align-items: center; justify-content: center; } + #wall-sidebar-backdrop.open { display: block; } + .wall-grid { gap: 5px; } + .wall-tile-head { padding: 3px 6px; font-size: 11px; } + .wall-tile-badge { font-size: 9px; padding: 0 5px; } + .wall-tile-close { font-size: 14px; padding: 1px 4px; } + .wall-layouts .view-btn { padding: 8px 9px; font-size: 12px; } + .wall-clear-btn { padding: 8px 10px; font-size: 12px; } + } @@ -1241,6 +1448,7 @@ + +

Screen Wall 0

+ +
+
+ + + + + +
+ + + +
+ +
+ +
+
+
+
🖥
+
Double-click a device on the left to add it to the wall
+
+
+
+
@@ -1433,6 +1685,41 @@ const canvas = document.getElementById('screen-canvas'); const ctx = canvas.getContext('2d'); + // ===================== Screen Wall (multi-device) ===================== + // 该常量必须在脚本顶部:wall 状态初始化立即用到它,后文声明会触发 TDZ。 + const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + + const WALL_MAX_TILES = 25; + const WALL_LAYOUTS = [1, 4, 9, 16, 25]; + const WALL_KEYFRAME_RETRY_MS = 1500; + const WALL_KEYFRAME_MAX_RETRIES = 3; + const WALL_STORAGE_KEY = 'yama_wall_state_v1'; + // 解码预算:桌面 9 路,触屏设备硬件解码器少,降到 4 路。 + // 用函数延迟求值(isTouchDevice 的 TDZ 已解决,但保留函数形式便于调试)。 + const wallLiveBudget = () => (isTouchDevice ? 4 : 9); + // 二进制帧头只带设备 ID 的低 32 位(C++ BuildFramePacket 截断)。 + const low32 = (id) => { + try { return Number(BigInt(id) & 0xFFFFFFFFn); } catch (e) { return NaN; } + }; + + // 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。 + let serverCaps = { multiWatch: false, keyframeRequest: false }; + let wall = { + active: false, + layout: isTouchDevice ? 4 : 9, + tiles: [], // {uid, deviceId, low32, canvas, el} + sessions: new Map(), // low32 -> WallSession + search: '', + focusDeviceId: null, + fromWall: false, // 单画面页是否由 tile 放大进入 + uidSeq: 1 + }; + let wallRestored = false; + // 本地订阅引用计数:key 为 JSON 协议里的完整 id 字符串。 + // owners = Set<'focus' | 'wall:uid'> + let streams = new Map(); + let reqSeq = 1; + // ====== Audio & Video Implementation ====== // // - Video: H.264 / AV1 → VideoDecoder Web API → canvas @@ -1768,19 +2055,45 @@ startPingInterval(); // Auto-restore session if token exists if (token) { - // Check if we were on screen-page with an active device const screenPage = document.getElementById('screen-page'); + const wallPage = document.getElementById('wall-page'); if (screenPage.classList.contains('active') && currentDevice) { // Reconnect to current device updateScreenStatus('connecting'); - ws.send(JSON.stringify({ cmd: 'connect', id: String(currentDevice.id), token })); + needKeyframe = true; + const sid = String(currentDevice.id); + const st = streams.get(sid); + if (!st || st.refcount === 0) acquireStream(sid, 'focus'); + else wallSendConnect(sid); + } else if (wallPage.classList.contains('active')) { + // 服务端已丢失 watch 集合:按本地引用计数重新订阅, + // 然后按预算恢复墙内解码(暂停中的 session 会请求 keyframe)。 + resubscribeAll(); + wallAllocBudget(); + getDevices(); } else { showPage('devices-page'); getDevices(); } } }; - ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); stopAllAudio(); audioFormat = null; scheduleReconnect(); }; + ws.onclose = () => { + stopPingInterval(); + updateWsStatus('disconnected'); + stopAllAudio(); + audioFormat = null; + // WS 断开:关闭所有墙解码器释放 GPU 资源,保留 tile 布局。 + // streams 保留,重连时 resubscribeAll() 重发 connect。 + for (const s of wall.sessions.values()) { + s.pendingConnect = false; + s.needKeyframe = true; + const wasLive = s.live; + wallCloseDecoder(s); + s.live = false; + if (wasLive) wallSetStatus(s, 'paused'); + } + scheduleReconnect(); + }; ws.onerror = (e) => console.error('WS error:', e); ws.onmessage = (event) => { if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data)); @@ -1804,7 +2117,13 @@ switch (msg.cmd) { case 'challenge': challengeNonce = msg.nonce || ''; - console.log('Received challenge nonce'); + // 能力协商:旧 C++ / Go 服务端没有 caps → 隐藏 Wall,保持单设备行为。 + serverCaps = { + multiWatch: !!(msg.caps && msg.caps.multiWatch), + keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest) + }; + updateWallAvailability(); + console.log('Received challenge nonce; caps=', serverCaps); break; case 'salt': if (msg.ok) { @@ -1845,9 +2164,26 @@ devices = msg.devices || []; // Keep current page (renderDeviceList adjusts if out of range) renderDeviceList(); + renderWallSidebar(); + wallUpdateAllTileMeta(); break; - case 'connect_result': + case 'connect_result': { if (!token) break; + const resId = (msg.id !== undefined && msg.id !== null) ? String(msg.id) : null; + const focusId = currentDevice ? String(currentDevice.id) : null; + const onScreen = document.getElementById('screen-page').classList.contains('active'); + const isFocus = resId !== null + ? (onScreen && focusId === resId) + : (onScreen && !!currentDevice); + + // 墙 session:记录尺寸/状态并请求关键帧。即使同时是聚焦设备 + // (重复 connect)也更新,避免墙 tile 卡在 Waiting。 + if (resId !== null) { + const wsess = wall.sessions.get(low32(resId)); + if (wsess && wsess.fullId === resId) wallApplyConnectResult(wsess, msg); + } + if (!isFocus) break; + if (msg.ok) { // Resolution may not be available yet (first connection) if (msg.width && msg.height) { @@ -1866,14 +2202,32 @@ if (typeof msg.audio_enabled === 'boolean') { applyAudioState(msg.audio_enabled); } + // 用新 IDR 重置参考链(缓存 IDR 只用于第一帧立即出图)。 + requestFocusKeyframe(focusId); } else { updateScreenStatus('error', msg.msg); - setTimeout(() => showPage('devices-page'), 2000); + const failedId = resId || focusId; + setTimeout(() => { + // 连接失败:释放引用,避免 streams 残留导致后续重订阅泄漏。 + // 无论用户是否已切换都必须释放(释放的是失败设备的 owner)。 + if (failedId) releaseStream(failedId, 'focus'); + // 2 秒内用户可能已返回设备页、进入墙或新开了别的设备: + // 只有仍停留在这台失败设备上时才导航,避免打断新会话。 + if (!currentDevice || String(currentDevice.id) !== failedId) return; + currentDevice = null; + wall.fromWall = false; + showPage('devices-page'); + }, 2000); } break; - case 'audio_state': + } + case 'audio_state': { + // 只处理当前聚焦设备;墙静音。 + if (msg.id !== undefined && msg.id !== null && currentDevice && + String(msg.id) !== String(currentDevice.id)) break; applyAudioState(!!msg.enabled); break; + } case 'audio_toggle_result': if (!msg.ok) console.warn('[Audio] toggle failed:', msg.msg); break; @@ -1899,7 +2253,33 @@ // disconnect() already handles navigation, this is just server acknowledgment // No action needed - prevents race conditions when switching devices break; - case 'resolution_changed': + case 'resolution_changed': { + const rcId = (msg.id !== undefined && msg.id !== null) ? String(msg.id) : null; + const focusId = currentDevice ? String(currentDevice.id) : null; + const onScreen = document.getElementById('screen-page').classList.contains('active'); + const isFocus = rcId !== null + ? (onScreen && focusId === rcId) + : onScreen; + + if (rcId !== null) { + const wsess = wall.sessions.get(low32(rcId)); + if (wsess && wsess.fullId === rcId && + typeof msg.width === 'number' && typeof msg.height === 'number') { + const changed = (wsess.width !== msg.width || wsess.height !== msg.height); + wsess.width = msg.width; + wsess.height = msg.height; + wsess.topDown = msg.top_down === true; + wallPrepareCanvasDimensions(wsess); + if (changed) wallCloseDecoder(wsess); + if (wall.active && wsess.live) { + wsess.needKeyframe = true; + wallSetStatus(wsess, 'resuming'); + wallRequestKeyframe(wsess, true); + } + } + } + if (!isFocus) break; + updateScreenStatus('connected'); isTopDown = msg.top_down === true; if (msg.client_type !== undefined) { @@ -1912,7 +2292,11 @@ } initDecoder(msg.width, msg.height); break; - case 'cursor': + } + case 'cursor': { + // 墙 tile 不画远程光标;只处理聚焦设备。 + if (msg.id !== undefined && msg.id !== null && currentDevice && + String(msg.id) !== String(currentDevice.id)) break; // Update remote cursor visual: native CSS cursor on desktop, // overlay variant on touch. 254=custom (unsupported), 255=unsupported. currentCursorIndex = msg.index; @@ -1920,19 +2304,41 @@ applyRemoteCursor(msg.index); } break; - case 'clipboard_text': - // 远程剪贴板文本回传:写回浏览器(即本机)剪贴板 + } + case 'clipboard_text': { + // 远程剪贴板文本回传:写回浏览器(即本机)剪贴板。墙忽略。 + if (msg.id !== undefined && msg.id !== null && currentDevice && + String(msg.id) !== String(currentDevice.id)) break; if (msg.text) { try { navigator.clipboard.writeText(msg.text); } catch (e) {} } break; - case 'device_offline': - // Only handle if this is the device we're currently viewing + } + case 'device_offline': { if (!token) break; - if (!currentDevice || String(msg.id) !== String(currentDevice.id)) break; + const offId = String(msg.id); + // 服务端已从 watch 集合移除该设备:本地引用同步失效(无论墙 + // 内是否有 session),tile 保留,设备重新上线后点击 tile 可重连。 + // 若不删除,残留 refcount 会让重新加入墙时缺少 0→1 的 connect。 + streams.delete(offId); + const wsess = wall.sessions.get(low32(offId)); + if (wsess && wsess.fullId === offId) { + wallCloseDecoder(wsess); + wsess.live = false; + wallSetStatus(wsess, 'offline'); + wallAllocBudget(); + wallUpdateSidebarBadges(); + } + // 聚焦设备继续走原有提示并返回设备页。 + if (!currentDevice || offId !== String(currentDevice.id)) break; updateScreenStatus('error', 'Device offline'); setTimeout(() => { showPage('devices-page'); getDevices(); }, 2000); break; + } + case 'keyframe_result': + // 以实际收到 keyframe 为准;失败时等待自然 IDR。 + if (msg.ok === false) console.warn('[Wall] keyframe request failed:', msg.msg); + break; case 'device_update': // Only update if on devices page if (document.getElementById('devices-page').classList.contains('active')) { @@ -1941,7 +2347,8 @@ break; case 'devices_changed': // Refresh device list when devices come online/offline - if (document.getElementById('devices-page').classList.contains('active')) { + if (document.getElementById('devices-page').classList.contains('active') || + document.getElementById('wall-page').classList.contains('active')) { getDevices(); } break; @@ -2059,6 +2466,915 @@ return videoBytes[0] === 0x00 ? 'avc' : 'av1'; } + // ===================================================================== + // Screen Wall: multi-device monitoring + // + // 一个标签页仍然只有一条 WS。墙为每台设备维护一个 WallSession + // (一个 VideoDecoder + N 个 tile canvas),同一设备的重复 tile 共享 + // session。streams 是对服务端 watch 集合的本地引用计数: + // refresh 0→1 时发送 connect,1→0 时发送 disconnect。 + // 二进制帧只按 low32(deviceId) 路由(帧头截断),JSON 响应按完整 id。 + // ===================================================================== + + function updateWallAvailability() { + const btn = document.getElementById('view-wall'); + if (!btn) return; + const ok = !!(serverCaps.multiWatch && checkWebCodecs().supported); + btn.classList.toggle('available', ok); + // Go / 旧 C++ 服务端没有 multiWatch:墙不可用。 + if (!ok && wall.active) { + // 能力在登录后才到达,若已在墙页则退回设备页。 + showPage('devices-page'); + } + } + + function wallNextReq() { return String(reqSeq++); } + + // 单画面页请求一次新 IDR:晚加入 / 从墙放大的设备可立即重建参考链。 + // 服务端按 1 次/秒/设备限流,失败时自然 GOP 兜底。 + function requestFocusKeyframe(deviceId) { + if (!serverCaps.keyframeRequest || !deviceId) return; + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + try { + ws.send(JSON.stringify({ + cmd: 'keyframe', id: String(deviceId), token, req: wallNextReq() + })); + } catch (e) {} + } + + function currentDeviceId() { + return currentDevice ? String(currentDevice.id) : undefined; + } + + // ---------------- 订阅引用计数 ---------------- + + function acquireStream(deviceId, owner) { + const id = String(deviceId); + let st = streams.get(id); + if (!st) { + st = { refcount: 0, owners: new Set(), connected: false }; + streams.set(id, st); + } + if (st.owners.has(owner)) return; + st.owners.add(owner); + st.refcount++; + if (st.refcount === 1) { + st.connected = false; + wallSendConnect(id); + } + } + + function releaseStream(deviceId, owner) { + const id = String(deviceId); + const st = streams.get(id); + if (!st || !st.owners.has(owner)) return; + st.owners.delete(owner); + st.refcount--; + if (st.refcount <= 0) { + streams.delete(id); + wallSendDisconnect(id); + } + } + + function wallSendConnect(id) { + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + const sid = String(id); + const st = streams.get(sid); + if (st) st.connected = true; + const s = wall.sessions.get(low32(sid)); + if (s) s.pendingConnect = true; + ws.send(JSON.stringify({ + cmd: 'connect', id: sid, token, req: wallNextReq(), multiWatch: true + })); + } + + function wallSendDisconnect(id) { + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + ws.send(JSON.stringify({ + cmd: 'disconnect', id: String(id), token, req: wallNextReq(), multiWatch: true + })); + } + + // WS 重连后,服务端已丢失所有 watch 状态,按本地引用计数重发 connect。 + function resubscribeAll() { + for (const [id, st] of streams) { + if (st.refcount > 0) wallSendConnect(id); + } + } + + // ---------------- WallSession / 解码器 ---------------- + + function wallCreateSession(fullId) { + const id = String(fullId); + const l32 = low32(id); + const s = { + fullId: id, low32: l32, + decoder: null, codec: '', + width: 0, height: 0, topDown: false, + needKeyframe: true, live: false, + status: 'waiting', error: '', + canvases: new Set(), + decodeTimestamp: 0, lastFrameAt: 0, + keyframeReqAt: 0, keyframeRetries: 0, keyframeTimer: null, + decodeFailures: 0, + pendingConnect: false + }; + wall.sessions.set(l32, s); + return s; + } + + function wallCloseDecoder(s) { + if (s.keyframeTimer) { clearTimeout(s.keyframeTimer); s.keyframeTimer = null; } + if (s.decoder) { try { s.decoder.close(); } catch (e) {} s.decoder = null; } + s.needKeyframe = true; + } + + function wallSetStatus(s, status, error) { + if (!s) return; + s.status = status; + s.error = error || ''; + for (const tile of wall.tiles) { + if (tile.low32 === s.low32) wallUpdateTileBadge(tile); + } + } + + function wallPrepareCanvasDimensions(s) { + if (!s.width || !s.height) return; + for (const c of s.canvases) { + if (c.width !== s.width) c.width = s.width; + if (c.height !== s.height) c.height = s.height; + const cx = c.getContext('2d'); + if (s.topDown) cx.setTransform(1, 0, 0, 1, 0, 0); + else cx.setTransform(1, 0, 0, -1, 0, c.height); + } + } + + function wallInitDecoder(s) { + wallCloseDecoder(s); + if (!s.live) return false; + const codecStr = s.codec === 'av1' ? 'av01.0.08M.08' : 'avc1.42E01E'; + try { + s.decoder = new VideoDecoder({ + output: (frame) => wallOnDecodedFrame(s, frame), + error: (e) => { + console.error('[Wall] decoder error', s.fullId, e); + // 已从墙移除的旧 session(被关闭后迟到的回调):不再操作状态或重试。 + if (wall.sessions.get(s.low32) !== s) return; + s.decodeFailures++; + s.needKeyframe = true; + wallCloseDecoder(s); + if (s.decodeFailures >= 3) { + s.live = false; + wallSetStatus(s, 'error', + 'Decoder failed: ' + ((e && e.message) ? e.message : e)); + wallAllocBudget(); + } else { + wallSetStatus(s, 'resuming'); + wallRequestKeyframe(s, true, 500); + } + } + }); + const cfg = { codec: codecStr, optimizeForLatency: true }; + // 码流参数(SPS)由首个 keyframe 携带;有 connect_result 尺寸时 + // 一并提供,便于解码器尽早分配输出缓冲。 + if (s.width > 0) cfg.codedWidth = s.width; + if (s.height > 0) cfg.codedHeight = s.height; + s.decoder.configure(cfg); + s.decodeTimestamp = 0; + s.needKeyframe = true; + return true; + } catch (e) { + console.error('[Wall] configure failed', s.fullId, e); + s.decoder = null; + s.decodeFailures++; + s.live = false; + wallSetStatus(s, 'error', + 'Cannot create decoder: ' + ((e && e.message) ? e.message : e)); + // 指数退避重试(硬件解码器耗尽等临时故障) + if (s.decodeFailures < 3) { + setTimeout(() => { + // session 可能已被移除、被移除后同 low32 重新创建,或已被其它路径恢复。 + // 必须比较对象身份:仅判断 key 存在会误操作已孤立的旧 session。 + if (s.live || wall.sessions.get(s.low32) !== s || s.status !== 'error') return; + s.live = true; + s.needKeyframe = true; + wallSetStatus(s, 'resuming'); + wallAllocBudget(); + wallRequestKeyframe(s, true); + }, 2000 * s.decodeFailures); + } else { + // 放弃该 session,把预算槽让给其它 tile。 + wallAllocBudget(); + } + return false; + } + } + + function wallOnDecodedFrame(s, frame) { + try { + if (!s.live) return; + s.lastFrameAt = performance.now(); + const fw = frame.displayWidth || s.width; + const fh = frame.displayHeight || s.height; + for (const c of s.canvases) { + if (!c.isConnected) continue; + if (fw && fh && (c.width !== fw || c.height !== fh)) { + c.width = fw; + c.height = fh; + if (fw !== s.width || fh !== s.height) { + s.width = fw; + s.height = fh; + } + } + const cx = c.getContext('2d'); + // Windows/Linux/macOS 客户端发的是 bottom-up,需要垂直翻转; + // Android MediaCodec 是 top-down。 + if (s.topDown) cx.setTransform(1, 0, 0, 1, 0, 0); + else cx.setTransform(1, 0, 0, -1, 0, c.height); + cx.drawImage(frame, 0, 0); + } + if (s.live && s.status !== 'live') wallSetStatus(s, 'live'); + } catch (e) { + console.warn('[Wall] draw failed', s.fullId, e); + } finally { + try { frame.close(); } catch (e) {} + } + } + + function wallHandleFrame(s, data, frameType, dataLen) { + if (!s || !s.live || s.status === 'error' || s.status === 'offline') return; + if (dataLen <= 0) return; + const isKey = frameType === 1; + const videoData = new Uint8Array(data, 9, dataLen); + const frameCodec = detectCodec(videoData); + + // codec 切换必须等 keyframe 才能重建解码器 + if (s.codec && frameCodec !== s.codec) { + if (!isKey) { + s.needKeyframe = true; + wallRequestKeyframe(s); + return; + } + s.codec = ''; + wallCloseDecoder(s); + } + + if (!s.decoder) { + if (!isKey) { + s.needKeyframe = true; + wallRequestKeyframe(s); + return; + } + s.codec = frameCodec; + if (!wallInitDecoder(s)) return; + } + if (!s.decoder || s.decoder.state !== 'configured') { + s.needKeyframe = true; + return; + } + if (s.needKeyframe && !isKey) { + wallRequestKeyframe(s); + return; + } + // 与单画面路径一致:解码队列积压时丢弃 P 帧并等待 IDR 重新同步 + if (!isKey && s.decoder.decodeQueueSize > 10) { + s.needKeyframe = true; + wallRequestKeyframe(s); + return; + } + try { + s.decoder.decode(new EncodedVideoChunk({ + type: isKey ? 'key' : 'delta', + timestamp: s.decodeTimestamp++, + data: videoData + })); + if (isKey) { + s.needKeyframe = false; + s.keyframeRetries = 0; + if (s.keyframeTimer) { + clearTimeout(s.keyframeTimer); + s.keyframeTimer = null; + } + } + } catch (e) { + console.warn('[Wall] decode failed', s.fullId, e); + s.needKeyframe = true; + wallRequestKeyframe(s); + } + } + + // 请求客户端下一帧强制 IDR。带节流 + 1.5s 重试(最多 3 次); + // 旧客户端不认识该命令时回退为等待自然 GOP,UI 显示 Resuming…。 + function wallRequestKeyframe(s, force, delay) { + if (!s || !s.live) return; + if (!serverCaps.keyframeRequest) return; + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + const now = performance.now(); + if (!force && now - s.keyframeReqAt < 1000) return; + if (!force && s.keyframeRetries >= WALL_KEYFRAME_MAX_RETRIES) return; + if (s.keyframeTimer) { clearTimeout(s.keyframeTimer); s.keyframeTimer = null; } + + const send = () => { + if (!s.live) return; + if (!s.needKeyframe && s.status === 'live') return; + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + s.keyframeReqAt = performance.now(); + if (!force) s.keyframeRetries++; + try { + ws.send(JSON.stringify({ + cmd: 'keyframe', id: s.fullId, token, req: wallNextReq() + })); + } catch (e) {} + if (s.status !== 'live') wallSetStatus(s, 'resuming'); + if (s.needKeyframe && s.keyframeRetries < WALL_KEYFRAME_MAX_RETRIES) { + s.keyframeTimer = setTimeout(() => { + s.keyframeTimer = null; + if (s.live && s.needKeyframe) wallRequestKeyframe(s); + }, WALL_KEYFRAME_RETRY_MS); + } + }; + if (delay) s.keyframeTimer = setTimeout(send, delay); + else send(); + } + + function wallResumeSession(s) { + if (!s || s.live) return; + if (s.status === 'error' || s.status === 'offline') return; + s.live = true; + s.needKeyframe = true; + s.keyframeRetries = 0; + s.decodeFailures = 0; + s.decodeTimestamp = 0; + // 解码器延迟到首个 keyframe 再创建:必须先用 detectCodec 嗅探 + // H.264/AV1,否则用错 codec 配置会直接解码失败。 + wallSetStatus(s, 'resuming'); + // connect 尚未完成时不要请求 keyframe:服务端还没开始抓屏,请求会被丢弃。 + // connect_result 到达后会再请求一次。 + if (!s.pendingConnect) wallRequestKeyframe(s, true); + } + + function wallPauseSession(s) { + if (!s || !s.live) return; + s.live = false; + wallCloseDecoder(s); + if (s.status === 'live' || s.status === 'resuming' || s.status === 'waiting') { + wallSetStatus(s, 'paused'); + } + } + + function wallPauseAll() { + for (const s of wall.sessions.values()) wallPauseSession(s); + } + + // 按当前布局/在线状态计算可见 session,并分配解码预算。 + // 超出预算的 tile 暂停解码但保留末帧;已有 live session 优先保留(LRU)。 + function wallAllocBudget() { + if (!wall.active) { wallPauseAll(); return; } + const budget = wallLiveBudget(); + const visible = []; + const seen = new Set(); + wall.tiles.forEach((tile, idx) => { + if (idx >= wall.layout) return; + const s = wall.sessions.get(tile.low32); + if (!s || seen.has(s.low32)) return; + seen.add(s.low32); + if (s.status === 'offline' || s.status === 'error') return; + visible.push(s); + }); + + let keep = visible.filter(s => s.live); + const rest = visible.filter(s => !s.live); + if (keep.length > budget) { + keep.sort((a, b) => (b.lastFrameAt || 0) - (a.lastFrameAt || 0)); + keep.slice(budget).forEach(wallPauseSession); + keep = keep.slice(0, budget); + } + const winners = keep.concat(rest.slice(0, Math.max(0, budget - keep.length))); + const winnerIds = new Set(winners.map(s => s.low32)); + for (const s of wall.sessions.values()) { + if (s.live && !winnerIds.has(s.low32)) wallPauseSession(s); + } + winners.forEach(wallResumeSession); + } + + // ---------------- tile / 布局 / 侧边栏 ---------------- + + function wallUpdateTileMeta(tile) { + if (!tile.el) return; + const dev = devices.find(d => String(d.id) === tile.deviceId); + const name = dev ? displayName(dev) : ('Device ' + tile.deviceId); + const nameEl = tile.el.querySelector('.wall-tile-name'); + if (nameEl) { + nameEl.textContent = name; + nameEl.title = name; + } + wallUpdateTileBadge(tile); + } + + function wallUpdateTileBadge(tile) { + if (!tile.el) return; + const s = wall.sessions.get(tile.low32); + let status = 'waiting', label = 'Waiting', error = ''; + if (s) { + status = s.status; + error = s.error || ''; + if (status === 'live') label = 'Live'; + else if (status === 'resuming') label = 'Resuming…'; + else if (status === 'paused') label = 'Paused'; + else if (status === 'offline') label = 'Offline'; + else if (status === 'error') label = 'Error'; + else label = 'Waiting'; + } else { + status = 'error'; + label = 'Error'; + error = 'Session unavailable'; + } + const badge = tile.el.querySelector('.wall-tile-badge'); + if (badge) { + badge.className = 'wall-tile-badge ' + status; + badge.textContent = label; + } + tile.el.classList.toggle('has-error', status === 'error'); + const errEl = tile.el.querySelector('.wall-tile-error'); + if (errEl) errEl.textContent = error; + } + + function wallCreateTileEl(tile) { + const el = document.createElement('div'); + el.className = 'wall-tile'; + el.dataset.uid = tile.uid; + el.innerHTML = + '
' + + '' + + 'Waiting' + + '' + + '
' + + '
' + + '
' + + '
Click to control
' + + '
'; + const body = el.querySelector('.wall-tile-body'); + // canvas 放在最前,保证 error / hover 覆盖层绘制在它上面。 + body.insertBefore(tile.canvas, body.firstChild); + el.querySelector('.wall-tile-close').addEventListener('click', (ev) => { + ev.stopPropagation(); + removeWallTile(tile.uid); + }); + body.addEventListener('click', () => focusWallTile(tile.uid)); + return el; + } + + function wallRenderGrid() { + const grid = document.getElementById('wall-grid'); + if (!grid) return; + const n = Math.max(1, Math.round(Math.sqrt(wall.layout)) || 1); + grid.style.gridTemplateColumns = 'repeat(' + n + ', minmax(0, 1fr))'; + grid.style.gridTemplateRows = 'repeat(' + n + ', minmax(0, 1fr))'; + const empty = document.getElementById('wall-empty'); + if (empty) empty.style.display = wall.tiles.length ? 'none' : 'flex'; + // 移除已删除的 tile 节点 + Array.from(grid.querySelectorAll('.wall-tile')).forEach(el => { + if (!wall.tiles.some(t => t.uid === el.dataset.uid)) el.remove(); + }); + wall.tiles.forEach((tile, idx) => { + if (tile.el.parentNode !== grid) grid.appendChild(tile.el); + tile.el.style.display = idx < wall.layout ? '' : 'none'; + wallUpdateTileMeta(tile); + }); + const count = document.getElementById('wall-tile-count'); + if (count) count.textContent = String(wall.tiles.length); + } + + function wallAddTileInternal(deviceId, restoring) { + const id = String(deviceId); + const l32 = low32(id); + if (!Number.isFinite(l32)) return null; + if (wall.tiles.length >= WALL_MAX_TILES) return null; + const existing = wall.sessions.get(l32); + if (existing && existing.fullId !== id) { + // 两台设备的低 32 位相同:绝不能共用一个解码器。 + if (!restoring) alert('Device ID collision (low 32 bits). Cannot open both on the same wall.'); + return null; + } + const uid = 'w' + (wall.uidSeq++); + const tile = { + uid, deviceId: id, low32: l32, + canvas: document.createElement('canvas'), + el: null + }; + tile.el = wallCreateTileEl(tile); + wall.tiles.push(tile); + + let s = existing; + if (!s) s = wallCreateSession(id); + s.canvases.add(tile.canvas); + if (s.width > 0 && s.height > 0) wallPrepareCanvasDimensions(s); + wallUpdateTileMeta(tile); + return tile; + } + + function addWallTile(deviceId) { + if (!serverCaps.multiWatch) { + alert('Screen Wall is not supported by this server'); + return; + } + if (wall.tiles.length >= WALL_MAX_TILES) { + alert('Wall is full (max ' + WALL_MAX_TILES + ' tiles)'); + return; + } + const dev = devices.find(d => String(d.id) === String(deviceId)); + const tile = wallAddTileInternal(deviceId, false); + if (!tile) return; + + if (dev && !dev.online) { + wallSetStatus(wall.sessions.get(tile.low32), 'offline'); + } else { + acquireStream(tile.deviceId, 'wall:' + tile.uid); + wallAllocBudget(); + } + wallRenderGrid(); + wallSaveState(); + wallUpdateSidebarBadges(); + if (isTouchDevice) toggleWallSidebar(false); + } + + function removeWallTile(uid) { + const idx = wall.tiles.findIndex(t => t.uid === uid); + if (idx < 0) return; + const tile = wall.tiles[idx]; + wall.tiles.splice(idx, 1); + releaseStream(tile.deviceId, 'wall:' + tile.uid); + const s = wall.sessions.get(tile.low32); + if (s) { + s.canvases.delete(tile.canvas); + if (s.canvases.size === 0) { + wallCloseDecoder(s); + wall.sessions.delete(s.low32); + } + } + if (tile.el && tile.el.parentNode) tile.el.remove(); + wallRenderGrid(); + wallSaveState(); + wallAllocBudget(); + wallUpdateSidebarBadges(); + } + + function clearWall() { + if (!wall.tiles.length) return; + if (!confirm('Close all tiles on the wall?')) return; + const tiles = wall.tiles.slice(); + wall.tiles = []; + for (const tile of tiles) { + releaseStream(tile.deviceId, 'wall:' + tile.uid); + if (tile.el && tile.el.parentNode) tile.el.remove(); + } + for (const s of wall.sessions.values()) wallCloseDecoder(s); + wall.sessions.clear(); + wallRenderGrid(); + wallSaveState(); + wallUpdateSidebarBadges(); + } + + function wallIsKnownOffline(id) { + const d = devices.find(x => String(x.id) === String(id)); + return !!d && !d.online; + } + + function wallSyncTileSubscriptions() { + wall.tiles.forEach((tile, idx) => { + const owner = 'wall:' + tile.uid; + if (idx < wall.layout && !wallIsKnownOffline(tile.deviceId)) { + acquireStream(tile.deviceId, owner); + } else { + releaseStream(tile.deviceId, owner); + } + }); + } + + function wallSyncLayoutButtons() { + document.querySelectorAll('#wall-layouts .view-btn').forEach(b => { + b.classList.toggle('active', parseInt(b.dataset.layout, 10) === wall.layout); + }); + } + + function setWallLayout(n) { + if (!WALL_LAYOUTS.includes(n)) return; + wall.layout = n; + wallSyncLayoutButtons(); + wallRenderGrid(); + wallSyncTileSubscriptions(); + if (wall.active) wallAllocBudget(); + wallSaveState(); + } + + function wallFilteredDevices() { + const q = (wall.search || '').toLowerCase(); + if (!q) return devices; + return devices.filter(d => + (d.name && d.name.toLowerCase().includes(q)) || + (d.remark && d.remark.toLowerCase().includes(q)) || + (d.ip && d.ip.toLowerCase().includes(q)) || + (d.os && d.os.toLowerCase().includes(q)) || + (d.location && d.location.toLowerCase().includes(q)) + ); + } + + // 侧边栏行的双击判定间隔。用 click 计时而不是原生 dblclick: + // 桌面双击与触屏双击(double-tap)都会产生两次 click,行为一致。 + const WALL_ROW_DBLCLICK_MS = 400; + let wallLastRowClick = null; + let wallTooltipEl = null; + + function wallFindDevice(id) { + return devices.find(d => String(d.id) === id) || null; + } + + function wallTooltipHtml(d) { + // 与 Grid 模式 device-card 的字段/顺序保持一致。 + const screenInfo = escapeHtml(d.screen || '-'); + const rtt = d.rtt || '-'; + const activeWin = d.activeWindow || ''; + return '

' + escapeHtml(displayName(d)) + '

' + + '
' + + '
IP: ' + escapeHtml(d.ip || '-') + '
' + + '
Loc: ' + escapeHtml(d.location || '-') + '
' + + '
' + + '
OS: ' + escapeHtml(d.os || '-') + '
' + + '
' + + 'RTT: ' + escapeHtml(rtt) + '' + + 'Ver: ' + escapeHtml(d.version || '-') + '' + + '' + screenInfo + '' + + '
' + + (activeWin ? '
' + + escapeHtml(activeWin) + '
' : ''); + } + + function wallShowTooltip(row) { + // 只在真正支持悬停的指针设备上显示:触屏 tap 会顺带触发 mouseenter, + // 浮层会挡住双击目标;触屏笔记本外接鼠标时 (hover: hover) 仍为真。 + const canHover = window.matchMedia && window.matchMedia('(hover: hover)').matches; + if (!canHover) return; + const d = wallFindDevice(row.dataset.deviceId); + if (!d) return; + if (!wallTooltipEl) { + wallTooltipEl = document.createElement('div'); + wallTooltipEl.className = 'device-card wall-tooltip'; + wallTooltipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(wallTooltipEl); + } + wallTooltipEl.innerHTML = wallTooltipHtml(d); + wallTooltipEl.classList.add('visible'); + const rect = row.getBoundingClientRect(); + const tipRect = wallTooltipEl.getBoundingClientRect(); + const gap = 10; + // 优先放在行右侧;右侧放不下(窄屏/抽屉)就夹在视口内。 + let left = rect.right + gap; + if (left + tipRect.width > window.innerWidth - 8) { + left = Math.max(8, window.innerWidth - tipRect.width - 8); + } + let top = rect.top + rect.height / 2 - tipRect.height / 2; + top = Math.max(8, Math.min(top, window.innerHeight - tipRect.height - 8)); + wallTooltipEl.style.left = left + 'px'; + wallTooltipEl.style.top = top + 'px'; + } + + function wallHideTooltip() { + if (wallTooltipEl) wallTooltipEl.classList.remove('visible'); + } + + function renderWallSidebar() { + const host = document.getElementById('wall-device-list'); + if (!host) return; + // 列表重渲染后旧行已销毁,必须先收起浮层,否则会悬在页面上不消失。 + // 注意不要清空 wallLastRowClick:重渲染可能发生在双击的两下之间, + // 保留计时才能让第二次点击仍然生效。 + wallHideTooltip(); + if (!devices.length) { + host.innerHTML = '
Loading devices...
'; + return; + } + const list = wallFilteredDevices(); + if (!list.length) { + host.innerHTML = '
No matching devices
'; + return; + } + const counts = new Map(); + for (const t of wall.tiles) counts.set(t.deviceId, (counts.get(t.deviceId) || 0) + 1); + host.innerHTML = list.map(d => { + const id = String(d.id); + const n = counts.get(id) || 0; + const name = displayName(d); + return '
' + + '' + + '' + escapeHtml(name) + '' + + (n ? '' + n + '' : '') + + '
'; + }).join(''); + host.onscroll = wallHideTooltip; + host.querySelectorAll('.wall-device-row').forEach(row => { + // 单击不做任何事,避免误触新开画面;双击才加入墙。 + row.addEventListener('click', () => { + if (row.classList.contains('offline')) return; + const id = row.dataset.deviceId; + const now = Date.now(); + if (wallLastRowClick && wallLastRowClick.id === id && + now - wallLastRowClick.at <= WALL_ROW_DBLCLICK_MS) { + wallLastRowClick = null; + addWallTile(id); + return; + } + wallLastRowClick = { id, at: now }; + }); + row.addEventListener('mouseenter', () => wallShowTooltip(row)); + row.addEventListener('mouseleave', wallHideTooltip); + }); + } + + function wallUpdateSidebarBadges() { + if (wall.active) renderWallSidebar(); + } + + function wallUpdateAllTileMeta() { + for (const tile of wall.tiles) wallUpdateTileMeta(tile); + } + + function onWallSearchChange() { + wall.search = document.getElementById('wall-search-input').value || ''; + const clear = document.getElementById('wall-search-clear'); + if (clear) clear.style.display = wall.search ? 'block' : 'none'; + renderWallSidebar(); + } + + function clearWallSearch() { + const input = document.getElementById('wall-search-input'); + if (input) input.value = ''; + onWallSearchChange(); + } + + function toggleWallSidebar(force) { + const sb = document.getElementById('wall-sidebar'); + const bd = document.getElementById('wall-sidebar-backdrop'); + if (!sb) return; + const open = (typeof force === 'boolean') ? force : !sb.classList.contains('open'); + sb.classList.toggle('open', open); + if (bd) bd.classList.toggle('open', open); + if (!open) { + wallHideTooltip(); + wallLastRowClick = null; + } + } + + function focusWallTile(uid) { + const tile = wall.tiles.find(t => t.uid === uid); + if (!tile) return; + const dev = devices.find(d => String(d.id) === tile.deviceId); + if (!dev) { alert('Device not found'); return; } + if (!dev.online) { alert('Device is offline'); return; } + const s = wall.sessions.get(tile.low32); + if (s && s.status === 'offline') { + // 设备重新上线:离线时服务端已移除 watch,且 streams 条目被整体删除, + // 因此需为所有属于该设备的 tile 重新建立 owner(只补当前 tile 会让 + // 同设备的其它 tile 在后续 release 时计数不平衡)。 + wallSetStatus(s, 'waiting'); + s.needKeyframe = true; + s.live = false; + wallSyncTileSubscriptions(); + wallAllocBudget(); + return; + } + connectDevice(tile.deviceId, true); + } + + // ---------------- 墙页生命周期 ---------------- + + function wallEnterPage() { + if (!wallRestored) wallRestoreState(); + // 视口变化后提示层的位置会失效,直接收起(同一回调重复注册会被去重)。 + window.addEventListener('resize', wallHideTooltip, { passive: true }); + renderWallSidebar(); + wallRenderGrid(); + wallSyncTileSubscriptions(); + wallAllocBudget(); + } + + function wallLeavePage() { + // 释放墙订阅:进入单画面时 focus 引用已在 connectDevice 中先建立, + // 因此聚焦设备的流不会 1→0 断开。 + for (const tile of wall.tiles) releaseStream(tile.deviceId, 'wall:' + tile.uid); + wallPauseAll(); + wallSaveState(); + toggleWallSidebar(false); + } + + function showWall() { + if (!serverCaps.multiWatch) { + alert('Screen Wall requires a newer server with multi-view support.'); + return; + } + const compat = checkWebCodecs(); + if (!compat.supported) { + alert('Browser does not support H.264: ' + compat.reason); + return; + } + if (!wallRestored) wallRestoreState(); + showPage('wall-page'); + } + + function closeWall() { + showPage('devices-page'); + getDevices(); + } + + function wallSaveState() { + try { + localStorage.setItem(WALL_STORAGE_KEY, JSON.stringify({ + layout: wall.layout, + tiles: wall.tiles.map(t => t.deviceId) + })); + } catch (e) {} + } + + function wallRestoreState() { + wallRestored = true; + try { + const raw = localStorage.getItem(WALL_STORAGE_KEY); + if (raw) { + const data = JSON.parse(raw); + if (WALL_LAYOUTS.includes(data.layout)) wall.layout = data.layout; + if (Array.isArray(data.tiles)) { + data.tiles.slice(0, WALL_MAX_TILES).forEach(id => { + wallAddTileInternal(String(id), true); + }); + } + } + } catch (e) { + console.warn('[Wall] restore failed', e); + } + wallSyncLayoutButtons(); + wallRenderGrid(); + } + + // 登出 / 关闭页面时彻底清理(不发送 disconnect:WS 即将关闭, + // 服务端 UnregisterClient 会清理 watch 集合)。 + function wallTeardown(clearStorage) { + wallHideTooltip(); + for (const tile of wall.tiles) { + if (tile.el && tile.el.parentNode) tile.el.remove(); + } + wall.tiles = []; + for (const s of wall.sessions.values()) wallCloseDecoder(s); + wall.sessions.clear(); + streams.clear(); + wall.fromWall = false; + wall.focusDeviceId = null; + if (clearStorage) { + try { localStorage.removeItem(WALL_STORAGE_KEY); } catch (e) {} + } + wallRenderGrid(); + wallUpdateSidebarBadges(); + } + + // connect_result / resolution_changed 的墙侧处理。 + function wallApplyConnectResult(s, msg) { + if (!s) return; + s.pendingConnect = false; + if (msg.ok === false) { + wallCloseDecoder(s); + s.live = false; + wallSetStatus(s, 'error', msg.msg || 'Connection failed'); + wallAllocBudget(); + return; + } + if (msg.width && msg.height) { + const changed = (s.width !== msg.width || s.height !== msg.height); + s.width = msg.width; + s.height = msg.height; + wallPrepareCanvasDimensions(s); + if (changed && s.decoder) wallCloseDecoder(s); + } + if (typeof msg.top_down === 'boolean') s.topDown = msg.top_down === true; + if (s.width && s.height) wallPrepareCanvasDimensions(s); + + if (s.live) { + // 服务端随 connect_result 会先发一帧缓存的 IDR(立即出图), + // 再请求一个新 IDR 重置参考链。 + s.needKeyframe = true; + s.keyframeRetries = 0; + if (s.keyframeTimer) { clearTimeout(s.keyframeTimer); s.keyframeTimer = null; } + wallSetStatus(s, 'resuming'); + wallRequestKeyframe(s, true); + } else { + // 之前失败/离线的 session 现在连接成功:解除 Error/Offline, + // 否则 wallAllocBudget 会永久跳过它,tile 再也无法恢复。 + if (s.status === 'waiting' || s.status === 'error' || s.status === 'offline') { + wallSetStatus(s, 'paused'); + if (wall.active) wallAllocBudget(); + } + } + } + + // ============================================================ // Minimal WebM-Opus muxer: wraps each Opus packet in a one-block // Cluster so it can be fed to a SourceBuffer of type @@ -2353,18 +3669,34 @@ // Audio frame: frameType byte at offset 4 indicates audio (96 = TOKEN_SCREEN_AUDIO) // Full frame format: [deviceID:4][frameType:1][dataLen:4][hasFormat:1][AudioFormat?][audio_data...] - if (u8.length > 4 && u8[4] === 96) { - // Skip frame header (9 bytes) and pass audio payload to handler + if (u8.length < 9) return; + const view = new DataView(data); + const frameDeviceId = view.getUint32(0, true); // 低 32 位 + const frameType = view.getUint8(4); + const dataLen = view.getUint32(5, true); + + const onScreenPage = document.getElementById('screen-page').classList.contains('active'); + const focusedLow = (onScreenPage && currentDevice) ? low32(currentDevice.id) : NaN; + + if (frameType === 96) { + // 墙是静音监控墙:只播放当前聚焦设备的音频。 + if (!onScreenPage || !currentDevice || focusedLow !== frameDeviceId) return; const audioPayload = data.slice(9); handleAudioFrame(audioPayload); return; } + if (dataLen < 0 || 9 + dataLen > data.byteLength) return; + + // 墙路由:二进制帧头只有低 32 位,必须用 low32 查 session。 + if (!onScreenPage || focusedLow !== frameDeviceId) { + const session = wall.sessions.get(frameDeviceId); + if (session && wall.active) { + wallHandleFrame(session, data, frameType, dataLen); + } + return; + } // Video frame: [deviceID:4][frameType:1][dataLen:4][videoData...] - const view = new DataView(data); - const deviceId = view.getUint32(0, true); - const frameType = view.getUint8(4); - const dataLen = view.getUint32(5, true); const isKeyframe = frameType === 1; const videoData = new Uint8Array(data, 9, dataLen); const frameCodec = dataLen > 0 ? detectCodec(videoData) : currentCodec; @@ -2624,6 +3956,16 @@ } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById(pageId).classList.add('active'); + + // 屏幕墙生命周期:进入时恢复 tile/订阅,离开时释放墙订阅并暂停解码。 + const wasWall = wall.active; + const willWall = pageId === 'wall-page'; + wall.active = willWall; + if (!wasWall && willWall) { + wallEnterPage(); + } else if (wasWall && !willWall) { + wallLeavePage(); + } } function updateScreenStatus(status, msg) { @@ -2683,6 +4025,8 @@ } function confirmLogout() { document.getElementById('logout-confirm-modal').classList.remove('active'); + // 彻底清理屏幕墙(tile/session/订阅引用)并清除持久化布局。 + wallTeardown(true); // Close and reconnect WebSocket to get new challenge if (ws) { ws.onclose = null; // Prevent auto-reconnect delay @@ -2806,7 +4150,7 @@ ws.send(JSON.stringify({ cmd: 'get_devices', token })); } - function connectDevice(id) { + function connectDevice(id, fromWall) { const dev = devices.find(d => d.id === id || d.id === String(id)); if (!dev || !dev.online) { alert('Device is offline'); @@ -2822,7 +4166,13 @@ } const compat = checkWebCodecs(); if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } + const sid = String(id); + // 同页直接切设备(正常 UI 不会发生):先释放旧 focus 引用。 + if (currentDevice && String(currentDevice.id) !== sid) { + releaseStream(currentDevice.id, 'focus'); + } currentDevice = dev; + wall.fromWall = !!fromWall; document.getElementById('device-name').textContent = displayName(currentDevice); document.getElementById('frame-info').textContent = ''; // Reset throughput / resolution read-outs for the new session @@ -2835,8 +4185,16 @@ // Default the audio button to "on" optimistically; server will // correct via connect_result.audio_enabled or audio_state event. applyAudioState(true); + + // 先建立 focus 引用,再 showPage(离开墙时会释放墙订阅), + // 这样墙里正在观看的这条流不会 1→0 被断开。 + const st = streams.get(sid); + const hadStream = !!st && st.refcount > 0; + acquireStream(sid, 'focus'); + // 已由墙订阅时显式重复 connect,以获取最新分辨率/关键帧; + // 服务端对已 watch 的同一设备放行。 + if (hadStream) wallSendConnect(sid); showPage('screen-page'); - ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token })); } // ====== Web 终端(xterm.js)====== @@ -3313,7 +4671,7 @@ function sendRdpReset() { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'rdp_reset', token })); + ws.send(JSON.stringify({ cmd: 'rdp_reset', token, id: currentDeviceId() })); // Visual feedback - rotate icon const btn = event.target; btn.style.transform = 'rotate(360deg)'; @@ -3345,12 +4703,12 @@ function toggleAudio() { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'audio_toggle', token })); + ws.send(JSON.stringify({ cmd: 'audio_toggle', token, id: currentDeviceId() })); } } // Detect touch device (mobile/tablet) - const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + // 注意:该常量在屏幕墙状态初始化时就要用到,因此必须在脚本顶部声明(见 wall 状态块)。 function toggleControl() { controlEnabled = !controlEnabled; @@ -3751,7 +5109,7 @@ // Update cursor overlay position updateCursorOverlay(x, y); if (ws && ws.readyState === WebSocket.OPEN && token) { - const msg = { cmd: 'mouse', token, type, x, y, button: button || 0 }; + const msg = { cmd: 'mouse', token, id: currentDeviceId(), type, x, y, button: button || 0 }; if (delta !== undefined) msg.delta = delta; ws.send(JSON.stringify(msg)); } @@ -3773,7 +5131,7 @@ // Filter Windows keys (handled locally, not sent to remote) if (keyCode === 91 || keyCode === 92) return; // VK_LWIN, VK_RWIN if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: !!altKey })); + ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: !!altKey })); } } @@ -3783,7 +5141,7 @@ const press = () => { sendKey(86, true, false); sendKey(86, false, false); }; navigator.clipboard.readText().then(function(text) { if (text && ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text })); + ws.send(JSON.stringify({ cmd: 'clipboard_set', token, id: currentDeviceId(), text })); } press(); }).catch(function() { press(); }); // 读剪贴板被拒/为空:仍执行一次普通远程粘贴 @@ -3794,7 +5152,7 @@ function scheduleCopyFromRemote() { setTimeout(function() { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'clipboard_get', token })); + ws.send(JSON.stringify({ cmd: 'clipboard_get', token, id: currentDeviceId() })); } }, 250); } @@ -3803,7 +5161,7 @@ // "I" 组合框和 1/2/3…/中 同属输入辅助栏,不应要求先开鼠标控制。 function sendKeyRaw(keyCode, isDown) { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false })); + ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: false })); } } @@ -3812,7 +5170,7 @@ function sendTextToRemote(text) { if (!text) return; if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text })); + ws.send(JSON.stringify({ cmd: 'clipboard_set', token, id: currentDeviceId(), text })); } sendKeyRaw(17, true); // Ctrl down sendKeyRaw(86, true); // V down @@ -4532,15 +5890,27 @@ canvas.style.transformOrigin = ''; if (decoder) { try { decoder.close(); } catch(e) {} decoder = null; } - if (ws && ws.readyState === WebSocket.OPEN && token && currentDevice) { - ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) })); - } + // 墙内设备仍可能被订阅(tile 还在),音频必须显式停止。 + stopAllAudio(); + audioFormat = null; + const focusId = currentDevice ? String(currentDevice.id) : null; + const backToWall = wall.fromWall; currentDevice = null; // Clear current device + wall.fromWall = false; isAndroidRemote = false; updateAndroidNavbar(); document.getElementById('screen-page').classList.remove('android-portrait'); - showPage('devices-page'); - getDevices(); + if (backToWall) { + // 先进入墙页(重新建立墙订阅),再释放 focus 引用: + // 该设备还有 tile 时 refcount 保持 ≥1,流不断,tile 立即可恢复。 + showPage('wall-page'); + if (focusId) releaseStream(focusId, 'focus'); + getDevices(); + } else { + if (focusId) releaseStream(focusId, 'focus'); + showPage('devices-page'); + getDevices(); + } } function escapeHtml(text) { @@ -4578,6 +5948,8 @@ function doBackgroundDisconnect() { cancelReconnect(); stopPingInterval(); + // 关闭墙解码器,避免留下失效的 VideoDecoder;tile 布局保留。 + wallPauseAll(); if (ws) { ws.onclose = null; ws.close(); @@ -4592,11 +5964,18 @@ // Page going to background const screenPage = document.getElementById('screen-page'); const termPage = document.getElementById('term-page'); + const wallPage = document.getElementById('wall-page'); const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; const onTermPage = termPage && termPage.classList.contains('active') && termState.deviceId; + const onWallPage = wallPage && wallPage.classList.contains('active'); - if (onScreenPage || onTermPage) { - // 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续; + if (onWallPage) { + // 墙:关闭所有解码器省电/释放 GPU,订阅保留,回前台按预算恢复。 + wallPauseAll(); + } + + if (onScreenPage || onTermPage || onWallPage) { + // 屏幕预览 / 终端 / 墙:移动端给 30 秒宽限,期间切回应用就无缝继续; // 桌面:保持长连,靠 ping 心跳 if (isTouchDevice) { backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE); @@ -4612,7 +5991,9 @@ backgroundDisconnectTimer = null; } const screenPage = document.getElementById('screen-page'); + const wallPage = document.getElementById('wall-page'); const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; + const onWallPage = wallPage && wallPage.classList.contains('active'); if (!ws || ws.readyState !== WebSocket.OPEN) { // WS was dropped — reset decoder before reconnect. @@ -4637,6 +6018,16 @@ ws.onclose = null; ws.close(); connectWebSocket(); + } else if (onWallPage && isTouchDevice) { + // 移动端同样重建连接(decoder 已在 hidden 时关闭), + // onopen 会按本地引用计数重新订阅墙内设备。 + ws.onclose = null; + ws.close(); + connectWebSocket(); + } else if (onWallPage) { + // 桌面:连接还在,直接恢复解码预算(暂停的 session 会请求 keyframe)。 + wallAllocBudget(); + if (token) ws.send(JSON.stringify({ cmd: 'ping', token })); } else if (token) { ws.send(JSON.stringify({ cmd: 'ping', token })); } @@ -4679,7 +6070,7 @@ // Shortcuts only visible when keyboard is open, so no extra check needed function sendShortcutKey(keyCode, isDown) { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false })); + ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: false })); } } const shortcutBtns = document.querySelectorAll('.shortcut-btn');