Persist every device that has ever logged in to the Go server in a small JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json convention: atomic temp-file write with fsync and rename, a 500 ms debounce for bursts of reconnects, first_seen preserved across logins, and pruning that evicts offline records before devices that are still connected. Devices connected at shutdown are stamped offline before the final flush so last_seen stays accurate across restarts. The server advertises an offlineDevices capability and device_list now appends historical devices that are not online, marked with online:false and last_seen. The web UI shows an Offline checkbox after Total/Showing (state kept in localStorage), renders offline cards dimmed and dashed, and always lists them in the screen-wall sidebar. Older servers without the capability keep the online-only list. Grid and List cards gain a delete button after the terminal button, admin only, behind a confirmation dialog. Deleting an offline device removes its history record; deleting an online device first sends the single-byte TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then removes the record too, so the device does not come back as a dead offline entry. The agent's own disconnect then runs the existing cleanup path: screen and terminal sub-connections are closed, the hub entry is removed, and browsers receive device_offline to release their watch sets, wall sessions, and stream references. Wall mode exposes no delete action. Tests cover history persistence/pruning/recovery, offline ordering, the device_list merge, and all three delete outcomes (offline removal, online uninstall with a single 0x3F byte, and viewer rejection). Co-Authored-By: deepseek-flash
6412 lines
308 KiB
HTML
6412 lines
308 KiB
HTML
<!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; }
|
||
/* 离线设备:只展示,无点击/终端操作。虚线边框 + 灰显与在线卡片区分。 */
|
||
.device-card.offline {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
border-style: dashed;
|
||
}
|
||
.device-card.offline:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
border-color: rgba(255,255,255,0.05);
|
||
}
|
||
.device-card.offline::before { display: none; }
|
||
.device-card.offline h3 { color: #999; }
|
||
.device-card .last-seen {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #777;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.device-grid.list-view .device-card .last-seen {
|
||
margin-top: 0;
|
||
max-width: 200px;
|
||
flex-shrink: 1;
|
||
}
|
||
/* 卡片操作图标(终端 / 删除):右上角,独立于卡片本体点击事件
|
||
(onclick stopPropagation)。List 视图卡片是一行,改为垂直居中。 */
|
||
.device-card .card-actions {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
display: flex;
|
||
gap: 6px;
|
||
z-index: 1;
|
||
}
|
||
.device-card .card-icon-btn {
|
||
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;
|
||
}
|
||
.device-card .card-icon-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
|
||
.device-card .card-icon-btn:active { transform: scale(0.92); }
|
||
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
|
||
.device-card .card-del-btn:hover { background: rgba(231, 76, 60, 0.25); color: #e74c3c; }
|
||
.device-card h3 { padding-right: 86px; } /* 给右上角两个操作图标让位 */
|
||
.device-grid.list-view .device-card .card-actions {
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
/* ====== 终端页面 ====== */
|
||
#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;
|
||
padding-right: 100px; /* 给垂直居中的操作图标列让位 */
|
||
gap: 20px;
|
||
}
|
||
.device-grid.list-view .device-card::before { display: none; }
|
||
.device-grid.list-view .device-card h3 { padding-right: 0; }
|
||
.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; }
|
||
/* 离线设备开关:服务端宣告 offlineDevices 能力后才显示(C++ 服务端没有该能力位)。 */
|
||
.stats-bar .stat.offline-toggle {
|
||
display: none;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-left: auto;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.stats-bar .stat.offline-toggle.visible { display: flex; }
|
||
.stats-bar .stat.offline-toggle input {
|
||
accent-color: #e94560;
|
||
cursor: pointer;
|
||
}
|
||
|
||
#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">👁</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>⚠ 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">🌐 Website</a>
|
||
<a href="https://git.simpleremoter.com/" target="_blank">📦 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()">×</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>
|
||
<label class="stat offline-toggle" id="offline-toggle"
|
||
title="Show devices that have connected before but are offline now">
|
||
<input type="checkbox" id="show-offline" onchange="onOfflineToggle(this.checked)">
|
||
Offline
|
||
</label>
|
||
</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">←</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×1</button>
|
||
<button class="view-btn" data-layout="4" onclick="setWallLayout(4)">2×2</button>
|
||
<button class="view-btn active" data-layout="9" onclick="setWallLayout(9)">3×3</button>
|
||
<button class="view-btn" data-layout="16" onclick="setWallLayout(16)">4×4</button>
|
||
<button class="view-btn" data-layout="25" onclick="setWallLayout(25)">5×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">☰</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()">×</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">🖥</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">↻</button>
|
||
<button class="toolbar-btn-bar" id="btn-audio-bar" onclick="toggleAudio()" title="Mute audio">🔊</button>
|
||
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">🖱</button>
|
||
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>⌨</button>
|
||
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">⛶</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">↻</button>
|
||
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">⌨</button>
|
||
<button class="qc-btn" id="qc-mouse" onclick="toggleControl()" title="Mouse" tabindex="-1">🖱</button>
|
||
<button class="qc-btn" id="qc-disconnect" onclick="disconnect()" title="Disconnect" tabindex="-1">✕</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">中</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()">•••</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">↻</button>
|
||
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">🖱</button>
|
||
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>⌨</button>
|
||
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">✕</button>
|
||
<button class="toolbar-btn" onclick="toggleFloatingToolbar()" title="Collapse">▴</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()">▼</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')">↑</button>
|
||
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">▼</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()">×</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>
|
||
|
||
<!-- Delete Device Confirmation Modal(离线=删除历史记录;在线=下发卸载主机) -->
|
||
<div class="modal-overlay" id="delete-device-modal" onclick="if(event.target===this)cancelDeleteDevice()">
|
||
<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">
|
||
<polyline points="3 6 5 6 21 6"/>
|
||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||
<path d="M10 11v6"/><path d="M14 11v6"/>
|
||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||
</svg>
|
||
</div>
|
||
<h3 id="delete-device-title">Delete Device</h3>
|
||
<p id="delete-device-msg"></p>
|
||
<div class="confirm-modal-actions">
|
||
<button class="cancel-btn" onclick="cancelDeleteDevice()">Cancel</button>
|
||
<button class="danger-btn" id="delete-device-confirm" onclick="confirmDeleteDevice()">Delete</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, offlineDevices: false };
|
||
|
||
// 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。
|
||
// 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。
|
||
const SHOW_OFFLINE_STORAGE_KEY = 'showOfflineDevices';
|
||
let showOffline = false;
|
||
try {
|
||
showOffline = localStorage.getItem(SHOW_OFFLINE_STORAGE_KEY) === '1';
|
||
} catch (e) { /* 隐私模式下 localStorage 可能不可用 */ }
|
||
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),
|
||
offlineDevices: !!(msg.caps && msg.caps.offlineDevices)
|
||
};
|
||
updateWallAvailability();
|
||
applyOfflineCapability();
|
||
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 'delete_device_result':
|
||
if (msg.ok) {
|
||
// mode=uninstall:命令已下发,设备稍后自行断开;
|
||
// mode=removed:历史记录已删除,刷新后卡片消失。
|
||
getDevices();
|
||
} else {
|
||
alert(msg.msg || 'Failed to delete device');
|
||
}
|
||
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">×</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 offline = d.online === false;
|
||
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">' +
|
||
(offline
|
||
? '<span class="meta-item offline-badge">Offline</span>'
|
||
: '<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>' +
|
||
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
|
||
(!offline && 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 scopeDevices() {
|
||
// Total / Showing 都基于当前范围:未勾选 Offline 时只统计在线设备。
|
||
if (showOffline) return devices;
|
||
return devices.filter(d => d.online !== false);
|
||
}
|
||
|
||
function getFilteredDevices() {
|
||
let filtered = scopeDevices();
|
||
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 applyOfflineCapability() {
|
||
const label = document.getElementById('offline-toggle');
|
||
const cb = document.getElementById('show-offline');
|
||
if (!label || !cb) return;
|
||
const supported = !!serverCaps.offlineDevices;
|
||
label.classList.toggle('visible', supported);
|
||
if (!supported) showOffline = false;
|
||
cb.checked = showOffline;
|
||
renderDeviceList();
|
||
}
|
||
|
||
function onOfflineToggle(checked) {
|
||
showOffline = !!checked;
|
||
try {
|
||
localStorage.setItem(SHOW_OFFLINE_STORAGE_KEY, showOffline ? '1' : '0');
|
||
} catch (e) { /* ignore */ }
|
||
currentPage = 1;
|
||
renderDeviceList();
|
||
}
|
||
|
||
function formatLastSeen(ts) {
|
||
if (!ts) return 'unknown';
|
||
const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||
if (secs < 60) return 'just now';
|
||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||
const days = Math.floor(secs / 86400);
|
||
if (days < 30) return days + 'd ago';
|
||
return new Date(ts * 1000).toLocaleDateString();
|
||
}
|
||
|
||
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. Total is the current scope (online-only unless the
|
||
// Offline toggle is on); Showing is after the search filter.
|
||
const scope = scopeDevices();
|
||
document.getElementById('device-count').textContent = scope.length;
|
||
document.getElementById('stat-total').textContent = scope.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 offline = d.online === false;
|
||
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
|
||
const loc = d.location || '-';
|
||
const rtt = d.rtt || '-';
|
||
const ver = d.version || '-';
|
||
const activeWin = d.activeWindow || '';
|
||
// 操作图标:在线卡片有终端;删除对所有卡片开放(离线=删记录,
|
||
// 在线=卸载主机),仅管理员可用。Wall 模式不提供删除。
|
||
const actions = [];
|
||
if (!offline) {
|
||
actions.push(
|
||
'<button class="card-icon-btn 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>');
|
||
}
|
||
if (canDeleteDevices()) {
|
||
actions.push(
|
||
'<button class="card-icon-btn card-del-btn" title="' +
|
||
(offline ? 'Delete record' : 'Uninstall host') + '" aria-label="Delete" ' +
|
||
'onclick="event.stopPropagation();askDeleteDevice(\'' + 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="3 6 5 6 21 6"/>' +
|
||
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
|
||
'<path d="M10 11v6"/><path d="M14 11v6"/>' +
|
||
'<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
|
||
'</svg>' +
|
||
'</button>');
|
||
}
|
||
const actionsHtml = actions.length
|
||
? '<div class="card-actions">' + actions.join('') + '</div>' : '';
|
||
return '<div class="device-card' + (offline ? ' offline' : '') + '"' +
|
||
(offline ? '' : ' onclick="connectDevice(\'' + d.id + '\')"') + '>' +
|
||
actionsHtml +
|
||
'<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">' +
|
||
(offline
|
||
? '<span class="meta-item offline-badge">Offline</span>'
|
||
: '<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>' +
|
||
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
|
||
(!offline && 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 = '👀'; // Eyes
|
||
} else {
|
||
input.type = 'password';
|
||
btn.innerHTML = '👁'; // 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');
|
||
}
|
||
|
||
// ===== 删除设备 =====
|
||
// 两种情况都删除服务端 devices.json 中的历史记录;在线设备额外按 MFC
|
||
// 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。
|
||
// 仅管理员可见/可用。
|
||
let pendingDeleteDeviceId = null;
|
||
|
||
function canDeleteDevices() {
|
||
return currentUserRole === 'admin';
|
||
}
|
||
|
||
function askDeleteDevice(id) {
|
||
if (!canDeleteDevices()) return;
|
||
const d = devices.find(x => String(x.id) === String(id));
|
||
if (!d) { alert('Device not found'); return; }
|
||
const offline = d.online === false;
|
||
const name = displayName(d);
|
||
pendingDeleteDeviceId = String(id);
|
||
document.getElementById('delete-device-title').textContent =
|
||
offline ? 'Delete Device Record' : 'Uninstall Host';
|
||
document.getElementById('delete-device-msg').innerHTML = offline
|
||
? 'Remove <strong>' + escapeHtml(name) + '</strong> from the server\'s device history?' +
|
||
'<br>This cannot be undone.'
|
||
: 'Send the uninstall command to <strong>' + escapeHtml(name) + '</strong>?' +
|
||
'<br>The remote agent will uninstall itself, disconnect, and its record will be deleted.';
|
||
document.getElementById('delete-device-confirm').textContent =
|
||
offline ? 'Delete' : 'Uninstall';
|
||
document.getElementById('delete-device-modal').classList.add('active');
|
||
}
|
||
|
||
function cancelDeleteDevice() {
|
||
pendingDeleteDeviceId = null;
|
||
document.getElementById('delete-device-modal').classList.remove('active');
|
||
}
|
||
|
||
function confirmDeleteDevice() {
|
||
const id = pendingDeleteDeviceId;
|
||
document.getElementById('delete-device-modal').classList.remove('active');
|
||
pendingDeleteDeviceId = null;
|
||
if (!id || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify({ cmd: 'delete_device', token, id }));
|
||
}
|
||
|
||
// 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 ? '🔊' : '🔇';
|
||
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> |