ownremote 0.0.0-stage → 1.0.1

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/public/app.js ADDED
@@ -0,0 +1,957 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ const $ = (id) => document.getElementById(id);
5
+ const store = {
6
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
7
+ set(k, v) { try { localStorage.setItem(k, v); } catch { /* mode privat */ } },
8
+ del(k) { try { localStorage.removeItem(k); } catch { /* mode privat */ } },
9
+ };
10
+
11
+ const TOKEN_KEY = 'rc_token';
12
+ const PC_KEY = 'rc_pc';
13
+ const SETTINGS_KEY = 'rc_settings';
14
+ const DEFAULTS = { sens: 1.6, accel: 0.8, scrollSpeed: 4, natural: true, haptic: true, screenFps: 5, screenWidth: 960, screenQuality: 50, screenZoom: 1 };
15
+
16
+ // Gesture
17
+ const TAP_MAX_MS = 300;
18
+ const TAP_MAX_TRAVEL = 12;
19
+ const HOLD_MS = 500;
20
+ const HOLD_MAX_TRAVEL = 6;
21
+
22
+ let settings = { ...DEFAULTS };
23
+ try { Object.assign(settings, JSON.parse(store.get(SETTINGS_KEY) || '{}')); } catch { /* default */ }
24
+
25
+ let token = store.get(TOKEN_KEY);
26
+ let ws = null;
27
+ let retry = 0;
28
+ let retryTimer = 0;
29
+ let autoReconnect = true;
30
+ let pongTimer = 0;
31
+
32
+ // ================================================================ layar
33
+ const pairScreen = $('pairScreen');
34
+ const padScreen = $('padScreen');
35
+ const overlay = $('overlay');
36
+
37
+ function showPair(message = '') {
38
+ padScreen.classList.add('hidden');
39
+ pairScreen.classList.remove('hidden');
40
+ $('pairError').textContent = message;
41
+ codeInput.value = '';
42
+ renderSlots();
43
+ }
44
+
45
+ function showPad() {
46
+ pairScreen.classList.add('hidden');
47
+ padScreen.classList.remove('hidden');
48
+ hideOverlay();
49
+ $('pcName').textContent = store.get(PC_KEY) || 'PC';
50
+ }
51
+
52
+ function setConn(state, text) {
53
+ $('connDot').className = `dot ${state}`;
54
+ $('connText').textContent = text;
55
+ }
56
+
57
+ function showOverlay(text, withButton = true) {
58
+ $('overlayText').textContent = text;
59
+ $('reconnectBtn').classList.toggle('hidden', !withButton);
60
+ overlay.classList.remove('hidden');
61
+ }
62
+
63
+ const hideOverlay = () => overlay.classList.add('hidden');
64
+
65
+ // ================================================================ pairing
66
+ const codeInput = $('codeInput');
67
+ const slots = [...document.querySelectorAll('.code-slots i')];
68
+ let pairing = false;
69
+
70
+ function renderSlots() {
71
+ const v = codeInput.value;
72
+ const focused = document.activeElement === codeInput;
73
+ slots.forEach((s, i) => {
74
+ s.textContent = v[i] || '';
75
+ s.classList.toggle('filled', Boolean(v[i]));
76
+ s.classList.toggle('active', focused && i === Math.min(v.length, 5));
77
+ });
78
+ }
79
+
80
+ codeInput.addEventListener('input', () => {
81
+ codeInput.value = codeInput.value.replace(/\D/g, '').slice(0, 6);
82
+ $('pairError').textContent = '';
83
+ renderSlots();
84
+ if (codeInput.value.length === 6) pair();
85
+ });
86
+ codeInput.addEventListener('focus', renderSlots);
87
+ codeInput.addEventListener('blur', renderSlots);
88
+
89
+ $('pairForm').addEventListener('submit', (e) => {
90
+ e.preventDefault();
91
+ pair();
92
+ });
93
+
94
+ async function pair() {
95
+ if (pairing) return;
96
+ const code = codeInput.value;
97
+ if (code.length !== 6) {
98
+ fail('The code has 6 digits.');
99
+ return;
100
+ }
101
+ pairing = true;
102
+ $('pairBtn').disabled = true;
103
+ $('pairBtn').textContent = 'Connecting…';
104
+ try {
105
+ const res = await fetch('/api/pair', {
106
+ method: 'POST',
107
+ headers: { 'content-type': 'application/json' },
108
+ body: JSON.stringify({ code, name: $('nameInput').value.trim() }),
109
+ });
110
+ const data = await res.json().catch(() => ({}));
111
+ if (!res.ok) throw new Error(data.error || 'Gagal terhubung.');
112
+ token = data.token;
113
+ store.set(TOKEN_KEY, token);
114
+ store.set(PC_KEY, data.pc);
115
+ codeInput.blur();
116
+ showPad();
117
+ retry = 0;
118
+ connect();
119
+ } catch (err) {
120
+ fail(err instanceof TypeError ? "Can't reach your PC. Make sure your phone is on the same Wi-Fi." : err.message);
121
+ } finally {
122
+ pairing = false;
123
+ $('pairBtn').disabled = false;
124
+ $('pairBtn').textContent = 'Connect';
125
+ }
126
+
127
+ function fail(msg) {
128
+ $('pairError').textContent = msg;
129
+ const field = document.querySelector('.code-field');
130
+ field.classList.remove('shake');
131
+ void field.offsetWidth;
132
+ field.classList.add('shake');
133
+ codeInput.value = '';
134
+ renderSlots();
135
+ }
136
+ }
137
+
138
+ // ================================================================ koneksi
139
+ function connect() {
140
+ clearTimeout(retryTimer);
141
+ if (!token) return showPair();
142
+ if (ws && ws.readyState <= 1) return;
143
+ autoReconnect = true;
144
+ setConn('', 'Connecting…');
145
+
146
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
147
+ const sock = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`);
148
+ ws = sock;
149
+ sock.binaryType = 'arraybuffer'; // frame layar dikirim sebagai JPEG biner
150
+
151
+ sock.onopen = () => {
152
+ retry = 0;
153
+ hideOverlay();
154
+ setConn('on', 'Connected');
155
+ if (screenOn) subscribeScreen();
156
+ };
157
+
158
+ sock.onmessage = (e) => {
159
+ if (e.data instanceof ArrayBuffer) {
160
+ onScreenFrame(e.data);
161
+ return;
162
+ }
163
+ let msg;
164
+ try { msg = JSON.parse(e.data); } catch { return; }
165
+ if (msg.t === 'hello') {
166
+ $('pcName').textContent = msg.pc;
167
+ store.set(PC_KEY, msg.pc);
168
+ $('deviceInfo').textContent = `This device is paired as "${msg.name}".`;
169
+ } else if (msg.t === 'pong') {
170
+ clearTimeout(pongTimer);
171
+ } else if (['cur', 'mon', 'screen-err', 'screen-off'].includes(msg.t)) {
172
+ onScreenMessage(msg);
173
+ }
174
+ };
175
+
176
+ sock.onclose = (e) => {
177
+ if (ws !== sock) return;
178
+ ws = null;
179
+ clearTimeout(pongTimer);
180
+ resetInputState();
181
+
182
+ if (e.code === 4001) {
183
+ forgetLocal();
184
+ showPair('This device was removed on the PC. Enter the code again.');
185
+ return;
186
+ }
187
+ if (e.code === 4003) return stopWith('Disconnected', 'Disconnected from the PC.');
188
+ if (e.code === 4002) return stopWith('In use elsewhere', 'This device is connected from another tab or window.');
189
+ if (!autoReconnect) return stopWith('Disconnected', 'Connection closed.');
190
+
191
+ setConn('off', 'Disconnected, retrying…');
192
+ if (retry >= 3) showOverlay("Can't connect to your PC. Make sure OwnRemote is running and your phone is on the same Wi-Fi.\nRetrying…");
193
+ const delay = Math.min(10_000, 500 * 2 ** retry++);
194
+ retryTimer = setTimeout(connect, delay);
195
+ };
196
+ }
197
+
198
+ function stopWith(status, message) {
199
+ autoReconnect = false;
200
+ setConn('off', status);
201
+ showOverlay(message);
202
+ }
203
+
204
+ function send(obj) {
205
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
206
+ }
207
+
208
+ function forgetLocal() {
209
+ token = null;
210
+ store.del(TOKEN_KEY);
211
+ }
212
+
213
+ $('reconnectBtn').addEventListener('click', () => {
214
+ hideOverlay();
215
+ retry = 0;
216
+ connect();
217
+ });
218
+
219
+ // HP habis sleep: koneksi bisa "setengah mati". Cek dengan ping, sambung ulang bila perlu.
220
+ // Preview layar dijeda saat HP dikunci / tab ditinggalkan supaya tidak boros data.
221
+ document.addEventListener('visibilitychange', () => {
222
+ if (document.hidden) {
223
+ if (screenOn) send({ t: 'screen', on: false });
224
+ return;
225
+ }
226
+ if (!token || padScreen.classList.contains('hidden')) return;
227
+ if (ws && ws.readyState === 1) {
228
+ send({ t: 'ping' });
229
+ clearTimeout(pongTimer);
230
+ pongTimer = setTimeout(() => ws && ws.close(), 3000);
231
+ if (screenOn) subscribeScreen();
232
+ } else if (autoReconnect) {
233
+ retry = 0;
234
+ connect();
235
+ }
236
+ });
237
+
238
+ // ================================================================ kirim gerakan (dibatch per frame)
239
+ let mx = 0, my = 0, sx = 0, sy = 0, raf = 0;
240
+ const r2 = (n) => Math.round(n * 100) / 100;
241
+
242
+ function schedule() {
243
+ if (!raf) raf = requestAnimationFrame(flush);
244
+ }
245
+
246
+ function flush() {
247
+ if (raf) cancelAnimationFrame(raf);
248
+ raf = 0;
249
+ if (mx || my) {
250
+ send({ t: 'm', x: r2(mx), y: r2(my) });
251
+ predictMove(r2(mx), r2(my)); // virtual cursor langsung ikut bergerak
252
+ mx = my = 0;
253
+ }
254
+ if (sx || sy) {
255
+ send({ t: 's', x: r2(sx), y: r2(sy) });
256
+ sx = sy = 0;
257
+ }
258
+ }
259
+
260
+ function vibrate(ms) {
261
+ if (settings.haptic && navigator.vibrate) navigator.vibrate(ms);
262
+ }
263
+
264
+ function click(b) {
265
+ flush();
266
+ send({ t: 'c', b });
267
+ flashCursor(b);
268
+ vibrate(12);
269
+ }
270
+
271
+ // ================================================================ trackpad
272
+ const pad = $('touchpad');
273
+ const strip = $('scrollStrip');
274
+ const pts = new Map(); // touch id -> { x, y, t }
275
+ let g = null; // gesture aktif
276
+
277
+ function ripple(x, y) {
278
+ const rect = pad.getBoundingClientRect();
279
+ const el = document.createElement('div');
280
+ el.className = 'ripple';
281
+ el.style.left = `${x - rect.left}px`;
282
+ el.style.top = `${y - rect.top}px`;
283
+ el.addEventListener('animationend', () => el.remove());
284
+ pad.appendChild(el);
285
+ }
286
+
287
+ function setDragging(on) {
288
+ $('dragBadge').classList.toggle('hidden', !on);
289
+ }
290
+
291
+ function cancelHold() {
292
+ if (g && g.holdTimer) {
293
+ clearTimeout(g.holdTimer);
294
+ g.holdTimer = 0;
295
+ }
296
+ }
297
+
298
+ function startDrag() {
299
+ if (!g || g.mode !== 'move' || pts.size !== 1) return;
300
+ g.holdTimer = 0;
301
+ g.dragging = true;
302
+ flush();
303
+ send({ t: 'd', b: 'l' });
304
+ setDragging(true);
305
+ vibrate(35);
306
+ }
307
+
308
+ pad.addEventListener('touchstart', (e) => {
309
+ e.preventDefault();
310
+ $('padHint').classList.add('faded');
311
+ const now = performance.now();
312
+ if (!g) {
313
+ g = { start: now, maxFingers: 0, travel: 0, mode: 'move', dragging: false, holdTimer: 0 };
314
+ if (e.changedTouches[0].target === strip) {
315
+ g.mode = 'strip';
316
+ strip.classList.add('active');
317
+ }
318
+ }
319
+ for (const t of e.changedTouches) {
320
+ pts.set(t.identifier, { x: t.clientX, y: t.clientY, t: now });
321
+ ripple(t.clientX, t.clientY);
322
+ }
323
+ g.maxFingers = Math.max(g.maxFingers, pts.size);
324
+
325
+ if (g.mode === 'strip') return;
326
+ if (pts.size >= 2 && !g.dragging) {
327
+ g.mode = 'scroll';
328
+ cancelHold();
329
+ } else if (pts.size === 1 && g.mode === 'move' && !g.holdTimer && !g.dragging) {
330
+ g.holdTimer = setTimeout(startDrag, HOLD_MS);
331
+ }
332
+ }, { passive: false });
333
+
334
+ pad.addEventListener('touchmove', (e) => {
335
+ e.preventDefault();
336
+ if (!g) return;
337
+ const now = performance.now();
338
+ let sumX = 0, sumY = 0, n = 0, dt = 16;
339
+ for (const t of e.changedTouches) {
340
+ const p = pts.get(t.identifier);
341
+ if (!p) continue;
342
+ sumX += t.clientX - p.x;
343
+ sumY += t.clientY - p.y;
344
+ dt = Math.max(1, now - p.t);
345
+ p.x = t.clientX;
346
+ p.y = t.clientY;
347
+ p.t = now;
348
+ n++;
349
+ }
350
+ if (!n) return;
351
+ const dx = sumX / n;
352
+ const dy = sumY / n;
353
+ g.travel += Math.hypot(dx, dy);
354
+ if (g.holdTimer && g.travel > HOLD_MAX_TRAVEL) cancelHold();
355
+
356
+ if (g.mode === 'move') {
357
+ const speed = Math.hypot(dx, dy) / dt; // px per ms
358
+ const gain = settings.sens * (1 + settings.accel * Math.min(speed, 3));
359
+ mx += dx * gain;
360
+ my += dy * gain;
361
+ } else {
362
+ // positif = scroll ke atas/kanan di Windows; natural = konten mengikuti jari
363
+ const dir = settings.natural ? 1 : -1;
364
+ const k = settings.scrollSpeed;
365
+ sy += dy * k * dir;
366
+ if (g.mode === 'scroll') sx += -dx * k * dir;
367
+ }
368
+ schedule();
369
+ }, { passive: false });
370
+
371
+ function onTouchEnd(e) {
372
+ e.preventDefault();
373
+ if (!g) return;
374
+ for (const t of e.changedTouches) pts.delete(t.identifier);
375
+ if (pts.size > 0) return;
376
+
377
+ // semua jari sudah terangkat
378
+ cancelHold();
379
+ strip.classList.remove('active');
380
+ const duration = performance.now() - g.start;
381
+ if (g.dragging) {
382
+ flush();
383
+ send({ t: 'u', b: 'l' });
384
+ setDragging(false);
385
+ } else if (e.type === 'touchend' && g.mode !== 'strip' && duration < TAP_MAX_MS && g.travel < TAP_MAX_TRAVEL) {
386
+ click(g.maxFingers >= 3 ? 'm' : g.maxFingers === 2 ? 'r' : 'l');
387
+ } else {
388
+ flush();
389
+ }
390
+ g = null;
391
+ }
392
+
393
+ pad.addEventListener('touchend', onTouchEnd, { passive: false });
394
+ pad.addEventListener('touchcancel', onTouchEnd, { passive: false });
395
+ pad.addEventListener('contextmenu', (e) => e.preventDefault());
396
+
397
+ // ================================================================ tombol fisik (tekan-tahan = drag)
398
+ const buttons = [...document.querySelectorAll('.mbtn')];
399
+ for (const btn of buttons) {
400
+ const b = btn.dataset.btn;
401
+ let pointer = null;
402
+ btn.addEventListener('pointerdown', (e) => {
403
+ e.preventDefault();
404
+ if (pointer !== null) return;
405
+ pointer = e.pointerId;
406
+ try { btn.setPointerCapture(pointer); } catch { /* abaikan */ }
407
+ btn.classList.add('down');
408
+ flush();
409
+ send({ t: 'd', b });
410
+ flashCursor(b);
411
+ vibrate(12);
412
+ });
413
+ const release = (e) => {
414
+ if (pointer === null || (e && e.pointerId !== pointer)) return;
415
+ pointer = null;
416
+ btn.classList.remove('down');
417
+ flush();
418
+ send({ t: 'u', b });
419
+ };
420
+ btn.addEventListener('pointerup', release);
421
+ btn.addEventListener('pointercancel', release);
422
+ btn.addEventListener('lostpointercapture', release);
423
+ btn.addEventListener('contextmenu', (e) => e.preventDefault());
424
+ btn._reset = () => { pointer = null; btn.classList.remove('down'); };
425
+ }
426
+
427
+ function resetInputState() {
428
+ buttons.forEach((b) => b._reset());
429
+ pts.clear();
430
+ if (g) cancelHold();
431
+ g = null;
432
+ setDragging(false);
433
+ mx = my = sx = sy = 0;
434
+ setMods(0);
435
+ }
436
+
437
+ // ================================================================ preview layar PC
438
+ // Opt-in: hanya berjalan setelah tombol layar ditekan. Gambar datang sesuai fps pilihan (JPEG biner).
439
+ // Akurasi virtual cursor:
440
+ // - posisi asli dari PC ~60x/detik dalam piksel fisik (DPI-aware)
441
+ // - saat jari menggeser trackpad, HP langsung memprediksi posisi dari delta piksel yang dikirim ke PC
442
+ // (tanpa menunggu jaringan), lalu kembali ke posisi asli dari PC begitu gerakan berhenti
443
+ // - ujung panah (hotspot) tepat di titik koordinat dan tanpa animasi transisi
444
+ const screenBtn = $('screenBtn');
445
+ const screenPanel = $('screenPanel');
446
+ const stage = $('stage');
447
+ const stageInner = $('stageInner');
448
+ const screenImg = $('screenImg');
449
+ const vCursor = $('vCursor');
450
+ const screenMsg = $('screenMsg');
451
+ const SCREEN_PRESETS = {
452
+ saver: { label: 'Saver', fps: 2, width: 640, quality: 40 },
453
+ normal: { label: 'Normal', fps: 5, width: 960, quality: 50 },
454
+ sharp: { label: 'Sharp', fps: 8, width: 1280, quality: 60 },
455
+ };
456
+ const PRESET_CYCLE = ['saver', 'normal', 'sharp'];
457
+ const SCREEN_WIDTHS = [640, 960, 1280, 1600, 1920];
458
+ const ZOOMS = [1, 2, 3];
459
+ const PREDICT_HOLD_MS = 250; // setelah gerakan terakhir, kembali memakai posisi asli dari PC
460
+ const CURSOR_SIZE = 22; // px, sama dengan .vcursor di CSS
461
+ const HOTSPOT = CURSOR_SIZE / 24; // ujung panah ada di (1,1) pada viewBox 24x24
462
+ let screenOn = false;
463
+ let screenBig = false;
464
+ let frameUrl = null;
465
+ let aspect = 16 / 9;
466
+ let monitor = null; // { w, h } piksel fisik monitor yang ditampilkan
467
+ let serverPos = null; // posisi asli dari PC, 0..1
468
+ let predicted = null; // posisi prediksi lokal selama jari bergerak
469
+ let predictTimer = 0;
470
+ let cursorRaf = 0;
471
+ let statBytes = 0;
472
+ let statFrames = 0;
473
+ let statTimer = 0;
474
+ let resubTimer = 0;
475
+
476
+ // Pengaturan lama (hanya nama preset) -> nilai fps/resolusi/kualitas
477
+ if (!Number.isFinite(settings.screenFps)) {
478
+ const p = SCREEN_PRESETS[settings.screenPreset] || SCREEN_PRESETS.normal;
479
+ Object.assign(settings, { screenFps: p.fps, screenWidth: p.width, screenQuality: p.quality });
480
+ }
481
+ delete settings.screenPreset;
482
+ // Dibuka lewat internet (Cloudflare Tunnel) dan belum pernah diatur: mulai dari preset Hemat supaya hemat kuota
483
+ const viaInternet = !/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/.test(location.hostname);
484
+ let screenStored = false;
485
+ try { screenStored = 'screenFps' in JSON.parse(store.get(SETTINGS_KEY) || '{}'); } catch { /* default */ }
486
+ if (viaInternet && !screenStored) Object.assign(settings, { screenFps: 2, screenWidth: 640, screenQuality: 40 });
487
+ if (!ZOOMS.includes(settings.screenZoom)) settings.screenZoom = 1;
488
+
489
+ function presetName() {
490
+ return PRESET_CYCLE.find((k) => {
491
+ const p = SCREEN_PRESETS[k];
492
+ return p.fps === settings.screenFps && p.width === settings.screenWidth && p.quality === settings.screenQuality;
493
+ }) || null;
494
+ }
495
+
496
+ function subscribeScreen() {
497
+ send({ t: 'screen', on: true, fps: settings.screenFps, width: settings.screenWidth, quality: settings.screenQuality });
498
+ }
499
+
500
+ // Slider digeser terus-menerus: kirim ke PC sekali saja setelah berhenti
501
+ function resubscribeSoon() {
502
+ clearTimeout(resubTimer);
503
+ resubTimer = setTimeout(() => { if (screenOn) subscribeScreen(); }, 300);
504
+ }
505
+
506
+ function setScreen(on) {
507
+ screenOn = on;
508
+ screenBtn.setAttribute('aria-pressed', String(on));
509
+ screenPanel.classList.toggle('hidden', !on);
510
+ clearInterval(statTimer);
511
+ predicted = null;
512
+ if (on) {
513
+ showStageMsg('Loading screen…');
514
+ vCursor.classList.add('hidden');
515
+ subscribeScreen();
516
+ statBytes = statFrames = 0;
517
+ statTimer = setInterval(updateStats, 1000);
518
+ $('screenStats').textContent = '';
519
+ layoutStage();
520
+ } else {
521
+ send({ t: 'screen', on: false });
522
+ if (frameUrl) URL.revokeObjectURL(frameUrl);
523
+ frameUrl = null;
524
+ screenImg.removeAttribute('src');
525
+ setBig(false);
526
+ }
527
+ }
528
+
529
+ function showStageMsg(text) {
530
+ screenMsg.textContent = text;
531
+ screenMsg.classList.toggle('hidden', !text);
532
+ }
533
+
534
+ function onScreenFrame(buffer) {
535
+ if (!screenOn) return;
536
+ statBytes += buffer.byteLength;
537
+ statFrames++;
538
+ const url = URL.createObjectURL(new Blob([buffer], { type: 'image/jpeg' }));
539
+ const prev = frameUrl;
540
+ frameUrl = url;
541
+ const done = () => {
542
+ if (prev) URL.revokeObjectURL(prev);
543
+ send({ t: 'fack' }); // siap menerima frame berikutnya
544
+ };
545
+ screenImg.onload = () => {
546
+ showStageMsg('');
547
+ const ar = screenImg.naturalWidth / screenImg.naturalHeight;
548
+ if (Math.abs(ar - aspect) > 0.001) {
549
+ aspect = ar;
550
+ layoutStage();
551
+ }
552
+ done();
553
+ };
554
+ screenImg.onerror = done;
555
+ screenImg.src = url;
556
+ }
557
+
558
+ function onScreenMessage(msg) {
559
+ if (msg.t === 'cur') {
560
+ serverPos = { x: msg.x, y: msg.y };
561
+ if (!predicted) drawCursorSoon();
562
+ } else if (msg.t === 'mon') {
563
+ monitor = { w: msg.w, h: msg.h };
564
+ aspect = msg.w / msg.h; // rasio pasti dari ukuran monitor, bukan dari gambar yang dibulatkan
565
+ layoutStage();
566
+ } else if (msg.t === 'screen-err') {
567
+ showStageMsg(msg.msg);
568
+ } else if (msg.t === 'screen-off') {
569
+ setScreen(false);
570
+ showOverlayToast(msg.reason === 'disabled' ? 'Screen preview is turned off on the PC.' : 'Screen preview stopped.');
571
+ }
572
+ }
573
+
574
+ // Dipanggil dari flush() dengan delta piksel yang baru saja dikirim ke PC
575
+ function predictMove(dx, dy) {
576
+ if (!screenOn || !monitor || !serverPos) return;
577
+ const base = predicted || serverPos;
578
+ const maxX = 1 - 1 / monitor.w;
579
+ const maxY = 1 - 1 / monitor.h;
580
+ predicted = {
581
+ x: Math.min(maxX, Math.max(0, base.x + dx / monitor.w)),
582
+ y: Math.min(maxY, Math.max(0, base.y + dy / monitor.h)),
583
+ };
584
+ clearTimeout(predictTimer);
585
+ predictTimer = setTimeout(() => {
586
+ predicted = null; // gerakan selesai: pakai posisi asli terbaru dari PC
587
+ drawCursorSoon();
588
+ }, PREDICT_HOLD_MS);
589
+ drawCursorSoon();
590
+ }
591
+
592
+ function drawCursorSoon() {
593
+ if (!cursorRaf) cursorRaf = requestAnimationFrame(() => { cursorRaf = 0; positionCursor(); });
594
+ }
595
+
596
+ function updateStats() {
597
+ const kb = statBytes / 1024;
598
+ $('screenStats').textContent = statFrames
599
+ ? `${statFrames} fps · ${kb >= 100 ? Math.round(kb) : kb.toFixed(1)} KB/s`
600
+ : 'screen idle · 0 KB/s';
601
+ statBytes = statFrames = 0;
602
+ }
603
+
604
+ // Ukuran preview: selebar layar HP, tinggi maksimal ~1/3 layar (atau ~60% saat diperbesar).
605
+ // Lebar & tinggi dihitung dari rasio monitor yang sama persis supaya koordinat cursor tidak melenceng.
606
+ function layoutStage() {
607
+ if (!screenOn) return;
608
+ const avail = padScreen.clientWidth - 24;
609
+ const maxH = padScreen.clientHeight * (screenBig ? 0.6 : 0.34);
610
+ const w = Math.min(avail, maxH * aspect);
611
+ stage.style.width = `${w}px`;
612
+ stage.style.height = `${w / aspect}px`;
613
+ stage.style.aspectRatio = 'auto';
614
+ positionCursor();
615
+ }
616
+
617
+ // Virtual cursor + zoom yang mengikuti cursor (gambar di-scale, cursor tetap berukuran normal)
618
+ function positionCursor() {
619
+ const pos = predicted || serverPos;
620
+ const z = settings.screenZoom;
621
+ const rect = stage.getBoundingClientRect();
622
+ const W = rect.width;
623
+ const H = rect.height;
624
+ if (!pos || !W) return;
625
+ const inside = pos.x >= 0 && pos.x <= 1 && pos.y >= 0 && pos.y <= 1;
626
+ vCursor.classList.toggle('hidden', !inside);
627
+ const cx = pos.x * W;
628
+ const cy = pos.y * H;
629
+ let tx = 0;
630
+ let ty = 0;
631
+ if (z > 1) {
632
+ tx = Math.min(0, Math.max(W - W * z, W / 2 - cx * z));
633
+ ty = Math.min(0, Math.max(H - H * z, H / 2 - cy * z));
634
+ }
635
+ stageInner.style.transform = `translate(${tx}px, ${ty}px) scale(${z})`;
636
+ // translate ke titik cursor, batalkan zoom pada ikon, lalu geser supaya UJUNG panah tepat di titik itu
637
+ vCursor.style.transform = `translate(${cx}px, ${cy}px) scale(${1 / z}) translate(${-HOTSPOT}px, ${-HOTSPOT}px)`;
638
+ }
639
+
640
+ // Animasi lingkaran di ujung virtual cursor saat klik, supaya terlihat apa yang diklik
641
+ function flashCursor(b) {
642
+ if (!screenOn) return;
643
+ vCursor.classList.remove('clicked', 'right');
644
+ void vCursor.offsetWidth;
645
+ vCursor.classList.add('clicked');
646
+ if (b === 'r') vCursor.classList.add('right');
647
+ }
648
+
649
+ function setBig(big) {
650
+ screenBig = big;
651
+ padScreen.classList.toggle('screen-big', big);
652
+ $('expandBtn').textContent = big ? '⤡' : '⤢';
653
+ layoutStage();
654
+ }
655
+
656
+ function showOverlayToast(text) {
657
+ setConn(ws && ws.readyState === 1 ? 'on' : 'off', text);
658
+ setTimeout(() => { if (ws && ws.readyState === 1) setConn('on', 'Connected'); }, 3000);
659
+ }
660
+
661
+ // Perkiraan data maksimum (saat layar terus berubah). Layar diam = ~0 karena frame identik tidak dikirim.
662
+ function estimateKBps() {
663
+ const w = settings.screenWidth;
664
+ const bitsPerPixel = 0.25 + settings.screenQuality * 0.009;
665
+ const kbPerFrame = (w * (w * 9 / 16) * bitsPerPixel) / 8 / 1024;
666
+ return Math.round(kbPerFrame * settings.screenFps);
667
+ }
668
+
669
+ function renderScreenSettings() {
670
+ const name = presetName();
671
+ $('zoomBtn').textContent = `Zoom ${settings.screenZoom}×`;
672
+ $('qualityBtn').textContent = name ? SCREEN_PRESETS[name].label : `${settings.screenFps} fps`;
673
+ $('screenFps').value = settings.screenFps;
674
+ $('screenFpsOut').textContent = `${settings.screenFps} fps`;
675
+ $('screenWidth').value = String(settings.screenWidth);
676
+ $('screenQuality').value = settings.screenQuality;
677
+ $('screenQualityOut').textContent = `${settings.screenQuality}%`;
678
+ $('screenEstimate').textContent = `Up to ±${estimateKBps()} KB/s while the screen keeps changing · idle screen ≈ 0`;
679
+ document.querySelectorAll('[data-screen-preset]').forEach((b) => b.classList.toggle('on', b.dataset.screenPreset === name));
680
+ }
681
+
682
+ function applyPreset(name) {
683
+ const p = SCREEN_PRESETS[name];
684
+ Object.assign(settings, { screenFps: p.fps, screenWidth: p.width, screenQuality: p.quality });
685
+ saveSettings();
686
+ renderScreenSettings();
687
+ if (screenOn) subscribeScreen();
688
+ }
689
+
690
+ screenBtn.addEventListener('click', () => setScreen(!screenOn));
691
+ $('zoomBtn').addEventListener('click', () => {
692
+ settings.screenZoom = ZOOMS[(ZOOMS.indexOf(settings.screenZoom) + 1) % ZOOMS.length];
693
+ saveSettings();
694
+ renderScreenSettings();
695
+ positionCursor();
696
+ });
697
+ // chip kualitas di bawah preview: putar preset Hemat -> Normal -> Jelas
698
+ $('qualityBtn').addEventListener('click', () => {
699
+ const name = presetName();
700
+ applyPreset(PRESET_CYCLE[(PRESET_CYCLE.indexOf(name) + 1) % PRESET_CYCLE.length]);
701
+ });
702
+ $('expandBtn').addEventListener('click', () => setBig(!screenBig));
703
+ stage.addEventListener('dblclick', () => setBig(!screenBig));
704
+ stage.addEventListener('contextmenu', (e) => e.preventDefault());
705
+ window.addEventListener('resize', layoutStage);
706
+
707
+ // pengaturan di panel ⚙
708
+ $('screenFps').addEventListener('input', (e) => {
709
+ settings.screenFps = Number(e.target.value);
710
+ saveSettings();
711
+ renderScreenSettings();
712
+ resubscribeSoon();
713
+ });
714
+ $('screenWidth').addEventListener('change', (e) => {
715
+ settings.screenWidth = Number(e.target.value);
716
+ saveSettings();
717
+ renderScreenSettings();
718
+ resubscribeSoon();
719
+ });
720
+ $('screenQuality').addEventListener('input', (e) => {
721
+ settings.screenQuality = Number(e.target.value);
722
+ saveSettings();
723
+ renderScreenSettings();
724
+ resubscribeSoon();
725
+ });
726
+ document.querySelectorAll('[data-screen-preset]').forEach((b) => {
727
+ b.addEventListener('click', () => applyPreset(b.dataset.screenPreset));
728
+ });
729
+ if (!SCREEN_WIDTHS.includes(settings.screenWidth)) settings.screenWidth = 960;
730
+ renderScreenSettings();
731
+
732
+ // ================================================================ keyboard
733
+ // Ketikan dikirim live dengan membandingkan isi textarea sebelum & sesudah setiap perubahan (diff).
734
+ // Cara ini tahan terhadap autocorrect, prediksi kata, dan swipe typing: kata yang diganti keyboard HP
735
+ // dihapus (backspace) lalu diketik ulang di PC. Textarea selalu diawali satu spasi "jangkar" supaya
736
+ // tombol backspace tetap memicu event walaupun belum ada teks.
737
+ const kbPanel = $('kbPanel');
738
+ const kbInput = $('kbInput');
739
+ const kbBtn = $('kbBtn');
740
+ const ANCHOR = ' ';
741
+ const MOD_CTRL = 1;
742
+ let lastValue = ANCHOR;
743
+ let composing = false;
744
+ let mods = 0;
745
+
746
+ function resetKb() {
747
+ kbInput.value = ANCHOR;
748
+ lastValue = ANCHOR;
749
+ try { kbInput.setSelectionRange(ANCHOR.length, ANCHOR.length); } catch { /* belum fokus */ }
750
+ $('kbPlaceholder').classList.remove('hidden');
751
+ }
752
+
753
+ function setMods(value) {
754
+ mods = value;
755
+ document.querySelectorAll('.kk.mod').forEach((b) => b.classList.toggle('on', (mods & Number(b.dataset.mod)) !== 0));
756
+ }
757
+
758
+ function sendKey(k, extraMods = 0) {
759
+ flush();
760
+ send({ t: 'key', k, m: mods | extraMods });
761
+ setMods(0);
762
+ vibrate(8);
763
+ }
764
+
765
+ function sendChar(c, extraMods = 0) {
766
+ flush();
767
+ send({ t: 'kc', c, m: mods | extraMods });
768
+ setMods(0);
769
+ vibrate(8);
770
+ }
771
+
772
+ kbInput.addEventListener('compositionstart', () => { composing = true; });
773
+ kbInput.addEventListener('compositionend', () => { composing = false; });
774
+
775
+ kbInput.addEventListener('input', () => {
776
+ const now = kbInput.value;
777
+ const before = Array.from(lastValue); // per code point, supaya emoji dihitung satu karakter
778
+ const after = Array.from(now);
779
+ let p = 0;
780
+ while (p < before.length && p < after.length && before[p] === after[p]) p++;
781
+ const removed = before.length - p;
782
+ const added = after.slice(p).join('');
783
+ lastValue = now;
784
+
785
+ if (removed > 0) send({ t: 'bs', n: removed });
786
+ if (added) {
787
+ // modifier aktif + satu karakter = shortcut (mis. Ctrl lalu ketik "c" = Ctrl+C)
788
+ if (mods && Array.from(added).length === 1 && added !== '\n') {
789
+ sendChar(added);
790
+ resetKb(); // huruf shortcut tidak ikut tersisa di kotak ketik
791
+ return;
792
+ }
793
+ send({ t: 'type', s: added });
794
+ }
795
+ $('kbPlaceholder').classList.toggle('hidden', now !== ANCHOR);
796
+
797
+ // mulai baris baru setelah Enter, saat jangkar terhapus, atau teks sudah panjang
798
+ if (now === '' || added.includes('\n') || (!composing && now.length > 200)) resetKb();
799
+ });
800
+
801
+ function setKeyboard(open) {
802
+ kbPanel.classList.toggle('hidden', !open);
803
+ padScreen.classList.toggle('kb-open', open);
804
+ kbBtn.setAttribute('aria-pressed', String(open));
805
+ if (open) {
806
+ resetKb();
807
+ kbInput.focus();
808
+ } else {
809
+ kbInput.blur();
810
+ setMods(0);
811
+ }
812
+ }
813
+
814
+ kbBtn.addEventListener('click', () => setKeyboard(kbPanel.classList.contains('hidden')));
815
+
816
+ // Tombol panel: aksi di pointerdown + preventDefault agar fokus tetap di textarea (keyboard HP tidak tertutup).
817
+ // Tombol dengan data-repeat diulang selama ditahan (backspace, panah, volume).
818
+ // Chip shortcut ada di baris yang bisa di-scroll: jalankan saat click (batal otomatis kalau jari menggeser).
819
+ kbPanel.querySelectorAll('.chip').forEach((chip) => {
820
+ chip.addEventListener('pointerdown', (e) => e.preventDefault()); // fokus tetap di textarea
821
+ chip.addEventListener('click', () => {
822
+ const extra = Number(chip.dataset.mods || 0);
823
+ if (chip.dataset.key) sendKey(chip.dataset.key, extra);
824
+ else if (chip.dataset.char) sendChar(chip.dataset.char, extra);
825
+ });
826
+ chip.addEventListener('contextmenu', (e) => e.preventDefault());
827
+ });
828
+
829
+ kbPanel.querySelectorAll('.kk').forEach((btn) => {
830
+ let repeatTimer = 0;
831
+ const stopRepeat = () => {
832
+ clearTimeout(repeatTimer);
833
+ clearInterval(repeatTimer);
834
+ btn.classList.remove('pressed');
835
+ };
836
+ const fire = () => {
837
+ const extra = Number(btn.dataset.mods || 0);
838
+ if (btn.dataset.key) sendKey(btn.dataset.key, extra);
839
+ else if (btn.dataset.char) sendChar(btn.dataset.char, extra);
840
+ };
841
+ btn.addEventListener('pointerdown', (e) => {
842
+ e.preventDefault();
843
+ if (btn.dataset.mod) {
844
+ setMods(mods ^ Number(btn.dataset.mod));
845
+ vibrate(8);
846
+ return;
847
+ }
848
+ btn.classList.add('pressed');
849
+ fire();
850
+ if ('repeat' in btn.dataset) {
851
+ repeatTimer = setTimeout(() => { repeatTimer = setInterval(fire, 70); }, 400);
852
+ }
853
+ });
854
+ btn.addEventListener('pointerup', stopRepeat);
855
+ btn.addEventListener('pointercancel', stopRepeat);
856
+ btn.addEventListener('pointerleave', stopRepeat);
857
+ btn.addEventListener('contextmenu', (e) => e.preventDefault());
858
+ });
859
+
860
+ // Ikuti tinggi area terlihat saat keyboard HP muncul (terutama iOS yang tidak mengecilkan layout)
861
+ const vv = window.visualViewport;
862
+ function fitViewport() {
863
+ const h = vv ? vv.height : window.innerHeight;
864
+ document.documentElement.style.setProperty('--app-h', `${Math.round(h)}px`);
865
+ if (vv && vv.offsetTop) window.scrollTo(0, 0);
866
+ layoutStage();
867
+ }
868
+ if (vv) {
869
+ vv.addEventListener('resize', fitViewport);
870
+ vv.addEventListener('scroll', fitViewport);
871
+ }
872
+ window.addEventListener('resize', fitViewport);
873
+ fitViewport();
874
+
875
+ // ================================================================ pengaturan
876
+ const sheet = $('sheet');
877
+ const openSheet = () => { sheet.classList.remove('hidden'); sheet.setAttribute('aria-hidden', 'false'); };
878
+ const closeSheet = () => { sheet.classList.add('hidden'); sheet.setAttribute('aria-hidden', 'true'); };
879
+ $('settingsBtn').addEventListener('click', openSheet);
880
+ sheet.querySelector('[data-close]').addEventListener('click', closeSheet);
881
+
882
+ const saveSettings = () => store.set(SETTINGS_KEY, JSON.stringify(settings));
883
+
884
+ function bindRange(id, outId, key, fmt) {
885
+ const input = $(id);
886
+ const out = $(outId);
887
+ input.value = settings[key];
888
+ out.textContent = fmt(settings[key]);
889
+ input.addEventListener('input', () => {
890
+ settings[key] = Number(input.value);
891
+ out.textContent = fmt(settings[key]);
892
+ saveSettings();
893
+ });
894
+ }
895
+
896
+ function bindToggle(id, key) {
897
+ const input = $(id);
898
+ input.checked = Boolean(settings[key]);
899
+ input.addEventListener('change', () => {
900
+ settings[key] = input.checked;
901
+ saveSettings();
902
+ });
903
+ }
904
+
905
+ bindRange('sens', 'sensOut', 'sens', (v) => `${v.toFixed(1)}×`);
906
+ bindRange('accel', 'accelOut', 'accel', (v) => (v === 0 ? 'off' : v.toFixed(1)));
907
+ bindRange('scrollSpeed', 'scrollOut', 'scrollSpeed', (v) => `${v}`);
908
+ bindToggle('natural', 'natural');
909
+ bindToggle('haptic', 'haptic');
910
+
911
+ const canFullscreen = Boolean(document.documentElement.requestFullscreen);
912
+ $('fullscreenBtn').classList.toggle('hidden', !canFullscreen);
913
+ $('fullscreenBtn').addEventListener('click', () => {
914
+ closeSheet();
915
+ if (document.fullscreenElement) document.exitFullscreen();
916
+ else document.documentElement.requestFullscreen().catch(() => {});
917
+ });
918
+
919
+ $('disconnectBtn').addEventListener('click', () => {
920
+ closeSheet();
921
+ autoReconnect = false;
922
+ clearTimeout(retryTimer);
923
+ if (ws) ws.close(1000, 'Diputus dari HP');
924
+ else stopWith('Disconnected', 'Connection closed.');
925
+ });
926
+
927
+ $('forgetBtn').addEventListener('click', async () => {
928
+ if (!confirm('Forget this PC? You will need the pairing code to connect again.')) return;
929
+ closeSheet();
930
+ autoReconnect = false;
931
+ clearTimeout(retryTimer);
932
+ const old = token;
933
+ forgetLocal();
934
+ try {
935
+ await fetch('/api/unpair', { method: 'POST', headers: { authorization: `Bearer ${old}` } });
936
+ } catch { /* PC tidak terjangkau, cukup hapus lokal */ }
937
+ if (ws) ws.close(1000);
938
+ hideOverlay();
939
+ showPair();
940
+ });
941
+
942
+ // ================================================================ mulai
943
+ const hashCode = (location.hash.match(/code=(\d{6})/) || [])[1];
944
+ if (location.hash) history.replaceState(null, '', location.pathname);
945
+
946
+ if (token) {
947
+ showPad();
948
+ connect();
949
+ } else {
950
+ showPair();
951
+ if (hashCode) {
952
+ codeInput.value = hashCode;
953
+ renderSlots();
954
+ pair();
955
+ }
956
+ }
957
+ })();