@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.
@@ -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>