@kln-mcp/ctrl-mobile-cn 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.js +314 -0
- package/cli/setup.js +304 -0
- package/config/index.d.ts +19 -0
- package/config/index.js +78 -0
- package/config/pro.json +12 -0
- package/core/index.d.ts +57 -0
- package/core/index.js +315 -0
- package/index.js +23 -0
- package/package.json +31 -0
- package/skills/kln-mobile-ctrl/SKILL.md +122 -0
- package/wrapper/index.d.ts +107 -0
- package/wrapper/index.js +2553 -0
- package/wrapper/live-view.html +438 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>KLN Live View</title>
|
|
7
|
+
<style>
|
|
8
|
+
html, body { margin: 0; height: 100%; background: #101114; color: #e8eaed; font-family: system-ui, sans-serif; }
|
|
9
|
+
body { display: grid; grid-template-rows: auto 1fr; }
|
|
10
|
+
header { display: flex; gap: 16px; align-items: center; padding: 10px 14px; background: #191b20; border-bottom: 1px solid #2b2f38; }
|
|
11
|
+
#status { color: #8ab4f8; }
|
|
12
|
+
#stage { display: grid; place-items: center; overflow: hidden; }
|
|
13
|
+
canvas { max-width: 100vw; max-height: calc(100vh - 44px); background: #000; touch-action: none; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<header><strong>KLN Live View</strong><span id="status">connecting...</span><span id="stats"></span></header>
|
|
18
|
+
<main id="stage"><canvas id="canvas"></canvas></main>
|
|
19
|
+
<script>
|
|
20
|
+
const statusEl = document.getElementById('status');
|
|
21
|
+
const statsEl = document.getElementById('stats');
|
|
22
|
+
const canvas = document.getElementById('canvas');
|
|
23
|
+
const ctx = canvas.getContext('2d');
|
|
24
|
+
let decoder;
|
|
25
|
+
let configData;
|
|
26
|
+
let frames = 0;
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
let configured = false;
|
|
29
|
+
let pointerStart = null;
|
|
30
|
+
let deviceContext = null;
|
|
31
|
+
|
|
32
|
+
function log(...args) {
|
|
33
|
+
console.log('[KLN Live View]', ...args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setStatus(text) {
|
|
37
|
+
log('status:', text);
|
|
38
|
+
statusEl.textContent = text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
log('page loaded', {
|
|
42
|
+
location: location.href,
|
|
43
|
+
userAgent: navigator.userAgent,
|
|
44
|
+
hasVideoDecoder: 'VideoDecoder' in window
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function configureDecoder() {
|
|
48
|
+
if (configured) {
|
|
49
|
+
log('decoder already configured');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!('VideoDecoder' in window)) {
|
|
53
|
+
setStatus('WebCodecs VideoDecoder is not available in this browser');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
log('configuring decoder');
|
|
57
|
+
decoder = new VideoDecoder({
|
|
58
|
+
output(frame) {
|
|
59
|
+
if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
|
|
60
|
+
log('resize canvas from decoded frame', {
|
|
61
|
+
width: frame.displayWidth,
|
|
62
|
+
height: frame.displayHeight
|
|
63
|
+
});
|
|
64
|
+
canvas.width = frame.displayWidth;
|
|
65
|
+
canvas.height = frame.displayHeight;
|
|
66
|
+
}
|
|
67
|
+
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
|
68
|
+
frame.close();
|
|
69
|
+
frames += 1;
|
|
70
|
+
statsEl.textContent = frames + ' frames, ' + Math.round(bytes / 1024) + ' KB';
|
|
71
|
+
},
|
|
72
|
+
error(error) {
|
|
73
|
+
setStatus('decoder error: ' + error.message);
|
|
74
|
+
console.error('[KLN Live View] decoder error', error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
decoder.configure({ codec: 'avc1.42E01E', optimizeForLatency: true });
|
|
78
|
+
configured = true;
|
|
79
|
+
setStatus('streaming');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readU32LE(view, offset) { return view.getUint32(offset, true); }
|
|
83
|
+
function readI64LE(view, offset) { return Number(view.getBigInt64(offset, true)); }
|
|
84
|
+
|
|
85
|
+
function concatBytes(a, b) {
|
|
86
|
+
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
87
|
+
out.set(new Uint8Array(a), 0);
|
|
88
|
+
out.set(new Uint8Array(b), a.byteLength);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handlePacket(buffer) {
|
|
93
|
+
const view = new DataView(buffer);
|
|
94
|
+
const kind = view.getUint8(0);
|
|
95
|
+
const flags = readU32LE(view, 1);
|
|
96
|
+
const width = readU32LE(view, 5);
|
|
97
|
+
const height = readU32LE(view, 9);
|
|
98
|
+
const timestamp = readI64LE(view, 13);
|
|
99
|
+
const len = readU32LE(view, 29);
|
|
100
|
+
const data = buffer.slice(33, 33 + len);
|
|
101
|
+
bytes += len;
|
|
102
|
+
|
|
103
|
+
if (width && height && (canvas.width === 0 || canvas.height === 0)) {
|
|
104
|
+
log('initialize canvas size from packet', { width, height });
|
|
105
|
+
canvas.width = width;
|
|
106
|
+
canvas.height = height;
|
|
107
|
+
}
|
|
108
|
+
if (kind === 1 || (flags & 2) === 2) {
|
|
109
|
+
log('decoder config packet received', { len });
|
|
110
|
+
configData = data;
|
|
111
|
+
configureDecoder();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (kind !== 2 || !configured) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isKey = (flags & 1) === 1;
|
|
119
|
+
const chunkData = isKey && configData ? concatBytes(configData, data) : new Uint8Array(data);
|
|
120
|
+
try {
|
|
121
|
+
decoder.decode(new EncodedVideoChunk({
|
|
122
|
+
type: isKey ? 'key' : 'delta',
|
|
123
|
+
timestamp: timestamp || performance.now() * 1000,
|
|
124
|
+
data: chunkData
|
|
125
|
+
}));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
setStatus('decode enqueue error: ' + error.message);
|
|
128
|
+
console.error('[KLN Live View] decode enqueue error', { kind, flags, len, isKey, error });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function canvasPoint(event) {
|
|
133
|
+
const rect = canvas.getBoundingClientRect();
|
|
134
|
+
if (!rect.width || !rect.height || !canvas.width || !canvas.height) {
|
|
135
|
+
log('cannot map pointer to canvas point', {
|
|
136
|
+
rectWidth: rect.width,
|
|
137
|
+
rectHeight: rect.height,
|
|
138
|
+
canvasWidth: canvas.width,
|
|
139
|
+
canvasHeight: canvas.height
|
|
140
|
+
});
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const point = {
|
|
144
|
+
x: Math.round((event.clientX - rect.left) * canvas.width / rect.width),
|
|
145
|
+
y: Math.round((event.clientY - rect.top) * canvas.height / rect.height)
|
|
146
|
+
};
|
|
147
|
+
log('pointer mapped to canvas point', {
|
|
148
|
+
clientX: event.clientX,
|
|
149
|
+
clientY: event.clientY,
|
|
150
|
+
rectLeft: rect.left,
|
|
151
|
+
rectTop: rect.top,
|
|
152
|
+
rectWidth: rect.width,
|
|
153
|
+
rectHeight: rect.height,
|
|
154
|
+
canvasWidth: canvas.width,
|
|
155
|
+
canvasHeight: canvas.height,
|
|
156
|
+
point
|
|
157
|
+
});
|
|
158
|
+
return point;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getBoundsSize(bounds) {
|
|
162
|
+
if (!bounds || typeof bounds !== 'object') return null;
|
|
163
|
+
const width = Number(bounds.width ?? (bounds.right - bounds.left));
|
|
164
|
+
const height = Number(bounds.height ?? (bounds.bottom - bounds.top));
|
|
165
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return { width, height };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toDevicePoint(videoPoint) {
|
|
172
|
+
const screen = getBoundsSize(deviceContext && (deviceContext.screenBounds || deviceContext.screen_bounds));
|
|
173
|
+
if (!screen || !canvas.width || !canvas.height) {
|
|
174
|
+
log('device context unavailable; skip input to avoid sending video coordinates as device coordinates', {
|
|
175
|
+
videoPoint,
|
|
176
|
+
deviceContext,
|
|
177
|
+
canvasWidth: canvas.width,
|
|
178
|
+
canvasHeight: canvas.height
|
|
179
|
+
});
|
|
180
|
+
setStatus('waiting for device context');
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const x = Math.round(videoPoint.x * screen.width / canvas.width);
|
|
184
|
+
const y = Math.round(videoPoint.y * screen.height / canvas.height);
|
|
185
|
+
const point = {
|
|
186
|
+
x: Math.max(0, Math.min(screen.width - 1, x)),
|
|
187
|
+
y: Math.max(0, Math.min(screen.height - 1, y)),
|
|
188
|
+
videoX: videoPoint.x,
|
|
189
|
+
videoY: videoPoint.y,
|
|
190
|
+
videoWidth: canvas.width,
|
|
191
|
+
videoHeight: canvas.height,
|
|
192
|
+
deviceWidth: screen.width,
|
|
193
|
+
deviceHeight: screen.height,
|
|
194
|
+
coordinateSpace: 'android-device'
|
|
195
|
+
};
|
|
196
|
+
log('video point mapped to device point', point);
|
|
197
|
+
return point;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sendInput(payload) {
|
|
201
|
+
const message = {
|
|
202
|
+
type: 'input',
|
|
203
|
+
profile: 'human',
|
|
204
|
+
clientSentAtMs: Date.now(),
|
|
205
|
+
clientPerfNowMs: performance.now(),
|
|
206
|
+
...payload
|
|
207
|
+
};
|
|
208
|
+
log('send input requested', { readyState: ws.readyState, message });
|
|
209
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
210
|
+
log('skip input send because websocket is not open', { readyState: ws.readyState, message });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
ws.send(JSON.stringify(message));
|
|
214
|
+
log('input sent', message);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const ws = new WebSocket('ws://' + location.host + '/ws');
|
|
218
|
+
log('websocket created', { url: ws.url });
|
|
219
|
+
ws.binaryType = 'arraybuffer';
|
|
220
|
+
ws.onopen = () => {
|
|
221
|
+
log('websocket open');
|
|
222
|
+
setStatus('view connected');
|
|
223
|
+
};
|
|
224
|
+
ws.onmessage = (event) => {
|
|
225
|
+
if (typeof event.data === 'string') {
|
|
226
|
+
const msg = JSON.parse(event.data);
|
|
227
|
+
log('websocket json message', msg);
|
|
228
|
+
if (msg.type === 'hello') setStatus('device ' + (msg.connectionState || 'unknown'));
|
|
229
|
+
if (msg.type === 'connection') setStatus('device ' + msg.state);
|
|
230
|
+
if (msg.type === 'end') setStatus('ended: ' + msg.reason);
|
|
231
|
+
if (msg.type === 'error') setStatus('error: ' + msg.message);
|
|
232
|
+
if (msg.type === 'input-error') setStatus('input error: ' + msg.message);
|
|
233
|
+
if (msg.type === 'device-context') {
|
|
234
|
+
deviceContext = msg.context;
|
|
235
|
+
log('device context updated', deviceContext);
|
|
236
|
+
}
|
|
237
|
+
if (msg.type === 'device-context-error') {
|
|
238
|
+
log('device context query failed', msg);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
handlePacket(event.data);
|
|
243
|
+
};
|
|
244
|
+
ws.onclose = (event) => {
|
|
245
|
+
log('websocket closed', {
|
|
246
|
+
code: event.code,
|
|
247
|
+
reason: event.reason,
|
|
248
|
+
wasClean: event.wasClean
|
|
249
|
+
});
|
|
250
|
+
setStatus('closed');
|
|
251
|
+
};
|
|
252
|
+
ws.onerror = (event) => {
|
|
253
|
+
console.error('[KLN Live View] websocket error', event);
|
|
254
|
+
setStatus('websocket error');
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const PATH_MIN_STEP_PX = 2;
|
|
258
|
+
const PATH_MAX_SAMPLES = 96;
|
|
259
|
+
const TAP_THRESHOLD_PX = 8;
|
|
260
|
+
// Android's default long-press timeout is 500ms. A press-hold that stays
|
|
261
|
+
// within tap distance for at least this long is dispatched as a long-press
|
|
262
|
+
// (instead of an instantaneous tap).
|
|
263
|
+
const LONG_PRESS_THRESHOLD_MS = 500;
|
|
264
|
+
// Samples within this radius of the press point (leading) or release point
|
|
265
|
+
// (trailing) are treated as idle "hold" wiggle and trimmed. Set above the
|
|
266
|
+
// 2px push threshold so several jittery micro-steps during a press-hold get
|
|
267
|
+
// absorbed rather than dispatched to Android as long-duration tiny-motion
|
|
268
|
+
// strokes (which the OS interprets as a long-press).
|
|
269
|
+
const IDLE_TRIM_RADIUS_PX = 8;
|
|
270
|
+
|
|
271
|
+
function pushPathSample(samples, vx, vy, t) {
|
|
272
|
+
const last = samples[samples.length - 1];
|
|
273
|
+
if (last && Math.hypot(vx - last.vx, vy - last.vy) < PATH_MIN_STEP_PX) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
samples.push({ vx, vy, t });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function trimIdleEnds(samples) {
|
|
280
|
+
if (samples.length < 3) return samples;
|
|
281
|
+
const origin = samples[0];
|
|
282
|
+
const endPoint = samples[samples.length - 1];
|
|
283
|
+
let lead = 0;
|
|
284
|
+
for (let i = 1; i < samples.length - 1; i += 1) {
|
|
285
|
+
if (Math.hypot(samples[i].vx - origin.vx, samples[i].vy - origin.vy) >= IDLE_TRIM_RADIUS_PX) break;
|
|
286
|
+
lead = i;
|
|
287
|
+
}
|
|
288
|
+
let trail = samples.length - 1;
|
|
289
|
+
for (let i = samples.length - 2; i > lead; i -= 1) {
|
|
290
|
+
if (Math.hypot(samples[i].vx - endPoint.vx, samples[i].vy - endPoint.vy) >= IDLE_TRIM_RADIUS_PX) break;
|
|
291
|
+
trail = i;
|
|
292
|
+
}
|
|
293
|
+
return samples.slice(lead, trail + 1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function downsamplePath(samples, max) {
|
|
297
|
+
if (samples.length <= max) return samples;
|
|
298
|
+
const stride = (samples.length - 1) / (max - 1);
|
|
299
|
+
const out = new Array(max);
|
|
300
|
+
for (let i = 0; i < max; i += 1) {
|
|
301
|
+
out[i] = samples[Math.min(samples.length - 1, Math.round(i * stride))];
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
307
|
+
const point = canvasPoint(event);
|
|
308
|
+
if (!point) return;
|
|
309
|
+
const now = performance.now();
|
|
310
|
+
pointerStart = {
|
|
311
|
+
...point,
|
|
312
|
+
pointerId: event.pointerId,
|
|
313
|
+
startedAt: now,
|
|
314
|
+
samples: [{ vx: point.x, vy: point.y, t: 0 }]
|
|
315
|
+
};
|
|
316
|
+
log('pointer down', pointerStart);
|
|
317
|
+
canvas.setPointerCapture(event.pointerId);
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
});
|
|
320
|
+
canvas.addEventListener('pointermove', (event) => {
|
|
321
|
+
if (!pointerStart || pointerStart.pointerId !== event.pointerId) return;
|
|
322
|
+
const point = canvasPoint(event);
|
|
323
|
+
if (!point) return;
|
|
324
|
+
const t = Math.round(performance.now() - pointerStart.startedAt);
|
|
325
|
+
pushPathSample(pointerStart.samples, point.x, point.y, t);
|
|
326
|
+
});
|
|
327
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
328
|
+
if (!pointerStart || pointerStart.pointerId !== event.pointerId) {
|
|
329
|
+
log('ignore pointer up without matching pointer start', {
|
|
330
|
+
pointerId: event.pointerId,
|
|
331
|
+
pointerStart
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const point = canvasPoint(event);
|
|
336
|
+
if (!point) return;
|
|
337
|
+
const wallDurationMs = Math.max(1, Math.round(performance.now() - pointerStart.startedAt));
|
|
338
|
+
pushPathSample(pointerStart.samples, point.x, point.y, wallDurationMs);
|
|
339
|
+
const rawSamples = pointerStart.samples;
|
|
340
|
+
const samples = trimIdleEnds(rawSamples);
|
|
341
|
+
const startSample = samples[0];
|
|
342
|
+
const endSample = samples[samples.length - 1];
|
|
343
|
+
const dx = endSample.vx - startSample.vx;
|
|
344
|
+
const dy = endSample.vy - startSample.vy;
|
|
345
|
+
// Effective duration covers only the active motion window — leading/trailing
|
|
346
|
+
// idle is trimmed so the dispatched gesture doesn't look like a long-press.
|
|
347
|
+
const effectiveDurationMs = Math.max(1, endSample.t - startSample.t);
|
|
348
|
+
log('pointer up', {
|
|
349
|
+
point,
|
|
350
|
+
dx,
|
|
351
|
+
dy,
|
|
352
|
+
wallDurationMs,
|
|
353
|
+
effectiveDurationMs,
|
|
354
|
+
rawSamples: rawSamples.length,
|
|
355
|
+
trimmedSamples: samples.length,
|
|
356
|
+
trimmedLeading: rawSamples.length - samples.length > 0
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
let totalDistance = 0;
|
|
360
|
+
for (let i = 1; i < samples.length; i += 1) {
|
|
361
|
+
totalDistance += Math.hypot(samples[i].vx - samples[i - 1].vx, samples[i].vy - samples[i - 1].vy);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (Math.hypot(dx, dy) < TAP_THRESHOLD_PX && totalDistance < TAP_THRESHOLD_PX * 2) {
|
|
365
|
+
const devicePoint = toDevicePoint({ x: endSample.vx, y: endSample.vy });
|
|
366
|
+
if (!devicePoint) return;
|
|
367
|
+
if (wallDurationMs >= LONG_PRESS_THRESHOLD_MS) {
|
|
368
|
+
log('recognize long-press', { ...devicePoint, wallDurationMs });
|
|
369
|
+
sendInput({ action: 'longPress', durationMs: wallDurationMs, ...devicePoint });
|
|
370
|
+
} else {
|
|
371
|
+
log('recognize tap', devicePoint);
|
|
372
|
+
sendInput({ action: 'tap', ...devicePoint });
|
|
373
|
+
}
|
|
374
|
+
} else if (samples.length <= 2) {
|
|
375
|
+
const deviceStart = toDevicePoint({ x: startSample.vx, y: startSample.vy });
|
|
376
|
+
const deviceEnd = toDevicePoint({ x: endSample.vx, y: endSample.vy });
|
|
377
|
+
if (!deviceStart || !deviceEnd) return;
|
|
378
|
+
log('recognize swipe (2-point)', { deviceStart, deviceEnd, effectiveDurationMs });
|
|
379
|
+
sendInput({
|
|
380
|
+
action: 'swipe',
|
|
381
|
+
startX: deviceStart.x,
|
|
382
|
+
startY: deviceStart.y,
|
|
383
|
+
endX: deviceEnd.x,
|
|
384
|
+
endY: deviceEnd.y,
|
|
385
|
+
videoStartX: startSample.vx,
|
|
386
|
+
videoStartY: startSample.vy,
|
|
387
|
+
videoEndX: endSample.vx,
|
|
388
|
+
videoEndY: endSample.vy,
|
|
389
|
+
videoWidth: canvas.width,
|
|
390
|
+
videoHeight: canvas.height,
|
|
391
|
+
deviceWidth: deviceStart.deviceWidth,
|
|
392
|
+
deviceHeight: deviceStart.deviceHeight,
|
|
393
|
+
coordinateSpace: deviceStart.coordinateSpace,
|
|
394
|
+
durationMs: effectiveDurationMs
|
|
395
|
+
});
|
|
396
|
+
} else {
|
|
397
|
+
const sampled = downsamplePath(samples, PATH_MAX_SAMPLES);
|
|
398
|
+
const baseT = sampled[0].t;
|
|
399
|
+
const devicePoints = [];
|
|
400
|
+
let deviceWidth = 0;
|
|
401
|
+
let deviceHeight = 0;
|
|
402
|
+
for (const s of sampled) {
|
|
403
|
+
const mapped = toDevicePoint({ x: s.vx, y: s.vy });
|
|
404
|
+
if (!mapped) return;
|
|
405
|
+
// Re-zero timestamps so the Android side sees t starting at 0; this
|
|
406
|
+
// keeps duration math consistent after leading-idle trim.
|
|
407
|
+
devicePoints.push({ x: mapped.x, y: mapped.y, tMs: s.t - baseT });
|
|
408
|
+
deviceWidth = mapped.deviceWidth;
|
|
409
|
+
deviceHeight = mapped.deviceHeight;
|
|
410
|
+
}
|
|
411
|
+
log('recognize gesture', {
|
|
412
|
+
rawSamples: rawSamples.length,
|
|
413
|
+
trimmedSamples: samples.length,
|
|
414
|
+
sentPoints: devicePoints.length,
|
|
415
|
+
effectiveDurationMs,
|
|
416
|
+
totalDistance: Math.round(totalDistance)
|
|
417
|
+
});
|
|
418
|
+
sendInput({
|
|
419
|
+
action: 'gesture',
|
|
420
|
+
points: devicePoints,
|
|
421
|
+
durationMs: effectiveDurationMs,
|
|
422
|
+
videoWidth: canvas.width,
|
|
423
|
+
videoHeight: canvas.height,
|
|
424
|
+
deviceWidth,
|
|
425
|
+
deviceHeight,
|
|
426
|
+
coordinateSpace: 'android-device'
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
pointerStart = null;
|
|
430
|
+
event.preventDefault();
|
|
431
|
+
});
|
|
432
|
+
canvas.addEventListener('pointercancel', (event) => {
|
|
433
|
+
log('pointer cancel', { pointerId: event.pointerId, pointerStart });
|
|
434
|
+
pointerStart = null;
|
|
435
|
+
});
|
|
436
|
+
</script>
|
|
437
|
+
</body>
|
|
438
|
+
</html>
|