Files
SimpleRemoter/server/2015Remote/WebPage.h

2637 lines
118 KiB
C++

#pragma once
#include <string>
// Embedded HTML page for web remote control
// Split into parts to avoid MSVC string literal limit (16KB)
inline std::string GetWebPageHTML() {
std::string html;
// Part 1: Head and base styles
html += R"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">
<title>SimpleRemoter</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="Remoter">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="data:image/png;base64,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">
<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; padding: 20px; min-height: 100vh; }
.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; }
)HTML";
// Part 2: Device page styles
html += R"HTML(
#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; }
.refresh-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%);
color: #fff;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.refresh-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); }
.logout-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%);
color: #fff;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
margin-left: 10px;
}
.logout-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
)HTML";
// Part 3: Device card styles
html += R"HTML(
.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 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 .meta-row { display: flex; gap: 12px; margin-top: 6px; font-size: 12px; color: #666; }
.device-card .meta-item { display: flex; align-items: center; gap: 4px; }
.device-card .meta-item.rtt { font-weight: 500; }
.device-card .meta-item.rtt.rtt-good { color: #4caf50; }
.device-card .meta-item.rtt.rtt-medium { color: #ff9800; }
.device-card .meta-item.rtt.rtt-poor { color: #f44336; }
.no-devices {
text-align: center;
color: #666;
padding: 60px 20px;
background: rgba(22, 33, 62, 0.5);
border-radius: 12px;
border: 1px dashed rgba(255,255,255,0.1);
}
.no-devices h3 { color: #888; margin-bottom: 10px; }
/* List view styles */
.device-grid.list-view {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-grid.list-view .device-card {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 20px;
}
.device-grid.list-view .device-card::before { display: none; }
.device-grid.list-view .device-card h3 {
min-width: 180px;
margin-bottom: 0;
font-size: 14px;
}
.device-grid.list-view .device-card .info {
margin-bottom: 0;
min-width: 120px;
}
.device-grid.list-view .device-card .info-row { flex-wrap: nowrap; gap: 12px; }
.device-grid.list-view .device-card .meta-row { margin-top: 0; }
.device-grid.list-view .device-card .active-window {
margin-top: 0; max-width: 200px; flex-shrink: 1;
}
)HTML";
// Part 4: Pagination styles
html += R"HTML(
.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; }
)HTML";
// Part 5: Screen page styles
html += R"HTML(
#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%);
padding: 12px 20px;
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: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 */
@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: 56px !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;
}
.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;
top: 8px;
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; }
.toolbar-toggle {
position: fixed;
top: 8px;
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 {
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 */
.canvas-container {
align-items: flex-start;
padding-top: 56px;
}
}
/* Landscape: vertical at right center */
@media (orientation: landscape) {
.quick-controls {
right: 8px;
top: 8px;
transform: none;
flex-direction: column;
}
}
.cursor-overlay {
position: fixed;
width: 24px;
height: 24px;
pointer-events: none;
display: none;
z-index: 1002;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23fff" stroke="%23000" stroke-width="1.5" d="M4 4l7 17 2.5-6.5L20 12z"/></svg>') no-repeat;
background-size: contain;
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6));
transform-origin: 0 0;
}
.cursor-overlay.active { display: block; }
/* 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); }
/* Mobile responsive */
@media (max-width: 768px) {
.page { padding: 10px; }
.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: 8px 12px; }
.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; }
}
</style>
</head>
<body>
)HTML";
// Part 6: HTML body
html += R"HTML(
<div id="login-page" class="page active">
<div class="login-form">
<h1>SimpleRemoter</h1>
<div id="ws-status" class="conn-status disconnected">Connecting...</div>
<input type="text" id="username" placeholder="Username" value="admin">
<div class="password-wrapper">
<input type="password" id="password" placeholder="Password">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()" title="Show/Hide Password">&#x1F441;</button>
</div>
<button id="login-btn" onclick="login()" disabled>Login</button>
<div id="login-error" class="error-msg"></div>
<div class="login-footer">
<div class="security-notice">
<strong>&#x26A0; Security Notice</strong>
SimpleRemoter.com may be flagged as "dangerous" by browsers due to the word "Remote" in its name. This is a false positive. The software is fully open-source and safe to use.
</div>
<div class="login-links">
<a href="https://simpleremoter.com/" target="_blank">&#x1F310; Website</a>
<a href="https://git.simpleremoter.com/" target="_blank">&#x1F4E6; Source Code</a>
</div>
</div>
</div>
</div>
<div id="devices-page" class="page">
<div class="page-header">
<h2>Devices <span id="device-count" class="device-count">0</span></h2>
<div class="toolbar">
<div class="search-box">
<input type="text" id="search-input" placeholder="Search by name, IP, OS..." oninput="onSearchChange()">
<button class="search-clear" id="search-clear" onclick="clearSearch()">&times;</button>
</div>
<div class="view-toggle">
<button id="view-grid" class="view-btn active" onclick="setViewMode('grid')" title="Grid View">Grid</button>
<button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button>
</div>
<button class="refresh-btn" onclick="getDevices()">Refresh</button>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</div>
<div id="stats-bar" class="stats-bar">
<div class="stat">Total: <strong id="stat-total">0</strong></div>
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
</div>
<div id="device-list" class="device-grid"></div>
<div id="pagination" class="pagination"></div>
</div>
<div id="screen-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="disconnect()">Back</button>
<div class="toolbar-info">
<div id="device-name" class="device-name"></div>
<div id="frame-info" class="conn-info"></div>
</div>
<div class="toolbar-right">
<span id="screen-status" class="screen-status connecting">Connecting...</span>
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button>
</div>
</div>
<div class="canvas-container">
<canvas id="screen-canvas"></canvas>
<div class="cursor-overlay" id="cursor-overlay"></div>
<div class="quick-controls" id="quick-controls">
<button class="qc-btn" id="qc-rdp" onclick="sendRdpReset()" title="RDP Reset" tabindex="-1">&#x21BB;</button>
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">&#x2328;</button>
<button class="qc-btn" id="qc-mouse" onclick="toggleControl()" title="Mouse" tabindex="-1">&#x1F5B1;</button>
<button class="qc-btn" id="qc-disconnect" onclick="disconnect()" title="Disconnect" tabindex="-1">&#x2715;</button>
</div>
<div class="input-shortcuts" id="input-shortcuts">
<button class="shortcut-btn" data-key="49" tabindex="-1">1</button>
<button class="shortcut-btn" data-key="50" tabindex="-1">2</button>
<button class="shortcut-btn" data-key="51" tabindex="-1">3</button>
<button class="shortcut-btn" data-key="52" tabindex="-1">4</button>
<button class="shortcut-btn" data-key="53" tabindex="-1">5</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>
</div>
</div>
<div class="touch-indicator" id="touch-indicator"></div>
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button>
<div class="floating-toolbar" id="floating-toolbar">
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button>
</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>
)HTML";
// Part 7: JavaScript - State and WebSocket
html += R"HTML(
<script>
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
const canvas = document.getElementById('screen-canvas');
const ctx = canvas.getContext('2d');
// 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;
}
}
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 + '/ws');
} catch (e) {
updateWsStatus('disconnected');
scheduleReconnect();
return;
}
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
updateWsStatus('connected');
startPingInterval();
// Auto-restore session if token exists
if (token) {
// Check if we were on screen-page with an active device
const screenPage = document.getElementById('screen-page');
if (screenPage.classList.contains('active') && currentDevice) {
// Reconnect to current device
updateScreenStatus('connecting');
ws.send(JSON.stringify({ cmd: 'connect', id: String(currentDevice.id), token }));
} else {
showPage('devices-page');
getDevices();
}
}
};
ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); 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;
}
}
)HTML";
// Part 8: JavaScript - Signaling handler
html += R"HTML(
function handleSignaling(msg) {
switch (msg.cmd) {
case 'challenge':
challengeNonce = msg.nonce || '';
console.log('Received challenge nonce');
break;
case 'login_result':
if (msg.ok) {
token = msg.token;
sessionStorage.setItem('token', token);
document.getElementById('login-error').textContent = '';
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;
sessionStorage.removeItem('token');
showPage('login-page');
break;
}
devices = msg.devices || [];
// Keep current page (renderDeviceList adjusts if out of range)
renderDeviceList();
break;
case 'connect_result':
if (!token) break;
if (msg.ok) {
// Resolution may not be available yet (first connection)
if (msg.width && msg.height) {
updateScreenStatus('connected');
initDecoder(msg.width, msg.height);
} else {
// Wait for resolution_changed message
updateScreenStatus('waiting', 'Waiting for video...');
}
} else {
updateScreenStatus('error', msg.msg);
setTimeout(() => showPage('devices-page'), 2000);
}
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':
updateScreenStatus('connected');
initDecoder(msg.width, msg.height);
break;
case 'device_offline':
// Only handle if this is the device we're currently viewing
if (!token) break;
if (!currentDevice || String(msg.id) !== String(currentDevice.id)) break;
updateScreenStatus('error', 'Device offline');
setTimeout(() => { showPage('devices-page'); getDevices(); }, 2000);
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')) {
getDevices();
}
break;
}
}
)HTML";
// Part 9: JavaScript - H264 decoder
html += R"HTML(
function checkWebCodecs() {
if (!('VideoDecoder' in window)) return { supported: false, reason: 'VideoDecoder not available' };
return { supported: true };
}
function initDecoder(width, height) {
// 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);
// Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height);
if (decoder) { try { decoder.close(); } catch(e) {} }
frameCount = 0;
lastFrameTime = performance.now();
decoder = new VideoDecoder({
output: (frame) => {
ctx.drawImage(frame, 0, 0);
frame.close();
frameCount++;
const now = performance.now();
if (now - lastFrameTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
frameCount = 0;
lastFrameTime = now;
document.getElementById('frame-info').textContent = width + 'x' + height + ' @ ' + fps + ' fps';
}
},
error: (e) => { console.error('Decoder error:', e); updateScreenStatus('error', 'Decode error'); }
});
decoder.configure({
codec: 'avc1.42E01E',
codedWidth: width,
codedHeight: height,
optimizeForLatency: true
});
}
function handleBinaryFrame(data) {
if (!decoder || decoder.state !== 'configured') return;
const view = new DataView(data);
const deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4);
const dataLen = view.getUint32(5, true);
const h264Data = new Uint8Array(data, 9, dataLen);
try {
decoder.decode(new EncodedVideoChunk({
type: frameType === 1 ? 'key' : 'delta',
timestamp: performance.now() * 1000,
data: h264Data
}));
} catch (e) { console.error('Decode error:', e); }
}
)HTML";
// Part 10: JavaScript - Filter and pagination
html += R"HTML(
function getFilteredDevices() {
let filtered = devices;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(d =>
(d.name && d.name.toLowerCase().includes(q)) ||
(d.ip && d.ip.toLowerCase().includes(q)) ||
(d.os && d.os.toLowerCase().includes(q)) ||
(d.location && d.location.toLowerCase().includes(q)) ||
(d.version && d.version.toLowerCase().includes(q)) ||
(d.activeWindow && d.activeWindow.toLowerCase().includes(q))
);
}
return filtered;
}
function onSearchChange() {
searchQuery = document.getElementById('search-input').value;
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
currentPage = 1;
renderDeviceList();
}
function clearSearch() {
document.getElementById('search-input').value = '';
onSearchChange();
}
function setViewMode(mode) {
viewMode = mode;
document.getElementById('view-grid').classList.toggle('active', mode === 'grid');
document.getElementById('view-list').classList.toggle('active', mode === 'list');
const list = document.getElementById('device-list');
list.classList.toggle('list-view', mode === 'list');
currentPage = 1;
renderDeviceList();
}
function goToPage(page) {
currentPage = page;
renderDeviceList();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
)HTML";
// Part 11: JavaScript - Render device list
html += R"HTML(
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 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;
} else if (winEl) {
winEl.remove();
}
break;
}
}
}
function renderDeviceList() {
const filtered = getFilteredDevices();
const ps = getPageSize();
const totalPages = Math.ceil(filtered.length / ps) || 1;
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * ps;
const pageDevices = filtered.slice(start, start + ps);
// Update stats
document.getElementById('device-count').textContent = devices.length;
document.getElementById('stat-total').textContent = devices.length;
document.getElementById('stat-showing').textContent = filtered.length;
// Render devices
const list = document.getElementById('device-list');
if (pageDevices.length === 0) {
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
} else {
list.innerHTML = pageDevices.map(d => {
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
const loc = d.location || '-';
const rtt = d.rtt || '-';
const ver = d.version || '-';
const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
'<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
'</div>';
}).join('');
}
// Render pagination
renderPagination(totalPages);
}
)HTML";
// Part 12: JavaScript - Pagination render
html += R"HTML(
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;
}
)HTML";
// Part 13: JavaScript - UI functions
html += R"HTML(
function showPage(pageId) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(pageId).classList.add('active');
}
function updateScreenStatus(status, msg) {
const el = document.getElementById('screen-status');
el.className = 'screen-status ' + status;
if (status === 'connected') el.textContent = 'Connected';
else if (status === 'connecting') el.textContent = 'Connecting...';
else if (status === 'waiting') el.textContent = msg || 'Waiting...';
else el.textContent = msg || 'Error';
}
function togglePasswordVisibility() {
const input = document.getElementById('password');
const btn = document.querySelector('.password-toggle');
if (input.type === 'password') {
input.type = 'text';
btn.innerHTML = '&#x1F440;'; // Eyes
} else {
input.type = 'password';
btn.innerHTML = '&#x1F441;'; // Eye
}
}
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; }
// Compute password hash (same as server stores)
passwordHash = await sha256(password);
// Compute response: SHA256(passwordHash + nonce)
const response = await sha256(passwordHash + challengeNonce);
ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce }));
}
function logout() {
// 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;
sessionStorage.removeItem('token');
devices = [];
showPage('login-page');
}
function getDevices() {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
ws.send(JSON.stringify({ cmd: 'get_devices', token }));
}
function connectDevice(id) {
const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
currentDevice = devices.find(d => d.id === id || d.id === String(id));
if (!currentDevice || !currentDevice.online) {
alert('Device is offline');
return;
}
document.getElementById('device-name').textContent = currentDevice.name;
document.getElementById('frame-info').textContent = '';
updateScreenStatus('connecting');
showPage('screen-page');
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
}
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();
}
}
)HTML";
// Part 14: JavaScript - Toolbar, zoom and touch
html += R"HTML(
// 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' && document.getElementById('screen-page').classList.contains('active')) {
e.preventDefault();
toggleFullscreen();
}
});
// Control mode state (mouse/keyboard control)
let controlEnabled = false;
// Floating toolbar state
let toolbarVisible = false;
let toolbarHideTimer = null;
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement ||
document.getElementById('screen-page')?.classList.contains('pseudo-fullscreen'));
}
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);
}
}
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
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 {
// Portrait mode: show quick controls
quickControls.classList.add('visible');
// Input shortcuts only visible when keyboard is open
const keyboardOpen = document.activeElement === mobileKeyboard;
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 8px below canvas
const canvasBottom = canvas.offsetTop + canvas.offsetHeight;
shortcuts.style.top = (canvasBottom + 8) + 'px';
}
function sendRdpReset() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'rdp_reset', token }));
// 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);
}
}
// Detect touch device (mobile/tablet)
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
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 devices: hide browser cursor, show overlay (touchpad mode)
// Desktop: keep browser cursor visible, no overlay needed (remote shows cursor)
canvas.style.cursor = (controlEnabled && isTouchDevice) ? 'none' : 'default';
cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice);
}
// 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))
};
}
)HTML";
// Part 14b: JavaScript - Zoom state and touch helpers
html += R"HTML(
// Two-finger gesture constants
const ZOOM_THRESHOLD = 0.05; // 5% distance change to trigger zoom
const SCROLL_SENSITIVITY = 3; // Scroll speed multiplier
const SCROLL_DEADZONE = 2; // Minimum scroll delta to send
// 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)
};
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');
// 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, type, x, y, button: button || 0 };
if (delta !== undefined) msg.delta = delta;
ws.send(JSON.stringify(msg));
}
}
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, keyCode, down: isDown, alt: !!altKey }));
}
}
function showTouchIndicator(x, y) {
touchIndicator.style.left = x + 'px';
touchIndicator.style.top = y + 'px';
touchIndicator.style.display = 'block';
setTimeout(() => touchIndicator.style.display = 'none', 200);
}
)HTML";
// Part 15: JavaScript - Touch and mouse event handlers
html += R"HTML(
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.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
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
// Use CUMULATIVE change to detect zoom intent (catches slow pinch gestures)
// Also treat as zoom if already at scale boundary and trying to zoom further
const atMinScale = zoomState.scale <= zoomState.minScale;
const atMaxScale = zoomState.scale >= zoomState.maxScale;
const tryingToShrink = totalDelta < 1; // Use cumulative for direction
const tryingToEnlarge = totalDelta > 1;
if (Math.abs(totalDelta - 1) > ZOOM_THRESHOLD ||
(atMinScale && tryingToShrink) ||
(atMaxScale && tryingToEnlarge)) {
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 - 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) ? 10 : 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 });
)HTML";
// Part 15b - Touch end handler
html += R"HTML(
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;
}
// 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
dblClickAtCursor(); // Then double click
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)
const isOpen = wasKeyboardFocused;
wasKeyboardFocused = false; // Reset
if (isOpen) {
mobileKeyboard.blur();
updateKeyboardButtons(false);
} else {
mobileKeyboard.focus();
updateKeyboardButtons(true);
}
}
mobileKeyboard.addEventListener('focus', function() {
updateKeyboardButtons(true);
});
mobileKeyboard.addEventListener('blur', function() {
// 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);
}
)HTML";
// Part 16: JavaScript - Desktop input and finalization
html += R"HTML(
// 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);
});
// Note: dblclick is handled by mousedown-mouseup sequence, no separate handler needed
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') return;
if (e.key === 'F11') return;
e.preventDefault();
sendKey(e.keyCode, true, e.altKey);
});
document.addEventListener('keyup', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return;
if (e.target.tagName === 'INPUT') return;
if (e.key === 'F11') return;
e.preventDefault();
sendKey(e.keyCode, false, e.altKey);
});
mobileKeyboard.addEventListener('input', function(e) {
const char = e.data;
if (char) {
// Check if character needs Shift key
const isUpperCase = char >= 'A' && char <= 'Z';
const shiftSymbols = '~!@#$%^&*()_+{}|:"<>?';
const needsShift = isUpperCase || shiftSymbols.includes(char);
// Map symbols to their base keys
const symbolMap = {
'~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
'+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
'"': 222, '<': 188, '>': 190, '?': 191
};
let keyCode;
if (symbolMap[char]) {
keyCode = symbolMap[char];
} else {
keyCode = char.toUpperCase().charCodeAt(0);
}
// Send Shift down if needed
if (needsShift) sendKey(16, true); // VK_SHIFT = 16
sendKey(keyCode, true);
sendKey(keyCode, false);
// Send Shift up if needed
if (needsShift) sendKey(16, false);
}
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 control mode
controlEnabled = false;
// Reset keyboard state (blur event will update button state)
mobileKeyboard.blur();
// 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';
document.getElementById('cursor-overlay').classList.remove('active');
// 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; }
if (ws && ws.readyState === WebSocket.OPEN && token && currentDevice) {
ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) }));
}
currentDevice = null; // Clear current device
showPage('devices-page');
getDevices();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
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();
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 onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice;
if (onScreenPage) {
// Mobile/tablet: delay disconnect 30s
// Desktop: keep connection alive (no timer)
if (isTouchDevice) {
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
}
} else {
// Other pages - disconnect immediately
doBackgroundDisconnect();
}
} else {
// Page coming to foreground
if (backgroundDisconnectTimer) {
clearTimeout(backgroundDisconnectTimer);
backgroundDisconnectTimer = null;
}
// Reconnect or send immediate ping
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWebSocket();
} else if (token) {
// Connection still open - send immediate ping to refresh server heartbeat
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);
});
// Listen for fullscreen changes
document.addEventListener('fullscreenchange', updateUIForOrientation);
document.addEventListener('webkitfullscreenchange', updateUIForOrientation);
// 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, keyCode, down: isDown, alt: false }));
}
}
const shortcutBtns = document.querySelectorAll('.shortcut-btn');
shortcutBtns.forEach(function(btn) {
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
const keyCode = parseInt(btn.dataset.key);
const needShift = btn.dataset.shift === '1';
if (needShift) sendShortcutKey(16, true); // Shift down
sendShortcutKey(keyCode, true);
sendShortcutKey(keyCode, false);
if (needShift) sendShortcutKey(16, false); // Shift 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';
if (needShift) sendShortcutKey(16, true);
sendShortcutKey(keyCode, true);
sendShortcutKey(keyCode, false);
if (needShift) sendShortcutKey(16, false);
}
});
});
} 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');
}
// 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 from sessionStorage
token = sessionStorage.getItem('token');
connectWebSocket();
};
</script>
</body>
</html>)HTML";
return html;
}