Files
SimpleRemoter/server/web/index.html
T
yuanyuanxianganddeepseek-flash 8eab26c1f6 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
2026-10-18 11:03:17 +02:00

6203 lines
297 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
<title>__BRAND_NAME__</title>
<!-- PWA / iOS Standalone App Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="__BRAND_NAME__">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="__BASE_PATH__/manifest.json">
<!-- xterm.js (远程终端). Files served by WebService from RC binary resources. -->
<link rel="stylesheet" href="__BASE_PATH__/static/xterm.css">
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAA0MSURBVFhHNZfnV1TnFof5G7xeoyBKG6p0ELBSRMFoVGyRgBqMscVYEq+aoldIjCVivxq7JsYOYkOadUDq9MZ0GDpDr2qynrvmYD781nvWOh/2s/dv7/2e4/Tu3TtaW9vp6Oihp2eAvr5B+nuHGOhxqJ/e7i76urvp7eqiu6ODzhY7rXVNNFptdLV20GZrwaw10Giqx6o3Y601Y1TXopWrqVVo0EiV1Cq1KKqkSGQ1lFe+QSKXoNGp6R/sw6mttYPc3wt4eu8VWokRk9qKVWOhXluHUW7AIKlFVa5AW6lGVSqj/NEznt1+zIm9h7h3/gYFNx5y8eApdq/dSvbunzm8fQ8Htu9hW9p6vly8kq/SviR9/qfMj01mduwskhPmkJQwmzWfZ2DQ63Hqsvfw+FYJl49cp6ygEr3UhElpQi/Vo6vWoqtQoX2jQFpSjvx5FdX5Yoqv5XJ02z52pn/F9SPnuZh5nM3z0tm6+HO+TV3Pl0nLSJ05n0Uxs5nmF0FCyFRC3PzxHueO25gJjB01ltlxiShkcpy67b1UPpfy6PdCnt9/ja7agFlZh0lhxiAzYKjRYqjWohHLkL+oRl5SQXlOEfeOX+H7zzZxdHsm17JOkr3pR9JjF5Aev5CMhPmkTU9mXngs0/zCiPYKZrJHIKET/AhyEeE+2pkFickoHQD9PYNISxWUF0l49fANilI1BqkZs2IEwijRYazWCqotV6J+KaEy7xn5F+9wZsd+9qVu5syOLH7bmcWOpRksDI/j84RPWBoZR3LgVBIDowh19SHCLYBQV18BZNI4N5JmxKFRqnAa7B9CJ6+l5rWCktxSiu8+Q1WqweiAUJowyfSYarSYanQYqrRoXlVRlfuE/LNXyT3yGyc27uLIF1s4+PlGMj9by6fRs1iTsICMGcksmTyT2YERTPcJIeAjd4LGezPdO4gpoknMm5mARqHEabBvELPWgqZaz4s8MbkX7iN+VIbmTS31KjN1ajP6ajXalzWonouRPS5B8rCQN7fyeHzqIn/8cIAjazbzS/o6MlPXsiZ+ActjZrEhaRFrZiaT4BNMYtBkotz98RszkWjPAAEgZVYyKocFvZ29GJQGLKo6pC8U5F16QNHNYiqevkH3Ro2xSob2RRm1r6qwVCowVirQlStQiWsQ33lE7q+nufr9T/yyeiOZK9ezLimFOYFRrJo1n4yEj1kSMY3EgHBiAyLwGe1KoKtIAFg1b/GIBcN9g9S8rhI6X1dZS9GtEopuFPLy5iPEtx6hL5VhkeqokxuxqkyYFQZUYgk1RWWIbz8gL/s01/bs5/iG7fxnwaesnp7I3JBokoLCWRY9g0VhU5jlF0K0px/+H7nh8S9nwtzcSU2ah1atwWloYAiT0oC0VIa+xkBNSTVPL92lLKcYWXE5unLlSANWqFCXSdGWK5CVlFN29wHPrt3i3uFjnNv8LccyvmRH0idsiJ9L2tR4ZvsGE+8dQJJ/CDM8fIgc706QsxuiUWMJGOfGkoRkNKoPTdhktAnLxwHgCFp6t5BXtwuQFpShEUvRV6qFXaAtk6MplVJdWErR5dvkHDvLjT2HOLJ6E3sWLWNb4jwyps1iWeQM4n2DiHL1ZJqHL1ETvAj+aDwBY1zxGjUW91FjmReXiEqmwKm/d4AmUwONBhsWmRFFUTmywje8uP6QivvPUD2vpqZQzOvcQp79mUfRpZvknbjAxZ1ZHF+7lUNp69k2awGbYz9m44wkloRPISlwMjO9Q4ic6EvERB8i3XwJcvZANMYV0b9dEP17HHPjEkemYKB3QMi+QV+PRaZHViBG8lRMaU4hD87+Se7xc9zOOsKZLd/x6xdb2btkNbs+Xs6mhIWsio5lWUgMSb7hzPYNIdE3mDjHmPmEEiEKJcjVVxi9YHd/Ahyb0NkLXxcv3EY7Mzd+NgqpDKe+7j708lpsOit1Ch3yAjHl94vJP/sHR9bsYE/q1xzadoTv1/3Mnh/Pkrbia5YtXk9UWBJezgF4TwjE08Uf17GejBvlgttH7ni7ByFyCcDPLRg/t0kEi4KZ5BmEl4sINxdPPFw8iZsai0quHLFAL9FiU5mpV+iQFryi8NRlzn1zgN1LNrH7iz2cPVfI/ccyjp4rYOGSbUTFzCcyLBlfjxBBE5x9Ge8iYvzYiYwd5YKvR/DIO/cgQvyiCfIOJdgnjEBRCG4fIBJnzEJaLcFpoKsPY41GkLlKRuWt+9zZc4D9GzLZvmATx/Zd4sGjKuT6Tg6fLWTOnA3Mjl9B7LTFhPjFIJoYiPv4ANxdfPFx9cF19Fg8XdwIEoUIAf09ggkPmEzEpChC/SLwc5+Em7Mnc+KS0Kq0OA1292OQqKmtUWGsqOb5oSzOrtvO1/M38u2izfxx8h5yZT11tj4OHc0jac5XfJayhaQZS5gSlsxHo90ZN06Eq6sPARNE+DlPxG/cBEI9vAny8CNIFEpUyBRiQqYS6h+J38RJeDl7kTI3BblE/gFAqsYs12GWyKi+fJLsz9azbsYKflyyjdzT99DrG2hr7+XK+fssX7iDVQs3snL+KuInf8K4cb6MGeOBs4sv4R5+hLl5EOY6kZk+IqLdvYjw9CM5KoG44CnE+IQT6RFMXFAMKxelotfocRruHsAs01KnMtBiqMMgFvNw/3F+W7ebk+k7uLP7BIrXcuwd3ahlBtYt3cLKxKWsT0rh44AwJrl64zLaFR+3AOIDI5nm7cdMby9SIoKI8xURI/JnUdR0koOmkOg/mZToRJZOSWJL6hdoVRqcBnscY2igQWvGbmmiUWdFIa5CWyGl8tZD7mzZx9PMC1g1Zpqb7Fw/+wcrExayemoC6VHTWBQYSpynF9NFImZ6BzDLN4DF4UF8PjWcEGdn4v1DmBsUybKoeNYmprAhaSlpsUlsTVuFxWRwbMJhbHoLjQYrHfUtdNpaaTHZ6Ghup7PJjuFVGcWZJym7lkdttQqDysLJvYdZGTuP9JiZrI2ewsJJwXzsP4klkeGsiA4nfepkprpNJMbDi7TImWyMn8+ulJXsmL+CrUmL2J6SSvbO77AYDTgNDwzTaKynxdJAZ2MbvS0d9DR3CAC99m562zqpr5JSdv5P5PmlaEsVaKr03D13l00pGSyPmcGi0EiWTY5ieXQ0KeFhxEzwYH5oDD+kruPYpu/JXJ7B3qVpHMzYwPFtuzi5/TuuHsqmucE2chc0m220mEcAuu1dghxfvB0tdrrbu+i1d9GqNiLPKUCWX4FFWYdF24jklZpbF5+wf/cpvlr5DVvStrJ30w+cyzrNjV8vc2nXT2SvWsvpjZu5/ONP3Dh8jOuHjnIt82fuHT9FY50Vp8GBYdqsjQKE3dZCrxCwm562LuzN7SNWtNjpaemgo64JS2kV8vslVN/JR/2imnpDM7VKK8rqWqqe1fD89lNyDp7i+s7/8vuPB7h7+ARF127w4mYOhdducu/oKa5l7ePmkWzaHBUYGhim1dpIo7GOjoZW+uzd9HX0COps7RAg2hta6LC1CupubKfV3IClRonk9iNenDhP8ekrPDlwmrx92Tw+eJLC/12l+M+HvMwp5OW9J4hzn/DyZg75F65x+5dfuLRrF7eys7GZTDgN9Q8L/jebbLTbWoTMBe8/6B8AR3Ucaq9vpsvWSlt988jPiNqAWa5FV6VAXSFDUylHUyFH/kZKeeFrXt64R/G58zw5cYq7mVlc3LaF0+vWc+Gb/9BoseL0buCtANBkqhcq0flP832oRFeLHXtDqxC4ra6JJmMdDQYrtlozNo2JBp2Zeo0Jk8SxztXoq5QoX1UgeVKC+OoNCg8eIm/vHu7s/o7L6zZwdNly9i9M4cLWndSbjCMADXqrsIgaay1CZt1tnUJwB4SjCR090PkBotVkE+yyaR2XVy0WqRZTeQ3Gl2VoCl+gyHlC5fW7lJ65SH7Wz9zZ9i1X1qzlbOoqji/+lGMrVnEsPYOrP+yjvbERp+H+YZp0ZqxVKmxqI23WJqEKjuZzqKe9S1Bns12woM3cIAA06a006izUK/WYqhTUvihH/rCIyus5vD5zheKD2Tz8IYs7jpH7cjO/fbaak5+u5MKm7Vz59jseHz9Da0MDTkN9g9gUtRjfyIWMHCV2VKG1rlEouSO4UIm2TuwO761NgveNeitN+jqadBZsKiOmGhW14irk+c+peVBExc3HiC/d5tnpqzw6fJKHvxzl7n9/Jm9/No+O/o+Xf9yhu90+sogcmVtrNDRpzLQY67HXNdPVbKfHYUVnD32dvfR39tLb1kW3Yz80tNJR10K7tUmQYyqajfU0aIyYa5RYpGoMlUrUpRIUz0tRloiRFbyi5kEhsvxiJI9LUIsr6O/uwenv938z3DvIUM+AcL4bGOb9wFveD73j/eBb/h7+i7/f/sXfw++F57+G3/P+7XveDr4VNDw4zNuBf56HGB4YZKhvQNBgbz9Dff0MdPUKwQR1fTi7e/nr/V/8HzLpSvkUrIc+AAAAAElFTkSuQmCC">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
overflow-x: hidden;
}
.page {
display: none !important;
min-height: 100vh;
/* iOS notch / Dynamic Island: viewport-fit=cover 让 viewport 顶到物理边缘,
这里用 env(safe-area-inset-*) 把内容推回到安全区内,避免被前摄/底部 Home 条遮挡。
旧设备 / 非全屏环境下 env() 解析为 0,等价于纯 20px 内边距。 */
padding: calc(20px + env(safe-area-inset-top))
calc(20px + env(safe-area-inset-right))
calc(20px + env(safe-area-inset-bottom))
calc(20px + env(safe-area-inset-left));
}
.page.active { display: block !important; }
#login-page.active {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f1a 100%);
}
.login-form {
background: rgba(22, 33, 62, 0.95);
padding: 40px;
border-radius: 16px;
width: 100%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(233, 69, 96, 0.2);
}
.login-form h1 {
text-align: center;
margin-bottom: 30px;
color: #e94560;
font-size: 28px;
text-shadow: 0 0 20px rgba(233, 69, 96, 0.3);
}
.login-form input {
width: 100%;
padding: 14px 16px;
margin-bottom: 16px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
background: rgba(15, 52, 96, 0.8);
color: #fff;
font-size: 16px;
outline: none;
transition: all 0.3s;
}
.login-form input:focus {
border-color: #e94560;
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2);
}
.login-form input::placeholder { color: #666; }
.login-form > button {
width: 100%;
padding: 14px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #e94560 0%, #c73e54 100%);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 1px;
}
.login-form > button:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(233, 69, 96, 0.4); }
.login-form > button:disabled { background: #444; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg { color: #e94560; text-align: center; margin-top: 16px; font-size: 14px; }
.conn-status { text-align: center; margin-bottom: 20px; font-size: 13px; color: #666; }
.conn-status.connected { color: #4caf50; }
.conn-status.disconnected { color: #f44336; }
/* Password input with toggle */
.password-wrapper {
position: relative;
width: 100%;
margin-bottom: 16px;
}
.password-wrapper input {
margin-bottom: 0;
padding-right: 48px;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
font-size: 16px;
line-height: 24px;
text-align: center;
transition: color 0.2s;
opacity: 0.6;
display: flex;
align-items: center;
justify-content: center;
}
.password-toggle:hover { color: #e94560; opacity: 1; }
/* Login footer */
.login-footer {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.security-notice {
background: rgba(255,152,0,0.1);
border: 1px solid rgba(255,152,0,0.3);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
font-size: 12px;
color: #ccc;
line-height: 1.5;
}
.security-notice strong {
color: #ff9800;
display: block;
margin-bottom: 4px;
}
.login-links {
display: flex;
justify-content: center;
gap: 24px;
font-size: 13px;
}
.login-links a {
color: #e94560;
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.login-links a:hover { color: #ff6b8a; }
#devices-page { max-width: 1400px; margin: 0 auto; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h2 {
color: #e94560;
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.device-count {
background: rgba(233, 69, 96, 0.2);
color: #e94560;
padding: 4px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: normal;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
flex: 1;
min-width: 180px;
max-width: 300px;
}
.search-box input {
width: 100%;
padding: 10px 16px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(15, 52, 96, 0.6);
color: #fff;
font-size: 14px;
outline: none;
transition: all 0.3s;
}
.search-box input:focus {
border-color: #e94560;
background: rgba(15, 52, 96, 0.9);
}
.search-box input::placeholder { color: #666; }
.search-box input { padding-right: 32px; }
.search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #666;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
display: none;
}
.search-clear:hover { color: #e94560; }
.view-toggle {
display: flex;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
overflow: hidden;
}
.view-btn {
padding: 8px 14px;
border: none;
background: rgba(15, 52, 96, 0.6);
color: #888;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.view-btn:first-child { border-right: 1px solid rgba(255,255,255,0.1); }
.view-btn:hover { color: #fff; }
.view-btn.active { background: rgba(233, 69, 96, 0.3); color: #e94560; }
/* Unified icon button (Refresh / Users / Logout). 40x40 方块更紧凑,留位置给未来按钮。
颜色身份通过修饰类(.refresh / .users / .logout)保留,hover 高光与原来一致。 */
.icon-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 8px;
}
.icon-btn svg { display: block; width: 20px; height: 20px; }
.icon-btn:hover { transform: translateY(-1px); }
.icon-btn:active { transform: translateY(0); }
.icon-btn.refresh { background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%); }
.icon-btn.refresh:hover { box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); }
.icon-btn.users { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); display: none; }
.icon-btn.users.visible { display: inline-flex; }
.icon-btn.users:hover { box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
.icon-btn.logout { background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%); }
.icon-btn.logout:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
/* User Management Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: rgba(22, 33, 62, 0.98);
border-radius: 16px;
padding: 24px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
border: 1px solid rgba(233, 69, 96, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.modal-header h3 { color: #e94560; margin: 0; }
.modal-close {
background: none;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover { color: #e94560; }
.user-form { margin-bottom: 24px; }
.user-form h4 { color: #ccc; margin-bottom: 12px; font-size: 14px; }
.user-form input, .user-form select {
width: 100%;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(15, 52, 96, 0.8);
color: #fff;
font-size: 14px;
}
.user-form input:focus, .user-form select:focus {
outline: none;
border-color: #e94560;
}
.user-form button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: #fff;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.user-form button:hover { transform: translateY(-1px); }
.user-list h4 { color: #ccc; margin-bottom: 12px; font-size: 14px; }
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: rgba(15, 52, 96, 0.5);
border-radius: 8px;
margin-bottom: 8px;
}
.user-item .user-info { flex: 1; }
.user-item .username { color: #fff; font-weight: 500; }
.user-item .role { color: #888; font-size: 12px; }
.user-item .role.admin { color: #e94560; }
.user-item .delete-btn {
background: rgba(231, 76, 60, 0.2);
border: 1px solid rgba(231, 76, 60, 0.5);
color: #e74c3c;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.user-item .delete-btn:hover { background: rgba(231, 76, 60, 0.4); }
.user-item .delete-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.user-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
.confirm-modal-content {
background: rgba(22, 33, 62, 0.98);
border-radius: 16px;
padding: 28px;
width: 90%;
max-width: 380px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
border: 1px solid rgba(231, 76, 60, 0.25);
text-align: center;
}
.confirm-modal-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: 50%;
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-modal-icon svg { width: 28px; height: 28px; }
.confirm-modal-content h3 { color: #fff; margin: 0 0 8px; font-size: 18px; }
.confirm-modal-content p { color: #aaa; margin: 0 0 24px; font-size: 14px; line-height: 1.5; }
.confirm-modal-actions { display: flex; gap: 12px; justify-content: center; }
.confirm-modal-actions button {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.confirm-modal-actions .cancel-btn {
background: rgba(255,255,255,0.08);
color: #ccc;
}
.confirm-modal-actions .cancel-btn:hover { background: rgba(255,255,255,0.15); }
.confirm-modal-actions .danger-btn {
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%);
color: #fff;
}
.confirm-modal-actions .danger-btn:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); transform: translateY(-1px); }
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.device-card {
background: rgba(22, 33, 62, 0.8);
padding: 20px;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid rgba(255,255,255,0.05);
position: relative;
overflow: hidden;
}
.device-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #e94560, #0f3460);
opacity: 0;
transition: opacity 0.3s;
}
.device-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
border-color: rgba(233, 69, 96, 0.3);
}
.device-card:hover::before { opacity: 1; }
/* 终端图标按钮:右上角,独立于卡片本体点击事件(onclick stopPropagation) */
.device-card .card-term-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: #aaa;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 1;
}
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
.device-card .card-term-btn:active { transform: scale(0.92); }
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
/* ====== 终端页面 ====== */
#term-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #000;
padding: 0;
display: none;
flex-direction: column;
overflow: hidden; /* 防 iOS 软键盘弹出时 body 偷偷加 scroll 顶起整页 */
overscroll-behavior: contain;
}
#term-page.active { display: flex !important; }
/* 顶部 toolbar 加 safe-area-inset-top,避免 iPhone 刘海 / 灵动岛压住 Back 按钮。
position: sticky + top:0 兜底:万一发生页面 scroll,Back 按钮也始终钉在顶部 */
#term-page .screen-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(0,0,0,0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.term-host {
flex: 1;
background: #000;
padding: 6px;
overflow: hidden;
min-height: 0; /* flex-item 在容器里 overflow 才生效 */
}
.term-host .xterm { height: 100% !important; width: 100% !important; }
.term-host .xterm .xterm-viewport { background-color: #000 !important; }
/* 始终可见的滚动条(覆盖 xterm.js 默认 + 浏览器 autohide)
Firefox 用 scrollbar-width / scrollbar-color;其它浏览器用 ::-webkit-scrollbar */
.term-host .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.35) transparent;
}
.term-host .xterm-viewport::-webkit-scrollbar { width: 8px; background: rgba(255,255,255,0.04); }
.term-host .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.35);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.55); background-clip: padding-box; }
/* 移动辅助按钮栏:底部固定一行,覆盖手机软键盘上方常用键
(Tab / Esc / Ctrl+C / 历史 ↑ —— 90% 应急场景够用) */
.term-aux-bar {
display: none; /* 默认隐藏;JS 在窄屏 / 触屏环境下显示 */
gap: 6px;
padding: 6px calc(8px + env(safe-area-inset-right))
calc(6px + env(safe-area-inset-bottom))
calc(8px + env(safe-area-inset-left));
background: rgba(0,0,0,0.85);
border-top: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
#term-page.active .term-aux-bar.visible { display: flex; }
.term-aux-bar button {
flex: 1;
min-width: 0;
height: 36px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #ddd;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.term-aux-bar button:active { background: rgba(76,175,80,0.35); transform: scale(0.96); }
.device-card h3 {
color: #fff;
font-size: 16px;
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-card .info {
color: #888;
font-size: 13px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.device-card .info-label { color: #666; min-width: 32px; }
.device-card .resolution { color: #666; font-size: 12px; }
.device-card .info-row { display: flex; gap: 16px; flex-wrap: wrap; }
.device-card .active-window {
color: #888; font-size: 12px; margin-top: 8px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 100%; opacity: 0.8;
}
.device-card .active-window.busy {
color: #e94560; opacity: 1; font-weight: 500;
}
.device-card .meta-row { display: flex; gap: 12px; margin-top: 6px; font-size: 12px; color: #666; }
.device-card .meta-item { display: flex; align-items: center; gap: 4px; }
.device-card .meta-item.rtt { font-weight: 500; }
.device-card .meta-item.rtt.rtt-good { color: #4caf50; }
.device-card .meta-item.rtt.rtt-medium { color: #ff9800; }
.device-card .meta-item.rtt.rtt-poor { color: #f44336; }
.no-devices {
text-align: center;
color: #666;
padding: 60px 20px;
background: rgba(22, 33, 62, 0.5);
border-radius: 12px;
border: 1px dashed rgba(255,255,255,0.1);
}
.no-devices h3 { color: #888; margin-bottom: 10px; }
/* List view styles */
.device-grid.list-view {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-grid.list-view .device-card {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 20px;
}
.device-grid.list-view .device-card::before { display: none; }
.device-grid.list-view .device-card h3 {
min-width: 180px;
margin-bottom: 0;
font-size: 14px;
}
.device-grid.list-view .device-card .info {
margin-bottom: 0;
min-width: 120px;
}
.device-grid.list-view .device-card .info-row { flex-wrap: nowrap; gap: 12px; }
.device-grid.list-view .device-card .meta-row { margin-top: 0; }
.device-grid.list-view .device-card .active-window {
margin-top: 0; max-width: 200px; flex-shrink: 1;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 20px 0;
}
.pagination button {
padding: 8px 14px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
background: rgba(15, 52, 96, 0.6);
color: #aaa;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.pagination button:hover:not(:disabled) {
border-color: #e94560;
color: #fff;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination button.active {
background: #e94560;
border-color: #e94560;
color: #fff;
}
.pagination .page-info {
color: #666;
font-size: 13px;
padding: 0 12px;
}
.stats-bar {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 12px 16px;
background: rgba(15, 52, 96, 0.4);
border-radius: 8px;
font-size: 13px;
}
.stats-bar .stat { color: #888; }
.stats-bar .stat strong { color: #e94560; }
#screen-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #000;
padding: 0;
}
#screen-page.active { display: flex !important; flex-direction: column; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #111; position: relative; }
#screen-canvas { max-width: 100%; max-height: 100%; }
.screen-toolbar {
background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.85) 100%);
/* 远程桌面顶部工具栏也避开 notch / Dynamic Island —— 顶 padding + 安全区 */
padding: calc(12px + env(safe-area-inset-top))
calc(20px + env(safe-area-inset-right))
12px
calc(20px + env(safe-area-inset-left));
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.back-btn {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 14px;
cursor: pointer;
padding: 8px 16px;
border-radius: 6px;
transition: all 0.2s;
}
.back-btn:hover { background: #e94560; }
.toolbar-info { text-align: center; flex: 1; }
.toolbar-info .device-name { font-weight: 600; font-size: 15px; color: #fff; }
.toolbar-info .conn-info { font-size: 12px; color: #888; margin-top: 4px; }
.screen-status { font-size: 12px; padding: 6px 14px; border-radius: 20px; font-weight: 500; }
.screen-status.connecting { background: rgba(255, 152, 0, 0.2); color: #ff9800; }
.screen-status.connected { background: rgba(76, 175, 80, 0.2); color: #4caf50; }
.screen-status.error { background: rgba(244, 67, 54, 0.2); color: #f44336; }
.toolbar-right { display: flex; align-items: center; gap: 12px; }
.fullscreen-btn {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
transition: all 0.2s;
line-height: 1;
}
.fullscreen-btn:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn-bar {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
transition: all 0.2s;
line-height: 1;
}
.toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn-bar.active { background: rgba(52,199,89,0.8); }
.toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); }
.toolbar-btn-bar.muted { opacity: 0.55; }
.toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; }
.toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); }
#screen-page:fullscreen .screen-toolbar { display: none; }
#screen-page:-webkit-full-screen .screen-toolbar { display: none; }
#screen-page:fullscreen .canvas-container { height: 100vh; }
#screen-page:-webkit-full-screen .canvas-container { height: 100vh; }
/* iOS pseudo-fullscreen (no native fullscreen API support) */
#screen-page.pseudo-fullscreen {
position: fixed !important;
top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
width: 100vw !important; height: 100vh !important;
height: 100dvh !important; /* Dynamic viewport for iOS */
z-index: 99999;
background: #000;
padding: 0 !important;
margin: 0 !important;
}
#screen-page.pseudo-fullscreen .screen-toolbar { display: none !important; }
#screen-page.pseudo-fullscreen .canvas-container {
width: 100vw !important; height: 100vh !important;
height: 100dvh !important;
max-height: none !important;
}
/* Portrait fullscreen: align canvas to top. 56px 是给浮动工具栏留出的空间;
竖屏 iPhone 全屏时再加上 safe-area-inset-top 把内容推到 notch / 灵动岛之下。 */
@media (orientation: portrait) {
#screen-page:fullscreen .canvas-container,
#screen-page:-webkit-full-screen .canvas-container,
#screen-page.pseudo-fullscreen .canvas-container {
align-items: flex-start !important;
padding-top: calc(56px + env(safe-area-inset-top)) !important;
}
}
#screen-page.pseudo-fullscreen #screen-canvas {
max-width: 100vw !important; max-height: 100vh !important;
max-height: 100dvh !important;
object-fit: contain;
}
#screen-page.pseudo-fullscreen .toolbar-toggle {
display: flex; /* Show toolbar toggle in fullscreen */
}
#screen-page:fullscreen .toolbar-toggle,
#screen-page:-webkit-full-screen .toolbar-toggle {
display: flex;
}
/* Android portrait mode: full-viewport canvas, no top toolbar */
#screen-page.android-portrait { overflow: hidden; }
#screen-page.android-portrait .screen-toolbar { display: none !important; }
#screen-page.android-portrait .canvas-container {
height: 100dvh !important; height: 100vh !important;
padding-top: 0 !important;
align-items: center !important;
}
#screen-page.android-portrait #screen-canvas {
max-width: 100vw !important;
max-height: 100dvh !important; max-height: 100vh !important;
}
#screen-page.android-portrait .toolbar-toggle { display: flex !important; }
#screen-page.android-portrait .quick-controls { display: none !important; }
.compat-warning {
background: linear-gradient(90deg, #ff9800, #f57c00);
color: #000;
padding: 12px;
text-align: center;
font-size: 14px;
font-weight: 500;
}
/* Mobile floating controls */
/* Floating toolbar menu - minimal icon style */
.floating-toolbar {
position: fixed;
/* 8px 基础留白 + safe-area-inset-top 避开 iPhone 刘海/灵动岛 */
top: calc(8px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 1001;
display: none;
background: rgba(0,0,0,0.75);
border-radius: 22px;
padding: 4px 8px;
gap: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
}
.floating-toolbar.visible { display: flex; align-items: center; }
.toolbar-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 50%;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-btn:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn:active { transform: scale(0.9); background: rgba(255,255,255,0.3); }
.toolbar-btn.active { background: rgba(52,199,89,0.8); }
.toolbar-btn.active:hover { background: rgba(52,199,89,1); }
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.toolbar-btn:disabled:hover { background: transparent; }
.toolbar-btn:disabled:active { transform: none; }
/* Throughput read-out inside the floating toolbar (fullscreen mode).
Hidden automatically while empty so the toolbar collapses cleanly. */
.fb-stats {
color: rgba(255,255,255,0.9);
font-size: 12px;
padding: 0 10px 0 6px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
user-select: none;
}
.fb-stats:empty { display: none; }
.toolbar-toggle {
position: fixed;
/* 同 .floating-toolbar:8px 基础 + 安全区 inset 避开刘海/灵动岛 */
top: calc(8px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(0,0,0,0.5);
border: none;
color: #fff;
font-size: 16px;
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 50%;
transition: all 0.15s;
display: none; /* Hidden by default, shown in fullscreen */
align-items: center;
justify-content: center;
}
.toolbar-toggle:hover { background: rgba(0,0,0,0.7); }
.toolbar-toggle:active { transform: translateX(-50%) scale(0.9); }
/* Zoom indicator */
.zoom-indicator {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 14px;
z-index: 1002;
display: none;
pointer-events: none;
}
.zoom-indicator.visible { display: block; }
.touch-indicator {
position: fixed;
width: 30px;
height: 30px;
border: 2px solid #e94560;
border-radius: 50%;
pointer-events: none;
display: none;
transform: translate(-50%, -50%);
z-index: 999;
}
/* Utility class for hiding elements */
.ui-hidden { display: none !important; }
/* Quick control buttons - always visible on touch devices */
.quick-controls {
position: absolute;
display: none;
gap: 8px;
z-index: 100;
padding: 8px;
}
.quick-controls.visible { display: flex; }
.quick-controls .qc-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.quick-controls .qc-btn:hover { background: rgba(0,0,0,0.8); }
.quick-controls .qc-btn:active { transform: scale(0.9); }
.quick-controls .qc-btn.active { background: rgba(52,199,89,0.9); }
.quick-controls .qc-btn:disabled { opacity: 0.4; }
/* Portrait: horizontal at top center */
@media (orientation: portrait) {
.quick-controls {
/* 非全屏:screen-toolbar 已经吃了一份 safe-area,这里再叠加会让快捷按钮
离 title 远到 ~2x 图标高度的空白,且 canvas-container 因此被推得过低,
底部 input-shortcuts 在键盘弹出时会被遮挡。
全屏:另在 fullscreen 选择器里补回 safe-area-inset-top 避开前置摄像头。 */
top: 4px;
left: 50%;
transform: translateX(-50%);
flex-direction: row;
padding: 4px;
gap: 6px;
}
.quick-controls .qc-btn {
width: 40px;
height: 40px;
font-size: 18px;
}
/* Portrait: align canvas to top, leave space for controls.
同上:非全屏 screen-toolbar 已避开刘海,这里只留固定 56px 给快捷按钮,
全屏路径在下方有独立 !important 覆写补回 safe-area。 */
.canvas-container {
align-items: flex-start;
padding-top: 56px;
}
}
/* 全屏(无 screen-toolbar 兜底)才需要 quick-controls 自己加 safe-area
避开 iPhone 刘海/灵动岛/前置摄像头。 */
@media (orientation: portrait) {
#screen-page:fullscreen .quick-controls,
#screen-page:-webkit-full-screen .quick-controls,
#screen-page.pseudo-fullscreen .quick-controls {
top: calc(4px + env(safe-area-inset-top));
}
}
/* Landscape: vertical at right center */
@media (orientation: landscape) {
.quick-controls {
right: 8px;
top: 8px;
transform: none;
flex-direction: column;
}
}
/* Cursor overlay (touchpad mode). Per-variant classes are swapped via JS based on
the remote IDC_* index. Each variant overrides --hx/--hy so the icon's tip
(hotspot), not the div's top-left, lands on the reported cursor position. */
.cursor-overlay {
position: fixed;
width: 24px;
height: 24px;
pointer-events: none;
display: none;
z-index: 1002;
background-repeat: no-repeat;
background-size: contain;
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6));
transform-origin: 0 0;
--hx: 4px;
--hy: 4px;
transform: translate(calc(-1 * var(--hx)), calc(-1 * var(--hy)));
/* IDC_ARROW (default) — also the fallback for unmapped indices. */
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" d="M4 4l7 17 2.5-6.5L20 12z"/></svg>');
}
.cursor-overlay.active { display: block; }
/* IDC_IBEAM (5) — text input */
.cursor-overlay.cursor-ibeam {
--hx: 8px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="white" stroke-width="3" d="M4 3h8M8 3v18M4 21h8"/><path fill="none" stroke="black" stroke-width="1" d="M4 3h8M8 3v18M4 21h8"/></svg>');
}
/* IDC_HAND (3) — Windows-style pointing hand: tall index finger, thumb on the left,
three visible bent fingers (middle/ring/pinky) with descending heights, rounded palm. */
.cursor-overlay.cursor-hand {
--hx: 11.5px; --hy: 1.5px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" d="M10 3 a1.5 1.5 0 0 1 3 0 v7 q1.5 -7 3 0 q1.5 -6 3 0 q1.5 -5 3 0 q1 1 1 2 v5 a4 4 0 0 1 -4 4 h-7 a4 4 0 0 1 -4 -4 v-2 q-2 0 -2 -2 q0 -2 2 -2 q1 0 1 1 q1 -1 1 -2 z"/></svg>');
}
/* IDC_WAIT (15) — busy hourglass */
.cursor-overlay.cursor-wait {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M6 3h12v4l-5 5 5 5v4H6v-4l5-5-5-5z"/></svg>');
}
/* IDC_NO (7) — forbidden */
.cursor-overlay.cursor-no {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="white" stroke-width="3"/><line x1="6.5" y1="6.5" x2="17.5" y2="17.5" stroke="white" stroke-width="3"/><circle cx="12" cy="12" r="8" fill="none" stroke="black" stroke-width="1.5"/><line x1="6.5" y1="6.5" x2="17.5" y2="17.5" stroke="black" stroke-width="1.5"/></svg>');
}
/* IDC_SIZEALL (9) — 4-way move */
.cursor-overlay.cursor-move {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v4H7V9l-4 3 4 3v-2h4v4H9l3 4 3-4h-2v-4h4v2l4-3-4-3v2h-4V7h2z"/></svg>');
}
/* IDC_SIZENS (11) — vertical resize */
.cursor-overlay.cursor-sizens {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v10H9l3 4 3-4h-2V7h2z"/></svg>');
}
/* IDC_SIZEWE (13) — horizontal resize */
.cursor-overlay.cursor-sizewe {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M3 12 7 9v2h10V9l4 3-4 3v-2H7v2z"/></svg>');
}
/* IDC_SIZENWSE (12) — diagonal NW-SE resize (\\). Rotating vertical NS arrow -45° (CCW)
tilts the top-left to upper-left and bottom-right to lower-right. */
.cursor-overlay.cursor-sizenwse {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g transform="rotate(-45 12 12)"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v10H9l3 4 3-4h-2V7h2z"/></g></svg>');
}
/* IDC_SIZENESW (10) — diagonal NE-SW resize (//). Rotating vertical NS arrow +45° (CW)
tilts the top toward upper-right and bottom toward lower-left. */
.cursor-overlay.cursor-sizenesw {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g transform="rotate(45 12 12)"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v10H9l3 4 3-4h-2V7h2z"/></g></svg>');
}
/* Input shortcut bar - below canvas, portrait mode only */
.input-shortcuts {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: none;
justify-content: center;
gap: 4px;
padding: 6px 4px;
z-index: 101;
}
.input-shortcuts.visible { display: flex; }
.input-shortcuts .shortcut-btn {
min-width: 36px;
height: 36px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(128,128,128,0.5);
background: rgba(128,128,128,0.4);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
text-shadow: 0 0 2px #000, 0 0 4px #000, 1px 1px 1px #000;
}
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
.input-shortcuts .shortcut-btn.active {
background: rgba(52,199,89,0.85);
border-color: rgba(52,199,89,0.9);
}
/* "I" 手机输入法组合框(微信聊天风格):固定在屏幕底部,键盘弹出时随视觉视口上浮 */
.ime-bar {
display: none;
align-items: flex-end;
gap: 8px;
padding: 8px 12px;
background: rgba(24,24,28,0.97);
border-top: 1px solid rgba(128,128,128,0.4);
flex-shrink: 0;
z-index: 200;
}
.ime-bar.visible { display: flex; }
.ime-bar textarea {
flex: 1;
min-width: 0;
min-height: 40px;
max-height: 80px;
padding: 10px 14px;
border: 1px solid rgba(128,128,128,0.5);
border-radius: 10px;
background: #fff;
color: #111;
font-size: 16px;
line-height: 20px;
outline: none;
resize: none;
overflow-y: auto;
font-family: inherit;
}
.ime-bar .ime-send,
.ime-bar .ime-collapse {
flex: none;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 6px;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.ime-bar .ime-send { background: rgba(52,199,89,0.9); }
.ime-bar .ime-collapse { background: rgba(128,128,128,0.5); width: 40px; padding: 0; }
/* Android virtual navigation bar - overlays bottom of canvas when controlling Android */
.android-navbar {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 44px;
display: none;
align-items: center;
justify-content: center;
gap: 56px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 102;
user-select: none;
}
.android-navbar.visible { display: flex; }
.android-navbar .nav-btn {
width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center;
border: none; background: transparent;
cursor: pointer; opacity: 0.85;
transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent;
padding: 0;
}
.android-navbar .nav-btn:active { opacity: 0.4; }
/* Mobile responsive */
@media (max-width: 768px) {
.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));
}
.header { flex-direction: column; gap: 12px; padding: 12px; }
.header h1 { font-size: 20px; }
.search-box { width: 100%; }
.search-input { font-size: 14px; }
.device-grid { grid-template-columns: 1fr !important; gap: 10px; }
.device-card { padding: 14px; }
.device-card .name { font-size: 15px; }
.device-card .ip { font-size: 12px; }
.device-card .meta-row { flex-wrap: wrap; gap: 8px; }
.device-card .active-window { font-size: 11px; }
.screen-toolbar {
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.back-btn { padding: 6px 12px; font-size: 13px; }
.toolbar-info .device-name { font-size: 13px; }
.toolbar-info .conn-info { font-size: 11px; }
.screen-status { font-size: 11px; padding: 4px 10px; }
.fullscreen-btn { font-size: 16px; padding: 4px 8px; }
.pagination { flex-wrap: wrap; gap: 4px; }
.pagination button { padding: 6px 10px; font-size: 12px; }
.login-form { padding: 24px; margin: 10px; }
.login-form h1 { font-size: 22px; }
}
@media (max-width: 480px) {
.header h1 { font-size: 18px; }
.device-card .info-row { flex-direction: column; gap: 4px; }
.toolbar-right { gap: 8px; }
.mobile-btn { width: 44px; height: 44px; font-size: 18px; }
/* Small screens: hide toolbar buttons, show floating toggle */
.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; }
}
</style>
</head>
<body>
<div id="login-page" class="page active">
<div class="login-form">
<h1>__BRAND_NAME__</h1>
<div id="ws-status" class="conn-status disconnected">Connecting...</div>
<input type="text" id="username" placeholder="Username" value="admin">
<div class="password-wrapper">
<input type="password" id="password" placeholder="Password">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()" title="Show/Hide Password">&#x1F441;</button>
</div>
<button id="login-btn" onclick="login()" disabled>Login</button>
<div id="login-error" class="error-msg"></div>
<div class="login-footer">
<!--BRAND-NOTICE-START-->
<div class="security-notice">
<strong>&#x26A0; Security Notice</strong>
This software is for lawful, authorized, and legitimate use only. You are solely responsible for your use and for complying with applicable law. The developer assumes no liability.
</div>
<!--BRAND-NOTICE-END-->
<!--BRAND-LINKS-START-->
<div class="login-links">
<a href="https://simpleremoter.com/" target="_blank">&#x1F310; Website</a>
<a href="https://git.simpleremoter.com/" target="_blank">&#x1F4E6; Source Code</a>
</div>
<!--BRAND-LINKS-END-->
</div>
</div>
</div>
<div id="devices-page" class="page">
<div class="page-header">
<h2>Devices <span id="device-count" class="device-count">0</span></h2>
<div class="toolbar">
<div class="search-box">
<input type="text" id="search-input" placeholder="Search by name, IP, OS..." oninput="onSearchChange()">
<button class="search-clear" id="search-clear" onclick="clearSearch()">&times;</button>
</div>
<div class="view-toggle">
<button id="view-grid" class="view-btn active" onclick="setViewMode('grid')" title="Grid View">Grid</button>
<button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button>
</div>
<button id="view-wall" class="view-btn wall-btn" onclick="showWall()" title="Screen Wall (multi-device)">Wall</button>
<button class="icon-btn refresh" onclick="getDevices()" title="Refresh" aria-label="Refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button class="icon-btn users" id="users-btn" onclick="openUsersModal()" title="User Management" aria-label="User Management">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
<div id="stats-bar" class="stats-bar">
<div class="stat">Total: <strong id="stat-total">0</strong></div>
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
</div>
<div id="device-list" class="device-grid"></div>
<div id="pagination" class="pagination"></div>
</div>
<div id="wall-page" class="page">
<div class="wall-header">
<div class="wall-title-wrap">
<button class="wall-back-btn" onclick="closeWall()" title="Back to devices">&#8592;</button>
<h2>Screen Wall <span id="wall-tile-count" class="device-count">0</span></h2>
</div>
<div class="wall-toolbar">
<div class="wall-layouts" id="wall-layouts">
<button class="view-btn" data-layout="1" onclick="setWallLayout(1)">1&times;1</button>
<button class="view-btn" data-layout="4" onclick="setWallLayout(4)">2&times;2</button>
<button class="view-btn active" data-layout="9" onclick="setWallLayout(9)">3&times;3</button>
<button class="view-btn" data-layout="16" onclick="setWallLayout(16)">4&times;4</button>
<button class="view-btn" data-layout="25" onclick="setWallLayout(25)">5&times;5</button>
</div>
<button class="wall-clear-btn" onclick="clearWall()" title="Close all tiles">Clear</button>
<button class="icon-btn refresh" onclick="getDevices()" title="Refresh" aria-label="Refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button class="wall-drawer-btn" onclick="toggleWallSidebar()" title="Devices">&#9776;</button>
</div>
</div>
<div class="wall-body">
<aside id="wall-sidebar" class="wall-sidebar">
<div class="wall-sidebar-head">
<div class="search-box wall-search">
<input type="text" id="wall-search-input" placeholder="Search devices..." oninput="onWallSearchChange()">
<button class="search-clear" id="wall-search-clear" onclick="clearWallSearch()">&times;</button>
</div>
</div>
<div id="wall-device-list" class="wall-device-list"></div>
</aside>
<div id="wall-sidebar-backdrop" onclick="toggleWallSidebar(false)"></div>
<main id="wall-grid" class="wall-grid">
<div class="wall-empty" id="wall-empty">
<div class="wall-empty-icon">&#128421;</div>
<div>Double-click a device on the left to add it to the wall</div>
</div>
</main>
</div>
</div>
<div id="screen-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="disconnect()">Back</button>
<div class="toolbar-info">
<div id="device-name" class="device-name"></div>
<div id="frame-info" class="conn-info"></div>
</div>
<div class="toolbar-right">
<span id="screen-status" class="screen-status connecting">Connecting...</span>
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn-bar" id="btn-audio-bar" onclick="toggleAudio()" title="Mute audio">&#x1F50A;</button>
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button>
</div>
</div>
<div class="canvas-container">
<canvas id="screen-canvas"></canvas>
<div class="cursor-overlay" id="cursor-overlay"></div>
<!-- Android virtual navigation bar: Back / Home / Recents -->
<div class="android-navbar" id="android-navbar">
<button class="nav-btn" onclick="sendAndroidNav(8)" title="Back" tabindex="-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<button class="nav-btn" onclick="sendAndroidNav(36)" title="Home" tabindex="-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>
</button>
<button class="nav-btn" onclick="sendAndroidNav(93)" title="Recents" tabindex="-1">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
</button>
</div>
<div class="quick-controls" id="quick-controls">
<button class="qc-btn" id="qc-rdp" onclick="sendRdpReset()" title="RDP Reset" tabindex="-1">&#x21BB;</button>
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">&#x2328;</button>
<button class="qc-btn" id="qc-mouse" onclick="toggleControl()" title="Mouse" tabindex="-1">&#x1F5B1;</button>
<button class="qc-btn" id="qc-disconnect" onclick="disconnect()" title="Disconnect" tabindex="-1">&#x2715;</button>
</div>
<div class="input-shortcuts" id="input-shortcuts">
<button class="shortcut-btn" data-key="49" tabindex="-1">1</button>
<button class="shortcut-btn" data-key="50" tabindex="-1">2</button>
<button class="shortcut-btn" data-key="51" tabindex="-1">3</button>
<button class="shortcut-btn" data-key="52" tabindex="-1">4</button>
<button class="shortcut-btn" data-key="190" tabindex="-1">.</button>
<button class="shortcut-btn" data-key="188" tabindex="-1">,</button>
<button class="shortcut-btn" data-key="191" data-shift="1" tabindex="-1">?</button>
<button class="shortcut-btn" data-key="32" data-ctrl="1" tabindex="-1" title="Ctrl+Space">&#x4E2D;</button>
<button class="shortcut-btn" id="btn-ime" tabindex="-1" title="Phone IME input">I</button>
</div>
</div>
<div class="touch-indicator" id="touch-indicator"></div>
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button>
<div class="floating-toolbar" id="floating-toolbar">
<span class="fb-stats" id="fb-stats"></span>
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button>
<button class="toolbar-btn" onclick="toggleFloatingToolbar()" title="Collapse">&#x25B4;</button>
</div>
<div class="zoom-indicator" id="zoom-indicator">100%</div>
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
<div class="ime-bar" id="ime-bar">
<button type="button" class="ime-collapse" id="ime-collapse" title="Collapse" tabindex="-1" onmousedown="event.preventDefault()">&#x25BC;</button>
<textarea id="ime-input" rows="1" enterkeyhint="send" autocomplete="off" autocorrect="off" autocapitalize="off" placeholder="Type to send"></textarea>
<button type="button" class="ime-send" id="ime-send" tabindex="-1" onmousedown="event.preventDefault()">Send</button>
</div>
</div>
<!-- Terminal Page (xterm.js) -->
<div id="term-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="closeTerminal()">Back</button>
<div class="toolbar-info">
<div id="term-title" class="device-name">Terminal</div>
<div id="term-status-info" class="conn-info">Connecting...</div>
</div>
</div>
<div id="term-host" class="term-host"></div>
<!-- 移动辅助按钮栏:触屏 / 窄屏环境下 JS 自动加 .visible -->
<div class="term-aux-bar" id="term-aux-bar">
<button onclick="termSendSpecial('tab')">Tab</button>
<button onclick="termSendSpecial('esc')">Esc</button>
<button onclick="termSendSpecial('ctrlc')">Ctrl+C</button>
<button onclick="termSendSpecial('up')">&uarr;</button>
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">&#x25BC;</button>
</div>
</div>
<!-- User Management Modal -->
<div class="modal-overlay" id="users-modal">
<div class="modal-content">
<div class="modal-header">
<h3>User Management</h3>
<button class="modal-close" onclick="closeUsersModal()">&times;</button>
</div>
<div id="user-msg"></div>
<div class="user-form">
<h4>Create New User</h4>
<input type="text" id="new-username" placeholder="Username" autocomplete="off">
<input type="password" id="new-password" placeholder="Password" autocomplete="new-password">
<select id="new-role" onchange="onRoleChange()">
<option value="viewer">Viewer (read-only)</option>
<option value="admin">Admin (full access)</option>
</select>
<div class="groups-section" id="groups-section">
<label style="font-size:13px;color:#aaa;display:block;margin:8px 0 4px;">Allowed Groups:</label>
<div id="groups-checkboxes" style="max-height:120px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:6px;padding:6px 8px;"></div>
</div>
<button onclick="createUser()">Create User</button>
</div>
<div class="user-list">
<h4>Existing Users</h4>
<div id="users-list"></div>
</div>
</div>
</div>
<!-- Logout Confirmation Modal -->
<div class="modal-overlay" id="logout-confirm-modal" onclick="if(event.target===this)cancelLogout()">
<div class="confirm-modal-content">
<div class="confirm-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</div>
<h3>Confirm Logout</h3>
<p>You will be returned to the login screen.<br>Continue?</p>
<div class="confirm-modal-actions">
<button class="cancel-btn" onclick="cancelLogout()">Cancel</button>
<button class="danger-btn" onclick="confirmLogout()">Logout</button>
</div>
</div>
</div>
<script src="__BASE_PATH__/static/xterm.js"></script>
<script src="__BASE_PATH__/static/xterm-fit.js"></script>
<!-- Opus WASM decoder (WebAudio fallback for browsers without MSE+WebM/Opus,
e.g. iOS Safari/Chrome where WebKit's MediaSource lacks WebM/Opus).
Self-hosted (served from /static/) so audio works without reaching the
internet — a CDN copy is unreliable behind GFW/offline LANs. -->
<script src="__BASE_PATH__/static/opus-decoder.min.js" charset="UTF-8"></script>
<script>
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
let skipNextCtrlVKeyup = false; // 剪贴板桥:抑制 pasteToRemote 合成的 Ctrl+V 物理 keyup
let imeMode = false; // "I" 手机输入法组合框是否展开(隐藏时不丢内容)
// FPS 计数:原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
// 简单直接,与本次会话改动前一致。
// 网络流量统计:handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
let bwBytesAccum = 0; // current-second byte accumulator
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
let currentWidth = 0, currentHeight = 0; // captured at frame decode time
let isTopDown = false; // true = Android/MediaCodec top-down H.264, skip vertical flip
let isAndroidRemote = false; // true = remote is Android (client_type==="APK") → direct-touch on mobile
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
// - Audio: client encodes PCM → Opus, server forwards raw Opus packets
// to web, web wraps each packet in a WebM SimpleBlock and
// feeds it to MediaSource → <audio> element (browser decodes
// Opus natively, plays via standard media-element pipeline).
//
// WS binary frame layout (matches C++ ScreenSpyDlg.cpp):
// Video : [deviceID:4][frameType:1][dataLen:4][videoData:N]
// Audio : [deviceID:4][frameType=96:1][dataLen:4]
// [hasFormat:1][AudioFormat:12][padding:1]?[opusPacket:N]
// Term : [magic:4='TRM1'][terminalData:N]
//
// AudioFormat (12 bytes, commands.h, pack(1)):
// channels:2 sampleRate:4 bitsPerSample:2 blockAlign:2
// compression:1 (0=PCM unsupported by web, 1=Opus) reserved:1
// MSE + WebM/Opus playback (desktop Chrome/Firefox/Android). Raw Opus
// packets arrive over WS; we wrap each one in a minimal WebM container
// and feed it to a SourceBuffer attached to a hidden <audio> element.
// Browsers without MSE+WebM/Opus (iOS Safari/Chrome — WebKit has no
// WebM/Opus in MediaSource) fall back to the WebAudio + WASM path below.
let audioFormat = null; // { compression, channels, sampleRate, bitsPerSample, blockAlign }
let audioEnabled = true; // Audio on/off flag (set by UI)
let syncDrift = 0; // A/V sync monitoring (milliseconds)
let _audioElement = null; // hidden <audio> sink
let _mediaSource = null; // MediaSource attached to _audioElement
let _sourceBuffer = null; // SourceBuffer (Opus in WebM)
const _sourceBufferQueue = []; // appendBuffer queue (one in-flight at a time)
let _sourceBufferBusy = false;
let _initSegmentSent = false; // first init segment appended for current format
let _opusTimestampMs = 0; // running absolute cluster timestamp (ms)
const OPUS_FRAME_MS = 20; // 960 samples @ 48k — matches client encoder
const _pendingOpusPackets = []; // packets received before SourceBuffer is ready
// ---- WebAudio + WASM Opus fallback path ----
// Used when MSE + WebM/Opus is unsupported (iOS Safari/Chrome — WebKit's
// MediaSource has no WebM/Opus). Raw Opus packets are decoded to PCM
// with a WASM decoder (opus-decoder) and played via AudioContext. The
// decision is made on kernel capability, not UA, so it covers every iOS
// browser at once.
const _mseOpusSupported = !!(window.MediaSource &&
MediaSource.isTypeSupported('audio/webm; codecs="opus"'));
const _useWebAudioPath = !_mseOpusSupported;
let _audioCtx = null; // AudioContext (created/resumed inside a gesture)
let _opusDecoder = null; // WASM OpusDecoder (created when format is known)
let _decoderReady = false; // _opusDecoder.ready resolved
let _waNextTime = null; // next AudioBufferSourceNode start time (ctx clock)
let _waDropLogged = false; // one-shot console.warn for per-frame drop reasons
let _waDecodeFailLogged = false; // one-shot console.error for decode failures
const WA_LOOKAHEAD = 0.15; // seconds of lead time kept in the scheduler
const WA_MAX_LATENCY = 0.60; // drop backlog if we drift this far ahead
const WA_DECODE_SR = 48000; // Opus always decodes to 48 kHz
if (_useWebAudioPath) console.log('[Audio] WebAudio fallback path (no MSE/WebM-Opus)');
// Browser autoplay policies require an HTMLAudioElement to be created
// and .play()'d synchronously inside a user-gesture event handler.
// We hook the first click/keydown to spin up the element + MediaSource.
// Subsequent activity (e.g. tab regaining focus) re-issues play().
function installAudioGestureUnlock() {
const onGesture = () => {
if (_useWebAudioPath) {
_setupWebAudioContext();
} else if (!_audioElement) {
try {
_setupAudioElementAndMediaSource();
console.log('[MSE] <audio> + MediaSource set up by gesture');
} catch (e) {
console.error('[MSE] setup failed:', e && e.message);
}
} else if (_audioElement.paused) {
_audioElement.play().catch(() => {});
}
};
const opts = { passive: true, capture: true };
// click/keydown cover desktop; touchend/pointerdown are the most
// reliable "user gesture" triggers for AudioContext.resume() on iOS.
window.addEventListener('click', onGesture, opts);
window.addEventListener('keydown', onGesture, opts);
window.addEventListener('touchend', onGesture, opts);
window.addEventListener('pointerdown', onGesture, opts);
// Recover when the app returns to the foreground (iOS may suspend
// the AudioContext while backgrounded).
document.addEventListener('visibilitychange', () => {
if (!document.hidden && _useWebAudioPath) _setupWebAudioContext();
});
}
installAudioGestureUnlock();
// ============================================================
// WebAudio + WASM Opus fallback playback.
// The AudioContext is created/resumed inside installAudioGestureUnlock's
// user-gesture handler above (required by iOS autoplay policy). The
// WASM decoder is created lazily when the first AudioFormat arrives
// (handleAudioFrame), so it knows nothing about the container/codec
// support the MSE path depends on.
// ============================================================
function _setupWebAudioContext() {
if (_audioCtx) {
if (_audioCtx.state === 'suspended') {
_audioCtx.resume()
.then(() => console.log('[Audio] resumed → ' + _audioCtx.state))
.catch(e => console.error('[Audio] resume rejected: ' + (e && e.message)));
}
return;
}
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { console.error('[Audio] Web Audio API unavailable'); return; }
try {
_audioCtx = new AC();
console.log('[Audio] AudioContext created, resuming…');
_audioCtx.resume()
.then(() => console.log('[Audio] AudioContext ' + _audioCtx.state))
.catch(e => console.error('[Audio] resume rejected: ' + (e && e.message)));
} catch (e) {
console.error('[Audio] AudioContext failed: ' + (e && e.message));
}
}
function _initWebAudioDecoder(fmt) {
if (_opusDecoder) { try { _opusDecoder.free().catch(() => {}); } catch (e) {} }
_opusDecoder = null;
_decoderReady = false;
_waNextTime = null;
_waDropLogged = false;
_waDecodeFailLogged = false;
const OpusDecoder = window['opus-decoder'] && window['opus-decoder'].OpusDecoder;
if (!OpusDecoder) {
console.error('[Audio] opus-decoder not loaded (static script missing?)');
return;
}
try {
_opusDecoder = new OpusDecoder({ sampleRate: WA_DECODE_SR, channels: (fmt && fmt.channels) || 2 });
_opusDecoder.ready.then(() => {
_decoderReady = true;
console.log('[Audio] WebAudio decoder ready ch=' + fmt.channels);
}).catch(e => {
console.error('[Audio] decoder ready failed: ' + (e && e.message));
});
} catch (e) {
console.error('[Audio] decoder construction failed: ' + (e && e.message));
}
}
function _pushOpusWebAudio(opusBytes) {
if (!_opusDecoder || !_decoderReady) return; // WASM still compiling
if (!_audioCtx) {
if (!_waDropLogged) { _waDropLogged = true; console.warn('[Audio] no AudioContext (tap the page first)'); }
return;
}
if (_audioCtx.state !== 'running') {
if (!_waDropLogged) { _waDropLogged = true; console.warn('[Audio] ctx ' + _audioCtx.state + ' — dropping audio'); }
return;
}
// decodeFrame in this bundle is SYNCHRONOUS: it drives the WASM
// decoder directly and returns {channelData, samplesDecoded,
// sampleRate, errors} — not a Promise. Calling .then() on it throws
// a TypeError before any error handling can run. Decode in try/catch.
let res;
try {
res = _opusDecoder.decodeFrame(new Uint8Array(opusBytes));
} catch (e) {
if (!_waDecodeFailLogged) { _waDecodeFailLogged = true; console.error('[Audio] decodeFrame threw: ' + (e && e.message)); }
return;
}
if (!res || !res.samplesDecoded) {
let errStr = 'no samples';
if (res && res.errors && res.errors.length) {
try { errStr = String(res.errors[0].message || res.errors[0]); } catch (e) { errStr = String(res.errors[0]); }
}
if (!_waDecodeFailLogged) { _waDecodeFailLogged = true; console.error('[Audio] decode fail: ' + errStr); }
return;
}
const ch = res.channelData ? res.channelData.length : 0;
if (ch < 1) {
if (!_waDecodeFailLogged) { _waDecodeFailLogged = true; console.error('[Audio] decode ch=0 samples=' + res.samplesDecoded); }
return;
}
if (!_audioCtx || _audioCtx.state !== 'running') {
if (!_waDropLogged) { _waDropLogged = true; console.warn('[Audio] ctx ' + (_audioCtx && _audioCtx.state) + ' at schedule'); }
return;
}
const samples = res.samplesDecoded;
const sr = res.sampleRate || WA_DECODE_SR;
const buffer = _audioCtx.createBuffer(ch, samples, sr);
for (let c = 0; c < ch; c++) {
if (res.channelData[c]) buffer.copyToChannel(res.channelData[c], c);
}
const src = _audioCtx.createBufferSource();
src.buffer = buffer;
src.connect(_audioCtx.destination);
const now = _audioCtx.currentTime;
if (_waNextTime === null || _waNextTime < now) {
_waNextTime = now + WA_LOOKAHEAD;
}
src.start(_waNextTime);
_waNextTime += samples / sr;
if (_waNextTime - now > WA_MAX_LATENCY) {
// Decoder/latency spike put us too far behind; drop the
// backlog and stay live instead of playing stale audio.
_waNextTime = now + WA_LOOKAHEAD;
}
}
// Pagination and filter state
let currentPage = 1;
let viewMode = 'grid'; // 'grid' or 'list'
let searchQuery = '';
let challengeNonce = ''; // Challenge nonce from server
let passwordHash = ''; // SHA256 of password (computed once)
// SHA256 using Web Crypto API
// SHA256 implementation (works in non-secure contexts)
async function sha256(message) {
// Try Web Crypto API first (secure contexts only)
if (window.crypto && window.crypto.subtle) {
try {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (e) { /* fallback below */ }
}
// Fallback: pure JS SHA256
return sha256_js(message);
}
// Pure JS SHA256 implementation
function sha256_js(str) {
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
const utf8 = unescape(encodeURIComponent(str));
const bytes = [];
for (let i = 0; i < utf8.length; i++) bytes.push(utf8.charCodeAt(i));
bytes.push(0x80);
while ((bytes.length + 8) % 64 !== 0) bytes.push(0);
const bitLen = (utf8.length * 8);
for (let i = 7; i >= 0; i--) bytes.push((bitLen / Math.pow(2, i * 8)) & 0xff);
let [h0, h1, h2, h3, h4, h5, h6, h7] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
for (let i = 0; i < bytes.length; i += 64) {
const w = [];
for (let j = 0; j < 16; j++) w[j] = (bytes[i+j*4]<<24)|(bytes[i+j*4+1]<<16)|(bytes[i+j*4+2]<<8)|bytes[i+j*4+3];
for (let j = 16; j < 64; j++) {
const s0 = rotr(w[j-15],7) ^ rotr(w[j-15],18) ^ (w[j-15]>>>3);
const s1 = rotr(w[j-2],17) ^ rotr(w[j-2],19) ^ (w[j-2]>>>10);
w[j] = (w[j-16] + s0 + w[j-7] + s1) >>> 0;
}
let [a,b,c,d,e,f,g,h] = [h0,h1,h2,h3,h4,h5,h6,h7];
for (let j = 0; j < 64; j++) {
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K[j] + w[j]) >>> 0;
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) >>> 0;
[h,g,f,e,d,c,b,a] = [g,f,e,(d+t1)>>>0,c,b,a,(t1+t2)>>>0];
}
[h0,h1,h2,h3,h4,h5,h6,h7] = [(h0+a)>>>0,(h1+b)>>>0,(h2+c)>>>0,(h3+d)>>>0,(h4+e)>>>0,(h5+f)>>>0,(h6+g)>>>0,(h7+h)>>>0];
}
return [h0,h1,h2,h3,h4,h5,h6,h7].map(x => x.toString(16).padStart(8,'0')).join('');
}
function getPageSize() {
return viewMode === 'grid' ? 12 : 50;
}
// Page visibility state for background detection (iOS PWA)
let isPageVisible = !document.hidden;
let reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) return; // Already scheduled
if (!isPageVisible) return; // Don't reconnect in background
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (isPageVisible) connectWebSocket();
}, 3000);
}
function cancelReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
// Attempt single sign-on via the portal's session cookie. On success
// the server returns a YAMA session token + role, which we persist
// exactly like a local login; the caller then connects the WS and skips
// the login form. Returns true on success, false to fall back to login.
async function trySSO() {
try {
const resp = await fetch('__BASE_PATH__/sso', { credentials: 'same-origin' });
if (!resp.ok) return false;
const data = await resp.json();
if (!data || !data.token) return false;
token = data.token;
currentUserRole = data.role || 'viewer';
localStorage.setItem('token', token);
localStorage.setItem('role', currentUserRole);
return true;
} catch (e) {
return false;
}
}
function connectWebSocket() {
if (!isPageVisible) return; // Don't connect in background
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
updateWsStatus('connecting');
try {
ws = new WebSocket(protocol + '//' + location.host + '__BASE_PATH__/ws');
} catch (e) {
updateWsStatus('disconnected');
scheduleReconnect();
return;
}
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
updateWsStatus('connected');
startPingInterval();
// Auto-restore session if token exists
if (token) {
const screenPage = document.getElementById('screen-page');
const wallPage = document.getElementById('wall-page');
if (screenPage.classList.contains('active') && currentDevice) {
// Reconnect to current device
updateScreenStatus('connecting');
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;
// 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));
else handleBinaryFrame(event.data);
};
}
function updateWsStatus(status) {
const el = document.getElementById('ws-status');
const btn = document.getElementById('login-btn');
if (status === 'connected') {
el.textContent = 'Connected'; el.className = 'conn-status connected'; btn.disabled = false;
} else if (status === 'connecting') {
el.textContent = 'Connecting...'; el.className = 'conn-status'; btn.disabled = true;
} else {
el.textContent = 'Disconnected'; el.className = 'conn-status disconnected'; btn.disabled = true;
}
}
function handleSignaling(msg) {
switch (msg.cmd) {
case 'challenge':
challengeNonce = msg.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) {
completeLogin(msg.salt || '');
} else {
pendingLogin = null; // Clear pending state on error
document.getElementById('login-error').textContent = msg.msg || 'Failed to get salt';
}
break;
case 'login_result':
if (msg.ok) {
token = msg.token;
currentUserRole = msg.role || 'viewer';
localStorage.setItem('token', token);
localStorage.setItem('role', currentUserRole);
document.getElementById('login-error').textContent = '';
// Show Users button for admin only
const usersBtn = document.getElementById('users-btn');
if (currentUserRole === 'admin') {
usersBtn.classList.add('visible');
} else {
usersBtn.classList.remove('visible');
}
showPage('devices-page');
getDevices();
} else {
document.getElementById('login-error').textContent = msg.msg || 'Login failed';
}
break;
case 'device_list':
if (msg.ok === false) {
// Token invalid (e.g., server restarted), go back to login
token = null;
localStorage.removeItem('token');
showPage('login-page');
break;
}
devices = msg.devices || [];
// Keep current page (renderDeviceList adjusts if out of range)
renderDeviceList();
renderWallSidebar();
wallUpdateAllTileMeta();
break;
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) {
updateScreenStatus('connected');
isTopDown = msg.top_down === true;
isAndroidRemote = msg.client_type === 'APK';
updateAndroidNavbar();
if (isTouchDevice) updateUIForOrientation();
initDecoder(msg.width, msg.height);
} else {
// Wait for resolution_changed message
updateScreenStatus('waiting', 'Waiting for video...');
}
// Audio state may or may not be cached yet on the server.
// If not, the audio_state event below will populate it.
if (typeof msg.audio_enabled === 'boolean') {
applyAudioState(msg.audio_enabled);
}
// 用新 IDR 重置参考链(缓存 IDR 只用于第一帧立即出图)。
requestFocusKeyframe(focusId);
} else {
updateScreenStatus('error', msg.msg);
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': {
// 只处理当前聚焦设备;墙静音。
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;
case 'term_ready':
termState.ready = true;
document.getElementById('term-status-info').textContent =
'Connected (' + (msg.mode === 'pty' ? 'PTY' : 'Legacy shell') + ')';
// 通知 server 当前 cols/rows,PTY 模式下 host 才知道窗口尺寸
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
notifyTerminalResize();
break;
case 'term_closed':
if (termState.deviceId) {
if (termState.term) {
termState.term.write('\r\n\x1b[33m[Session closed' +
(msg.msg ? ': ' + msg.msg : '') + ']\x1b[0m\r\n');
}
termState.ready = false;
// 不立刻跳回 devices-page,让用户看到提示。点 Back 才真返回。
}
break;
case 'disconnect_result':
// disconnect() already handles navigation, this is just server acknowledgment
// No action needed - prevents race conditions when switching devices
break;
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) {
isAndroidRemote = msg.client_type === 'APK';
updateAndroidNavbar();
// Refresh cursor overlay and immersive layout
document.getElementById('cursor-overlay').classList.toggle(
'active', controlEnabled && isTouchDevice && !isAndroidRemote);
if (isTouchDevice) updateUIForOrientation();
}
initDecoder(msg.width, msg.height);
break;
}
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;
if (controlEnabled) {
applyRemoteCursor(msg.index);
}
break;
}
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': {
if (!token) 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')) {
updateDeviceInfo(msg.id, msg.rtt, msg.activeWindow);
}
break;
case 'devices_changed':
// Refresh device list when devices come online/offline
if (document.getElementById('devices-page').classList.contains('active') ||
document.getElementById('wall-page').classList.contains('active')) {
getDevices();
}
break;
case 'create_user_result':
if (msg.ok) {
showUserMsg('User created successfully', false);
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
listUsers();
} else {
showUserMsg(msg.msg || 'Failed to create user', true);
}
break;
case 'delete_user_result':
if (msg.ok) {
showUserMsg('User deleted', false);
listUsers();
} else {
showUserMsg(msg.msg || 'Failed to delete user', true);
}
break;
case 'list_users_result':
if (msg.ok) {
renderUsersList(msg.users);
}
break;
case 'groups':
if (msg.ok) {
renderGroupsCheckboxes(msg.groups);
}
break;
}
}
function checkWebCodecs() {
if (!('VideoDecoder' in window)) return { supported: false, reason: 'VideoDecoder not available' };
return { supported: true };
}
function initDecoder(width, height) {
decoderWidth = width;
decoderHeight = height;
needKeyframe = false;
decodeTimestamp = 0;
// Clear canvas before resizing to prevent residual content
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = width;
canvas.height = height;
// Clear again after resize (in case of smaller resolution)
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, width, height);
// Update input shortcuts position after canvas resize
requestAnimationFrame(updateInputShortcutsPosition);
// Windows/Linux/macOS clients send bottom-up H.264 (BMP convention) → flip.
// Android MediaCodec sends top-down H.264 → no flip needed.
if (isTopDown) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
} else {
ctx.setTransform(1, 0, 0, -1, 0, height);
}
if (decoder) { try { decoder.close(); } catch(e) {} }
// Reset FPS sliding window on decoder (re)init so a resolution
// change or codec switch doesn't carry over stale counts.
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
// 记录当前分辨率供 renderStats 重组 frame-info 文案
currentWidth = width;
currentHeight = height;
decoder = new VideoDecoder({
output: (frame) => {
// Check if frame dimensions match canvas
if (frame.displayWidth !== canvas.width || frame.displayHeight !== canvas.height) {
console.warn(`Frame size mismatch: frame=${frame.displayWidth}x${frame.displayHeight}, canvas=${canvas.width}x${canvas.height}`);
}
ctx.drawImage(frame, 0, 0);
frame.close();
// 原始风格的 FPS 计数:1 秒采样窗口
frameCount++;
const now = performance.now();
if (now - lastFrameTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
frameCount = 0;
lastFrameTime = now;
}
},
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
});
// codec string 由首帧嗅探得到的 currentCodec 决定:
// 'avc' → 'avc1.42E01E' (H.264 Constrained Baseline Level 3.0)
// 'av1' → 'av01.0.08M.08' (AV1 Main Profile Level 4.0 8-bit)
// 客户端硬件支持 AV1 编码时浏览器收到 AV1 流;fallback 到 H.264 时浏览器
// 收到 H.264 流。两条路径在同一前端代码中并存,运维侧无须感知。
const codecStr = currentCodec === 'av1' ? 'av01.0.08M.08' : 'avc1.42E01E';
decoder.configure({
codec: codecStr,
codedWidth: width,
codedHeight: height,
optimizeForLatency: true
});
}
let decoderWidth = 0, decoderHeight = 0, needKeyframe = false;
let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder
let currentCodec = null; // 'avc' | 'av1' | null(initDecoder 读取)
// 首字节嗅探:H.264 Annex B 起始码必以 0x00 开头;AV1 OBU header
// bit7=0 且 bits[3:6] = obu_type ∈ [1,15],首字节落在 [0x08,0x78] 区间且
// 绝不为 0x00。单字节即可干净区分。
function detectCodec(videoBytes) {
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 =
'<div class="wall-tile-head">' +
'<span class="wall-tile-name"></span>' +
'<span class="wall-tile-badge waiting">Waiting</span>' +
'<button class="wall-tile-close" title="Close tile">&times;</button>' +
'</div>' +
'<div class="wall-tile-body">' +
'<div class="wall-tile-error"></div>' +
'<div class="wall-tile-hover">Click to control</div>' +
'</div>';
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 '<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(d.location || '-') + '</div>' +
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
'<span class="meta-item">Ver: ' + escapeHtml(d.version || '-') + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
escapeHtml(activeWin) + '</div>' : '');
}
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 = '<div class="wall-sidebar-empty">Loading devices...</div>';
return;
}
const list = wallFilteredDevices();
if (!list.length) {
host.innerHTML = '<div class="wall-sidebar-empty">No matching devices</div>';
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 '<div class="wall-device-row' + (d.online ? '' : ' offline') +
'" data-device-id="' + escapeHtml(id) + '">' +
'<span class="wall-dot"></span>' +
'<span class="wall-device-name">' + escapeHtml(name) + '</span>' +
(n ? '<span class="wall-device-badge">' + n + '</span>' : '') +
'</div>';
}).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
// 'audio/webm; codecs="opus"'. The init segment (EBML header +
// Segment header + Tracks with OpusHead) is built once when the
// format is known and appended before any media clusters.
// ============================================================
const WebMMuxer = (function () {
// Variable-length integer (EBML VINT). Marker bit selects byte count.
function vint(value) {
if (value < 0x7F) return [0x80 | value];
if (value < 0x3FFF) return [0x40 | (value >> 8), value & 0xFF];
if (value < 0x1FFFFF) return [0x20 | (value >> 16), (value >> 8) & 0xFF, value & 0xFF];
if (value < 0x0FFFFFFF) return [0x10 | (value >> 24), (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];
// 8-byte VINT for larger values (we don't usually need this)
const out = [0x01];
for (let i = 6; i >= 0; i--) out.push(Math.floor(value / Math.pow(2, i * 8)) & 0xFF);
return out;
}
// Unsigned int big-endian, n bytes
function uintBE(value, n) {
const out = new Array(n);
for (let i = n - 1; i >= 0; i--) { out[i] = value & 0xFF; value = Math.floor(value / 256); }
return out;
}
// 64-bit float big-endian
function f64BE(value) {
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, value, false);
return Array.from(new Uint8Array(buf));
}
// EBML element = ID + size(VINT) + payload
function elem(idBytes, payload) {
const sz = vint(payload.length);
const out = new Array(idBytes.length + sz.length + payload.length);
let i = 0;
for (const b of idBytes) out[i++] = b;
for (const b of sz) out[i++] = b;
for (const b of payload) out[i++] = b;
return out;
}
// OpusHead codec-private structure (19 bytes). Per WebM/Opus spec,
// the authoritative encoder delay is CodecDelay (in ns) in the
// TrackEntry; pre-skip here is left at 0 to avoid double-skipping.
function opusHead(sampleRate, channels) {
return [
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
0x01, // version
channels & 0xFF, // channel count
0x00, 0x00, // pre-skip (use CodecDelay instead)
sampleRate & 0xFF, (sampleRate >> 8) & 0xFF,
(sampleRate >> 16) & 0xFF, (sampleRate >> 24) & 0xFF,
0x00, 0x00, // output gain (LE)
0x00 // channel mapping family
];
}
function buildInitSegment(sampleRate, channels) {
const ebml = elem([0x1A, 0x45, 0xDF, 0xA3], [].concat(
elem([0x42, 0x86], [0x01]), // EBMLVersion
elem([0x42, 0xF7], [0x01]), // EBMLReadVersion
elem([0x42, 0xF2], [0x04]), // EBMLMaxIDLength
elem([0x42, 0xF3], [0x08]), // EBMLMaxSizeLength
elem([0x42, 0x82], [0x77, 0x65, 0x62, 0x6D]), // DocType "webm"
elem([0x42, 0x87], [0x04]), // DocTypeVersion
elem([0x42, 0x85], [0x02]) // DocTypeReadVersion
));
const info = elem([0x15, 0x49, 0xA9, 0x66], [].concat(
elem([0x2A, 0xD7, 0xB1], uintBE(1000000, 3)), // TimecodeScale 1ms
elem([0x4D, 0x80], [0x59, 0x61, 0x6D, 0x61]), // MuxingApp "Yama"
elem([0x57, 0x41], [0x59, 0x61, 0x6D, 0x61]) // WritingApp "Yama"
));
const trackEntry = [].concat(
elem([0xD7], [0x01]), // TrackNumber 1
elem([0x73, 0xC5], uintBE(1, 1)), // TrackUID 1
elem([0x83], [0x02]), // TrackType 2 (audio)
elem([0xB9], [0x01]), // FlagEnabled
elem([0x88], [0x01]), // FlagDefault
elem([0x9C], [0x00]), // FlagLacing 0
elem([0x86], [0x41, 0x5F, 0x4F, 0x50, 0x55, 0x53]), // CodecID "A_OPUS"
elem([0x63, 0xA2], opusHead(sampleRate, channels)), // CodecPrivate
elem([0x56, 0xAA], uintBE(6500000, 3)), // CodecDelay 6.5ms (ns)
elem([0x56, 0xBB], uintBE(80000000, 4)), // SeekPreRoll 80ms (ns)
elem([0xE1], [].concat( // Audio
elem([0xB5], f64BE(sampleRate)), // SamplingFrequency
elem([0x9F], [channels & 0xFF]) // Channels
))
);
const tracks = elem([0x16, 0x54, 0xAE, 0x6B], elem([0xAE], trackEntry));
// Segment uses unknown-size signal so we can stream clusters indefinitely
const segmentOpen = [0x18, 0x53, 0x80, 0x67,
0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
return new Uint8Array([].concat(ebml, segmentOpen, info, tracks));
}
function buildCluster(opusBytes, absMs) {
const simpleBlock = elem([0xA3], [].concat(
[0x81, 0x00, 0x00, 0x80], // TrackNumber=1, ts=0, flags=keyframe
Array.from(opusBytes)
));
const cluster = elem([0x1F, 0x43, 0xB6, 0x75], [].concat(
elem([0xE7], uintBE(absMs, 4)), // Timestamp (absolute, ms)
simpleBlock
));
return new Uint8Array(cluster);
}
return { buildInitSegment, buildCluster };
})();
// Create the hidden <audio> + MediaSource pair INSIDE a user-gesture
// call stack. Must complete .play() synchronously before any await.
function _setupAudioElementAndMediaSource() {
_audioElement = document.createElement('audio');
_audioElement.autoplay = true;
_audioElement.volume = 1.0;
_audioElement.style.display = 'none';
document.body.appendChild(_audioElement);
_mediaSource = new MediaSource();
_mediaSource.addEventListener('sourceopen', _onSourceOpen);
_audioElement.src = URL.createObjectURL(_mediaSource);
_audioElement.play().then(
() => console.log('[MSE] audio.play() ok'),
e => console.error('[MSE] audio.play() rejected:', e && e.message)
);
}
function _onSourceOpen() {
console.log('[MSE] sourceopen, readyState=' + (_mediaSource && _mediaSource.readyState));
if (audioFormat && audioFormat.compression === 1) {
_addSourceBufferAndInit();
}
}
function _addSourceBufferAndInit() {
if (!_mediaSource || _mediaSource.readyState !== 'open' || _sourceBuffer) return;
const mime = 'audio/webm; codecs="opus"';
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
console.error('[MSE] ' + mime + ' not supported by this browser');
return;
}
try {
_sourceBuffer = _mediaSource.addSourceBuffer(mime);
} catch (e) {
console.error('[MSE] addSourceBuffer failed:', e && e.message);
return;
}
_sourceBuffer.addEventListener('updateend', () => {
_sourceBufferBusy = false;
_flushSourceBufferQueue();
});
_sourceBuffer.addEventListener('error', e => console.error('[MSE] sourceBuffer error', e));
// Init segment first
_enqueueAppend(WebMMuxer.buildInitSegment(audioFormat.sampleRate, audioFormat.channels));
_initSegmentSent = true;
_opusTimestampMs = 0;
// Flush packets that arrived before SourceBuffer was ready
while (_pendingOpusPackets.length > 0) {
const pkt = _pendingOpusPackets.shift();
_enqueueAppend(WebMMuxer.buildCluster(pkt, _opusTimestampMs));
_opusTimestampMs += OPUS_FRAME_MS;
}
console.log('[MSE] SourceBuffer ready, init segment + ' +
(_opusTimestampMs / OPUS_FRAME_MS) + ' queued packets appended');
}
function _enqueueAppend(data) {
_sourceBufferQueue.push(data);
_flushSourceBufferQueue();
}
function _flushSourceBufferQueue() {
if (!_sourceBuffer || _sourceBufferBusy) return;
if (_sourceBufferQueue.length === 0) return;
const next = _sourceBufferQueue.shift();
_sourceBufferBusy = true;
try {
_sourceBuffer.appendBuffer(next);
} catch (e) {
console.error('[MSE] appendBuffer threw:', e && e.message);
_sourceBufferBusy = false;
}
}
function pushOpusPacket(opusBytes) {
if (!audioFormat || audioFormat.compression !== 1) return;
if (_useWebAudioPath) {
_pushOpusWebAudio(opusBytes);
return;
}
if (_sourceBuffer && _initSegmentSent) {
_enqueueAppend(WebMMuxer.buildCluster(opusBytes, _opusTimestampMs));
_opusTimestampMs += OPUS_FRAME_MS;
} else {
// Stash until SourceBuffer is ready. Cap at ~3s of audio.
const maxQueued = Math.ceil(3000 / OPUS_FRAME_MS);
while (_pendingOpusPackets.length >= maxQueued) _pendingOpusPackets.shift();
_pendingOpusPackets.push(new Uint8Array(opusBytes));
}
}
// Remove the SourceBuffer (so a new format/codec can be set up) but
// KEEP the same MediaSource and <audio> element. They hold our
// gesture-acquired play() permission — recreating either would
// require a fresh user tap on iOS. Never call endOfStream(), that
// transitions MediaSource to 'ended' which forbids future
// addSourceBuffer().
function stopAllAudio() {
if (_sourceBuffer && _mediaSource && _mediaSource.readyState === 'open') {
try { _mediaSource.removeSourceBuffer(_sourceBuffer); } catch (e) {}
}
_sourceBuffer = null;
_sourceBufferQueue.length = 0;
_sourceBufferBusy = false;
_initSegmentSent = false;
_opusTimestampMs = 0;
_pendingOpusPackets.length = 0;
// Reset the WebAudio fallback (keep the AudioContext alive so we
// don't forfeit the gesture-acquired play permission on reinit).
if (_opusDecoder) { try { _opusDecoder.free().catch(() => {}); } catch (e) {} }
_opusDecoder = null;
_decoderReady = false;
_waNextTime = null;
}
function handleAudioFrame(data) {
if (!audioEnabled) return;
const u8 = new Uint8Array(data);
if (u8.length < 1) return;
let offset = 0;
const hasFormat = u8[offset++];
if (hasFormat) {
if (u8.length < offset + 12) {
console.warn('[Audio] truncated format header');
return;
}
// AudioFormat (12 bytes, commands.h, pack(1))
const view = new DataView(data, offset, 12);
const channels = view.getUint16(0, true);
const sampleRate = view.getUint32(2, true);
const bitsPerSample = view.getUint16(6, true);
const blockAlign = view.getUint16(8, true);
const compression = view.getUint8(10);
offset += 12;
offset += 1; // padding byte
if (channels === 0 || channels > 8) { console.error('[Audio] bad channels:', channels); return; }
if (sampleRate < 8000 || sampleRate > 48000) { console.error('[Audio] bad sampleRate:', sampleRate); return; }
const fmt = { compression, channels, sampleRate, bitsPerSample, blockAlign };
const needReinit = !audioFormat ||
audioFormat.sampleRate !== fmt.sampleRate ||
audioFormat.channels !== fmt.channels ||
audioFormat.compression !== fmt.compression;
audioFormat = fmt;
if (needReinit) {
if (fmt.compression !== 1) {
console.error('[Audio] PCM payload not supported by web; set USING_OPUS=1 on client');
stopAllAudio();
return;
}
stopAllAudio();
if (_useWebAudioPath) {
_initWebAudioDecoder(fmt);
} else if (_mediaSource && _mediaSource.readyState === 'open') {
_addSourceBufferAndInit();
}
// else (MSE): sourceopen handler will pick up audioFormat when it fires
console.log('[Audio] Format → ch=' + fmt.channels +
' sr=' + fmt.sampleRate + ' compression=' + fmt.compression);
}
}
if (!audioFormat || audioFormat.compression !== 1) return;
if (u8.length <= offset) return;
// The remaining bytes are one Opus packet (variable length).
const opusBytes = new Uint8Array(data, offset);
pushOpusPacket(opusBytes);
}
function handleBinaryFrame(data) {
// 全部进入的二进制都计入带宽统计:视频帧 + 音频帧 + 终端帧
bwBytesAccum += data.byteLength;
// 终端输出帧:4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
const u8 = new Uint8Array(data);
if (u8.length >= 4 &&
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
if (termState && termState.term) termState.term.write(u8.subarray(4));
return;
}
// 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 < 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 isKeyframe = frameType === 1;
const videoData = new Uint8Array(data, 9, dataLen);
const frameCodec = dataLen > 0 ? detectCodec(videoData) : currentCodec;
// codec 切换(客户端硬件 fallback、首次连接等):必须等到 keyframe 才能
// 重建 decoder,delta 帧没有 SPS/PPS 或 SEQ HDR,无法独立初始化。
if (decoder && currentCodec && frameCodec !== currentCodec) {
if (!isKeyframe) {
needKeyframe = true;
return;
}
try { decoder.close(); } catch (e) {}
decoder = null;
currentCodec = null;
}
// If decoder is closed or errored, wait for keyframe to reinitialize
if (!decoder || decoder.state === 'closed') {
if (isKeyframe && decoderWidth > 0) {
currentCodec = frameCodec;
console.log('Reinitializing decoder on keyframe, codec=' + currentCodec);
initDecoder(decoderWidth, decoderHeight);
needKeyframe = false;
} else {
needKeyframe = true;
return;
}
}
if (decoder.state !== 'configured') return;
// Skip delta frames if we need a keyframe
if (needKeyframe && !isKeyframe) return;
if (isKeyframe) needKeyframe = false;
try {
// Check decoder queue to avoid overwhelming it (but never skip keyframes)
if (!isKeyframe && decoder.decodeQueueSize > 10) {
needKeyframe = true; // Need keyframe to resync after skipping
return;
}
decoder.decode(new EncodedVideoChunk({
type: isKeyframe ? 'key' : 'delta',
timestamp: decodeTimestamp++,
data: videoData
}));
} catch (e) {
console.error('Decode error:', e);
needKeyframe = true;
}
}
function getFilteredDevices() {
let filtered = devices;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.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)) ||
(d.version && d.version.toLowerCase().includes(q)) ||
(d.activeWindow && d.activeWindow.toLowerCase().includes(q))
);
}
return filtered;
}
function onSearchChange() {
searchQuery = document.getElementById('search-input').value;
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
currentPage = 1;
renderDeviceList();
}
function clearSearch() {
document.getElementById('search-input').value = '';
onSearchChange();
}
function setViewMode(mode) {
viewMode = mode;
document.getElementById('view-grid').classList.toggle('active', mode === 'grid');
document.getElementById('view-list').classList.toggle('active', mode === 'list');
const list = document.getElementById('device-list');
list.classList.toggle('list-view', mode === 'list');
currentPage = 1;
renderDeviceList();
}
function goToPage(page) {
currentPage = page;
renderDeviceList();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function getRttClass(rtt) {
if (!rtt || rtt === '-') return '';
const ms = parseInt(rtt);
if (isNaN(ms)) return '';
if (ms < 100) return 'rtt-good'; // Green: < 100ms
if (ms < 300) return 'rtt-medium'; // Orange: 100-300ms
return 'rtt-poor'; // Red: > 300ms
}
function isWindowBusy(activeWindow) {
if (!activeWindow || activeWindow.trim() === '') return false;
const lower = activeWindow.toLowerCase();
if (lower.includes('locked') || lower.includes('inactive')) return false;
return true;
}
function updateDeviceInfo(deviceId, rtt, activeWindow) {
// Update device in array
const device = devices.find(d => d.id === deviceId || d.id === String(deviceId));
if (!device) return;
let changed = false;
if (rtt && device.rtt !== rtt) {
device.rtt = rtt;
changed = true;
}
if (activeWindow !== undefined && device.activeWindow !== activeWindow) {
device.activeWindow = activeWindow;
changed = true;
}
if (!changed) return;
// Update DOM directly for efficiency (avoid full re-render)
const cards = document.querySelectorAll('.device-card');
for (const card of cards) {
if (card.onclick && card.onclick.toString().includes(deviceId)) {
// Update RTT
const rttEl = card.querySelector('.meta-item.rtt');
if (rttEl && rtt) {
rttEl.textContent = 'RTT: ' + rtt;
rttEl.className = 'meta-item rtt ' + getRttClass(rtt);
}
// Update active window
let winEl = card.querySelector('.active-window');
if (activeWindow) {
if (!winEl) {
winEl = document.createElement('div');
winEl.className = 'active-window';
card.appendChild(winEl);
}
winEl.textContent = activeWindow;
winEl.title = activeWindow;
winEl.className = 'active-window' + (isWindowBusy(activeWindow) ? ' busy' : '');
} else if (winEl) {
winEl.remove();
}
break;
}
}
}
function renderDeviceList() {
const filtered = getFilteredDevices();
const ps = getPageSize();
const totalPages = Math.ceil(filtered.length / ps) || 1;
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * ps;
const pageDevices = filtered.slice(start, start + ps);
// Update stats
document.getElementById('device-count').textContent = devices.length;
document.getElementById('stat-total').textContent = devices.length;
document.getElementById('stat-showing').textContent = filtered.length;
// Render devices
const list = document.getElementById('device-list');
if (pageDevices.length === 0) {
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
} else {
list.innerHTML = pageDevices.map(d => {
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
const loc = d.location || '-';
const rtt = d.rtt || '-';
const ver = d.version || '-';
const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>' +
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
'</div>';
}).join('');
}
// Render pagination
renderPagination(totalPages);
}
function renderPagination(totalPages) {
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
html += '<button onclick="goToPage(1)" ' + (currentPage === 1 ? 'disabled' : '') + '>First</button>';
html += '<button onclick="goToPage(' + (currentPage - 1) + ')" ' + (currentPage === 1 ? 'disabled' : '') + '>Prev</button>';
// Page numbers
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, startPage + 4);
if (endPage - startPage < 4) startPage = Math.max(1, endPage - 4);
for (let i = startPage; i <= endPage; i++) {
html += '<button onclick="goToPage(' + i + ')" class="' + (i === currentPage ? 'active' : '') + '">' + i + '</button>';
}
html += '<button onclick="goToPage(' + (currentPage + 1) + ')" ' + (currentPage === totalPages ? 'disabled' : '') + '>Next</button>';
html += '<button onclick="goToPage(' + totalPages + ')" ' + (currentPage === totalPages ? 'disabled' : '') + '>Last</button>';
html += '<span class="page-info">Page ' + currentPage + ' of ' + totalPages + '</span>';
pagination.innerHTML = html;
}
function showPage(pageId) {
// Leaving the remote-desktop page? Drop fullscreen first.
// #screen-page is the requestFullscreen target; while it remains
// the document.fullscreenElement the browser blocks pointer /
// keyboard interaction with anything outside its subtree —
// devices-page lives outside it, so without this exit a click
// ANYWHERE on the device list silently no-ops until the user
// hits ESC themselves. Same hazard for device_offline auto-
// navigation, manual Back, and any future navigation we add.
if (pageId !== 'screen-page') {
const sp = document.getElementById('screen-page');
if (sp && sp.classList.contains('active')) {
if (document.fullscreenElement || document.webkitFullscreenElement) {
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document).catch(() => {});
}
sp.classList.remove('pseudo-fullscreen'); // iOS fallback path
}
}
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) {
const el = document.getElementById('screen-status');
el.className = 'screen-status ' + status;
if (status === 'connected') el.textContent = 'Connected';
else if (status === 'connecting') el.textContent = 'Connecting...';
else if (status === 'waiting') el.textContent = msg || 'Waiting...';
else el.textContent = msg || 'Error';
}
function togglePasswordVisibility() {
const input = document.getElementById('password');
const btn = document.querySelector('.password-toggle');
if (input.type === 'password') {
input.type = 'text';
btn.innerHTML = '&#x1F440;'; // Eyes
} else {
input.type = 'password';
btn.innerHTML = '&#x1F441;'; // Eye
}
}
// Pending login state for salt-based auth
let pendingLogin = null;
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!username || !password) { document.getElementById('login-error').textContent = 'Please enter credentials'; return; }
if (!ws || ws.readyState !== WebSocket.OPEN) { document.getElementById('login-error').textContent = 'Not connected'; return; }
if (!challengeNonce) { document.getElementById('login-error').textContent = 'No challenge received'; return; }
// Store pending login info and request salt first
pendingLogin = { username, password };
ws.send(JSON.stringify({ cmd: 'get_salt', username }));
}
async function completeLogin(salt) {
if (!pendingLogin) return;
const { username, password } = pendingLogin;
pendingLogin = null;
// Compute password hash with salt: SHA256(password + salt)
passwordHash = await sha256(password + salt);
// Compute response: SHA256(passwordHash + nonce)
const response = await sha256(passwordHash + challengeNonce);
ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce }));
}
// Logout 二次确认:onclick="logout()" 仅打开确认弹窗;用户点 "Logout" 才真正退出。
function logout() {
document.getElementById('logout-confirm-modal').classList.add('active');
}
function cancelLogout() {
document.getElementById('logout-confirm-modal').classList.remove('active');
}
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
ws.close();
}
challengeNonce = '';
connectWebSocket(); // Reconnect immediately
token = null;
localStorage.removeItem('token');
devices = [];
showPage('login-page');
// Hide users button
document.getElementById('users-btn').classList.remove('visible');
}
// User Management Functions
let currentUserRole = 'viewer';
function openUsersModal() {
document.getElementById('users-modal').classList.add('active');
document.getElementById('user-msg').innerHTML = '';
document.getElementById('new-role').value = 'viewer'; // Reset to default
onRoleChange(); // Update groups section visibility
listUsers();
getGroups();
}
function getGroups() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'get_groups', token }));
}
}
function renderGroupsCheckboxes(groups) {
const container = document.getElementById('groups-checkboxes');
if (!groups || groups.length === 0) {
container.innerHTML = '<span style="color:#666;font-size:12px;">No groups available</span>';
return;
}
container.innerHTML = groups.map(g =>
'<label style="display:flex;align-items:center;padding:3px 0;cursor:pointer;white-space:nowrap;">' +
'<input type="checkbox" value="' + escapeHtml(g) + '" style="margin:0 6px 0 0;flex-shrink:0;width:14px;height:14px;">' +
escapeHtml(g) + '</label>'
).join('');
}
function onRoleChange() {
const role = document.getElementById('new-role').value;
const groupsSection = document.getElementById('groups-section');
groupsSection.style.display = (role === 'admin') ? 'none' : 'block';
}
function closeUsersModal() {
document.getElementById('users-modal').classList.remove('active');
}
function showUserMsg(msg, isError) {
const el = document.getElementById('user-msg');
el.className = 'user-msg ' + (isError ? 'error' : 'success');
el.textContent = msg;
setTimeout(() => { el.innerHTML = ''; }, 3000);
}
function createUser() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) {
showUserMsg('Username and password are required', true);
return;
}
// Collect selected groups
const checkboxes = document.querySelectorAll('#groups-checkboxes input[type="checkbox"]:checked');
const allowed_groups = Array.from(checkboxes).map(cb => cb.value);
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role, allowed_groups }));
}
}
function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return;
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'delete_user', token, username }));
}
}
function listUsers() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'list_users', token }));
}
}
function renderUsersList(users) {
const container = document.getElementById('users-list');
if (!users || users.length === 0) {
container.innerHTML = '<div style="color:#666;padding:12px;">No users</div>';
return;
}
container.innerHTML = users.map(u => {
const isAdmin = u.role === 'admin';
const canDelete = u.username !== 'admin'; // Cannot delete built-in admin
const groups = u.allowed_groups || [];
const groupsText = u.username === 'admin' ? '(all)' :
(groups.length > 0 ? groups.join(', ') : '(none)');
return '<div class="user-item">' +
'<div class="user-info">' +
'<div class="username">' + escapeHtml(u.username) + '</div>' +
'<div class="role ' + (isAdmin ? 'admin' : '') + '">' + u.role + '</div>' +
'<div class="groups" style="font-size:11px;color:#888;margin-top:2px;">Groups: ' + escapeHtml(groupsText) + '</div>' +
'</div>' +
(canDelete ? '<button class="delete-btn" onclick="deleteUser(\'' + escapeHtml(u.username) + '\')">Delete</button>' : '') +
'</div>';
}).join('');
}
function getDevices() {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
ws.send(JSON.stringify({ cmd: 'get_devices', token }));
}
function connectDevice(id, fromWall) {
const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) {
alert('Device is offline');
return;
}
// 无显示器(screen 字段格式 "n:WxH",n=0 表示无显示器)→ 远程桌面没有意义,
// 直接走终端。
const screenStr = String(dev.screen || '');
const screenCount = parseInt(screenStr.split(':')[0], 10);
if (!isNaN(screenCount) && screenCount === 0) {
openTerminal(id);
return;
}
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
currentWidth = 0; currentHeight = 0;
bwBytesAccum = 0; bwBytesPerSec = 0;
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
const fbs = document.getElementById('fb-stats');
if (fbs) fbs.textContent = '';
updateScreenStatus('connecting');
// 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');
}
// ====== Web 终端(xterm.js)======
// 单设备单 web 终端:本地状态保留 deviceId、xterm 实例、fit-addon。
let termState = { deviceId: null, term: null, fit: null, ready: false };
function openTerminal(id) {
if (typeof Terminal === 'undefined') {
alert('Terminal library not loaded yet, please retry');
return;
}
const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) { alert('Device is offline'); return; }
// 已经有终端在跑:直接 show(重连同设备视为重置)
if (termState.deviceId && termState.deviceId !== String(id)) {
closeTerminal();
}
termState.deviceId = String(id);
termState.ready = false;
document.getElementById('term-title').textContent = displayName(dev) + ' Terminal';
document.getElementById('term-status-info').textContent = 'Connecting...';
// 先 showPage 让 term-host 拿到真实尺寸;xterm.open() 必须在容器有 size 时调用,
// 否则首次 fit 会算成 0 列 0 行,渲染异常 + 输入捕获也不灵。
showPage('term-page');
// 触屏 / 窄屏:显示辅助按钮栏(Tab/Esc/Ctrl+C/↑)
// 桌面浏览器有物理键盘,不需要这一行,节省屏幕空间
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
const auxBar = document.getElementById('term-aux-bar');
if (isTouch || window.innerWidth <= 768) {
auxBar.classList.add('visible');
} else {
auxBar.classList.remove('visible');
}
// 用 requestAnimationFrame + 50ms 双重保险,确保 reflow 完成
requestAnimationFrame(() => setTimeout(() => {
if (!termState.term) {
termState.term = new Terminal({
cursorBlink: true,
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
fontSize: 13,
theme: { background: '#000000', foreground: '#e0e0e0' },
convertEol: true, // 将 \n 视为 \r\n(兼容只发 LF 的程序)
scrollback: 5000
});
if (typeof FitAddon !== 'undefined') {
termState.fit = new FitAddon.FitAddon();
termState.term.loadAddon(termState.fit);
}
termState.term.open(document.getElementById('term-host'));
// 用户键入 → 发给 server
termState.term.onData(data => {
if (!termState.ready || !termState.deviceId) return;
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data, token }));
});
// 移动端:点击容器任意位置都把焦点拉回 xterm 的隐藏输入元素
document.getElementById('term-host').addEventListener('click', () => {
if (termState.term) termState.term.focus();
});
} else {
termState.term.clear();
}
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
termState.term.focus();
}, 30));
ws.send(JSON.stringify({ cmd: 'term_open', id: String(id), token }));
}
// 主动收起 iOS 软键盘:blur xterm 的隐藏 textarea。iOS 没有原生关闭键盘按钮,
// 必须由我们提供一个,否则用户在终端里只能下拉浏览器关键盘。
function termHideKeyboard() {
if (termState.term) {
try { termState.term.blur(); } catch (e) {}
}
// 兜底:直接 blur 活动元素
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
}
// visualViewport 适配:iOS 软键盘弹出时 layout viewport 不变,但 visualViewport.height 缩小。
// 把 term-page 的 padding-bottom 加大 = 键盘高度,挤压内容上移,辅助栏跟着浮在键盘正上方。
// 桌面浏览器 visualViewport 永远 = innerHeight,bottomInset = 0,这段是 no-op。
function adjustTermViewport() {
if (!window.visualViewport) return;
const page = document.getElementById('term-page');
if (!page || !page.classList.contains('active')) return;
const layoutH = window.innerHeight;
const visualH = window.visualViewport.height;
const offsetTop = window.visualViewport.offsetTop || 0;
const bottomInset = Math.max(0, Math.round(layoutH - visualH - offsetTop));
page.style.paddingBottom = bottomInset + 'px';
// 内容大小变了,xterm 重 fit + 通知 server 调 PTY 尺寸
if (termState.fit) try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustTermViewport);
window.visualViewport.addEventListener('scroll', adjustTermViewport);
}
// iOS 双指手势会缩放 / 平移 visual viewport,把页面顶起 → Back 按钮跑出视野。
// viewport meta 的 user-scalable=no 在 iOS 10+ 已被忽略(无障碍考虑),必须用 JS
// 主动阻止双指 touchmove 和 gesture 事件。仅 term-page 激活时拦截,screen-page 上
// 的双指 pinch-to-zoom 不受影响(既有交互保留)。
const __isTermActive = () => {
const p = document.getElementById('term-page');
return p && p.classList.contains('active');
};
document.addEventListener('touchmove', function(e) {
if (e.touches.length > 1 && __isTermActive()) {
e.preventDefault();
}
}, { passive: false });
['gesturestart', 'gesturechange', 'gestureend'].forEach(ev => {
document.addEventListener(ev, function(e) {
if (__isTermActive()) e.preventDefault();
}, { passive: false });
});
// 发送特殊按键到终端(手机辅助栏 onclick 调用)。直接走 ws,不经 xterm(避免 focus 抢夺)。
function termSendSpecial(name) {
if (!termState.ready || !termState.deviceId) return;
const seq = ({
tab: '\t',
esc: '\x1b',
ctrlc: '\x03', // ETX = Ctrl+C 信号
up: '\x1b[A', // ANSI 上方向键 = 历史命令
})[name];
if (!seq) return;
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data: seq, token }));
if (termState.term) termState.term.focus(); // 按完辅助键自动把焦点拉回 xterm
}
function closeTerminal() {
if (termState.deviceId) {
ws.send(JSON.stringify({ cmd: 'term_close', id: termState.deviceId, token }));
}
termState.deviceId = null;
termState.ready = false;
// 离开终端页前清掉 visualViewport 留下的 padding-bottom,避免下次切回时 stale
const page = document.getElementById('term-page');
if (page) page.style.paddingBottom = '';
showPage('devices-page');
}
// 终端窗口大小变化 → 通知 server 调 PTY 尺寸(仅 PTY 模式有效,老 cmd 服务端会忽略)
function notifyTerminalResize() {
if (!termState.ready || !termState.deviceId || !termState.term) return;
const cols = termState.term.cols, rows = termState.term.rows;
ws.send(JSON.stringify({ cmd: 'term_resize', id: termState.deviceId, cols, rows, token }));
}
window.addEventListener('resize', () => {
if (termState.fit && document.getElementById('term-page').classList.contains('active')) {
try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
}
});
function toggleFullscreen() {
const el = document.getElementById('screen-page');
const isFs = document.fullscreenElement || document.webkitFullscreenElement;
const isPseudo = el.classList.contains('pseudo-fullscreen');
// Try native fullscreen first
if (!isPseudo && (el.requestFullscreen || el.webkitRequestFullscreen)) {
if (!isFs) {
(el.requestFullscreen || el.webkitRequestFullscreen).call(el).catch(() => {
// Native fullscreen failed (iOS), use pseudo-fullscreen
el.classList.add('pseudo-fullscreen');
window.scrollTo(0, 1);
updateUIForOrientation();
});
} else {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
}
} else {
// Toggle pseudo-fullscreen (iOS fallback)
el.classList.toggle('pseudo-fullscreen');
if (el.classList.contains('pseudo-fullscreen')) window.scrollTo(0, 1);
updateUIForOrientation();
}
}
// Double-tap to exit pseudo-fullscreen on mobile (only when control mode is OFF)
let lastTapTime = 0;
document.getElementById('screen-canvas').addEventListener('touchend', function(e) {
// When control mode is enabled, double-tap sends double-click, not exit fullscreen
if (controlEnabled) return;
const el = document.getElementById('screen-page');
if (!el.classList.contains('pseudo-fullscreen')) return;
// Only exit fullscreen on single-finger double tap, not during pinch
if (touchState.touchCount > 1 || zoomState.isPinching || zoomState.scale > 1) return;
const now = Date.now();
if (now - lastTapTime < 300 && e.changedTouches.length === 1) {
el.classList.remove('pseudo-fullscreen');
updateUIForOrientation();
e.preventDefault();
}
lastTapTime = now;
});
document.addEventListener('keydown', function(e) {
if (e.key !== 'F11') return;
if (!document.getElementById('screen-page').classList.contains('active')) return;
// In control mode F11 is a remote keystroke — let it fall through
// to the desktop key handler (which calls sendKey). Outside control
// mode F11 toggles our page fullscreen as the local convenience.
if (controlEnabled) return;
e.preventDefault();
toggleFullscreen();
});
// Control mode state (mouse/keyboard control)
let controlEnabled = false;
// Remote cursor mapping (Windows cursor index -> CSS cursor)
// Index matches CursorInfo.h: IDC_APPSTARTING(0) to IDC_WAIT(15), 254=custom, 255=unsupported
// Custom I-beam cursor with white fill and black stroke for visibility on any background
const ibeamCursor = "url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"24\" viewBox=\"0 0 16 24\"><path fill=\"none\" stroke=\"white\" stroke-width=\"3\" d=\"M4 3h8M8 3v18M4 21h8\"/><path fill=\"none\" stroke=\"black\" stroke-width=\"1\" d=\"M4 3h8M8 3v18M4 21h8\"/></svg>') 8 12, text";
const cursorMap = [
'progress', // 0: IDC_APPSTARTING
'default', // 1: IDC_ARROW
'crosshair', // 2: IDC_CROSS
'pointer', // 3: IDC_HAND
'help', // 4: IDC_HELP
ibeamCursor, // 5: IDC_IBEAM - custom cursor with outline
'default', // 6: IDC_ICON (no direct CSS equivalent)
'not-allowed', // 7: IDC_NO
'default', // 8: IDC_SIZE (deprecated, use default)
'move', // 9: IDC_SIZEALL
'nesw-resize', // 10: IDC_SIZENESW
'ns-resize', // 11: IDC_SIZENS
'nwse-resize', // 12: IDC_SIZENWSE
'ew-resize', // 13: IDC_SIZEWE
'default', // 14: IDC_UPARROW (no direct CSS equivalent)
'wait' // 15: IDC_WAIT
];
let currentCursorIndex = 1; // Default: arrow
// IDC_* index -> CSS class on .cursor-overlay (touch-mode visual cursor).
// Indices not listed (0,2,4,6,8,14, custom) fall back to default arrow.
const cursorOverlayClassMap = {
3: 'cursor-hand',
5: 'cursor-ibeam',
7: 'cursor-no',
9: 'cursor-move',
10: 'cursor-sizenesw',
11: 'cursor-sizens',
12: 'cursor-sizenwse',
13: 'cursor-sizewe',
15: 'cursor-wait'
};
// Apply the remote cursor: native CSS cursor on desktop, overlay variant on touch.
function applyRemoteCursor(index) {
if (isTouchDevice) {
const overlay = document.getElementById('cursor-overlay');
if (!overlay) return;
const wasActive = overlay.classList.contains('active');
overlay.className = 'cursor-overlay' + (wasActive ? ' active' : '');
const cls = cursorOverlayClassMap[index];
if (cls) overlay.classList.add(cls);
} else {
const canvas = document.getElementById('screen-canvas');
if (!canvas) return;
const cssCursor = (index >= 0 && index < cursorMap.length)
? cursorMap[index]
: 'default';
canvas.style.cursor = cssCursor;
}
}
// Floating toolbar state
let toolbarVisible = false;
let toolbarHideTimer = null;
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement ||
document.getElementById('screen-page')?.classList.contains('pseudo-fullscreen'));
}
// Keyboard Lock keeps F11 / Esc from triggering their browser-default
// behaviour (F11 → toggle browser fullscreen, Esc → exit native page
// fullscreen) while we're actively controlling a remote desktop —
// those keys belong to the remote OS in that mode, not the local
// browser. Lock is no-op on browsers without the API.
function updateKeyboardLock() {
if (!navigator.keyboard || !navigator.keyboard.lock) return;
if (isFullscreen() && controlEnabled) {
navigator.keyboard.lock(['Escape', 'F11']).catch(() => {});
} else {
navigator.keyboard.unlock();
}
}
// Unified fullscreen-change handler. Always clears the floating
// quick-action toolbar (its three buttons duplicate the top-right
// toolbar that becomes visible again on exit — leaving them pinned
// to the top of the page is just noise). Then re-evaluates the
// keyboard lock and, for touch devices, refreshes the orientation-
// dependent layout.
function onFullscreenChange() {
if (!isFullscreen()) {
const fb = document.getElementById('floating-toolbar');
if (fb) fb.classList.remove('visible');
toolbarVisible = false;
if (toolbarHideTimer) {
clearTimeout(toolbarHideTimer);
toolbarHideTimer = null;
}
}
updateKeyboardLock();
if (isTouchDevice) updateUIForOrientation();
}
function toggleFloatingToolbar() {
const toolbar = document.getElementById('floating-toolbar');
toolbarVisible = !toolbarVisible;
toolbar.classList.toggle('visible', toolbarVisible);
// Clear any existing timer
if (toolbarHideTimer) {
clearTimeout(toolbarHideTimer);
toolbarHideTimer = null;
}
// Only auto-hide when NOT in fullscreen (desktop behavior)
// In fullscreen (touch device), user must click again to close
if (toolbarVisible && !isFullscreen()) {
toolbarHideTimer = setTimeout(() => {
toolbarVisible = false;
toolbar.classList.remove('visible');
}, 4000);
}
}
// Pretty-print bytes/sec with a compact 1-byte / 1K / 1M scale.
function formatBandwidth(bytesPerSec) {
if (bytesPerSec < 1024) return bytesPerSec + 'B/s';
if (bytesPerSec < 1024 * 1024) return Math.round(bytesPerSec / 1024) + 'K/s';
return (bytesPerSec / 1024 / 1024).toFixed(1) + 'M/s';
}
// 1-second ticker that pulls the FPS/bandwidth counters and refreshes
// the two read-outs: #frame-info (always visible above the canvas) and
// #fb-stats (a chip inside the floating toolbar, only visible while
// the toolbar is expanded — that's how the fullscreen mode shows
// throughput without cluttering the screen).
function renderStats() {
// FPS 已经在 decoder.onOutput 里就地更新,renderStats 只负责
// 把最新的 fps、bandwidth 组装成显示串写到 DOM。
bwBytesPerSec = bwBytesAccum;
bwBytesAccum = 0;
const fi = document.getElementById('frame-info');
const fs = document.getElementById('fb-stats');
if (currentWidth > 0 && currentHeight > 0) {
const bw = formatBandwidth(bwBytesPerSec);
if (fi) fi.textContent = currentWidth + 'x' + currentHeight +
' @ ' + fps + ' fps · ' + bw;
if (fs) fs.textContent = bw;
} else {
if (fi) fi.textContent = '';
if (fs) fs.textContent = '';
}
}
setInterval(renderStats, 1000);
function isLandscape() {
return window.innerWidth > window.innerHeight;
}
// Cached DOM elements (initialized in window.onload)
let uiElements = null;
function initUIElements() {
uiElements = {
quickControls: document.getElementById('quick-controls'),
toolbarToggle: document.getElementById('toolbar-toggle'),
floatingToolbar: document.getElementById('floating-toolbar'),
btnRdpResetBar: document.getElementById('btn-rdp-reset-bar'),
btnMouseBar: document.getElementById('btn-mouse-bar'),
btnKeyboardBar: document.getElementById('btn-keyboard-bar'),
inputShortcuts: document.getElementById('input-shortcuts')
};
}
function updateUIForOrientation() {
if (!isTouchDevice || !uiElements) return;
// Clear any pending toolbar hide timer when orientation/fullscreen changes
if (toolbarHideTimer) {
clearTimeout(toolbarHideTimer);
toolbarHideTimer = null;
}
const { quickControls, toolbarToggle, floatingToolbar,
btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements;
if (isLandscape()) {
// Landscape mode: always clear android-portrait immersive class
document.getElementById('screen-page').classList.remove('android-portrait');
quickControls.classList.remove('visible');
inputShortcuts.classList.remove('visible');
if (isFullscreen()) {
// Landscape fullscreen: show three-dot menu
toolbarToggle.classList.remove('ui-hidden');
btnRdpResetBar.classList.add('ui-hidden');
btnMouseBar.classList.add('ui-hidden');
btnKeyboardBar.classList.add('ui-hidden');
} else {
// Landscape non-fullscreen: show top toolbar buttons
toolbarToggle.classList.add('ui-hidden');
floatingToolbar.classList.remove('visible');
toolbarVisible = false;
btnRdpResetBar.classList.remove('ui-hidden');
btnMouseBar.classList.remove('ui-hidden');
btnKeyboardBar.classList.remove('ui-hidden');
}
} else if (isAndroidRemote && isFullscreen()) {
// Android portrait fullscreen: immersive mode — hide top toolbar, show three-dot toggle.
// Canvas fills the full viewport via .android-portrait CSS class.
document.getElementById('screen-page').classList.add('android-portrait');
quickControls.classList.remove('visible');
inputShortcuts.classList.remove('visible');
toolbarToggle.classList.remove('ui-hidden');
floatingToolbar.classList.remove('visible');
toolbarVisible = false;
btnRdpResetBar.classList.add('ui-hidden');
btnMouseBar.classList.add('ui-hidden');
btnKeyboardBar.classList.add('ui-hidden');
} else {
// Portrait mode (non-Android): show quick controls
document.getElementById('screen-page').classList.remove('android-portrait');
quickControls.classList.add('visible');
// Input shortcuts only visible when keyboard is open
const keyboardOpen = document.activeElement === mobileKeyboard || imeMode;
inputShortcuts.classList.toggle('visible', keyboardOpen);
toolbarToggle.classList.add('ui-hidden');
floatingToolbar.classList.remove('visible');
toolbarVisible = false;
btnRdpResetBar.classList.add('ui-hidden');
btnMouseBar.classList.add('ui-hidden');
btnKeyboardBar.classList.add('ui-hidden');
}
// Position input shortcuts below canvas
updateInputShortcutsPosition();
}
// Position input shortcuts bar below the canvas
function updateInputShortcutsPosition() {
if (!isTouchDevice || !uiElements || !uiElements.inputShortcuts) return;
if (isLandscape()) return; // Only for portrait mode
const canvas = document.getElementById('screen-canvas');
const shortcuts = uiElements.inputShortcuts;
if (!canvas || canvas.offsetHeight === 0) return;
// Position shortcuts 2px below canvas
const canvasBottom = canvas.offsetTop + canvas.offsetHeight;
shortcuts.style.top = (canvasBottom + 2) + 'px';
}
function sendRdpReset() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'rdp_reset', token, id: currentDeviceId() }));
// Visual feedback - rotate icon
const btn = event.target;
btn.style.transform = 'rotate(360deg)';
btn.style.transition = 'transform 0.5s';
setTimeout(() => {
btn.style.transform = '';
btn.style.transition = '';
}, 500);
}
}
// Reflect server-confirmed audio on/off on the toolbar icon. Server is
// authoritative — toggleAudio() does not flip state locally; it only
// sends the request and waits for the audio_state broadcast.
function applyAudioState(enabled) {
const prev = audioEnabled;
audioEnabled = !!enabled;
if (prev !== audioEnabled) {
console.log('[Audio] ' + (audioEnabled ? 'enabled' : 'disabled'));
}
const btn = document.getElementById('btn-audio-bar');
if (btn) {
// 0x1F50A speaker / 0x1F507 muted speaker
btn.innerHTML = audioEnabled ? '&#x1F50A;' : '&#x1F507;';
btn.title = audioEnabled ? 'Mute audio' : 'Unmute audio';
btn.classList.toggle('muted', !audioEnabled);
}
}
function toggleAudio() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'audio_toggle', token, id: currentDeviceId() }));
}
}
// Detect touch device (mobile/tablet)
// 注意:该常量在屏幕墙状态初始化时就要用到,因此必须在脚本顶部声明(见 wall 状态块)。
function toggleControl() {
controlEnabled = !controlEnabled;
// Update floating toolbar buttons
const btnMouse = document.getElementById('btn-mouse');
btnMouse.classList.toggle('active', controlEnabled);
// Update top toolbar buttons (sync state)
const btnMouseBar = document.getElementById('btn-mouse-bar');
btnMouseBar.classList.toggle('active', controlEnabled);
// Update quick controls buttons
const qcMouse = document.getElementById('qc-mouse');
if (qcMouse) qcMouse.classList.toggle('active', controlEnabled);
// Desktop only: keyboard requires mouse control enabled
if (!isTouchDevice) {
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.disabled = !controlEnabled;
if (btnKeyboardBar) btnKeyboardBar.disabled = !controlEnabled;
}
// Cursor handling
const canvas = document.getElementById('screen-canvas');
const cursorOverlay = document.getElementById('cursor-overlay');
// Touch: hide native cursor and show our overlay (touchpad mode).
// Desktop: native CSS cursor is set by applyRemoteCursor below.
if (controlEnabled && isTouchDevice) {
canvas.style.cursor = 'none';
} else if (!controlEnabled) {
canvas.style.cursor = 'default';
}
// Android direct mode: no cursor overlay (finger IS the pointer)
cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice && !isAndroidRemote);
if (controlEnabled) {
applyRemoteCursor(currentCursorIndex);
}
// Sync the keyboard lock: in fullscreen + control mode, ESC and F11
// must be passed through to the remote OS instead of triggering the
// browser's exit-fullscreen / toggle-fullscreen behavior. The
// updateKeyboardLock helper no-ops if either condition is missing
// or the API is unavailable.
updateKeyboardLock();
}
// Update cursor overlay position (accounting for zoom/pan transform)
// Only used on touch devices (touchpad mode)
function updateCursorOverlay(canvasX, canvasY) {
if (!controlEnabled || !isTouchDevice) return;
const canvas = document.getElementById('screen-canvas');
const cursorOverlay = document.getElementById('cursor-overlay');
// Get canvas base position (without transform, use container + offset)
// offsetLeft/offsetTop are layout positions, unaffected by CSS transform
const container = document.querySelector('.canvas-container');
const containerRect = container.getBoundingClientRect();
const canvasDisplayWidth = canvas.offsetWidth;
const canvasDisplayHeight = canvas.offsetHeight;
const canvasLeft = containerRect.left + canvas.offsetLeft;
const canvasTop = containerRect.top + canvas.offsetTop;
// Convert canvas coords to position on unzoomed canvas
// Map [0, width-1] to [0, 1] for proper edge-to-edge display
const relX = canvas.width > 1 ? canvasX / (canvas.width - 1) : 0;
const relY = canvas.height > 1 ? canvasY / (canvas.height - 1) : 0;
// Position on unzoomed canvas (in pixels from canvas top-left)
const unzoomedX = relX * canvasDisplayWidth;
const unzoomedY = relY * canvasDisplayHeight;
// Apply zoom transform
// Transform origin
const originX = canvasDisplayWidth * zoomState.originX / 100;
const originY = canvasDisplayHeight * zoomState.originY / 100;
// Scale around origin, then translate
const scaledX = originX + (unzoomedX - originX) * zoomState.scale + zoomState.translateX * zoomState.scale;
const scaledY = originY + (unzoomedY - originY) * zoomState.scale + zoomState.translateY * zoomState.scale;
// Final screen position
const screenX = canvasLeft + scaledX;
const screenY = canvasTop + scaledY;
cursorOverlay.style.left = screenX + 'px';
cursorOverlay.style.top = screenY + 'px';
}
// Inverse transform: screen position -> canvas coordinates
// Used to recalculate cursor position after zoom/pan
function screenToCanvas(screenX, screenY) {
const canvas = document.getElementById('screen-canvas');
// Get canvas base position (without transform, use container + offset)
const container = document.querySelector('.canvas-container');
const containerRect = container.getBoundingClientRect();
const canvasDisplayWidth = canvas.offsetWidth;
const canvasDisplayHeight = canvas.offsetHeight;
const canvasLeft = containerRect.left + canvas.offsetLeft;
const canvasTop = containerRect.top + canvas.offsetTop;
// Reverse the transform chain
const scaledX = screenX - canvasLeft;
const scaledY = screenY - canvasTop;
const originX = canvasDisplayWidth * zoomState.originX / 100;
const originY = canvasDisplayHeight * zoomState.originY / 100;
// Reverse: scaledX = originX + (unzoomedX - originX) * scale + translateX * scale
const unzoomedX = (scaledX - originX) / zoomState.scale + originX - zoomState.translateX;
const unzoomedY = (scaledY - originY) / zoomState.scale + originY - zoomState.translateY;
// Reverse: unzoomedX = relX * canvasDisplayWidth
const relX = canvasDisplayWidth > 0 ? unzoomedX / canvasDisplayWidth : 0;
const relY = canvasDisplayHeight > 0 ? unzoomedY / canvasDisplayHeight : 0;
// Reverse: relX = canvasX / (canvas.width - 1)
const canvasX = relX * (canvas.width - 1);
const canvasY = relY * (canvas.height - 1);
// Clamp to valid range
return {
x: Math.max(0, Math.min(canvas.width - 1, canvasX)),
y: Math.max(0, Math.min(canvas.height - 1, canvasY))
};
}
// Two-finger gesture constants
// 缩放 vs 滚动判定(仅在双指 + 触摸场景):
// 1) 间距比例变化 > ZOOM_THRESHOLD 且
// 2) 间距绝对变化 > ZOOM_MIN_PX 且
// 3) 间距变化 > 中心垂直位移 → 判为缩放
// 三个条件同时满足才触发缩放,避免双指上下滚动时手指自然张合的 ~5% 抖动被误判。
// 真实缩放:双指主动张合,间距变化幅度本身就远大于这些阈值。
const ZOOM_THRESHOLD = 0.15; // 间距比例变化阈值(15%)
const ZOOM_MIN_PX = 30; // 间距绝对变化阈值(px)
// 注:服务端 (WebService.cpp HandleMouse) 把 wheelDelta 钳成 ±120 一格 notch,
// SCROLL_SENSITIVITY 实际不起作用;真正决定滚动速度的是 SCROLL_DEADZONE。
// 触摸 touchmove 在 60fps 下高频触发,旧值 2px 等于手指动一下就连发一堆 notch,体感非常飘。
// 现在 28px ≈ 让手指移动 ~1 个文本行距离才发一格,接近 iOS 原生触摸滚动节奏。
const SCROLL_SENSITIVITY = 1; // (server clamps to ±120, kept for clarity only)
const SCROLL_DEADZONE = 28; // Minimum finger-Y delta (px) to send one wheel notch
// Pinch-to-zoom state
let zoomState = {
scale: 1,
minScale: 1,
maxScale: 4,
translateX: 0,
translateY: 0,
lastPinchDist: 0,
isPinching: false,
pinchCenterX: 0,
pinchCenterY: 0,
// Transform origin relative to canvas (percentage)
originX: 50,
originY: 50,
// Two-finger gesture detection
hasZoomed: false, // Whether zoom occurred in current gesture
lastScrollY: 0, // For scroll delta calculation
initialPinchDist: 0, // Distance at gesture start (for cumulative detection)
initialCenterX: 0, // Pinch center at gesture start (for scroll-vs-zoom intent)
initialCenterY: 0
};
const zoomIndicator = document.getElementById('zoom-indicator');
let zoomIndicatorTimer = null;
function showZoomIndicator() {
zoomIndicator.textContent = Math.round(zoomState.scale * 100) + '%';
zoomIndicator.classList.add('visible');
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
zoomIndicatorTimer = setTimeout(() => {
zoomIndicator.classList.remove('visible');
}, 1500);
}
function resetZoom() {
zoomState.scale = 1;
zoomState.translateX = 0;
zoomState.translateY = 0;
applyZoomTransform();
showZoomIndicator();
}
// Track previous scale to detect zoom reset
let prevScale = 1;
function applyZoomTransform() {
const container = document.querySelector('.canvas-container');
if (zoomState.scale === 1) {
canvas.style.transform = '';
canvas.style.transformOrigin = '';
// Reset all zoom state when scale returns to 1
zoomState.originX = 50;
zoomState.originY = 50;
zoomState.translateX = 0;
zoomState.translateY = 0;
// If we just returned from zoomed state, force cursor overlay update
if (prevScale !== 1 && controlEnabled) {
console.log('[Zoom] Reset from ' + prevScale.toFixed(2) + ' to 1, updating cursor overlay');
updateCursorOverlay(cursorState.x, cursorState.y);
}
prevScale = 1;
} else {
prevScale = zoomState.scale;
// Clamp translate to prevent canvas from going out of view
const rect = container.getBoundingClientRect();
const maxTranslateX = (canvas.width * zoomState.scale - rect.width) / 2 / zoomState.scale;
const maxTranslateY = (canvas.height * zoomState.scale - rect.height) / 2 / zoomState.scale;
if (canvas.width * zoomState.scale > rect.width) {
zoomState.translateX = Math.max(-maxTranslateX, Math.min(maxTranslateX, zoomState.translateX));
} else {
zoomState.translateX = 0;
}
if (canvas.height * zoomState.scale > rect.height) {
zoomState.translateY = Math.max(-maxTranslateY, Math.min(maxTranslateY, zoomState.translateY));
} else {
zoomState.translateY = 0;
}
// Use pinch center as transform origin
canvas.style.transformOrigin = zoomState.originX + '% ' + zoomState.originY + '%';
canvas.style.transform = 'scale(' + zoomState.scale + ') translate(' + zoomState.translateX + 'px, ' + zoomState.translateY + 'px)';
}
}
function getPinchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getPinchCenter(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
};
}
// Mobile touch handling (touchpad mode - like Microsoft Remote Desktop)
// Cursor position is separate from finger position
let cursorState = { x: 0, y: 0, initialized: false }; // Remote cursor position
// Touch state machine - delayed mousedown approach
// Key insight: Don't send mousedown on first touchstart
// This allows clean separation of "move cursor" vs "click/drag"
//
// States:
// IDLE: No touch
// FIRST_DOWN: First finger down, waiting to see intent
// MOVING: User is moving cursor (no mouse buttons involved)
// WAITING_SECOND: First tap done, waiting for possible second tap
// SECOND_DOWN: Second tap down, waiting to see drag or dblclick
// DRAGGING: Confirmed drag operation (mousedown sent)
const T_IDLE = 0;
const T_FIRST_DOWN = 1;
const T_MOVING = 2;
const T_WAITING_SECOND = 3;
const T_SECOND_DOWN = 4;
const T_DRAGGING = 5;
let touchState = {
state: T_IDLE,
longPressTimer: null,
lastX: 0, lastY: 0,
touchCount: 0,
startX: 0, startY: 0,
startTime: 0,
moved: false,
secondTapTimer: null // Timer for waiting second tap
};
const touchIndicator = document.getElementById('touch-indicator');
const mobileKeyboard = document.getElementById('mobile-keyboard');
// ── Android direct-touch state ────────────────────────────────────────
// Active when isAndroidRemote && isTouchDevice.
// Single finger maps directly to remote screen coordinates.
let ad = {
active: false,
startX: 0, startY: 0,
lastX: 0, lastY: 0,
hasMoved: false,
longPressTimer: null,
lastTapX: 0, lastTapY: 0,
lastTapTime: 0
};
function adTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const pos = getTouchPos(touch);
const now = Date.now();
if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; }
// Double-tap detection
const dx = pos.x - ad.lastTapX, dy = pos.y - ad.lastTapY;
if (now - ad.lastTapTime < 300 && dx*dx + dy*dy < 1600) {
ad.lastTapTime = 0;
ad.active = false;
sendMouse('dblclick', pos.x, pos.y, 0);
return;
}
ad.active = true;
ad.startX = ad.lastX = pos.x;
ad.startY = ad.lastY = pos.y;
ad.hasMoved = false;
sendMouse('down', pos.x, pos.y, 0);
// Long press → right-click (Android long-press)
ad.longPressTimer = setTimeout(function() {
if (ad.active && !ad.hasMoved) {
sendMouse('up', ad.lastX, ad.lastY, 0);
sendMouse('down', ad.lastX, ad.lastY, 2);
sendMouse('up', ad.lastX, ad.lastY, 2);
ad.active = false;
showTouchIndicator(touch.clientX, touch.clientY);
}
ad.longPressTimer = null;
}, 600);
}
function adTouchMove(e) {
if (e.touches.length !== 1 || !ad.active) return;
const pos = getTouchPos(e.touches[0]);
const dx = pos.x - ad.startX, dy = pos.y - ad.startY;
if (!ad.hasMoved && dx*dx + dy*dy > 64) { // 8px threshold
ad.hasMoved = true;
if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; }
}
if (ad.hasMoved) sendMouse('move', pos.x, pos.y, 0);
ad.lastX = pos.x;
ad.lastY = pos.y;
}
function adTouchEnd() {
if (!ad.active) return;
if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; }
ad.active = false;
if (!ad.hasMoved) {
ad.lastTapTime = Date.now();
ad.lastTapX = ad.lastX;
ad.lastTapY = ad.lastY;
}
sendMouse('up', ad.lastX, ad.lastY, 0);
}
// ─────────────────────────────────────────────────────────────────────
// Initialize cursor to center of screen
function initCursor() {
if (!cursorState.initialized && canvas.width > 0) {
cursorState.x = Math.round(canvas.width / 2);
cursorState.y = Math.round(canvas.height / 2);
cursorState.initialized = true;
updateCursorOverlay(cursorState.x, cursorState.y);
}
}
// Move cursor by delta (touchpad mode)
function moveCursorBy(dx, dy) {
initCursor();
// Sensitivity multiplier (adjust for comfortable control)
const sensitivity = 1.5;
cursorState.x = Math.max(0, Math.min(canvas.width - 1, cursorState.x + dx * sensitivity));
cursorState.y = Math.max(0, Math.min(canvas.height - 1, cursorState.y + dy * sensitivity));
updateCursorOverlay(cursorState.x, cursorState.y);
// Send move to remote
sendMouse('move', Math.round(cursorState.x), Math.round(cursorState.y), 0);
}
// Click at current cursor position
function clickAtCursor(button) {
initCursor();
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('down', x, y, button);
sendMouse('up', x, y, button);
}
function dblClickAtCursor() {
initCursor();
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('dblclick', x, y, 0);
}
function getTouchPos(touch) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.round((touch.clientX - rect.left) * scaleX),
y: Math.round((touch.clientY - rect.top) * scaleY)
};
}
function sendMouse(type, x, y, button, delta) {
if (!controlEnabled) return; // Control mode required
// Update cursor overlay position
updateCursorOverlay(x, y);
if (ws && ws.readyState === WebSocket.OPEN && token) {
const msg = { cmd: 'mouse', token, id: currentDeviceId(), type, x, y, button: button || 0 };
if (delta !== undefined) msg.delta = delta;
ws.send(JSON.stringify(msg));
}
}
function sendAndroidNav(keyCode) {
sendKey(keyCode, true);
setTimeout(() => sendKey(keyCode, false), 50);
}
function updateAndroidNavbar() {
const nb = document.getElementById('android-navbar');
if (!nb) return;
nb.classList.toggle('visible', !!isAndroidRemote);
}
function sendKey(keyCode, isDown, altKey) {
if (!controlEnabled) return; // Control mode required
// 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, id: currentDeviceId(), keyCode, down: isDown, alt: !!altKey }));
}
}
// 本地剪贴板 → 远程:读浏览器剪贴板,先 clipboard_set 到远程,再合成一次完整 Ctrl+V。
// 合成按键放在 readText 的 then 里,保证 clipboard_set 先于 Ctrl+V 到达(同一条 WS 有序)。
function pasteToRemote() {
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, id: currentDeviceId(), text }));
}
press();
}).catch(function() { press(); }); // 读剪贴板被拒/为空:仍执行一次普通远程粘贴
}
// 远程剪贴板 → 本地:等远程完成拷贝(启发式延时)后请求远程剪贴板,回包写回浏览器剪贴板。
// 不保证 100% 命中,失败用户二次 Ctrl+C 即可。
function scheduleCopyFromRemote() {
setTimeout(function() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'clipboard_get', token, id: currentDeviceId() }));
}
}, 250);
}
// 直接注入单个按键到远程(不经过 sendKey 的 controlEnabled 门槛)。
// "I" 组合框和 1/2/3…/中 同属输入辅助栏,不应要求先开鼠标控制。
function sendKeyRaw(keyCode, isDown) {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: false }));
}
}
// "I" 手机输入法组合框:一次性把文本经剪贴板桥发到远程光标处。
// 与 pasteToRemote 同款:先 clipboard_set(服务端转 GBK),再合成完整 Ctrl+V。
function sendTextToRemote(text) {
if (!text) return;
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'clipboard_set', token, id: currentDeviceId(), text }));
}
sendKeyRaw(17, true); // Ctrl down
sendKeyRaw(86, true); // V down
sendKeyRaw(86, false); // V up
sendKeyRaw(17, false); // Ctrl up
}
// 空输入时向远程发送 Backspace(删除远程窗口内容)
function imeBackspace() {
sendKeyRaw(8, true); // VK_BACK
sendKeyRaw(8, false);
}
// 输入框随内容自动长高:文字换行时像微信一样扩展为多行(超过 max-height 后滚动)
function autoGrowImeInput() {
const el = document.getElementById('ime-input');
if (!el) return;
el.style.height = 'auto';
// box-sizing:border-box 下 scrollHeight 不含上下 border(各 1px),补回 2px 避免末行出现滚动条
el.style.height = (el.scrollHeight + 2) + 'px';
}
function toggleIme() {
imeMode = !imeMode;
const imeBar = document.getElementById('ime-bar');
const imeInput = document.getElementById('ime-input');
const btnIme = document.getElementById('btn-ime');
const page = document.getElementById('screen-page');
if (imeMode) {
if (btnIme) btnIme.classList.add('active');
if (imeBar) { imeBar.classList.add('visible'); adjustImeBar(); }
if (imeInput) {
imeInput.focus();
adjustImeBar();
startImeWatcher();
// iOS 上软键盘弹出后 visualViewport 常要一次滚动才归位,强制滚回顶部触发重排
setTimeout(function() { window.scrollTo(0, 0); adjustImeBar(); }, 60);
}
} else {
stopImeWatcher();
if (btnIme) btnIme.classList.remove('active');
if (imeBar) imeBar.classList.remove('visible');
if (imeInput) imeInput.blur();
if (page) page.style.paddingBottom = '';
updateKeyboardButtons(false); // 收起键盘,便于移动远程光标
}
updateInputShortcutsPosition();
}
function imeSend() {
const input = document.getElementById('ime-input');
if (!input) return;
const text = input.value;
if (text) {
sendTextToRemote(text);
input.value = ''; // 清空输入框,保持键盘弹出
autoGrowImeInput(); // 复位为单行高度
input.focus();
} else {
// 空输入框:向远程发送回车(提交远程已编辑好的消息)
sendKeyRaw(13, true); // VK_RETURN
sendKeyRaw(13, false);
}
}
// 键盘弹出时给 screen-page 加 padding-bottom = 键盘高度,把底部输入条顶到键盘上方。
// 与 adjustTermViewport 同款做法:screen-page 是 flex 列,ime-bar 是最后一个 flex 子项。
function adjustImeBar() {
if (!imeMode) return;
const page = document.getElementById('screen-page');
if (!page) return;
let inset = 0;
if (window.visualViewport) {
inset = Math.max(0, Math.round(
window.innerHeight - window.visualViewport.height - (window.visualViewport.offsetTop || 0)
));
}
const next = inset + 'px';
if (page.style.paddingBottom !== next) {
page.style.paddingBottom = next;
updateInputShortcutsPosition();
}
}
// 键盘弹出是异步动画,iOS 上 visualViewport 甚至要一次滚动才更新,用 rAF 循环
// 在 imeMode 期间持续修正,避免"必须手动滚动网页才归位"的问题。
let imeRafId = null;
function imeWatcher() {
if (!imeMode) { imeRafId = null; return; }
adjustImeBar();
imeRafId = requestAnimationFrame(imeWatcher);
}
function startImeWatcher() {
if (imeRafId == null) imeRafId = requestAnimationFrame(imeWatcher);
}
function stopImeWatcher() {
if (imeRafId != null) { cancelAnimationFrame(imeRafId); imeRafId = null; }
}
function showTouchIndicator(x, y) {
touchIndicator.style.left = x + 'px';
touchIndicator.style.top = y + 'px';
touchIndicator.style.display = 'block';
setTimeout(() => touchIndicator.style.display = 'none', 200);
}
canvas.addEventListener('touchstart', function(e) {
e.preventDefault();
e.stopPropagation();
touchState.touchCount = e.touches.length;
if (e.touches.length === 2) {
// Two finger - pinch zoom or scroll
zoomState.isPinching = true;
const initialDist = getPinchDistance(e.touches);
zoomState.initialPinchDist = initialDist;
zoomState.lastPinchDist = initialDist;
zoomState.hasZoomed = false;
const center = getPinchCenter(e.touches);
zoomState.pinchCenterX = center.x;
zoomState.pinchCenterY = center.y;
zoomState.initialCenterX = center.x;
zoomState.initialCenterY = center.y;
zoomState.lastScrollY = center.y;
if (zoomState.scale === 1) {
const rect = canvas.getBoundingClientRect();
const relX = (center.x - rect.left) / rect.width * 100;
const relY = (center.y - rect.top) / rect.height * 100;
zoomState.originX = Math.max(0, Math.min(100, relX));
zoomState.originY = Math.max(0, Math.min(100, relY));
}
// Clean up single-touch state
if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; }
if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; }
// If we were dragging, end it
if (touchState.state === T_DRAGGING) {
sendMouse('up', Math.round(cursorState.x), Math.round(cursorState.y), 0);
}
touchState.state = T_IDLE;
return;
}
// Single finger touch — Android direct mode bypasses the touchpad state machine
if (isAndroidRemote && isTouchDevice) { adTouchStart(e); return; }
initCursor();
const touch = e.touches[0];
const oldStartX = touchState.startX;
const oldStartY = touchState.startY;
touchState.startX = touch.clientX;
touchState.startY = touch.clientY;
touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY;
touchState.moved = false;
touchState.startTime = Date.now();
zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY;
if (!controlEnabled) {
touchState.state = T_FIRST_DOWN;
return;
}
if (touchState.state === T_WAITING_SECOND) {
// Second tap detected
if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; }
// Check distance from first tap
const dx = touch.clientX - oldStartX;
const dy = touch.clientY - oldStartY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 50) {
// Too far - treat as new first tap, complete previous click first
clickAtCursor(0);
console.log('[Touch] Second tap too far (' + Math.round(dist) + 'px), new first tap');
// Move cursor to new position
const rect = canvas.getBoundingClientRect();
cursorState.x = Math.max(0, Math.min(canvas.width - 1, cursorState.x + dx * canvas.width / rect.width * 1.5));
cursorState.y = Math.max(0, Math.min(canvas.height - 1, cursorState.y + dy * canvas.height / rect.height * 1.5));
updateCursorOverlay(cursorState.x, cursorState.y);
touchState.state = T_FIRST_DOWN;
// Set up long press for right click
touchState.longPressTimer = setTimeout(() => {
if (!touchState.moved && touchState.state === T_FIRST_DOWN) {
clickAtCursor(2);
showTouchIndicator(touch.clientX, touch.clientY);
touchState.state = T_IDLE;
}
touchState.longPressTimer = null;
}, 500);
} else {
// Close enough - this is second tap for double-click or drag
touchState.state = T_SECOND_DOWN;
console.log('[Touch] Second tap - waiting for drag or dblclick');
}
} else {
// First tap
if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; }
touchState.state = T_FIRST_DOWN;
console.log('[Touch] First tap');
// Long press for right click
touchState.longPressTimer = setTimeout(() => {
if (!touchState.moved && touchState.state === T_FIRST_DOWN) {
clickAtCursor(2);
showTouchIndicator(touch.clientX, touch.clientY);
touchState.state = T_IDLE;
}
touchState.longPressTimer = null;
}, 500);
}
}, { passive: false });
canvas.addEventListener('touchmove', function(e) {
e.preventDefault();
e.stopPropagation(); // Prevent exiting fullscreen
if (e.touches.length === 2 && zoomState.isPinching) {
// Two finger move - zoom+pan or scroll
const newDist = getPinchDistance(e.touches);
const newCenter = getPinchCenter(e.touches);
const frameDelta = newDist / zoomState.lastPinchDist; // Frame-by-frame change
const totalDelta = newDist / zoomState.initialPinchDist; // Cumulative change from gesture start
// Detect gesture type: zoom vs scroll
// 缩放意图 = 间距比例显著变 + 间距绝对显著变 + 间距变化 > 中心垂直位移
// 单纯比例阈值 5% 太敏感:双指上下滚动时手指自然张合 ~5% 就被误判为缩放。
// 结合绝对像素阈值 + "间距变化 vs 中心位移" 的方向性,能稳定区分两种意图。
const distAbsChange = Math.abs(newDist - zoomState.initialPinchDist);
const distRatioChange = Math.abs(totalDelta - 1);
const centerMoveY = Math.abs(newCenter.y - zoomState.initialCenterY);
const isClearZoom = distRatioChange > ZOOM_THRESHOLD &&
distAbsChange > ZOOM_MIN_PX &&
distAbsChange > centerMoveY;
// 已到缩放边界仍朝同方向尝试 → 给视觉反馈,但要求绝对像素变化 > 半阈值,
// 避免静止时手指轻微抖动也被认为"想缩放"。
const atMinScale = zoomState.scale <= zoomState.minScale;
const atMaxScale = zoomState.scale >= zoomState.maxScale;
const tryingToShrink = totalDelta < 1;
const tryingToEnlarge = totalDelta > 1;
const boundaryFeedback = distAbsChange > ZOOM_MIN_PX / 2 && (
(atMinScale && tryingToShrink) || (atMaxScale && tryingToEnlarge)
);
if (isClearZoom || boundaryFeedback) {
zoomState.hasZoomed = true;
}
if (zoomState.hasZoomed) {
// Zoom + pan mode (once zoomed in this gesture, stay in this mode)
const newScale = Math.max(zoomState.minScale, Math.min(zoomState.maxScale, zoomState.scale * frameDelta));
const dx = newCenter.x - zoomState.pinchCenterX;
const dy = newCenter.y - zoomState.pinchCenterY;
// Pan when zoomed or zooming
if (zoomState.scale > 1 || newScale > 1) {
zoomState.translateX += dx / zoomState.scale;
zoomState.translateY += dy / zoomState.scale;
}
// Update state
zoomState.pinchCenterX = newCenter.x;
zoomState.pinchCenterY = newCenter.y;
zoomState.lastPinchDist = newDist;
if (newScale !== zoomState.scale) {
zoomState.scale = newScale;
showZoomIndicator();
}
applyZoomTransform();
// Note: cursor overlay stays fixed on screen during zoom (Microsoft RD style)
// cursorState will be recalculated on touchend via screenToCanvas()
} else {
// Scroll mode (no zoom occurred yet)
const scrollDelta = newCenter.y - zoomState.lastScrollY;
if (Math.abs(scrollDelta) > SCROLL_DEADZONE) {
initCursor();
// Send wheel event at cursor position
sendMouse('wheel', Math.round(cursorState.x), Math.round(cursorState.y), 0, -scrollDelta * SCROLL_SENSITIVITY);
zoomState.lastScrollY = newCenter.y;
}
// Always update state to prevent jump if user starts zooming
zoomState.pinchCenterX = newCenter.x;
zoomState.pinchCenterY = newCenter.y;
zoomState.lastPinchDist = newDist;
}
return;
}
if (e.touches.length === 1 && zoomState.scale > 1 && touchState.state !== T_DRAGGING && !controlEnabled) {
// Pan when zoomed (only when control mode is OFF - view-only mode)
const touch = e.touches[0];
const dx = touch.clientX - zoomState.pinchCenterX;
const dy = touch.clientY - zoomState.pinchCenterY;
// Only pan if finger moved significantly (prevents accidental pan on tap)
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
// Cancel tap detection since user is panning
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
zoomState.translateX += dx / zoomState.scale;
zoomState.translateY += dy / zoomState.scale;
zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY;
applyZoomTransform();
}
return;
}
// Single finger move — Android direct mode
if (isAndroidRemote && isTouchDevice) { adTouchMove(e); return; }
// Single finger move - state machine based
const touch = e.touches[0];
const dx = touch.clientX - touchState.lastX;
const dy = touch.clientY - touchState.lastY;
const totalDx = touch.clientX - touchState.startX;
const totalDy = touch.clientY - touchState.startY;
const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy);
// Different thresholds for different states
const moveThreshold = (touchState.state === T_SECOND_DOWN) ? 22 : 20;
if (totalDist > moveThreshold && !touchState.moved) {
touchState.moved = true;
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
}
const rect = canvas.getBoundingClientRect();
const canvasDx = dx * canvas.width / rect.width;
const canvasDy = dy * canvas.height / rect.height;
// State machine transitions and actions
if (touchState.state === T_FIRST_DOWN && touchState.moved) {
// First tap + move = pure cursor movement (no click involved)
touchState.state = T_MOVING;
console.log('[Touch] Moving cursor');
}
if (touchState.state === T_SECOND_DOWN && touchState.moved) {
// Second tap + move = START DRAG NOW
// Send mousedown at CURRENT position (after some movement)
// This prevents Windows from treating it as double-click
touchState.state = T_DRAGGING;
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('down', x, y, 0);
const overlay = document.getElementById('cursor-overlay');
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)';
console.log('[Touch] Drag started at', x, y);
}
// Move cursor based on state
if (touchState.state === T_MOVING || touchState.state === T_DRAGGING) {
moveCursorBy(canvasDx, canvasDy);
}
touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY;
}, { passive: false });
canvas.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation(); // Prevent exiting fullscreen
// Handle pinch end
if (zoomState.isPinching) {
if (e.touches.length < 2) {
// Recalculate cursor position after zoom/pan (Microsoft RD style)
// Cursor stayed fixed on screen, so reverse-calculate its new canvas coords
if (cursorState.initialized && zoomState.hasZoomed) {
const cursorOverlay = document.getElementById('cursor-overlay');
const cursorScreenX = parseFloat(cursorOverlay.style.left) || 0;
const cursorScreenY = parseFloat(cursorOverlay.style.top) || 0;
const newCoords = screenToCanvas(cursorScreenX, cursorScreenY);
cursorState.x = newCoords.x;
cursorState.y = newCoords.y;
}
zoomState.isPinching = false;
// Update pan center and lastX/lastY for smooth transition
if (e.touches.length === 1) {
const touch = e.touches[0];
zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY;
// Reset lastX/lastY to prevent delta jump on next move
touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY;
}
}
touchState.touchCount = e.touches.length;
return;
}
// Android direct mode touchend
if (isAndroidRemote && isTouchDevice) { adTouchEnd(); return; }
// State machine based touchend
const overlay = document.getElementById('cursor-overlay');
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
if (touchState.state === T_DRAGGING) {
// End drag
sendMouse('up', x, y, 0);
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))';
console.log('[Touch] Drag ended at', x, y);
touchState.state = T_IDLE;
} else if (touchState.state === T_SECOND_DOWN) {
// Second tap released without moving = double click
// Must send first click before dblclick for Windows to recognize
console.log('[Touch] Double click');
clickAtCursor(0); // First click
if (currentDevice && currentDevice.clientType === 'MAC') {
// macOS uses a real dblclick event; two sequential clicks don't work
dblClickAtCursor(); // Then double click
} else {
// Windows/Linux: simulate physical double-click with two clicks 20ms apart
setTimeout(() => {
clickAtCursor(0);
}, 20);
}
touchState.state = T_IDLE;
} else if (touchState.state === T_FIRST_DOWN && !touchState.moved) {
// First tap released without moving = single click
// Wait briefly for possible second tap
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) brightness(2)';
setTimeout(() => overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))', 150);
console.log('[Touch] First tap done, waiting for second');
touchState.state = T_WAITING_SECOND;
// Set timer: if no second tap, complete the single click
touchState.secondTapTimer = setTimeout(() => {
if (touchState.state === T_WAITING_SECOND) {
console.log('[Touch] Single click (no second tap)');
clickAtCursor(0);
touchState.state = T_IDLE;
}
touchState.secondTapTimer = null;
}, 250);
} else if (touchState.state === T_MOVING) {
// Was just moving cursor, no action needed
console.log('[Touch] Cursor move done');
touchState.state = T_IDLE;
} else {
touchState.state = T_IDLE;
}
touchState.moved = false;
touchState.touchCount = e.touches.length;
}, { passive: false });
let lastKeyboardBtnTouch = 0; // Timestamp of last keyboard button touch
let wasKeyboardFocused = false; // Capture focus state at touchstart (before blur)
function updateKeyboardButtons(active) {
// Update all keyboard buttons across different toolbars
const qcKeyboard = document.getElementById('qc-keyboard');
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (qcKeyboard) qcKeyboard.classList.toggle('active', active);
if (btnKeyboard) btnKeyboard.classList.toggle('active', active);
if (btnKeyboardBar) btnKeyboardBar.classList.toggle('active', active);
// Show/hide input shortcuts based on keyboard state (portrait only)
const shortcuts = document.getElementById('input-shortcuts');
if (shortcuts && !isLandscape()) {
shortcuts.classList.toggle('visible', active);
if (active) updateInputShortcutsPosition();
}
}
function toggleKeyboard() {
// Use focus state captured at touchstart (more reliable than button's active class)
// 若 "I" 输入框开着,键盘按钮此时也视为"开启"——取消时一并收起输入框、清掉 "I" 状态。
const isOpen = wasKeyboardFocused || imeMode;
wasKeyboardFocused = false; // Reset
if (isOpen) {
if (imeMode) toggleIme(); // 关闭 "I" 输入框(内部会 updateKeyboardButtons(false))
mobileKeyboard.blur();
updateKeyboardButtons(false);
} else {
mobileKeyboard.focus();
updateKeyboardButtons(true);
}
}
mobileKeyboard.addEventListener('focus', function() {
updateKeyboardButtons(true);
});
mobileKeyboard.addEventListener('blur', function() {
// "I" 组合框聚焦会让 mobile-keyboard 失焦,此时保持快捷键栏可见
if (imeMode) return;
// Skip if keyboard button was touched within last 300ms
if (Date.now() - lastKeyboardBtnTouch < 300) return;
updateKeyboardButtons(false);
});
function sendRightClick() {
// Use cursor position (canvas coordinates), not touch position (screen coordinates)
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('down', x, y, 2);
sendMouse('up', x, y, 2);
}
// Desktop mouse handling
let lastMouseMove = 0;
let lastMouseX = 0, lastMouseY = 0;
function getMousePos(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY)
};
}
canvas.addEventListener('mousedown', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('down', pos.x, pos.y, e.button);
});
canvas.addEventListener('mouseup', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('up', pos.x, pos.y, e.button);
});
// dblclick handler - server will forward only to macOS clients
canvas.addEventListener('dblclick', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('dblclick', pos.x, pos.y, e.button);
});
canvas.addEventListener('mousemove', function(e) {
const now = Date.now();
const pos = getMousePos(e);
// Always update cursor overlay for smooth visual feedback
updateCursorOverlay(pos.x, pos.y);
// Throttle network sends
const dx = Math.abs(pos.x - lastMouseX);
const dy = Math.abs(pos.y - lastMouseY);
const distSq = dx * dx + dy * dy;
const minInterval = distSq > 2500 ? 33 : 50;
if (now - lastMouseMove < minInterval && distSq < 100) return;
lastMouseMove = now;
lastMouseX = pos.x;
lastMouseY = pos.y;
sendMouse('move', pos.x, pos.y, 0);
});
canvas.addEventListener('wheel', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('wheel', pos.x, pos.y, 0, e.deltaY);
}, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
// Desktop keyboard handling
document.addEventListener('keydown', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// F11 belongs to the local fullscreen toggle UNLESS we're actively
// controlling the remote — see the F11 handler above. Skip here
// to let that one handle it.
if (e.key === 'F11' && !controlEnabled) return;
// 文本剪贴板桥(secure context 下 navigator.clipboard 可用)。
// Ctrl/Cmd+V:把本地剪贴板文本先灌到远程剪贴板,再合成一次 Ctrl+V 粘贴。
// Ctrl/Cmd+C / Ctrl/Cmd+X:照常转发给远程(远程完成拷贝/剪切),随后延迟拉取远程剪贴板回本地。
if (controlEnabled && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
if (e.keyCode === 86) { // Ctrl/Cmd+V
e.preventDefault();
skipNextCtrlVKeyup = true;
pasteToRemote();
return;
}
// e.keyCode === 67(C)/88(X) 时落到下面的 sendKey 原样转发,转发后再延迟拉取
}
e.preventDefault();
sendKey(e.keyCode, true, e.altKey);
if (controlEnabled && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && (e.keyCode === 67 || e.keyCode === 88)) {
scheduleCopyFromRemote();
}
});
document.addEventListener('keyup', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'F11' && !controlEnabled) return;
// 由 pasteToRemote 合成的完整 Ctrl+V 按下:物理 keyup 不再转发,避免 down/up 乱序
if (e.keyCode === 86 && skipNextCtrlVKeyup) {
skipNextCtrlVKeyup = false;
e.preventDefault();
return;
}
e.preventDefault();
sendKey(e.keyCode, false, e.altKey);
});
// 字符 → Win32 VK 映射表(提到 listener 外避免每次输入重建)。
// US 键盘上"不需要 Shift"的 OEM 符号 ASCII 码 ≠ VK 码,必须显式映射。
const SHIFT_SYMBOLS = '~!@#$%^&*()_+{}|:"<>?';
const SYMBOL_VK_MAP = {
// —— Unshifted OEM symbols (US layout) ——
'`': 192, '-': 189, '=': 187, '[': 219, ']': 221, '\\': 220,
';': 186, "'": 222, ',': 188, '.': 190, '/': 191,
// —— Shifted symbols (与 above 共享 VK,多了 Shift 修饰) ——
'~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
'+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
'"': 222, '<': 188, '>': 190, '?': 191
};
// 把单个字符发成一对 keyDown/keyUp(必要时夹一对 Shift)
function sendCharAsKey(ch) {
const isUpperCase = ch >= 'A' && ch <= 'Z';
const needsShift = isUpperCase || SHIFT_SYMBOLS.includes(ch);
const keyCode = SYMBOL_VK_MAP[ch] || ch.toUpperCase().charCodeAt(0);
if (needsShift) sendKey(16, true); // VK_SHIFT = 16
sendKey(keyCode, true);
sendKey(keyCode, false);
if (needsShift) sendKey(16, false);
}
mobileKeyboard.addEventListener('input', function(e) {
// e.data 可能携带多个字符(中/日/韩 IME 候选词上屏、Gboard 滑行输入、
// 剪贴板粘贴一段文本都会一次性 commit)。逐字符发送,保证每个都到达 host。
const text = e.data;
if (text) {
for (const ch of text) sendCharAsKey(ch);
}
mobileKeyboard.value = '';
});
mobileKeyboard.addEventListener('keydown', function(e) {
const keyMap = {
'Backspace': 8, 'Tab': 9, 'Enter': 13, 'Escape': 27,
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
'Delete': 46
};
if (keyMap[e.key]) {
e.preventDefault();
sendKey(keyMap[e.key], true);
sendKey(keyMap[e.key], false);
}
});
function disconnect() {
// Reset throughput / resolution read-outs
currentWidth = 0; currentHeight = 0;
bwBytesAccum = 0; bwBytesPerSec = 0;
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
// Reset control mode
controlEnabled = false;
// Reset keyboard state (blur event will update button state)
mobileKeyboard.blur();
// Reset "I" 手机输入法组合框
imeMode = false;
stopImeWatcher();
const imeBar = document.getElementById('ime-bar');
if (imeBar) imeBar.classList.remove('visible');
const imePage = document.getElementById('screen-page');
if (imePage) imePage.style.paddingBottom = '';
const btnIme = document.getElementById('btn-ime');
if (btnIme) btnIme.classList.remove('active');
// Reset floating toolbar buttons
const btnMouse = document.getElementById('btn-mouse');
if (btnMouse) btnMouse.classList.remove('active');
// Reset top toolbar buttons
const btnMouseBar = document.getElementById('btn-mouse-bar');
if (btnMouseBar) btnMouseBar.classList.remove('active');
// Desktop only: disable keyboard button
if (!isTouchDevice) {
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.disabled = true;
if (btnKeyboardBar) btnKeyboardBar.disabled = true;
}
// Reset quick control buttons
const qcMouse = document.getElementById('qc-mouse');
if (qcMouse) qcMouse.classList.remove('active');
document.getElementById('screen-canvas').style.cursor = 'default';
// Strip any cursor-* variant on the overlay, leaving the bare default arrow.
document.getElementById('cursor-overlay').className = 'cursor-overlay';
currentCursorIndex = 1; // Reset to default arrow
// Reset zoom state
zoomState.scale = 1;
zoomState.translateX = 0;
zoomState.translateY = 0;
zoomState.isPinching = false;
canvas.style.transform = '';
canvas.style.transformOrigin = '';
if (decoder) { try { decoder.close(); } catch(e) {} decoder = null; }
// 墙内设备仍可能被订阅(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');
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) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// 设备显示名:有备注则 "hostname (备注)",否则就是 hostname。
// 服务端备注从 m_ClientMap MAP_NOTE 取(参看 BuildDeviceListJson)。
function displayName(d) {
if (!d) return '';
const name = d.name || 'Unknown';
return d.remark ? (name + ' (' + d.remark + ')') : name;
}
function startPingInterval() {
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN && token) ws.send(JSON.stringify({ cmd: 'ping', token }));
}, 30000);
}
function stopPingInterval() {
if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && document.querySelector('.page.active').id === 'login-page') login();
});
// Handle page visibility change (iOS PWA background/foreground)
let backgroundDisconnectTimer = null;
const BACKGROUND_TIMEOUT_MOBILE = 30000; // 30s for mobile/tablet
function doBackgroundDisconnect() {
cancelReconnect();
stopPingInterval();
// 关闭墙解码器,避免留下失效的 VideoDecoder;tile 布局保留。
wallPauseAll();
if (ws) {
ws.onclose = null;
ws.close();
ws = null;
}
updateWsStatus('disconnected');
}
document.addEventListener('visibilitychange', () => {
isPageVisible = !document.hidden;
if (document.hidden) {
// 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 (onWallPage) {
// 墙:关闭所有解码器省电/释放 GPU,订阅保留,回前台按预算恢复。
wallPauseAll();
}
if (onScreenPage || onTermPage || onWallPage) {
// 屏幕预览 / 终端 / 墙:移动端给 30 秒宽限,期间切回应用就无缝继续;
// 桌面:保持长连,靠 ping 心跳
if (isTouchDevice) {
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
}
} else {
// 其它页面 - 立即断开省流量
doBackgroundDisconnect();
}
} else {
// Page coming to foreground
if (backgroundDisconnectTimer) {
clearTimeout(backgroundDisconnectTimer);
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.
if (onScreenPage && decoder) {
try { decoder.close(); } catch(e) {}
decoder = null;
needKeyframe = true;
}
connectWebSocket();
} else if (onScreenPage && isTouchDevice) {
// iOS suspends the tab and silently invalidates the VideoDecoder
// GPU context (or quietly closes the WS while readyState stays OPEN).
// Always force a clean WS reconnect on mobile so the server delivers
// a fresh stream start; the server no longer sends a stale cached
// keyframe (cleared on disconnect), so the client waits for the next
// natural IDR — no mosaic, no freeze.
if (decoder) {
try { decoder.close(); } catch(e) {}
decoder = null;
needKeyframe = true;
}
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 }));
}
}
});
window.onload = function() {
// Initialize cached DOM elements
initUIElements();
const compat = checkWebCodecs();
if (!compat.supported) {
document.body.insertAdjacentHTML('afterbegin',
'<div class="compat-warning">Warning: Your browser may not support H264 decoding.</div>');
}
// On touch devices: setup UI based on orientation and fullscreen state
if (isTouchDevice) {
// Initial UI setup
updateUIForOrientation();
// Touch devices: keyboard is independent from mouse, enable keyboard buttons
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.disabled = false;
if (btnKeyboardBar) btnKeyboardBar.disabled = false;
// Listen for orientation changes (with debounce)
let resizeTimer = null;
window.addEventListener('resize', function() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(updateUIForOrientation, 100);
});
// Touch-device-only handlers continue here; the unified
// fullscreen change handler (onFullscreenChange) is registered
// below outside this branch so desktop browsers also get the
// floating-toolbar cleanup + keyboard lock sync.
// Input shortcut buttons event handlers
// 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, id: currentDeviceId(), keyCode, down: isDown, alt: false }));
}
}
const shortcutBtns = document.querySelectorAll('.shortcut-btn');
shortcutBtns.forEach(function(btn) {
// "I" 按钮不是按键注入,交由 toggleIme 处理
if (btn.id === 'btn-ime') return;
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
const keyCode = parseInt(btn.dataset.key);
const needShift = btn.dataset.shift === '1';
const needCtrl = btn.dataset.ctrl === '1';
if (needCtrl) sendShortcutKey(17, true); // Ctrl down
if (needShift) sendShortcutKey(16, true); // Shift down
sendShortcutKey(keyCode, true);
sendShortcutKey(keyCode, false);
if (needShift) sendShortcutKey(16, false); // Shift up
if (needCtrl) sendShortcutKey(17, false); // Ctrl up
});
btn.addEventListener('click', function(e) {
e.preventDefault();
// Only handle click for non-touch (mouse)
if (!('ontouchstart' in window)) {
const keyCode = parseInt(btn.dataset.key);
const needShift = btn.dataset.shift === '1';
const needCtrl = btn.dataset.ctrl === '1';
if (needCtrl) sendShortcutKey(17, true); // Ctrl down
if (needShift) sendShortcutKey(16, true);
sendShortcutKey(keyCode, true);
sendShortcutKey(keyCode, false);
if (needShift) sendShortcutKey(16, false);
if (needCtrl) sendShortcutKey(17, false); // Ctrl up
}
});
});
// "I" 手机输入法组合框:触摸记时、开关、回车/按钮发送、键盘上浮
const btnIme = document.getElementById('btn-ime');
if (btnIme) {
btnIme.addEventListener('touchstart', function() {
lastKeyboardBtnTouch = Date.now();
}, { passive: true });
btnIme.addEventListener('mousedown', function() {
lastKeyboardBtnTouch = Date.now();
});
btnIme.addEventListener('click', function(e) {
e.preventDefault();
toggleIme();
});
}
const imeInput = document.getElementById('ime-input');
if (imeInput) {
imeInput.addEventListener('keydown', function(e) {
if (e.isComposing) return; // 输入法确认候选时一律不转发
if (e.key === 'Enter') {
e.preventDefault();
imeSend();
} else if (e.key === 'Backspace' && !imeInput.value) {
// 空输入框:删除键改为向远程发送 Backspace
e.preventDefault();
imeBackspace();
}
});
imeInput.addEventListener('input', autoGrowImeInput);
}
const imeSendBtn = document.getElementById('ime-send');
if (imeSendBtn) {
imeSendBtn.addEventListener('click', function(e) {
e.preventDefault();
imeSend();
});
}
const imeCollapse = document.getElementById('ime-collapse');
if (imeCollapse) {
imeCollapse.addEventListener('click', function(e) {
e.preventDefault();
toggleIme();
});
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustImeBar);
window.visualViewport.addEventListener('scroll', adjustImeBar);
}
// iOS / 部分浏览器键盘弹出时触发的是 window resize,作为上浮兜底
window.addEventListener('resize', adjustImeBar);
} else {
// Desktop: hide keyboard buttons (physical keyboard available)
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.classList.add('ui-hidden');
if (btnKeyboardBar) btnKeyboardBar.classList.add('ui-hidden');
}
// Unified fullscreen change handler — registered for all devices.
// Touch devices need updateUIForOrientation; desktop devices need
// the floating-toolbar cleanup. Both need the keyboard lock sync.
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
// Keyboard buttons: capture focus state and prevent blur from updating button state
function bindKeyboardBtnEvents(btn) {
if (!btn) return;
btn.addEventListener('touchstart', function() {
lastKeyboardBtnTouch = Date.now();
wasKeyboardFocused = (document.activeElement === mobileKeyboard);
}, { passive: true });
btn.addEventListener('mousedown', function() {
lastKeyboardBtnTouch = Date.now();
wasKeyboardFocused = (document.activeElement === mobileKeyboard);
});
}
bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar'));
// Restore token and role from localStorage (local-login fallback).
token = localStorage.getItem('token');
currentUserRole = localStorage.getItem('role') || 'viewer';
// Attempt portal SSO first: if the user is already logged into the
// parent domain, the server bridges the portal session cookie into
// a YAMA session token so the login form is skipped. On failure
// (not logged in, or SSO disabled) fall back to the restored
// session or the built-in login form.
trySSO().then(() => {
// Show Users button for admin only (final role known after SSO).
if (token && currentUserRole === 'admin') {
document.getElementById('users-btn').classList.add('visible');
}
connectWebSocket();
});
};
</script>
</body>
</html>