@limrun/ui 0.4.2 → 0.5.0

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.
@@ -14,6 +14,7 @@ import {
14
14
  createTouchControlMessage,
15
15
  createInjectKeycodeMessage,
16
16
  createSetClipboardMessage,
17
+ createTwoFingerTouchControlMessage,
17
18
  } from '../core/webrtc-messages';
18
19
 
19
20
  declare global {
@@ -81,6 +82,14 @@ const debugWarn = (...args: any[]) => {
81
82
  }
82
83
  };
83
84
 
85
+ const motionActionToString = (action: number): string => {
86
+ // AMOTION_EVENT is a constants object; find the matching ACTION_* key if present
87
+ const match = Object.entries(AMOTION_EVENT).find(
88
+ ([key, value]) => key.startsWith('ACTION_') && value === action,
89
+ );
90
+ return match?.[0] ?? String(action);
91
+ };
92
+
84
93
  type DevicePlatform = 'ios' | 'android';
85
94
 
86
95
  const detectPlatform = (url: string): DevicePlatform => {
@@ -170,6 +179,7 @@ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number
170
179
 
171
180
  export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
172
181
  ({ className, url, token, sessionId: propSessionId, openUrl, showFrame = true }: RemoteControlProps, ref) => {
182
+ const containerRef = useRef<HTMLDivElement>(null);
173
183
  const videoRef = useRef<HTMLVideoElement>(null);
174
184
  const frameRef = useRef<HTMLImageElement>(null);
175
185
  const [videoLoaded, setVideoLoaded] = useState(false);
@@ -184,10 +194,46 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
184
194
  >(new Map());
185
195
  const pendingScreenshotRejectersRef = useRef<Map<string, (reason?: any) => void>>(new Map());
186
196
 
187
- // Map to track active pointers (mouse or touch) and their last known position inside the video
197
+ // Map to track active pointers for real touch/mouse single-finger events.
188
198
  // Key: pointerId (-1 for mouse, touch.identifier for touch), Value: { x: number, y: number }
189
199
  const activePointers = useRef<Map<number, { x: number; y: number }>>(new Map());
190
200
 
201
+ // Alt/Option modifier state for pinch emulation.
202
+ // We use a ref as the source of truth (for synchronous event handler access)
203
+ // and state only to trigger re-renders for the visual indicators.
204
+ const isAltHeldRef = useRef(false);
205
+ const [isAltHeld, setIsAltHeld] = useState(false);
206
+
207
+ // State for any two-finger gesture (Alt+mouse simulated or real two-finger touch).
208
+ // Tracks positions, video size, source, and pointer IDs (for Android protocol).
209
+ type TwoFingerState = {
210
+ finger0: { x: number; y: number };
211
+ finger1: { x: number; y: number };
212
+ videoSize: { width: number; height: number };
213
+ // Track source so we know when to clear (Alt release vs touch end)
214
+ source: 'alt-mouse' | 'real-touch';
215
+ // Pointer IDs for Android (real touch.identifier or simulated -1/-2)
216
+ pointerId0: number;
217
+ pointerId1: number;
218
+ };
219
+ const twoFingerStateRef = useRef<TwoFingerState | null>(null);
220
+
221
+ // Hover point for rendering two-finger indicators when Alt is held.
222
+ // Only computed/set when Alt is held to avoid unnecessary re-renders.
223
+ type HoverPoint = {
224
+ containerX: number;
225
+ containerY: number;
226
+ mirrorContainerX: number;
227
+ mirrorContainerY: number;
228
+ videoX: number;
229
+ videoY: number;
230
+ mirrorVideoX: number;
231
+ mirrorVideoY: number;
232
+ videoWidth: number;
233
+ videoHeight: number;
234
+ };
235
+ const [hoverPoint, setHoverPoint] = useState<HoverPoint | null>(null);
236
+
191
237
  const sessionId = useMemo(
192
238
  () =>
193
239
  propSessionId ||
@@ -210,130 +256,449 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
210
256
  dataChannelRef.current.send(data);
211
257
  };
212
258
 
213
- // Unified handler for both mouse and touch interactions
214
- const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
215
- event.preventDefault();
216
- event.stopPropagation();
217
-
218
- if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open' || !videoRef.current) {
219
- return;
259
+ // Fixed pointer IDs for Alt-simulated two-finger gestures
260
+ const ALT_POINTER_ID_PRIMARY = -1;
261
+ const ALT_POINTER_ID_MIRROR = -2;
262
+
263
+ // Helper to send a single-touch control message (used by both single-finger and Android two-finger paths)
264
+ const sendSingleTouch = (
265
+ action: number,
266
+ pointerId: number,
267
+ videoWidth: number,
268
+ videoHeight: number,
269
+ x: number,
270
+ y: number,
271
+ ) => {
272
+ const message = createTouchControlMessage(
273
+ action,
274
+ pointerId,
275
+ videoWidth,
276
+ videoHeight,
277
+ x,
278
+ y,
279
+ 1.0, // pressure
280
+ AMOTION_EVENT.BUTTON_PRIMARY,
281
+ AMOTION_EVENT.BUTTON_PRIMARY,
282
+ );
283
+ if (message) {
284
+ debugLog('[rc-touch] sendSingleTouch', {
285
+ action,
286
+ actionName: motionActionToString(action),
287
+ pointerId,
288
+ x,
289
+ y,
290
+ video: { width: videoWidth, height: videoHeight },
291
+ });
292
+ sendBinaryControlMessage(message);
220
293
  }
294
+ };
221
295
 
222
- const video = videoRef.current;
223
- const rect = video.getBoundingClientRect();
224
- const videoWidth = video.videoWidth;
225
- const videoHeight = video.videoHeight;
226
-
227
- if (!videoWidth || !videoHeight) return; // Video dimensions not ready
228
-
229
- // Helper to process a single pointer event (either mouse or a single touch point)
230
- const processPointer = (
231
- pointerId: number,
232
- clientX: number,
233
- clientY: number,
234
- eventType: 'down' | 'move' | 'up' | 'cancel',
235
- ) => {
236
- // --- Start: Coordinate Calculation ---
237
- const displayWidth = rect.width;
238
- const displayHeight = rect.height;
239
- const videoAspectRatio = videoWidth / videoHeight;
240
- const containerAspectRatio = displayWidth / displayHeight;
241
- let actualWidth = displayWidth;
242
- let actualHeight = displayHeight;
243
- if (videoAspectRatio > containerAspectRatio) {
244
- actualHeight = displayWidth / videoAspectRatio;
245
- } else {
246
- actualWidth = displayHeight * videoAspectRatio;
247
- }
248
- const offsetX = (displayWidth - actualWidth) / 2;
249
- const offsetY = (displayHeight - actualHeight) / 2;
250
- const relativeX = clientX - rect.left - offsetX;
251
- const relativeY = clientY - rect.top - offsetY;
252
- const isInside =
253
- relativeX >= 0 && relativeX <= actualWidth && relativeY >= 0 && relativeY <= actualHeight;
254
-
255
- let videoX = 0;
256
- let videoY = 0;
257
- if (isInside) {
258
- videoX = Math.max(0, Math.min(videoWidth, (relativeX / actualWidth) * videoWidth));
259
- videoY = Math.max(0, Math.min(videoHeight, (relativeY / actualHeight) * videoHeight));
260
- }
261
- // --- End: Coordinate Calculation ---
296
+ // Minimal geometry for single-finger touch events (no mirror/container coords needed).
297
+ type PointerGeometry = {
298
+ inside: boolean;
299
+ videoX: number;
300
+ videoY: number;
301
+ videoWidth: number;
302
+ videoHeight: number;
303
+ };
262
304
 
263
- let action: number | null = null;
264
- let positionToSend: { x: number; y: number } | null = null;
265
- let pressure = 1.0; // Default pressure
266
- const buttons = AMOTION_EVENT.BUTTON_PRIMARY; // Assume primary button
305
+ const applyPointerEvent = (
306
+ pointerId: number,
307
+ eventType: 'down' | 'move' | 'up' | 'cancel',
308
+ geometry: PointerGeometry | null,
309
+ ) => {
310
+ if (!geometry) return;
311
+ const { inside: isInside, videoX, videoY, videoWidth, videoHeight } = geometry;
312
+
313
+ let action: number | null = null;
314
+ let positionToSend: { x: number; y: number } | null = null;
315
+ let pressure = 1.0; // Default pressure
316
+ const buttons = AMOTION_EVENT.BUTTON_PRIMARY; // Assume primary button
317
+
318
+ switch (eventType) {
319
+ case 'down':
320
+ if (isInside) {
321
+ // For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
322
+ const currentPointerCount = activePointers.current.size;
323
+ action =
324
+ currentPointerCount === 0
325
+ ? AMOTION_EVENT.ACTION_DOWN
326
+ : AMOTION_EVENT.ACTION_POINTER_DOWN;
327
+ positionToSend = { x: videoX, y: videoY };
328
+ activePointers.current.set(pointerId, positionToSend);
329
+ if (pointerId === -1) {
330
+ // Focus on mouse down
331
+ videoRef.current?.focus();
332
+ }
333
+ } else {
334
+ // If the initial down event is outside, ignore it for this pointer
335
+ activePointers.current.delete(pointerId);
336
+ }
337
+ break;
267
338
 
268
- switch (eventType) {
269
- case 'down':
339
+ case 'move':
340
+ if (activePointers.current.has(pointerId)) {
270
341
  if (isInside) {
271
- action = AMOTION_EVENT.ACTION_DOWN;
342
+ action = AMOTION_EVENT.ACTION_MOVE;
272
343
  positionToSend = { x: videoX, y: videoY };
344
+ // Update the last known position for this active pointer
273
345
  activePointers.current.set(pointerId, positionToSend);
274
- if (pointerId === -1) {
275
- // Focus on mouse down
276
- videoRef.current?.focus();
277
- }
278
346
  } else {
279
- // If the initial down event is outside, ignore it for this pointer
280
- activePointers.current.delete(pointerId);
347
+ // Moved outside while active - do nothing, UP/CANCEL will use last known pos
281
348
  }
282
- break;
349
+ }
350
+ break;
283
351
 
284
- case 'move':
285
- if (activePointers.current.has(pointerId)) {
286
- if (isInside) {
287
- action = AMOTION_EVENT.ACTION_MOVE;
288
- positionToSend = { x: videoX, y: videoY };
289
- // Update the last known position for this active pointer
290
- activePointers.current.set(pointerId, positionToSend);
291
- } else {
292
- // Moved outside while active - do nothing, UP/CANCEL will use last known pos
293
- }
294
- }
295
- break;
352
+ case 'up':
353
+ case 'cancel': // Treat cancel like up, but use ACTION_CANCEL
354
+ if (activePointers.current.has(pointerId)) {
355
+ // IMPORTANT: Send the UP/CANCEL at the *last known position* inside the video
356
+ positionToSend = activePointers.current.get(pointerId)!;
357
+ activePointers.current.delete(pointerId); // Remove pointer as it's no longer active
296
358
 
297
- case 'up':
298
- case 'cancel': // Treat cancel like up, but use ACTION_CANCEL
299
- if (activePointers.current.has(pointerId)) {
300
- action = eventType === 'cancel' ? AMOTION_EVENT.ACTION_CANCEL : AMOTION_EVENT.ACTION_UP;
301
- // IMPORTANT: Send the UP/CANCEL at the *last known position* inside the video
302
- positionToSend = activePointers.current.get(pointerId)!;
303
- activePointers.current.delete(pointerId); // Remove pointer as it's no longer active
359
+ if (eventType === 'cancel') {
360
+ action = AMOTION_EVENT.ACTION_CANCEL;
361
+ } else {
362
+ // For multi-touch: use ACTION_UP for last pointer, ACTION_POINTER_UP for non-last pointers
363
+ const remainingPointerCount = activePointers.current.size;
364
+ action =
365
+ remainingPointerCount === 0
366
+ ? AMOTION_EVENT.ACTION_UP
367
+ : AMOTION_EVENT.ACTION_POINTER_UP;
304
368
  }
305
- break;
306
- }
369
+ }
370
+ break;
371
+ }
307
372
 
308
- // Send message if action and position determined
309
- if (action !== null && positionToSend !== null) {
310
- const message = createTouchControlMessage(
311
- action,
373
+ // Send message if action and position determined
374
+ if (action !== null && positionToSend !== null) {
375
+ debugLog('[rc-touch][mouse->touch] sending', {
312
376
  pointerId,
313
- videoWidth,
314
- videoHeight,
315
- positionToSend.x,
316
- positionToSend.y,
317
- pressure,
318
- buttons,
319
- buttons,
320
- );
321
- if (message) {
322
- sendBinaryControlMessage(message);
323
- }
324
- } else if (eventType === 'up' || eventType === 'cancel') {
325
- // Clean up map just in case if 'down' was outside and 'up'/'cancel' is triggered
326
- activePointers.current.delete(pointerId);
377
+ eventType,
378
+ action,
379
+ actionName: motionActionToString(action),
380
+ isInside,
381
+ positionToSend,
382
+ video: { width: videoWidth, height: videoHeight },
383
+ altHeld: isAltHeldRef.current,
384
+ activePointersAfter: Array.from(activePointers.current.entries()).map(([id, pos]) => ({
385
+ id,
386
+ x: pos.x,
387
+ y: pos.y,
388
+ })),
389
+ });
390
+ const message = createTouchControlMessage(
391
+ action,
392
+ pointerId,
393
+ videoWidth,
394
+ videoHeight,
395
+ positionToSend.x,
396
+ positionToSend.y,
397
+ pressure,
398
+ buttons,
399
+ buttons,
400
+ );
401
+ if (message) {
402
+ debugLog('[rc-touch][mouse->touch] buffer', {
403
+ pointerId,
404
+ actionName: motionActionToString(action),
405
+ byteLength: message.byteLength,
406
+ });
407
+ sendBinaryControlMessage(message);
408
+ }
409
+ } else if (eventType === 'up' || eventType === 'cancel') {
410
+ // Clean up map just in case if 'down' was outside and 'up'/'cancel' is triggered
411
+ activePointers.current.delete(pointerId);
412
+ }
413
+ };
414
+
415
+ // Update Alt modifier state. Only iOS Simulator uses Indigo modifier injection.
416
+ const updateAltHeld = (nextHeld: boolean) => {
417
+ if (isAltHeldRef.current === nextHeld) {
418
+ return;
419
+ }
420
+ isAltHeldRef.current = nextHeld;
421
+ setIsAltHeld(nextHeld);
422
+
423
+ // Clear hover point when Alt is released to hide indicators immediately.
424
+ if (!nextHeld) {
425
+ setHoverPoint(null);
426
+ }
427
+
428
+ debugLog('[rc-touch][alt] updateAltHeld', {
429
+ nextHeld,
430
+ activePointerIds: Array.from(activePointers.current.keys()),
431
+ });
432
+
433
+ // iOS Simulator pinch (Option/Alt+drag) behavior depends on the Option modifier being
434
+ // active on the Indigo HID side. Send Alt key down/up immediately on toggle so the
435
+ // sequence matches Simulator.app (Alt down -> mouse down/drag -> mouse up -> Alt up).
436
+ // This is iOS-specific; Android doesn't use this modifier injection.
437
+ if (platform === 'ios' && dataChannelRef.current && dataChannelRef.current.readyState === 'open') {
438
+ const action = nextHeld ? ANDROID_KEYS.ACTION_DOWN : ANDROID_KEYS.ACTION_UP;
439
+ const message = createInjectKeycodeMessage(action, ANDROID_KEYS.KEYCODE_ALT_LEFT, 0, ANDROID_KEYS.META_NONE);
440
+ debugLog('[rc-touch][alt] sending Indigo modifier keycode', {
441
+ action,
442
+ keycode: ANDROID_KEYS.KEYCODE_ALT_LEFT,
443
+ });
444
+ if (message) {
445
+ sendBinaryControlMessage(message);
327
446
  }
447
+ }
448
+ };
449
+
450
+ // Mapping context computed once per DOM event, then reused for each pointer.
451
+ type VideoMappingContext = {
452
+ videoWidth: number;
453
+ videoHeight: number;
454
+ videoRect: DOMRect;
455
+ containerRect: DOMRect;
456
+ actualWidth: number;
457
+ actualHeight: number;
458
+ offsetX: number;
459
+ offsetY: number;
460
+ };
461
+
462
+ // Compute mapping context from current video/container state (once per event).
463
+ const computeVideoMappingContext = (): VideoMappingContext | null => {
464
+ const video = videoRef.current;
465
+ const container = containerRef.current;
466
+ if (!video || !container) return null;
467
+
468
+ const videoWidth = video.videoWidth;
469
+ const videoHeight = video.videoHeight;
470
+ if (!videoWidth || !videoHeight) return null;
471
+
472
+ const videoRect = video.getBoundingClientRect();
473
+ const containerRect = container.getBoundingClientRect();
474
+
475
+ const displayWidth = videoRect.width;
476
+ const displayHeight = videoRect.height;
477
+ const videoAspectRatio = videoWidth / videoHeight;
478
+ const containerAspectRatio = displayWidth / displayHeight;
479
+
480
+ let actualWidth = displayWidth;
481
+ let actualHeight = displayHeight;
482
+ if (videoAspectRatio > containerAspectRatio) {
483
+ actualHeight = displayWidth / videoAspectRatio;
484
+ } else {
485
+ actualWidth = displayHeight * videoAspectRatio;
486
+ }
487
+
488
+ const offsetX = (displayWidth - actualWidth) / 2;
489
+ const offsetY = (displayHeight - actualHeight) / 2;
490
+
491
+ return {
492
+ videoWidth,
493
+ videoHeight,
494
+ videoRect,
495
+ containerRect,
496
+ actualWidth,
497
+ actualHeight,
498
+ offsetX,
499
+ offsetY,
500
+ };
501
+ };
502
+
503
+ // Map a client point to video coordinates using a pre-computed context.
504
+ // Returns null if outside the video content area or context is missing.
505
+ const mapClientPointToVideo = (
506
+ ctx: VideoMappingContext,
507
+ clientX: number,
508
+ clientY: number,
509
+ ): PointerGeometry | null => {
510
+ const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
511
+ const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
512
+
513
+ const isInside =
514
+ relativeX >= 0 && relativeX <= ctx.actualWidth &&
515
+ relativeY >= 0 && relativeY <= ctx.actualHeight;
516
+
517
+ if (!isInside) {
518
+ return {
519
+ inside: false,
520
+ videoX: 0,
521
+ videoY: 0,
522
+ videoWidth: ctx.videoWidth,
523
+ videoHeight: ctx.videoHeight,
524
+ };
525
+ }
526
+
527
+ const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
528
+ const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
529
+
530
+ return {
531
+ inside: true,
532
+ videoX,
533
+ videoY,
534
+ videoWidth: ctx.videoWidth,
535
+ videoHeight: ctx.videoHeight,
328
536
  };
537
+ };
538
+
539
+ // Compute full hover point with mirror/container coordinates (for Alt indicator rendering).
540
+ const computeFullHoverPoint = (
541
+ ctx: VideoMappingContext,
542
+ clientX: number,
543
+ clientY: number,
544
+ ): HoverPoint | null => {
545
+ const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
546
+ const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
547
+
548
+ const isInside =
549
+ relativeX >= 0 && relativeX <= ctx.actualWidth &&
550
+ relativeY >= 0 && relativeY <= ctx.actualHeight;
551
+
552
+ if (!isInside) {
553
+ return null;
554
+ }
555
+
556
+ const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
557
+ const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
558
+ const mirrorVideoX = ctx.videoWidth - videoX;
559
+ const mirrorVideoY = ctx.videoHeight - videoY;
560
+
561
+ const contentLeft = ctx.videoRect.left + ctx.offsetX;
562
+ const contentTop = ctx.videoRect.top + ctx.offsetY;
563
+ const containerX = contentLeft - ctx.containerRect.left + relativeX;
564
+ const containerY = contentTop - ctx.containerRect.top + relativeY;
565
+ const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth - relativeX);
566
+ const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight - relativeY);
567
+
568
+ return {
569
+ containerX,
570
+ containerY,
571
+ mirrorContainerX,
572
+ mirrorContainerY,
573
+ videoX,
574
+ videoY,
575
+ mirrorVideoX,
576
+ mirrorVideoY,
577
+ videoWidth: ctx.videoWidth,
578
+ videoHeight: ctx.videoHeight,
579
+ };
580
+ };
581
+
582
+ // Helper to send a two-finger touch message (iOS-specific type=18 message).
583
+ const sendTwoFingerMessage = (
584
+ action: number,
585
+ videoWidth: number,
586
+ videoHeight: number,
587
+ x0: number,
588
+ y0: number,
589
+ x1: number,
590
+ y1: number,
591
+ ) => {
592
+ const msg = createTwoFingerTouchControlMessage(
593
+ action,
594
+ videoWidth,
595
+ videoHeight,
596
+ x0,
597
+ y0,
598
+ x1,
599
+ y1,
600
+ );
601
+ debugLog('[rc-touch2] sendTwoFingerMessage (iOS)', {
602
+ actionName: motionActionToString(action),
603
+ video: { width: videoWidth, height: videoHeight },
604
+ p0: { x: x0, y: y0 },
605
+ p1: { x: x1, y: y1 },
606
+ byteLength: msg.byteLength,
607
+ });
608
+ sendBinaryControlMessage(msg);
609
+ };
610
+
611
+ // Generic two-finger event handler - sends platform-appropriate messages.
612
+ // iOS: uses special two-finger message (type=18)
613
+ // Android: sends two separate single-touch messages with proper action sequencing
614
+ const applyTwoFingerEvent = (
615
+ eventType: 'down' | 'move' | 'up',
616
+ videoWidth: number,
617
+ videoHeight: number,
618
+ x0: number,
619
+ y0: number,
620
+ x1: number,
621
+ y1: number,
622
+ pointerId0: number,
623
+ pointerId1: number,
624
+ ) => {
625
+ debugLog('[rc-touch2] applyTwoFingerEvent', {
626
+ platform,
627
+ eventType,
628
+ video: { width: videoWidth, height: videoHeight },
629
+ p0: { x: x0, y: y0, id: pointerId0 },
630
+ p1: { x: x1, y: y1, id: pointerId1 },
631
+ });
632
+
633
+ if (platform === 'ios') {
634
+ // iOS: use special two-finger message (type=18)
635
+ const action = eventType === 'down' ? AMOTION_EVENT.ACTION_DOWN
636
+ : eventType === 'move' ? AMOTION_EVENT.ACTION_MOVE
637
+ : AMOTION_EVENT.ACTION_UP;
638
+ sendTwoFingerMessage(action, videoWidth, videoHeight, x0, y0, x1, y1);
639
+ } else {
640
+ // Android: send two separate single-touch messages with proper action codes
641
+ // Per scrcpy protocol, each finger is a separate INJECT_TOUCH_EVENT with unique pointerId
642
+ if (eventType === 'down') {
643
+ // First finger down (ACTION_DOWN), then second finger down (ACTION_POINTER_DOWN)
644
+ sendSingleTouch(AMOTION_EVENT.ACTION_DOWN, pointerId0, videoWidth, videoHeight, x0, y0);
645
+ sendSingleTouch(AMOTION_EVENT.ACTION_POINTER_DOWN, pointerId1, videoWidth, videoHeight, x1, y1);
646
+ } else if (eventType === 'move') {
647
+ // Both fingers move (ACTION_MOVE for each)
648
+ sendSingleTouch(AMOTION_EVENT.ACTION_MOVE, pointerId0, videoWidth, videoHeight, x0, y0);
649
+ sendSingleTouch(AMOTION_EVENT.ACTION_MOVE, pointerId1, videoWidth, videoHeight, x1, y1);
650
+ } else {
651
+ // Second finger up (ACTION_POINTER_UP), then first finger up (ACTION_UP)
652
+ sendSingleTouch(AMOTION_EVENT.ACTION_POINTER_UP, pointerId1, videoWidth, videoHeight, x1, y1);
653
+ sendSingleTouch(AMOTION_EVENT.ACTION_UP, pointerId0, videoWidth, videoHeight, x0, y0);
654
+ }
655
+ }
656
+ };
657
+
658
+ // Update hover point only when Alt is held (to avoid re-renders in normal path).
659
+ const updateHoverPoint = (ctx: VideoMappingContext, clientX: number, clientY: number) => {
660
+ if (!isAltHeldRef.current) {
661
+ // Don't compute or update when Alt isn't held
662
+ if (hoverPoint !== null) {
663
+ setHoverPoint(null);
664
+ }
665
+ return;
666
+ }
667
+ const fullPoint = computeFullHoverPoint(ctx, clientX, clientY);
668
+ setHoverPoint(fullPoint);
669
+ };
670
+
671
+ // Unified handler for both mouse and touch interactions
672
+ const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
673
+ event.preventDefault();
674
+ event.stopPropagation();
675
+
676
+ // Compute mapping context once per event (reused for all pointers)
677
+ const ctx = computeVideoMappingContext();
678
+
679
+ // Handle hover point updates for mouse events (only when Alt is held)
680
+ if (!('touches' in event) && ctx) {
681
+ if (event.type === 'mousemove') {
682
+ updateHoverPoint(ctx, event.clientX, event.clientY);
683
+ } else if (event.type === 'mouseleave') {
684
+ setHoverPoint(null);
685
+ }
686
+ // Note: Alt state is tracked via global keydown/keyup listeners, not event.altKey,
687
+ // to ensure consistent behavior across focus transitions.
688
+ }
689
+
690
+ if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open' || !videoRef.current || !ctx) {
691
+ return;
692
+ }
329
693
 
330
694
  // --- Event Type Handling ---
331
695
 
332
696
  if ('touches' in event) {
333
- // Touch Events
334
- const touches = event.changedTouches; // Use changedTouches for start/end/cancel
335
- let eventType: 'down' | 'move' | 'up' | 'cancel';
697
+ // Touch Events - handle both single-finger and two-finger gestures
698
+ const allTouches = event.touches; // All currently active touches
699
+ const changedTouches = event.changedTouches;
336
700
 
701
+ let eventType: 'down' | 'move' | 'up' | 'cancel';
337
702
  switch (event.type) {
338
703
  case 'touchstart':
339
704
  eventType = 'down';
@@ -348,45 +713,210 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
348
713
  eventType = 'cancel';
349
714
  break;
350
715
  default:
351
- return; // Should not happen
716
+ return;
352
717
  }
353
718
 
354
- for (let i = 0; i < touches.length; i++) {
355
- const touch = touches[i];
356
- processPointer(touch.identifier, touch.clientX, touch.clientY, eventType);
719
+ // Check if we have exactly 2 active touches - route to two-finger logic
720
+ if (allTouches.length === 2) {
721
+ const t0 = allTouches[0];
722
+ const t1 = allTouches[1];
723
+ const g0 = mapClientPointToVideo(ctx, t0.clientX, t0.clientY);
724
+ const g1 = mapClientPointToVideo(ctx, t1.clientX, t1.clientY);
725
+
726
+ if (!g0 || !g1) return;
727
+
728
+ if (!twoFingerStateRef.current) {
729
+ // Starting a new two-finger gesture
730
+ if (g0.inside && g1.inside) {
731
+ twoFingerStateRef.current = {
732
+ finger0: { x: g0.videoX, y: g0.videoY },
733
+ finger1: { x: g1.videoX, y: g1.videoY },
734
+ videoSize: { width: g0.videoWidth, height: g0.videoHeight },
735
+ source: 'real-touch',
736
+ pointerId0: t0.identifier,
737
+ pointerId1: t1.identifier,
738
+ };
739
+ applyTwoFingerEvent('down', g0.videoWidth, g0.videoHeight,
740
+ g0.videoX, g0.videoY, g1.videoX, g1.videoY,
741
+ t0.identifier, t1.identifier);
742
+ }
743
+ } else if (twoFingerStateRef.current.source === 'real-touch') {
744
+ // Continuing two-finger gesture (move)
745
+ if (g0.inside && g1.inside) {
746
+ twoFingerStateRef.current.finger0 = { x: g0.videoX, y: g0.videoY };
747
+ twoFingerStateRef.current.finger1 = { x: g1.videoX, y: g1.videoY };
748
+ applyTwoFingerEvent('move', g0.videoWidth, g0.videoHeight,
749
+ g0.videoX, g0.videoY, g1.videoX, g1.videoY,
750
+ twoFingerStateRef.current.pointerId0,
751
+ twoFingerStateRef.current.pointerId1);
752
+ }
753
+ }
754
+ } else if (allTouches.length < 2 && twoFingerStateRef.current?.source === 'real-touch') {
755
+ // Finger lifted - end two-finger gesture using last known state
756
+ const state = twoFingerStateRef.current;
757
+ applyTwoFingerEvent('up', state.videoSize.width, state.videoSize.height,
758
+ state.finger0.x, state.finger0.y,
759
+ state.finger1.x, state.finger1.y,
760
+ state.pointerId0, state.pointerId1);
761
+ twoFingerStateRef.current = null;
762
+ // Don't process remaining finger - gesture ended
763
+ return;
764
+ } else if (allTouches.length > 2) {
765
+ // 3+ fingers - not supported, ignore
766
+ return;
767
+ } else {
768
+ // Single finger touch (allTouches is 0 or 1)
769
+ // Note: allTouches=0 happens on touchend when last finger lifts
770
+ const touch = changedTouches[0];
771
+ if (touch) {
772
+ const geometry = mapClientPointToVideo(ctx, touch.clientX, touch.clientY);
773
+ applyPointerEvent(touch.identifier, eventType, geometry);
774
+ }
357
775
  }
358
776
  } else {
359
777
  // Mouse Events
360
- const pointerId = -1; // Use -1 for mouse pointer
778
+ const pointerId = -1; // Primary mouse pointer
361
779
  let eventType: 'down' | 'move' | 'up' | 'cancel' | null = null;
362
780
 
781
+ // Determine if we're in two-finger mode (Alt+mouse drag)
782
+ const inTwoFingerMode = twoFingerStateRef.current?.source === 'alt-mouse';
783
+
363
784
  switch (event.type) {
364
785
  case 'mousedown':
365
- if (event.button === 0) eventType = 'down'; // Only primary button
786
+ if (event.button === 0) eventType = 'down';
366
787
  break;
367
788
  case 'mousemove':
368
- // Only process move if primary button is down (check map)
369
- if (activePointers.current.has(pointerId)) {
789
+ // Process move if either in two-finger mode or has active pointer (normal drag)
790
+ if (inTwoFingerMode || activePointers.current.has(pointerId)) {
370
791
  eventType = 'move';
371
792
  }
372
793
  break;
373
794
  case 'mouseup':
374
- if (event.button === 0) eventType = 'up'; // Only primary button
795
+ if (event.button === 0) eventType = 'up';
375
796
  break;
376
797
  case 'mouseleave':
377
- // Treat leave like up only if button was down
378
- if (activePointers.current.has(pointerId)) {
798
+ // Treat leave like up only if in drag/two-finger mode
799
+ if (inTwoFingerMode || activePointers.current.has(pointerId)) {
379
800
  eventType = 'up';
380
801
  }
381
802
  break;
382
803
  }
383
804
 
384
805
  if (eventType) {
385
- processPointer(pointerId, event.clientX, event.clientY, eventType);
806
+ const geometry = mapClientPointToVideo(ctx, event.clientX, event.clientY);
807
+ if (!geometry) {
808
+ return;
809
+ }
810
+
811
+ debugLog('[rc-touch][mouse] event', {
812
+ domType: event.type,
813
+ eventType,
814
+ button: event.button,
815
+ buttons: (event as React.MouseEvent).buttons,
816
+ client: { x: event.clientX, y: event.clientY },
817
+ altHeldRef: isAltHeldRef.current,
818
+ inTwoFingerMode,
819
+ geometry: {
820
+ inside: geometry.inside,
821
+ videoX: geometry.videoX,
822
+ videoY: geometry.videoY,
823
+ videoWidth: geometry.videoWidth,
824
+ videoHeight: geometry.videoHeight,
825
+ },
826
+ activePointerIds: Array.from(activePointers.current.keys()),
827
+ });
828
+
829
+ // Route to two-finger (Alt+mouse) or single-finger path
830
+ if (isAltHeldRef.current || inTwoFingerMode) {
831
+ // Two-finger mode - Alt simulates second finger at mirror position
832
+ handleAltMouseGesture(eventType, geometry);
833
+ } else {
834
+ // Normal single-finger touch
835
+ applyPointerEvent(pointerId, eventType, geometry);
836
+ }
837
+ }
838
+ }
839
+ };
840
+
841
+ // Handle Alt+mouse gestures (simulated two-finger with mirror position).
842
+ // Works on both iOS and Android - applyTwoFingerEvent handles platform differences.
843
+ const handleAltMouseGesture = (
844
+ eventType: 'down' | 'move' | 'up' | 'cancel',
845
+ geometry: PointerGeometry,
846
+ ) => {
847
+ const { inside, videoX, videoY, videoWidth, videoHeight } = geometry;
848
+ const mirrorX = videoWidth - videoX;
849
+ const mirrorY = videoHeight - videoY;
850
+
851
+ if (eventType === 'down') {
852
+ if (inside) {
853
+ // Start two-finger gesture
854
+ twoFingerStateRef.current = {
855
+ finger0: { x: videoX, y: videoY },
856
+ finger1: { x: mirrorX, y: mirrorY },
857
+ videoSize: { width: videoWidth, height: videoHeight },
858
+ source: 'alt-mouse',
859
+ pointerId0: ALT_POINTER_ID_PRIMARY,
860
+ pointerId1: ALT_POINTER_ID_MIRROR,
861
+ };
862
+ videoRef.current?.focus();
863
+ applyTwoFingerEvent('down', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
864
+ ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
386
865
  }
866
+ return;
867
+ }
868
+
869
+ if (eventType === 'move') {
870
+ if (twoFingerStateRef.current?.source === 'alt-mouse' && inside) {
871
+ // Update positions
872
+ twoFingerStateRef.current.finger0 = { x: videoX, y: videoY };
873
+ twoFingerStateRef.current.finger1 = { x: mirrorX, y: mirrorY };
874
+ applyTwoFingerEvent('move', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
875
+ ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
876
+ }
877
+ // If outside, we just don't send a move - UP will use last known position
878
+ return;
879
+ }
880
+
881
+ if (eventType === 'up' || eventType === 'cancel') {
882
+ const state = twoFingerStateRef.current;
883
+ if (state?.source === 'alt-mouse') {
884
+ // End gesture at last known inside positions
885
+ const { finger0, finger1, videoSize } = state;
886
+ applyTwoFingerEvent('up', videoSize.width, videoSize.height,
887
+ finger0.x, finger0.y, finger1.x, finger1.y,
888
+ ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
889
+ twoFingerStateRef.current = null;
890
+ }
891
+ return;
387
892
  }
388
893
  };
389
894
 
895
+ useEffect(() => {
896
+ const handleKeyDown = (event: KeyboardEvent) => {
897
+ if (event.key === 'Alt') {
898
+ updateAltHeld(true);
899
+ }
900
+ };
901
+ const handleKeyUp = (event: KeyboardEvent) => {
902
+ if (event.key === 'Alt') {
903
+ updateAltHeld(false);
904
+ }
905
+ };
906
+ const handleWindowBlur = () => {
907
+ updateAltHeld(false);
908
+ };
909
+ // Use capture phase so these fire before handleKeyboard's stopPropagation
910
+ window.addEventListener('keydown', handleKeyDown, true);
911
+ window.addEventListener('keyup', handleKeyUp, true);
912
+ window.addEventListener('blur', handleWindowBlur);
913
+ return () => {
914
+ window.removeEventListener('keydown', handleKeyDown, true);
915
+ window.removeEventListener('keyup', handleKeyUp, true);
916
+ window.removeEventListener('blur', handleWindowBlur);
917
+ };
918
+ }, []);
919
+
390
920
  const handleKeyboard = (event: React.KeyboardEvent) => {
391
921
  event.preventDefault();
392
922
  event.stopPropagation();
@@ -990,8 +1520,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
990
1520
  },
991
1521
  }));
992
1522
 
1523
+ // Show indicators when Alt is held and we have a valid hover point (null when outside)
1524
+ const showAltIndicators = isAltHeld && hoverPoint !== null;
1525
+
993
1526
  return (
994
1527
  <div
1528
+ ref={containerRef}
995
1529
  className={clsx(
996
1530
  'rc-container',
997
1531
  className,
@@ -1008,12 +1542,30 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1008
1542
  onTouchEnd={handleInteraction}
1009
1543
  onTouchCancel={handleInteraction}
1010
1544
  >
1545
+ {showAltIndicators && hoverPoint && (
1546
+ <>
1547
+ <div
1548
+ className="rc-touch-indicator"
1549
+ style={{
1550
+ left: `${hoverPoint.containerX}px`,
1551
+ top: `${hoverPoint.containerY}px`,
1552
+ }}
1553
+ />
1554
+ <div
1555
+ className="rc-touch-indicator"
1556
+ style={{
1557
+ left: `${hoverPoint.mirrorContainerX}px`,
1558
+ top: `${hoverPoint.mirrorContainerY}px`,
1559
+ }}
1560
+ />
1561
+ </>
1562
+ )}
1011
1563
  {showFrame && (
1012
1564
  <img
1013
1565
  ref={frameRef}
1014
1566
  src={isLandscape ? config.frame.imageLandscape : config.frame.image}
1015
1567
  alt=""
1016
- className={'rc-phone-frame'}
1568
+ className={platform === 'ios' ? clsx('rc-phone-frame', 'rc-phone-frame-ios') : 'rc-phone-frame'}
1017
1569
  draggable={false}
1018
1570
  />
1019
1571
  )}