Feat(viewport): 添加实时触控拖动, 跳帧节省带宽, 和空闲暂停

- 支持实时touch事件流式传输(touch_down/move/up), 拖动即时反馈
- 修复拖动开头触发点击, 绕过minitouch 50ms延迟
- 5s无操作后跳过未变化帧, 300s空闲停止截屏并显示蓝色提示
- 前端事件重构为handlePointerStart/Move/End, 支持10px阈值区分点击和拖动
This commit is contained in:
W1NDes 2026-02-28 02:36:23 +08:00
parent 71e0910216
commit 85b8ed71c6
2 changed files with 339 additions and 99 deletions

View File

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -292,6 +293,43 @@
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);
@ -321,6 +359,7 @@
}
</style>
</head>
<body>
<div class="status-bar">
<button class="btn btn-home" onclick="window.open('./', '_blank')">Home</button>
@ -352,8 +391,8 @@
</div>
<div class="slider-container">
<label>Quality:</label>
<input type="range" id="qualitySlider" min="10" max="95" value="60">
<span class="slider-value" id="qualityValue">60</span>
<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>
@ -409,6 +448,8 @@
<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>
@ -513,6 +554,22 @@
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', () => {
@ -521,8 +578,8 @@
isPaused = true;
sendAction({ action: 'pause', paused: true });
console.log('Page hidden, streaming paused');
} else {
// Page is visible, resume streaming
} 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');
@ -669,6 +726,11 @@
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
@ -695,6 +757,19 @@
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);
}
@ -737,101 +812,142 @@
}
}
// Event handlers
canvas.addEventListener('mousedown', (e) => {
// 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;
const rect = canvas.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
lastX = startX;
lastY = startY;
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) => {
if (!isMouseDown || scriptRunning) return;
const rect = canvas.getBoundingClientRect();
lastX = e.clientX - rect.left;
lastY = e.clientY - rect.top;
handlePointerMove(e.clientX - rect.left, e.clientY - rect.top);
});
canvas.addEventListener('mouseup', (e) => {
if (!isMouseDown || scriptRunning) return;
isMouseDown = false;
const rect = canvas.getBoundingClientRect();
const endX = e.clientX - rect.left;
const endY = e.clientY - rect.top;
const dx = endX - startX;
const dy = endY - startY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) {
// Tap
const coords = mapCoordinates(endX, endY);
sendAction({ action: 'tap', x: coords.x, y: coords.y });
} else {
// Swipe
const start = mapCoordinates(startX, startY);
const end = mapCoordinates(endX, endY);
sendAction({
action: 'swipe',
x1: start.x, y1: start.y,
x2: end.x, y2: end.y,
duration: 300
});
}
handlePointerEnd(e.clientX - rect.left, e.clientY - rect.top);
});
canvas.addEventListener('mouseleave', () => {
isMouseDown = false;
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) => {
if (scriptRunning) return;
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
startX = touch.clientX - rect.left;
startY = touch.clientY - rect.top;
lastX = startX;
lastY = startY;
isMouseDown = true;
handlePointerStart(touch.clientX - rect.left, touch.clientY - rect.top);
});
canvas.addEventListener('touchmove', (e) => {
if (!isMouseDown || scriptRunning) return;
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
lastX = touch.clientX - rect.left;
lastY = touch.clientY - rect.top;
handlePointerMove(touch.clientX - rect.left, touch.clientY - rect.top);
});
canvas.addEventListener('touchend', (e) => {
if (!isMouseDown || scriptRunning) return;
e.preventDefault();
isMouseDown = false;
const dx = lastX - startX;
const dy = lastY - startY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 10) {
const coords = mapCoordinates(lastX, lastY);
sendAction({ action: 'tap', x: coords.x, y: coords.y });
} else {
const start = mapCoordinates(startX, startY);
const end = mapCoordinates(lastX, lastY);
sendAction({
action: 'swipe',
x1: start.x, y1: start.y,
x2: end.x, y2: end.y,
duration: 300
});
}
handlePointerEnd(lastX, lastY);
});
// Quality slider
@ -1021,4 +1137,5 @@
init();
</script>
</body>
</html>
</html>

View File

@ -179,12 +179,16 @@ class DeviceConnection:
self._connected = False
return None
def screenshot_jpeg(self, quality: int = 60, scale: float = 1.0) -> Optional[bytes]:
def screenshot_encode(self, quality: int = 30, scale: float = 1.0, skip_unchanged: bool = False) -> Optional[bytes]:
"""Get screenshot as JPEG bytes.
Args:
quality: JPEG quality (1-100)
scale: Resolution scale (0.25-1.0), e.g. 0.5 = half resolution
skip_unchanged: If True, skip encoding when frame content is unchanged
Returns:
bytes: Encoded frame data, or None if screenshot failed or frame unchanged.
"""
import time
t0 = time.perf_counter()
@ -207,15 +211,25 @@ class DeviceConnection:
new_h = int(h * scale)
img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
# Frame-skip detection: only check when idle (skip_unchanged=True)
if skip_unchanged:
if hasattr(self, '_last_frame') and self._last_frame is not None:
if self._last_frame.shape == img.shape:
diff = cv2.absdiff(img, self._last_frame)
if np.mean(diff) < 1.0:
# Frame unchanged, skip
return None
self._last_frame = img
# Convert BGR to RGB for correct colors in browser
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
t2 = time.perf_counter()
# Encode to JPEG
_, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, quality])
_, encoded = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, quality])
t3 = time.perf_counter()
result = jpeg.tobytes()
result = encoded.tobytes()
t4 = time.perf_counter()
# Log timing every 100 frames
@ -224,7 +238,7 @@ class DeviceConnection:
self._total_times = [0, 0, 0, 0]
self._frame_count += 1
self._total_times[0] += t1 - t0 # screenshot
self._total_times[1] += t2 - t1 # resize
self._total_times[1] += t2 - t1 # resize + diff
self._total_times[2] += t3 - t2 # imencode
self._total_times[3] += t4 - t3 # tobytes
@ -243,7 +257,7 @@ class DeviceConnection:
return result
except Exception as e:
if self._error_count == 0:
logger.info(f'[Viewport] JPEG encode error: {e}')
logger.info(f'[Viewport] Encode error: {e}')
return None
def touch(self, x: int, y: int):
@ -259,7 +273,7 @@ class DeviceConnection:
else:
# Fallback to adb click
self._device.click_adb(x, y)
logger.info(f'[Viewport] Touch ({x}, {y})')
# logger.info(f'[Viewport] Touch ({x}, {y})')
except Exception as e:
logger.debug(f'[Viewport] Touch error: {e}')
@ -284,6 +298,82 @@ class DeviceConnection:
except Exception as e:
logger.debug(f'[Viewport] Swipe error: {e}')
@property
def supports_raw_touch(self) -> bool:
"""Whether the control method supports raw touch_down/move/up primitives."""
return self._control_method in ('minitouch', 'MaaTouch', 'nemu_ipc', 'scrcpy')
def _minitouch_send_no_delay(self):
"""Send minitouch commands without the DEFAULT_DELAY sleep.
The normal builder.send() calls minitouch_send() which sleeps 50ms+ after
each send. For real-time touch streaming this delay is unacceptable as it
causes the game to register a 'down' as a press/click before the 'move' arrives.
"""
builder = self._device.minitouch_builder
content = builder.to_minitouch()
byte_content = content.encode('utf-8')
self._device._minitouch_client.sendall(byte_content)
self._device._minitouch_client.recv(0)
builder.clear()
def swipe_start(self, x_down: int, y_down: int, x_move: int, y_move: int):
"""Atomically send touch down + first move (starts a swipe without click gap)."""
if not self._connected or self._device is None:
return
try:
if self._control_method in ('minitouch', 'MaaTouch'):
builder = self._device.minitouch_builder
builder.down(x_down, y_down).commit()
builder.move(x_move, y_move).commit()
self._minitouch_send_no_delay()
elif self._control_method == 'nemu_ipc':
self._device.nemu_ipc.down(x_down, y_down)
self._device.nemu_ipc.down(x_move, y_move)
elif self._control_method == 'scrcpy':
from module.device.method.scrcpy import const
self._device.scrcpy_ensure_running()
self._device._scrcpy_control.touch(x_down, y_down, const.ACTION_DOWN)
self._device._scrcpy_control.touch(x_move, y_move, const.ACTION_MOVE)
except Exception as e:
logger.debug(f'[Viewport] Swipe start error: {e}')
def touch_move(self, x: int, y: int):
"""Send touch move event (finger drag)."""
if not self._connected or self._device is None:
return
try:
if self._control_method in ('minitouch', 'MaaTouch'):
builder = self._device.minitouch_builder
builder.move(x, y).commit()
self._minitouch_send_no_delay()
elif self._control_method == 'nemu_ipc':
# nemu_ipc uses down() for move as well
self._device.nemu_ipc.down(x, y)
elif self._control_method == 'scrcpy':
from module.device.method.scrcpy import const
self._device._scrcpy_control.touch(x, y, const.ACTION_MOVE)
except Exception as e:
logger.debug(f'[Viewport] Touch move error: {e}')
def touch_up(self):
"""Send touch up event (finger release)."""
if not self._connected or self._device is None:
return
try:
if self._control_method in ('minitouch', 'MaaTouch'):
builder = self._device.minitouch_builder
builder.up().commit()
self._minitouch_send_no_delay()
elif self._control_method == 'nemu_ipc':
self._device.nemu_ipc.up()
elif self._control_method == 'scrcpy':
from module.device.method.scrcpy import const
# scrcpy needs coordinates for up, use (0,0) as placeholder
self._device._scrcpy_control.touch(0, 0, const.ACTION_UP)
except Exception as e:
logger.debug(f'[Viewport] Touch up error: {e}')
def disconnect(self):
self._connected = False
if self._device is not None:
@ -668,7 +758,7 @@ async def websocket_endpoint(websocket: WebSocket):
manager.add_client(instance_name) # Track client connection
try:
quality = 60
quality = 30
scale = 0.5 # Resolution scale (1.0 = 720p, 0.5 = 360p, etc.) - default 360p for better performance
target_fps = 30 # Default 30 FPS for smooth streaming
is_paused = False # Pause state for visibility-based streaming
@ -693,10 +783,12 @@ async def websocket_endpoint(websocket: WebSocket):
'fps': target_fps,
'screenshot_method': conn.screenshot_method,
'control_method': conn.control_method,
'client_count': manager.get_client_count(instance_name)
'client_count': manager.get_client_count(instance_name),
'supports_raw_touch': conn.supports_raw_touch
})
last_status_time = time.monotonic()
last_interaction_time = time.monotonic() # Track last touch/swipe for idle frame-skip
loop = asyncio.get_event_loop()
while True:
@ -711,6 +803,7 @@ async def websocket_endpoint(websocket: WebSocket):
if action == 'tap':
if not manager.is_script_running(instance_name):
loop.run_in_executor(executor, conn.touch, int(data['x']), int(data['y']))
last_interaction_time = time.monotonic()
elif action == 'swipe':
if not manager.is_script_running(instance_name):
loop.run_in_executor(
@ -719,12 +812,31 @@ async def websocket_endpoint(websocket: WebSocket):
int(data['x2']), int(data['y2']),
int(data.get('duration', 300))
)
last_interaction_time = time.monotonic()
elif action == 'swipe_start':
if not manager.is_script_running(instance_name):
loop.run_in_executor(
executor, conn.swipe_start,
int(data['x1']), int(data['y1']),
int(data['x2']), int(data['y2'])
)
last_interaction_time = time.monotonic()
elif action == 'touch_move':
if not manager.is_script_running(instance_name):
loop.run_in_executor(executor, conn.touch_move, int(data['x']), int(data['y']))
last_interaction_time = time.monotonic()
elif action == 'touch_up':
if not manager.is_script_running(instance_name):
loop.run_in_executor(executor, conn.touch_up)
last_interaction_time = time.monotonic()
elif action == 'set_quality':
quality = max(10, min(95, int(data['quality'])))
quality = max(10, min(99, int(data['quality'])))
elif action == 'set_fps':
target_fps = max(1, min(60, int(data['fps'])))
elif action == 'set_scale':
scale = max(0.25, min(1.0, float(data['scale'])))
elif action == 'resume_idle':
last_interaction_time = time.monotonic()
elif action == 'pause':
is_paused = data.get('paused', False)
logger.info(f'[Viewport] Stream {"paused" if is_paused else "resumed"} for {instance_name}')
@ -746,7 +858,8 @@ async def websocket_endpoint(websocket: WebSocket):
'fps': target_fps,
'screenshot_method': conn.screenshot_method,
'control_method': conn.control_method,
'client_count': manager.get_client_count(instance_name)
'client_count': manager.get_client_count(instance_name),
'supports_raw_touch': conn.supports_raw_touch
})
else:
await websocket.send_json({'type': 'error', 'message': 'Reconnect failed'})
@ -775,7 +888,8 @@ async def websocket_endpoint(websocket: WebSocket):
'fps': target_fps,
'screenshot_method': conn.screenshot_method,
'control_method': conn.control_method,
'client_count': manager.get_client_count(instance_name)
'client_count': manager.get_client_count(instance_name),
'supports_raw_touch': conn.supports_raw_touch
})
else:
await websocket.send_json({'type': 'error', 'message': 'Device disconnected'})
@ -800,9 +914,17 @@ async def websocket_endpoint(websocket: WebSocket):
# Capture and send frame
t_cap_start = time.monotonic()
jpeg_data = await loop.run_in_executor(
executor, lambda: conn.screenshot_jpeg(quality, scale)
)
idle_seconds = t_cap_start - last_interaction_time
skip_unchanged = idle_seconds >= 5.0
is_idle = idle_seconds >= 300.0
# When idle for 300s, skip capturing entirely (save CPU)
if is_idle:
jpeg_data = None
else:
jpeg_data = await loop.run_in_executor(
executor, lambda: conn.screenshot_encode(quality, scale, skip_unchanged)
)
t_cap_end = time.monotonic()
if jpeg_data:
@ -816,17 +938,6 @@ async def websocket_endpoint(websocket: WebSocket):
stats_total_latency += frame_latency
stats_total_bytes += len(jpeg_data)
# Calculate stats every second
stats_elapsed = time.monotonic() - stats_start_time
if stats_elapsed >= 1.0:
current_latency_ms = stats_total_latency / max(1, stats_frame_count)
current_bandwidth_kbps = (stats_total_bytes * 8) / stats_elapsed / 1000 # kbps
# Reset stats
stats_frame_count = 0
stats_total_latency = 0.0
stats_total_bytes = 0
stats_start_time = time.monotonic()
# Track WebSocket timing
if not hasattr(websocket, '_ws_frame_count'):
websocket._ws_frame_count = 0
@ -839,13 +950,24 @@ async def websocket_endpoint(websocket: WebSocket):
if websocket._ws_frame_count >= 100:
avg_cap = websocket._ws_cap_time / websocket._ws_frame_count * 1000
avg_send = websocket._ws_send_time / websocket._ws_frame_count * 1000
logger.info(
f'[Viewport] WS Timing (avg ms): capture={avg_cap:.1f}, send={avg_send:.1f}'
)
# logger.info(
# f'[Viewport] WS Timing (avg ms): capture={avg_cap:.1f}, send={avg_send:.1f}'
# )
websocket._ws_frame_count = 0
websocket._ws_cap_time = 0
websocket._ws_send_time = 0
# Calculate stats every second (outside if jpeg_data so stats update during skips)
stats_elapsed = time.monotonic() - stats_start_time
if stats_elapsed >= 1.0:
current_latency_ms = stats_total_latency / max(1, stats_frame_count)
current_bandwidth_kbps = (stats_total_bytes * 8) / stats_elapsed / 1000 # kbps
# Reset stats
stats_frame_count = 0
stats_total_latency = 0.0
stats_total_bytes = 0
stats_start_time = time.monotonic()
# Periodic status update
now = time.monotonic()
if now - last_status_time >= 1.0: # Update every second for stats
@ -860,7 +982,8 @@ async def websocket_endpoint(websocket: WebSocket):
'control_method': conn.control_method,
'latency_ms': round(current_latency_ms, 1),
'bandwidth_kbps': round(current_bandwidth_kbps, 0),
'client_count': manager.get_client_count(instance_name)
'client_count': manager.get_client_count(instance_name),
'idle': is_idle
})
# Frame rate limiting