W1NDes 8f26b37900 Fix(viewport): 修复触控并发崩溃, 延迟显示包含网络传输, 默认白天主题
- 添加_control_lock锁保护所有触控方法(touch/swipe/swipe_start/touch_move/touch_up)
  防止线程池并发调用导致崩溃
- 连接时预初始化control method, 避免cached_property非线程安全的懒加载竞态
- 添加WebSocket ping/pong机制测量真实网络RTT,
  总延迟 = 服务端处理延迟 + 网络RTT/2, 本地和远程延迟正确区分
- CSS默认主题改为light, 消除页面加载时的深色闪烁

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 00:15:21 +08:00

1229 lines
45 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Viewport</title>
<style>
:root {
/* Light theme (default) */
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-tertiary: #e0e0e0;
--text-primary: #333;
--text-secondary: #666;
--text-muted: #999;
--accent: #e94560;
--accent-hover: #d13652;
--success: #00b386;
--border: #ddd;
--canvas-bg: #222;
--overlay-bg: rgba(245, 245, 245, 0.95);
--shadow: rgba(0, 0, 0, 0.15);
}
[data-theme="dark"] {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--text-primary: #eee;
--text-secondary: #aaa;
--text-muted: #666;
--accent: #e94560;
--accent-hover: #ff6b6b;
--success: #00d9a0;
--border: #0f3460;
--canvas-bg: #000;
--overlay-bg: rgba(26, 26, 46, 0.9);
--shadow: rgba(0, 0, 0, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color 0.3s, color 0.3s;
}
/* Hide scrollbar only when embedded in iframe */
body.in-iframe {
overflow: hidden;
}
.status-bar {
background: var(--bg-secondary);
padding: 8px 16px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
transition: background-color 0.3s, border-color 0.3s;
}
.status-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}
.status-dot.connected {
background: var(--success);
}
.instance-name {
font-weight: 600;
color: var(--text-primary);
}
.slider-container {
display: flex;
align-items: center;
gap: 8px;
}
.slider-container label {
font-size: 12px;
color: var(--text-secondary);
}
.slider-container input[type="range"] {
width: 80px;
height: 4px;
-webkit-appearance: none;
background: var(--bg-tertiary);
border-radius: 2px;
outline: none;
}
.slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
}
.slider-value {
min-width: 24px;
text-align: right;
font-size: 12px;
color: var(--text-secondary);
}
.viewport-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
position: relative;
}
#gameCanvas {
background: var(--canvas-bg);
max-width: 100%;
max-height: 100%;
border-radius: 4px;
box-shadow: 0 4px 20px var(--shadow);
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--overlay-bg);
gap: 16px;
transition: background-color 0.3s;
}
.overlay.hidden {
display: none;
}
.overlay-icon {
font-size: 48px;
}
.overlay-text {
font-size: 18px;
color: var(--text-secondary);
text-align: center;
}
.overlay-subtext {
font-size: 13px;
color: var(--text-muted);
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-home {
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
padding: 6px 12px;
}
.btn-home:hover {
background: var(--border);
color: var(--text-primary);
}
.btn-theme {
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 14px;
padding: 6px 10px;
min-width: 32px;
}
.btn-theme:hover {
background: var(--border);
color: var(--text-primary);
}
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.auth-input {
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
width: 200px;
outline: none;
transition: background-color 0.3s, border-color 0.3s, color 0.3s;
}
.auth-input:focus {
border-color: var(--accent);
}
.auth-error {
color: var(--accent);
font-size: 13px;
}
.script-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(233, 69, 96, 0.9);
color: #fff;
padding: 12px 24px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
pointer-events: none;
z-index: 10;
display: none;
}
.script-banner.visible {
display: block;
}
.canvas-wrapper {
position: relative;
display: inline-block;
max-width: 1280px;
}
.script-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: none;
pointer-events: none;
}
.script-overlay.visible {
display: block;
}
/* Idle overlay - blue version of script overlay */
.idle-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: none;
cursor: pointer;
z-index: 15;
}
.idle-overlay.visible {
display: block;
}
.idle-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(52, 120, 246, 0.9);
color: #fff;
padding: 12px 24px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
pointer-events: none;
z-index: 20;
display: none;
}
.idle-banner.visible {
display: block;
}
/* Control button styles */
.control-btn {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: none;
border-radius: 2px;
padding: 2px 8px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.control-btn:hover {
background: var(--border);
color: var(--text-primary);
}
.control-select {
background: var(--bg-tertiary);
color: var(--text-primary);
border: none;
border-radius: 2px;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
</style>
</head>
<body>
<div class="status-bar">
<button class="btn btn-home" onclick="window.open('./', '_blank')">Home</button>
<button class="btn btn-theme" id="themeToggle" title="Toggle light/dark mode">🌙</button>
<div class="status-item">
<div class="status-dot" id="statusDot"></div>
<span class="instance-name" id="instanceName">--</span>
</div>
<div class="status-item">
<span id="connectionStatus">Connecting...</span>
</div>
<div class="status-item">
<span id="methodInfo">--</span>
</div>
<div class="status-item">
<span id="resolutionInfo">--</span>
</div>
<div class="status-item">
<span id="fpsInfo">-- FPS</span>
</div>
<div class="status-item">
<span id="latencyInfo">--ms</span>
</div>
<div class="status-item">
<span id="bandwidthInfo">--kbps</span>
</div>
<div class="status-item">
<span id="clientCountInfo" title="Connected clients">👥 --</span>
</div>
<div class="slider-container">
<label>Quality:</label>
<input type="range" id="qualitySlider" min="10" max="99" value="65">
<span class="slider-value" id="qualityValue">65</span>
</div>
<div class="slider-container">
<label>FPS:</label>
<input type="range" id="fpsSlider" min="1" max="60" value="30">
<span class="slider-value" id="fpsValue">30</span>
</div>
<div class="slider-container">
<label>Res:</label>
<select id="scaleSelect" class="control-select">
<option value="1.0">720p</option>
<option value="0.75">540p</option>
<option value="0.5" selected>360p</option>
<option value="0.375">270p</option>
<option value="0.25">180p</option>
</select>
</div>
<div class="slider-container">
<label>Zoom:</label>
<button id="zoomOutBtn" class="control-btn"></button>
<span class="slider-value" id="zoomValue" style="min-width: 40px; text-align: center;">100%</span>
<button id="zoomInBtn" class="control-btn">+</button>
</div>
</div>
<div class="viewport-container">
<div class="overlay hidden" id="authOverlay">
<div class="overlay-icon">🔒</div>
<div class="overlay-text">Password Required</div>
<div class="auth-container">
<input type="password" class="auth-input" id="authInput" placeholder="Enter password">
<button class="btn btn-primary" id="authBtn">Login</button>
<div class="auth-error hidden" id="authError">Invalid password</div>
</div>
</div>
<div class="overlay" id="connectingOverlay">
<div class="overlay-icon">...</div>
<div class="overlay-text">Connecting to emulator...</div>
</div>
<div class="overlay hidden" id="errorOverlay">
<div class="overlay-icon" id="errorIcon">!</div>
<div class="overlay-text" id="errorMessage">Cannot connect to emulator</div>
<div class="overlay-subtext" id="errorSubtext">Please ensure the emulator is running</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="btn btn-primary" id="startEmulatorBtn">Start Emulator</button>
<button class="btn btn-primary" id="retryBtn">Retry</button>
</div>
<div class="overlay-subtext" id="startStatus" style="margin-top: 10px; display: none;"></div>
</div>
<div class="canvas-wrapper" id="canvasWrapper" style="display: none;">
<canvas id="gameCanvas" width="1280" height="720"></canvas>
<div class="script-overlay" id="scriptOverlay"></div>
<div class="script-banner" id="scriptBanner">Script Running - Touch Disabled</div>
<div class="idle-overlay" id="idleOverlay"></div>
<div class="idle-banner" id="idleBanner">💤 Idle - Click to Resume</div>
</div>
</div>
<script>
// Theme management
const themeToggle = document.getElementById('themeToggle');
function getStoredTheme() {
return localStorage.getItem('viewport_theme') || 'light';
}
function setTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
themeToggle.textContent = '🌙';
themeToggle.title = 'Switch to light mode';
} else {
document.documentElement.removeAttribute('data-theme');
themeToggle.textContent = '☀️';
themeToggle.title = 'Switch to dark mode';
}
localStorage.setItem('viewport_theme', theme);
}
// Initialize theme
setTheme(getStoredTheme());
// Toggle theme on button click
themeToggle.addEventListener('click', () => {
const currentTheme = getStoredTheme();
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
const params = new URLSearchParams(window.location.search);
const instanceName = params.get('instance') || 'alas';
const urlToken = params.get('token') || '';
document.getElementById('instanceName').textContent = instanceName;
document.title = `Viewport - ${instanceName}`;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const canvasWrapper = document.getElementById('canvasWrapper');
const authOverlay = document.getElementById('authOverlay');
const connectingOverlay = document.getElementById('connectingOverlay');
const errorOverlay = document.getElementById('errorOverlay');
const errorMessage = document.getElementById('errorMessage');
const statusDot = document.getElementById('statusDot');
const connectionStatus = document.getElementById('connectionStatus');
const methodInfo = document.getElementById('methodInfo');
const resolutionInfo = document.getElementById('resolutionInfo');
const fpsInfo = document.getElementById('fpsInfo');
const qualitySlider = document.getElementById('qualitySlider');
const qualityValue = document.getElementById('qualityValue');
const fpsSlider = document.getElementById('fpsSlider');
const fpsValue = document.getElementById('fpsValue');
const scaleSelect = document.getElementById('scaleSelect');
const latencyInfo = document.getElementById('latencyInfo');
const bandwidthInfo = document.getElementById('bandwidthInfo');
const clientCountInfo = document.getElementById('clientCountInfo');
const retryBtn = document.getElementById('retryBtn');
const scriptBanner = document.getElementById('scriptBanner');
const scriptOverlay = document.getElementById('scriptOverlay');
const authInput = document.getElementById('authInput');
const authBtn = document.getElementById('authBtn');
const authError = document.getElementById('authError');
let ws = null;
let deviceResolution = [1280, 720];
let scriptRunning = false;
let frameCount = 0;
let lastFpsUpdate = Date.now();
let currentFps = 0;
let authToken = '';
let isPaused = false; // Pause state for visibility
let clientCount = 0; // Number of connected clients
let zoomLevel = 100; // Zoom percentage (50-150)
// H.264 / WebCodecs state
const supportsWebCodecs = typeof VideoDecoder !== 'undefined';
let videoDecoder = null;
let isH264Mode = false;
let h264Timestamp = 0;
// Cached image object for frame rendering (prevents flickering)
const frameImage = new Image();
let pendingFrame = null;
// Frame image onload handler - set once globally, reused for all frames
frameImage.onload = () => {
// Only draw JPEG frames if NOT in H.264 mode
if (!isH264Mode) {
ctx.drawImage(frameImage, 0, 0, canvas.width, canvas.height);
}
if (pendingFrame) {
URL.revokeObjectURL(pendingFrame);
pendingFrame = null;
}
frameCount++;
const now = Date.now();
if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
fpsInfo.textContent = `${currentFps} FPS`;
}
};
// Mouse/touch state for swipe detection
let isMouseDown = false;
let startX = 0, startY = 0;
let lastX = 0, lastY = 0;
let supportsRawTouch = false; // Server tells us if raw touch is available
let isSwiping = false; // True once we've sent touch_down and are swiping
let pendingTouch = false; // True between mousedown and deciding tap vs swipe
let lastTouchMoveTime = 0; // Throttle touch_move events
let isIdle = false; // True when backend reports idle
let networkRtt = 0; // Network round-trip time in ms
let pingInterval = null; // Ping timer handle
// Idle overlay elements
const idleOverlay = document.getElementById('idleOverlay');
const idleBanner = document.getElementById('idleBanner');
// Click on idle overlay to resume
idleOverlay.addEventListener('click', () => {
if (isIdle) {
sendAction({ action: 'resume_idle' });
}
});
// Page visibility handling
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Page is hidden, pause streaming
isPaused = true;
sendAction({ action: 'pause', paused: true });
console.log('Page hidden, streaming paused');
} else if (!isIdle) {
// Page is visible, resume streaming (but not if idle)
isPaused = false;
sendAction({ action: 'pause', paused: false });
console.log('Page visible, streaming resumed');
}
});
// Properly close WebSocket when page is unloaded (refresh/close)
window.addEventListener('beforeunload', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'Page unload');
}
});
// Get auth token: URL param > localStorage
function getAuthToken() {
if (urlToken) {
// URL has token, save to localStorage for future use
localStorage.setItem('viewport_token', urlToken);
return urlToken;
}
return localStorage.getItem('viewport_token') || '';
}
function showAuthOverlay() {
authOverlay.classList.remove('hidden');
connectingOverlay.classList.add('hidden');
errorOverlay.classList.add('hidden');
canvasWrapper.style.display = 'none';
authInput.focus();
}
function hideAuthOverlay() {
authOverlay.classList.add('hidden');
}
function handleAuth() {
const password = authInput.value.trim();
if (!password) return;
// Save to localStorage
localStorage.setItem('viewport_token', password);
authToken = password;
authError.classList.add('hidden');
hideAuthOverlay();
connect();
}
// Auth button click
authBtn.addEventListener('click', handleAuth);
// Enter key in password input
authInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleAuth();
});
function mapCoordinates(canvasX, canvasY) {
const rect = canvas.getBoundingClientRect();
const scaleX = deviceResolution[0] / rect.width;
const scaleY = deviceResolution[1] / rect.height;
return {
x: Math.round(canvasX * scaleX),
y: Math.round(canvasY * scaleY)
};
}
function connect() {
// Get token first
authToken = getAuthToken();
connectingOverlay.classList.remove('hidden');
authOverlay.classList.add('hidden');
errorOverlay.classList.add('hidden');
canvasWrapper.style.display = 'none';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/${instanceName}?token=${encodeURIComponent(authToken)}`;
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary frame data
if (isH264Mode && videoDecoder && videoDecoder.state !== 'closed') {
// H.264 mode: first byte is type header, rest is NAL data
const buffer = event.data;
if (buffer.byteLength < 2) return;
const view = new Uint8Array(buffer);
const isKey = view[0] === 0x01;
const nalData = buffer.slice(1);
try {
const chunk = new EncodedVideoChunk({
type: isKey ? 'key' : 'delta',
timestamp: h264Timestamp,
data: nalData,
});
h264Timestamp += 33333; // ~30fps in microseconds
videoDecoder.decode(chunk);
} catch (e) {
console.error('H.264 decode error:', e);
}
} else {
// JPEG fallback mode
if (pendingFrame) {
URL.revokeObjectURL(pendingFrame);
}
pendingFrame = URL.createObjectURL(new Blob([event.data], { type: 'image/jpeg' }));
frameImage.src = pendingFrame;
}
} else if (typeof event.data === 'string') {
// JSON message
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
}
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
if (event.code === 4001) {
// Authentication failed
localStorage.removeItem('viewport_token');
authError.classList.remove('hidden');
showAuthOverlay();
} else {
showError('Connection closed', 'connection_closed');
}
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
showError('Connection error', 'connection_error');
};
// Request JPEG fallback if browser doesn't support WebCodecs
ws.addEventListener('open', () => {
if (!supportsWebCodecs) {
console.log('WebCodecs not supported, requesting JPEG fallback');
sendAction({ action: 'set_encoding', encoding: 'jpeg' });
}
// Start periodic ping for latency measurement
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
sendAction({ action: 'ping', client_time: performance.now() });
}
}, 2000);
});
}
function handleMessage(data) {
if (data.type === 'codec_init') {
// H.264 encoder initialized on backend, set up VideoDecoder
if (supportsWebCodecs) {
if (videoDecoder && videoDecoder.state !== 'closed') {
videoDecoder.close();
}
h264Timestamp = 0;
videoDecoder = new VideoDecoder({
output: (frame) => {
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
frame.close();
// Update FPS counter
frameCount++;
const now = Date.now();
if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
fpsInfo.textContent = `${currentFps} FPS`;
}
},
error: (e) => {
console.error('VideoDecoder error:', e);
// Fall back to JPEG on decoder error
isH264Mode = false;
sendAction({ action: 'set_encoding', encoding: 'jpeg' });
}
});
videoDecoder.configure({
codec: data.codec,
codedWidth: data.width,
codedHeight: data.height,
});
isH264Mode = true;
console.log(`H.264 decoder configured: ${data.codec} ${data.width}x${data.height}`);
}
} else if (data.type === 'pong') {
// Measure network round-trip time
if (data.client_time) {
networkRtt = performance.now() - data.client_time;
}
// Total latency = server processing + one-way network delay
const totalLatency = (data.server_latency_ms || 0) + networkRtt / 2;
latencyInfo.textContent = `${Math.round(totalLatency)}ms`;
} else if (data.type === 'status') {
if (data.connected) {
connectingOverlay.classList.add('hidden');
errorOverlay.classList.add('hidden');
canvasWrapper.style.display = 'inline-block';
statusDot.classList.add('connected');
connectionStatus.textContent = 'Connected';
if (data.resolution) {
// Only update canvas size if resolution actually changed
// Setting canvas.width/height clears the canvas, causing flicker
if (deviceResolution[0] !== data.resolution[0] || deviceResolution[1] !== data.resolution[1]) {
deviceResolution = data.resolution;
canvas.width = deviceResolution[0];
canvas.height = deviceResolution[1];
resolutionInfo.textContent = `${deviceResolution[0]}x${deviceResolution[1]}`;
}
}
if (data.screenshot_method) {
methodInfo.textContent = data.screenshot_method;
}
// Update raw touch support from server
if (data.supports_raw_touch !== undefined) {
supportsRawTouch = data.supports_raw_touch;
}
}
// Update stats
if (data.bandwidth_kbps !== undefined) {
if (data.bandwidth_kbps >= 1000) {
bandwidthInfo.textContent = `${(data.bandwidth_kbps / 1000).toFixed(1)}Mbps`;
} else {
bandwidthInfo.textContent = `${Math.round(data.bandwidth_kbps)}kbps`;
}
}
if (data.client_count !== undefined) {
clientCount = data.client_count;
clientCountInfo.textContent = `👥 ${clientCount}`;
}
scriptRunning = data.script_running;
if (scriptRunning) {
scriptBanner.classList.add('visible');
scriptOverlay.classList.add('visible');
} else {
scriptBanner.classList.remove('visible');
scriptOverlay.classList.remove('visible');
}
// Idle state from backend
if (data.idle !== undefined) {
if (data.idle && !isIdle) {
isIdle = true;
idleOverlay.classList.add('visible');
idleBanner.classList.add('visible');
} else if (!data.idle && isIdle) {
isIdle = false;
idleOverlay.classList.remove('visible');
idleBanner.classList.remove('visible');
}
}
} else if (data.type === 'error') {
showError(data.message, data.code);
}
}
// Error code to icon and subtext mapping
const ERROR_INFO = {
'emulator_not_running': { icon: '📴', subtext: 'Start the emulator and try again' },
'device_not_found': { icon: '🔍', subtext: 'Check if the emulator is running' },
'connection_failed': { icon: '🔌', subtext: 'Check serial configuration in ALAS settings' },
'adb_error': { icon: '⚠️', subtext: 'Restart ADB server or ALAS' },
'config_not_found': { icon: '📄', subtext: 'Instance configuration file is missing' },
'screenshot_failed': { icon: '📷', subtext: 'Ensure the game is running in emulator' },
'unknown_error': { icon: '❓', subtext: 'Check logs for more details' },
'connection_closed': { icon: '🔗', subtext: 'WebSocket connection was closed' },
'connection_error': { icon: '⚡', subtext: 'Network error occurred' },
};
const errorIcon = document.getElementById('errorIcon');
const errorSubtext = document.getElementById('errorSubtext');
function showError(message, code) {
connectingOverlay.classList.add('hidden');
errorOverlay.classList.remove('hidden');
canvasWrapper.style.display = 'none';
errorMessage.textContent = message;
// Update icon and subtext based on error code
const info = ERROR_INFO[code] || { icon: '!', subtext: 'Please check your setup' };
errorIcon.textContent = info.icon;
errorSubtext.textContent = info.subtext;
statusDot.classList.remove('connected');
connectionStatus.textContent = 'Disconnected';
}
function sendAction(action) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(action));
}
}
// Helper: reset all touch/swipe state
function resetTouchState() {
isMouseDown = false;
isSwiping = false;
pendingTouch = false;
}
// Helper: handle pointer start (mousedown / touchstart)
function handlePointerStart(canvasX, canvasY) {
if (scriptRunning) return;
startX = canvasX;
startY = canvasY;
lastX = canvasX;
lastY = canvasY;
isMouseDown = true;
pendingTouch = true;
isSwiping = false;
}
// Helper: handle pointer move (mousemove / touchmove)
function handlePointerMove(canvasX, canvasY) {
if (!isMouseDown || scriptRunning) return;
lastX = canvasX;
lastY = canvasY;
if (supportsRawTouch && pendingTouch) {
// Check if we've moved far enough to start a swipe
const dx = canvasX - startX;
const dy = canvasY - startY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= 10) {
// Crossed threshold — start real-time swipe
pendingTouch = false;
isSwiping = true;
const startCoords = mapCoordinates(startX, startY);
const curCoords = mapCoordinates(canvasX, canvasY);
// Send combined down+move atomically to avoid click at start
sendAction({
action: 'swipe_start',
x1: startCoords.x, y1: startCoords.y,
x2: curCoords.x, y2: curCoords.y
});
lastTouchMoveTime = Date.now();
}
} else if (supportsRawTouch && isSwiping) {
// Throttle touch_move to every 16ms
const now = Date.now();
if (now - lastTouchMoveTime >= 16) {
const coords = mapCoordinates(canvasX, canvasY);
sendAction({ action: 'touch_move', x: coords.x, y: coords.y });
lastTouchMoveTime = now;
}
}
}
// Helper: handle pointer end (mouseup / touchend)
function handlePointerEnd(canvasX, canvasY) {
if (!isMouseDown || scriptRunning) return;
if (supportsRawTouch) {
if (isSwiping) {
// Send final touch_move to ensure we reach the exact release point
const coords = mapCoordinates(canvasX, canvasY);
sendAction({ action: 'touch_move', x: coords.x, y: coords.y });
sendAction({ action: 'touch_up' });
} else if (pendingTouch) {
// Didn't move far enough — it's a tap
const coords = mapCoordinates(canvasX, canvasY);
sendAction({ action: 'tap', x: coords.x, y: coords.y });
}
} else {
// Fallback: batch swipe (for adb)
const dx = canvasX - startX;
const dy = canvasY - startY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) {
const coords = mapCoordinates(canvasX, canvasY);
sendAction({ action: 'tap', x: coords.x, y: coords.y });
} else {
const start = mapCoordinates(startX, startY);
const end = mapCoordinates(canvasX, canvasY);
sendAction({
action: 'swipe',
x1: start.x, y1: start.y,
x2: end.x, y2: end.y,
duration: 300
});
}
}
resetTouchState();
}
// Mouse event handlers
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
handlePointerStart(e.clientX - rect.left, e.clientY - rect.top);
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
handlePointerMove(e.clientX - rect.left, e.clientY - rect.top);
});
canvas.addEventListener('mouseup', (e) => {
const rect = canvas.getBoundingClientRect();
handlePointerEnd(e.clientX - rect.left, e.clientY - rect.top);
});
canvas.addEventListener('mouseleave', () => {
if (isSwiping && supportsRawTouch) {
// If swiping, send touch_up to avoid stuck gesture
sendAction({ action: 'touch_up' });
}
resetTouchState();
});
// Touch events for mobile
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
handlePointerStart(touch.clientX - rect.left, touch.clientY - rect.top);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
handlePointerMove(touch.clientX - rect.left, touch.clientY - rect.top);
});
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
handlePointerEnd(lastX, lastY);
});
// Quality slider
qualitySlider.addEventListener('input', () => {
qualityValue.textContent = qualitySlider.value;
});
qualitySlider.addEventListener('change', () => {
sendAction({ action: 'set_quality', quality: parseInt(qualitySlider.value) });
});
// FPS slider
fpsSlider.addEventListener('input', () => {
fpsValue.textContent = fpsSlider.value;
});
fpsSlider.addEventListener('change', () => {
sendAction({ action: 'set_fps', fps: parseInt(fpsSlider.value) });
});
// Resolution scale selector
scaleSelect.addEventListener('change', () => {
sendAction({ action: 'set_scale', scale: parseFloat(scaleSelect.value) });
});
// Retry button
retryBtn.addEventListener('click', () => {
if (ws) {
ws.close();
}
connect();
});
// Start emulator button
const startEmulatorBtn = document.getElementById('startEmulatorBtn');
const startStatus = document.getElementById('startStatus');
startEmulatorBtn.addEventListener('click', async () => {
startEmulatorBtn.disabled = true;
startEmulatorBtn.textContent = 'Starting...';
startStatus.style.display = 'block';
startStatus.textContent = 'Initiating emulator start...';
try {
const response = await fetch(`/start/${instanceName}`, { method: 'POST' });
const data = await response.json();
if (data.status === 'starting') {
startStatus.textContent = 'Emulator is starting, please wait...';
// Poll for status
pollEmulatorStatus();
} else {
startStatus.textContent = data.error || 'Failed to start emulator';
startEmulatorBtn.disabled = false;
startEmulatorBtn.textContent = 'Start Emulator';
}
} catch (e) {
console.error('Failed to start emulator:', e);
startStatus.textContent = 'Failed to send start request';
startEmulatorBtn.disabled = false;
startEmulatorBtn.textContent = 'Start Emulator';
}
});
async function pollEmulatorStatus() {
const maxAttempts = 60; // 60 seconds timeout
let attempts = 0;
const checkStatus = async () => {
try {
const response = await fetch(`/start/${instanceName}/status`);
const data = await response.json();
if (data.status === 'starting') {
attempts++;
if (attempts < maxAttempts) {
startStatus.textContent = `Emulator is starting... (${attempts}s)`;
setTimeout(checkStatus, 1000);
} else {
startStatus.textContent = 'Start timeout, please try again';
startEmulatorBtn.disabled = false;
startEmulatorBtn.textContent = 'Start Emulator';
}
} else if (data.status === 'success') {
startStatus.textContent = 'Emulator started! Connecting...';
startEmulatorBtn.disabled = false;
startEmulatorBtn.textContent = 'Start Emulator';
// Auto retry connection
setTimeout(() => {
if (ws) ws.close();
connect();
}, 2000);
} else {
startStatus.textContent = 'Failed to start emulator';
startEmulatorBtn.disabled = false;
startEmulatorBtn.textContent = 'Start Emulator';
}
} catch (e) {
console.error('Failed to check status:', e);
startStatus.textContent = 'Failed to check status';
startEmulatorBtn.disabled = false;
startEmulatorBtn.textContent = 'Start Emulator';
}
};
checkStatus();
}
// Start: check auth first
function init() {
authToken = getAuthToken();
if (authToken) {
// Have token, try to connect
connect();
} else {
// No token, show auth overlay
connectingOverlay.classList.add('hidden');
showAuthOverlay();
}
}
// Report required size to parent iframe
function reportSize() {
if (window.parent === window) return; // Not in iframe
const statusBar = document.querySelector('.status-bar');
const statusBarHeight = statusBar ? statusBar.offsetHeight : 50;
const viewportPadding = 32; // 16px * 2
const maxCanvasWidth = 1280 * (zoomLevel / 100); // Scaled max canvas width
// Canvas aspect ratio is 16:9
// Required height = statusBarHeight + canvasHeight + padding
// canvasHeight should maintain 16:9 ratio based on available width
// Send size info to parent
window.parent.postMessage({
type: 'viewport-size',
statusBarHeight: statusBarHeight,
padding: viewportPadding,
aspectRatio: 16 / 9,
maxContentWidth: maxCanvasWidth + viewportPadding // Max width including padding
}, '*');
}
// Apply zoom level to canvas wrapper
function applyZoom() {
const maxCanvasWidth = 1280 * (zoomLevel / 100);
canvasWrapper.style.maxWidth = maxCanvasWidth + 'px';
document.getElementById('zoomValue').textContent = zoomLevel + '%';
reportSize();
}
// Zoom button handlers
document.getElementById('zoomInBtn').addEventListener('click', () => {
if (zoomLevel < 150) {
zoomLevel += 10;
applyZoom();
}
});
document.getElementById('zoomOutBtn').addEventListener('click', () => {
if (zoomLevel > 30) {
zoomLevel -= 10;
applyZoom();
}
});
// Report size on load and resize
window.addEventListener('load', () => {
reportSize();
// Report again after a short delay to account for dynamic content
setTimeout(reportSize, 500);
});
window.addEventListener('resize', reportSize);
// Also report when status bar content changes (e.g., connection status)
const resizeObserver = new ResizeObserver(() => {
reportSize();
});
resizeObserver.observe(document.querySelector('.status-bar'));
// Detect if running in iframe and add class for styling
if (window.parent !== window) {
document.body.classList.add('in-iframe');
}
init();
</script>
</body>
</html>