W1NDes 51e27344c1 Feat(viewport): 添加实时触控拖动, 跳帧节省带宽, 和空闲暂停
- 支持实时touch事件流式传输(touch_down/move/up), 拖动即时反馈
- 修复拖动开头触发点击, 绕过minitouch 50ms延迟
- 5s无操作后跳过未变化帧, 300s空闲停止截屏并显示蓝色提示
- 前端事件重构为handlePointerStart/Move/End, 支持10px阈值区分点击和拖动
2026-03-09 00:49:29 +08:00

1141 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Viewport</title>
<style>
:root {
/* Dark theme (default) */
--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);
}
[data-theme="light"] {
--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);
}
* {
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="30">
<span class="slider-value" id="qualityValue">30</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') || 'dark';
}
function setTheme(theme) {
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
themeToggle.textContent = '☀️';
themeToggle.title = 'Switch to dark mode';
} else {
document.documentElement.removeAttribute('data-theme');
themeToggle.textContent = '🌙';
themeToggle.title = 'Switch to light 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)
// 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 = () => {
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
// 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 (JPEG)
// Use cached image object to prevent flickering
if (pendingFrame) {
URL.revokeObjectURL(pendingFrame);
}
pendingFrame = URL.createObjectURL(new Blob([event.data], { type: 'image/jpeg' }));
frameImage.src = pendingFrame;
} else {
// 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 (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');
};
}
function handleMessage(data) {
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.latency_ms !== undefined) {
latencyInfo.textContent = `${data.latency_ms}ms`;
}
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>