@myrialabs/clopen 0.2.5 → 0.2.7
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/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +16 -7
- package/backend/index.ts +25 -3
- package/backend/mcp/servers/browser-automation/browser.ts +23 -6
- package/backend/preview/browser/browser-mcp-control.ts +32 -16
- package/backend/preview/browser/browser-pool.ts +3 -1
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-tab-manager.ts +1 -1
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/audio-stream.ts +11 -0
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/chat/stream.ts +1 -1
- package/backend/ws/preview/browser/tab-info.ts +5 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/frontend/components/chat/input/ChatInput.svelte +6 -4
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/files/FileViewer.svelte +13 -2
- package/frontend/components/history/HistoryModal.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
- package/frontend/components/preview/browser/components/Container.svelte +23 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
- package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/PanelHeader.svelte +15 -0
- package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
- package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
- package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
- package/frontend/services/chat/chat.service.ts +9 -8
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
- package/frontend/stores/core/app.svelte.ts +4 -3
- package/frontend/stores/core/presence.svelte.ts +3 -2
- package/frontend/stores/core/sessions.svelte.ts +2 -0
- package/frontend/stores/ui/notification.svelte.ts +4 -1
- package/package.json +1 -1
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
onCursorUpdate = $bindable<(cursor: string) => void>(() => {}),
|
|
25
25
|
onFrameUpdate = $bindable<(data: any) => void>(() => {}),
|
|
26
26
|
onStatsUpdate = $bindable<(stats: BrowserWebCodecsStreamStats | null) => void>(() => {}),
|
|
27
|
-
onRequestScreencastRefresh = $bindable<() => void>(() => {}) // Called when stream is stuck
|
|
27
|
+
onRequestScreencastRefresh = $bindable<() => void>(() => {}), // Called when stream is stuck
|
|
28
|
+
touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
|
|
29
|
+
onTouchCursorUpdate = $bindable<(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) => void>(() => {})
|
|
28
30
|
} = $props();
|
|
29
31
|
|
|
30
32
|
// WebCodecs service instance
|
|
@@ -47,17 +49,24 @@
|
|
|
47
49
|
let isRecovering = $state(false); // Track recovery attempts
|
|
48
50
|
let connectionFailed = $state(false); // Track if connection actually failed (not just slow)
|
|
49
51
|
let hasRequestedScreencastRefresh = false; // Track if we've already requested refresh for this stream
|
|
52
|
+
let screencastRefreshCount = 0; // Track retry count for stuck detection
|
|
50
53
|
let navigationJustCompleted = false; // Track if navigation just completed (for fast refresh)
|
|
51
54
|
|
|
55
|
+
// Canvas snapshot storage for instant tab switching
|
|
56
|
+
// Stores a clone of the canvas per sessionId so switching back shows content immediately
|
|
57
|
+
const canvasSnapshots = new Map<string, HTMLCanvasElement>();
|
|
58
|
+
const MAX_SNAPSHOTS = 10;
|
|
59
|
+
let hasRestoredSnapshot = false; // Prevents canvas clear/reset during streaming start
|
|
60
|
+
|
|
52
61
|
// Recovery is only triggered by ACTUAL failures, not timeouts
|
|
53
62
|
// - ICE connection failed
|
|
54
63
|
// - WebCodecs connection closed unexpectedly
|
|
55
64
|
// - Explicit errors
|
|
56
65
|
const MAX_CONSECUTIVE_FAILURES = 2;
|
|
57
66
|
const HEALTH_CHECK_INTERVAL = 2000; // Check every 2 seconds for connection health
|
|
58
|
-
const FRAME_CHECK_INTERVAL =
|
|
59
|
-
const STUCK_STREAM_TIMEOUT =
|
|
60
|
-
const NAVIGATION_FAST_REFRESH_DELAY =
|
|
67
|
+
const FRAME_CHECK_INTERVAL = 100; // Fallback poll for first frame (primary path is onFirstFrame callback)
|
|
68
|
+
const STUCK_STREAM_TIMEOUT = 3000; // Fallback: Request screencast refresh after 3 seconds of connected but no frame
|
|
69
|
+
const NAVIGATION_FAST_REFRESH_DELAY = 300; // Fast refresh after navigation: 300ms
|
|
61
70
|
|
|
62
71
|
// Sync isStreamReady with hasReceivedFirstFrame for parent component
|
|
63
72
|
$effect(() => {
|
|
@@ -73,6 +82,11 @@
|
|
|
73
82
|
if (lastProjectId && currentProjectId && lastProjectId !== currentProjectId) {
|
|
74
83
|
debug.log('webcodecs', `🔄 Project changed (${lastProjectId} → ${currentProjectId}), destroying old WebCodecs service`);
|
|
75
84
|
|
|
85
|
+
// Clear canvas snapshots - they belong to old project's sessions
|
|
86
|
+
canvasSnapshots.clear();
|
|
87
|
+
hasRestoredSnapshot = false;
|
|
88
|
+
lastStartRequestId = null; // Clear so new project sessions aren't blocked by old tab IDs
|
|
89
|
+
|
|
76
90
|
// Destroy old service
|
|
77
91
|
if (webCodecsService) {
|
|
78
92
|
webCodecsService.destroy();
|
|
@@ -285,6 +299,24 @@
|
|
|
285
299
|
let mouseDownTime = $state(0);
|
|
286
300
|
let dragStarted = $state(false); // Track if we've sent mousedown for drag
|
|
287
301
|
|
|
302
|
+
// Touch-specific tracking (non-reactive for performance)
|
|
303
|
+
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
304
|
+
let touchLongPressed = false;
|
|
305
|
+
let lastTouchCoords: { x: number; y: number } | null = null;
|
|
306
|
+
|
|
307
|
+
// Trackpad cursor state (cursor/trackpad mode - persists between touch gestures)
|
|
308
|
+
let trackpadCursorX = 0;
|
|
309
|
+
let trackpadCursorY = 0;
|
|
310
|
+
let trackpadLastClientX = 0;
|
|
311
|
+
let trackpadLastClientY = 0;
|
|
312
|
+
let trackpadTouchStartClientX = 0;
|
|
313
|
+
let trackpadTouchStartClientY = 0;
|
|
314
|
+
let trackpadTwoFingerActive = false;
|
|
315
|
+
let trackpadTwoFingerStartTime = 0;
|
|
316
|
+
let trackpadTwoFingerLastCenterX = 0;
|
|
317
|
+
let trackpadTwoFingerLastCenterY = 0;
|
|
318
|
+
let trackpadTwoFingerTotalDist = 0;
|
|
319
|
+
|
|
288
320
|
function handleCanvasMouseDown(event: MouseEvent, canvas: HTMLCanvasElement) {
|
|
289
321
|
if (!sessionId) return;
|
|
290
322
|
|
|
@@ -392,31 +424,39 @@
|
|
|
392
424
|
|
|
393
425
|
// Start WebCodecs streaming
|
|
394
426
|
async function startStreaming() {
|
|
395
|
-
|
|
427
|
+
debug.log('webcodecs', `[DIAG] startStreaming() called: sessionId=${sessionId}, canvasElement=${!!canvasElement}, isStartingStream=${isStartingStream}, isWebCodecsActive=${isWebCodecsActive}, activeStreamingSessionId=${activeStreamingSessionId}, lastStartRequestId=${lastStartRequestId}`);
|
|
428
|
+
|
|
429
|
+
if (!sessionId || !canvasElement) {
|
|
430
|
+
debug.log('webcodecs', `[DIAG] startStreaming() early exit: missing sessionId=${!sessionId} or canvasElement=${!canvasElement}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
396
433
|
|
|
397
434
|
// Prevent concurrent start attempts
|
|
398
435
|
if (isStartingStream) {
|
|
399
|
-
debug.log('webcodecs', '
|
|
436
|
+
debug.log('webcodecs', '[DIAG] startStreaming() skipped: already starting stream');
|
|
400
437
|
return;
|
|
401
438
|
}
|
|
402
439
|
|
|
403
440
|
// If already streaming same session, skip
|
|
404
441
|
if (isWebCodecsActive && activeStreamingSessionId === sessionId) {
|
|
405
|
-
debug.log('webcodecs', '
|
|
442
|
+
debug.log('webcodecs', '[DIAG] startStreaming() skipped: already streaming same session');
|
|
406
443
|
return;
|
|
407
444
|
}
|
|
408
445
|
|
|
409
446
|
// Prevent duplicate requests for same session
|
|
410
447
|
const requestId = `${sessionId}-${Date.now()}`;
|
|
411
448
|
if (lastStartRequestId && lastStartRequestId.startsWith(sessionId)) {
|
|
412
|
-
debug.log('webcodecs', `
|
|
449
|
+
debug.log('webcodecs', `[DIAG] startStreaming() skipped: duplicate request for ${sessionId}, lastStartRequestId=${lastStartRequestId}`);
|
|
413
450
|
return;
|
|
414
451
|
}
|
|
415
452
|
lastStartRequestId = requestId;
|
|
416
453
|
|
|
417
454
|
isStartingStream = true;
|
|
418
455
|
isStreamStarting = true; // Show loading overlay
|
|
419
|
-
|
|
456
|
+
// Don't reset if we restored a snapshot - keep showing it
|
|
457
|
+
if (!hasRestoredSnapshot) {
|
|
458
|
+
hasReceivedFirstFrame = false; // Reset first frame state
|
|
459
|
+
}
|
|
420
460
|
|
|
421
461
|
try {
|
|
422
462
|
// If streaming a different session, stop first
|
|
@@ -480,6 +520,25 @@
|
|
|
480
520
|
onStatsUpdate(stats);
|
|
481
521
|
});
|
|
482
522
|
|
|
523
|
+
// Setup first frame handler - fires immediately when first frame decoded
|
|
524
|
+
// This eliminates the 500ms polling delay for hiding the loading overlay
|
|
525
|
+
webCodecsService.setFirstFrameHandler(() => {
|
|
526
|
+
if (!hasReceivedFirstFrame) {
|
|
527
|
+
debug.log('webcodecs', 'First frame callback - immediately updating UI');
|
|
528
|
+
hasReceivedFirstFrame = true;
|
|
529
|
+
consecutiveFailures = 0;
|
|
530
|
+
connectionFailed = false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Always reset reconnecting state on first real frame
|
|
534
|
+
// (outside !hasReceivedFirstFrame to handle snapshot + reconnect case)
|
|
535
|
+
if (isReconnecting) {
|
|
536
|
+
setTimeout(() => {
|
|
537
|
+
isReconnecting = false;
|
|
538
|
+
}, 300);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
483
542
|
// Setup cursor change handler
|
|
484
543
|
webCodecsService.setOnCursorChange((cursor: string) => {
|
|
485
544
|
updateCanvasCursor(cursor);
|
|
@@ -491,8 +550,8 @@
|
|
|
491
550
|
|
|
492
551
|
let success = false;
|
|
493
552
|
let retries = 0;
|
|
494
|
-
const maxRetries = 5;
|
|
495
|
-
const retryDelay =
|
|
553
|
+
const maxRetries = 5;
|
|
554
|
+
const retryDelay = 300;
|
|
496
555
|
|
|
497
556
|
while (!success && retries < maxRetries) {
|
|
498
557
|
try {
|
|
@@ -502,12 +561,20 @@
|
|
|
502
561
|
isConnected = true;
|
|
503
562
|
activeStreamingSessionId = sessionId;
|
|
504
563
|
consecutiveFailures = 0; // Reset failure counter on success
|
|
505
|
-
startHealthCheck(); //
|
|
564
|
+
startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
|
|
565
|
+
hasRestoredSnapshot = false; // Reset after using
|
|
506
566
|
debug.log('webcodecs', 'Streaming started successfully');
|
|
507
|
-
|
|
567
|
+
} else {
|
|
568
|
+
// Service handles errors internally and returns false.
|
|
569
|
+
// Never retry here — retrying immediately creates a stop/start loop.
|
|
570
|
+
debug.warn('webcodecs', 'Streaming start returned false, stopping retries');
|
|
508
571
|
}
|
|
572
|
+
// Always break after the service returns (success or failure).
|
|
573
|
+
// The service catches all exceptions internally, so the catch block
|
|
574
|
+
// below never runs, making retries/retryDelay dead code anyway.
|
|
575
|
+
break;
|
|
509
576
|
} catch (error: any) {
|
|
510
|
-
//
|
|
577
|
+
// This block only runs if the service unexpectedly throws.
|
|
511
578
|
const isRetriable = error?.message?.includes('not found') ||
|
|
512
579
|
error?.message?.includes('invalid') ||
|
|
513
580
|
error?.message?.includes('Failed to start') ||
|
|
@@ -520,11 +587,9 @@
|
|
|
520
587
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
521
588
|
} else {
|
|
522
589
|
debug.error('webcodecs', 'Max retries reached, streaming still not ready');
|
|
523
|
-
// Don't throw - just fail silently and let user retry manually
|
|
524
590
|
break;
|
|
525
591
|
}
|
|
526
592
|
} else {
|
|
527
|
-
// Other errors - don't retry, just log
|
|
528
593
|
debug.error('webcodecs', 'Streaming error:', error);
|
|
529
594
|
break;
|
|
530
595
|
}
|
|
@@ -533,6 +598,7 @@
|
|
|
533
598
|
} finally {
|
|
534
599
|
isStartingStream = false;
|
|
535
600
|
isStreamStarting = false; // Hide "Launching browser..." (but may still show "Connecting..." until first frame)
|
|
601
|
+
hasRestoredSnapshot = false; // Always reset in finally
|
|
536
602
|
}
|
|
537
603
|
}
|
|
538
604
|
|
|
@@ -561,6 +627,7 @@
|
|
|
561
627
|
}
|
|
562
628
|
connectionFailed = false;
|
|
563
629
|
hasRequestedScreencastRefresh = false; // Reset for new stream
|
|
630
|
+
screencastRefreshCount = 0; // Reset retry counter
|
|
564
631
|
|
|
565
632
|
const startTime = Date.now();
|
|
566
633
|
|
|
@@ -587,6 +654,7 @@
|
|
|
587
654
|
consecutiveFailures = 0;
|
|
588
655
|
connectionFailed = false;
|
|
589
656
|
hasRequestedScreencastRefresh = false; // Reset on success
|
|
657
|
+
screencastRefreshCount = 0; // Reset retry counter on success
|
|
590
658
|
|
|
591
659
|
// Reset reconnecting state after successful frame reception
|
|
592
660
|
// This completes the fast reconnect cycle
|
|
@@ -621,10 +689,21 @@
|
|
|
621
689
|
// STUCK STREAM DETECTION (FALLBACK): If connected but no first frame for too long,
|
|
622
690
|
// request screencast refresh (hot-swap) to restart CDP screencast.
|
|
623
691
|
// This handles cases where WebRTC is connected but CDP frames aren't flowing.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
692
|
+
// Retries: 1st at 3s (screencast refresh), 2nd at 6s (another refresh), 3rd at 10s (full recovery)
|
|
693
|
+
if (stats?.isConnected && !stats?.firstFrameRendered && !hasRequestedScreencastRefresh) {
|
|
694
|
+
const MAX_SCREENCAST_RETRIES = 2;
|
|
695
|
+
const retryThreshold = STUCK_STREAM_TIMEOUT + (screencastRefreshCount * 3000); // 3s, 6s
|
|
696
|
+
|
|
697
|
+
if (elapsed >= retryThreshold && screencastRefreshCount < MAX_SCREENCAST_RETRIES) {
|
|
698
|
+
screencastRefreshCount++;
|
|
699
|
+
debug.warn('webcodecs', `Stream stuck (connected, no frame for ${elapsed}ms), screencast refresh attempt ${screencastRefreshCount}/${MAX_SCREENCAST_RETRIES}`);
|
|
700
|
+
onRequestScreencastRefresh();
|
|
701
|
+
} else if (elapsed >= 10000 && screencastRefreshCount >= MAX_SCREENCAST_RETRIES) {
|
|
702
|
+
// Screencast refreshes didn't help - attempt full recovery
|
|
703
|
+
debug.warn('webcodecs', `Stream still stuck after ${screencastRefreshCount} screencast refreshes (${elapsed}ms), attempting full recovery`);
|
|
704
|
+
hasRequestedScreencastRefresh = true; // Prevent further retries
|
|
705
|
+
attemptRecovery();
|
|
706
|
+
}
|
|
628
707
|
}
|
|
629
708
|
|
|
630
709
|
}, FRAME_CHECK_INTERVAL);
|
|
@@ -770,11 +849,11 @@
|
|
|
770
849
|
lastStartRequestId = null; // Clear to allow new requests
|
|
771
850
|
// Note: Don't reset hasReceivedFirstFrame here - let startStreaming do it
|
|
772
851
|
// This prevents flashing when switching tabs
|
|
773
|
-
// Clear canvas to prevent stale frames, BUT keep last frame during navigation
|
|
774
|
-
if (!isNavigating) {
|
|
852
|
+
// Clear canvas to prevent stale frames, BUT keep last frame during navigation or snapshot restore
|
|
853
|
+
if (!isNavigating && !hasRestoredSnapshot) {
|
|
775
854
|
clearCanvas();
|
|
776
855
|
} else {
|
|
777
|
-
debug.log('webcodecs',
|
|
856
|
+
debug.log('webcodecs', `Skipping canvas clear - navigation: ${isNavigating}, snapshot: ${hasRestoredSnapshot}`);
|
|
778
857
|
}
|
|
779
858
|
}
|
|
780
859
|
}
|
|
@@ -802,6 +881,8 @@
|
|
|
802
881
|
// Start/restart streaming when session is ready
|
|
803
882
|
// This handles both initial start and session changes (viewport switch, etc.)
|
|
804
883
|
$effect(() => {
|
|
884
|
+
debug.log('webcodecs', `[DIAG] streaming $effect triggered: sessionId=${sessionId}, canvasElement=${!!canvasElement}, sessionInfo=${!!sessionInfo}, isReconnecting=${isReconnecting}, isWebCodecsActive=${isWebCodecsActive}, activeStreamingSessionId=${activeStreamingSessionId}`);
|
|
885
|
+
|
|
805
886
|
if (sessionId && canvasElement && sessionInfo) {
|
|
806
887
|
// Skip during fast reconnect - fastReconnect() handles this case
|
|
807
888
|
if (isReconnecting) {
|
|
@@ -811,12 +892,55 @@
|
|
|
811
892
|
|
|
812
893
|
// Check if we need to start or restart streaming
|
|
813
894
|
const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
|
|
895
|
+
debug.log('webcodecs', `[DIAG] streaming $effect: needsStreaming=${needsStreaming}`);
|
|
814
896
|
|
|
815
897
|
if (needsStreaming) {
|
|
816
|
-
// Clear canvas immediately when session changes to prevent stale frames
|
|
817
898
|
if (activeStreamingSessionId !== sessionId) {
|
|
818
|
-
|
|
819
|
-
|
|
899
|
+
// SNAPSHOT: Save current canvas before switching to new session
|
|
900
|
+
if (activeStreamingSessionId && hasReceivedFirstFrame && canvasElement.width > 0) {
|
|
901
|
+
try {
|
|
902
|
+
const clone = document.createElement('canvas');
|
|
903
|
+
clone.width = canvasElement.width;
|
|
904
|
+
clone.height = canvasElement.height;
|
|
905
|
+
const cloneCtx = clone.getContext('2d');
|
|
906
|
+
if (cloneCtx) {
|
|
907
|
+
cloneCtx.drawImage(canvasElement, 0, 0);
|
|
908
|
+
// Limit snapshot count
|
|
909
|
+
if (canvasSnapshots.size >= MAX_SNAPSHOTS) {
|
|
910
|
+
const firstKey = canvasSnapshots.keys().next().value;
|
|
911
|
+
if (firstKey) canvasSnapshots.delete(firstKey);
|
|
912
|
+
}
|
|
913
|
+
canvasSnapshots.set(activeStreamingSessionId, clone);
|
|
914
|
+
debug.log('webcodecs', `📸 Saved canvas snapshot for session ${activeStreamingSessionId}`);
|
|
915
|
+
}
|
|
916
|
+
} catch (e) {
|
|
917
|
+
debug.warn('webcodecs', 'Failed to capture canvas snapshot:', e);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// SNAPSHOT: Restore for new session if available
|
|
922
|
+
const existingSnapshot = canvasSnapshots.get(sessionId);
|
|
923
|
+
if (existingSnapshot) {
|
|
924
|
+
setupCanvasInternal(); // Ensure canvas dimensions are correct
|
|
925
|
+
try {
|
|
926
|
+
const ctx = canvasElement.getContext('2d');
|
|
927
|
+
if (ctx) {
|
|
928
|
+
ctx.drawImage(existingSnapshot, 0, 0, canvasElement.width, canvasElement.height);
|
|
929
|
+
hasRestoredSnapshot = true;
|
|
930
|
+
// Don't reset hasReceivedFirstFrame - snapshot is visible
|
|
931
|
+
debug.log('webcodecs', `📸 Restored canvas snapshot for session ${sessionId}`);
|
|
932
|
+
}
|
|
933
|
+
} catch (e) {
|
|
934
|
+
debug.warn('webcodecs', 'Failed to restore canvas snapshot:', e);
|
|
935
|
+
hasRestoredSnapshot = false;
|
|
936
|
+
clearCanvas();
|
|
937
|
+
hasReceivedFirstFrame = false;
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
hasRestoredSnapshot = false;
|
|
941
|
+
clearCanvas();
|
|
942
|
+
hasReceivedFirstFrame = false; // Reset to show loading overlay
|
|
943
|
+
}
|
|
820
944
|
}
|
|
821
945
|
|
|
822
946
|
// Stop existing streaming first if session changed
|
|
@@ -831,12 +955,10 @@
|
|
|
831
955
|
await startStreaming();
|
|
832
956
|
};
|
|
833
957
|
|
|
834
|
-
//
|
|
835
|
-
// This is especially important during viewport/device change
|
|
836
|
-
// when session is being recreated
|
|
958
|
+
// Small delay to ensure backend session is ready
|
|
837
959
|
const timeout = setTimeout(() => {
|
|
838
960
|
doStartStreaming();
|
|
839
|
-
},
|
|
961
|
+
}, 50);
|
|
840
962
|
|
|
841
963
|
return () => clearTimeout(timeout);
|
|
842
964
|
}
|
|
@@ -880,9 +1002,20 @@
|
|
|
880
1002
|
canvas.focus();
|
|
881
1003
|
});
|
|
882
1004
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1005
|
+
const touchStartHandler = (e: TouchEvent) => handleTouchStart(e, canvas);
|
|
1006
|
+
let lastTouchMoveTime = 0;
|
|
1007
|
+
const touchMoveHandler = (e: TouchEvent) => {
|
|
1008
|
+
const now = Date.now();
|
|
1009
|
+
if (now - lastTouchMoveTime >= 16) {
|
|
1010
|
+
lastTouchMoveTime = now;
|
|
1011
|
+
handleTouchMove(e, canvas);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const touchEndHandler = (e: TouchEvent) => handleTouchEnd(e, canvas);
|
|
1015
|
+
|
|
1016
|
+
canvas.addEventListener('touchstart', touchStartHandler, { passive: false });
|
|
1017
|
+
canvas.addEventListener('touchmove', touchMoveHandler, { passive: false });
|
|
1018
|
+
canvas.addEventListener('touchend', touchEndHandler, { passive: false });
|
|
886
1019
|
|
|
887
1020
|
const handleMouseLeave = () => {
|
|
888
1021
|
if (isMouseDown) {
|
|
@@ -912,76 +1045,299 @@
|
|
|
912
1045
|
canvas.removeEventListener('mousedown', (e) => handleCanvasMouseDown(e, canvas));
|
|
913
1046
|
canvas.removeEventListener('mouseup', (e) => handleCanvasMouseUp(e, canvas));
|
|
914
1047
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
915
|
-
canvas.removeEventListener('touchstart',
|
|
916
|
-
canvas.removeEventListener('touchmove',
|
|
917
|
-
canvas.removeEventListener('touchend',
|
|
1048
|
+
canvas.removeEventListener('touchstart', touchStartHandler);
|
|
1049
|
+
canvas.removeEventListener('touchmove', touchMoveHandler);
|
|
1050
|
+
canvas.removeEventListener('touchend', touchEndHandler);
|
|
918
1051
|
};
|
|
919
1052
|
}
|
|
920
1053
|
});
|
|
921
1054
|
|
|
1055
|
+
// Convert canvas coordinates to viewport (screen) coordinates for VirtualCursor display
|
|
1056
|
+
function canvasToScreen(cx: number, cy: number): { x: number; y: number } {
|
|
1057
|
+
if (!canvasElement) return { x: 0, y: 0 };
|
|
1058
|
+
const rect = canvasElement.getBoundingClientRect();
|
|
1059
|
+
return {
|
|
1060
|
+
x: rect.left + cx * (rect.width / canvasElement.width),
|
|
1061
|
+
y: rect.top + cy * (rect.height / canvasElement.height)
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Show / hide cursor when touchMode changes
|
|
1066
|
+
$effect(() => {
|
|
1067
|
+
if (touchMode === 'cursor') {
|
|
1068
|
+
// Init cursor at canvas center on first activation
|
|
1069
|
+
if (canvasElement && trackpadCursorX === 0 && trackpadCursorY === 0) {
|
|
1070
|
+
trackpadCursorX = canvasElement.width / 2;
|
|
1071
|
+
trackpadCursorY = canvasElement.height / 2;
|
|
1072
|
+
}
|
|
1073
|
+
if (canvasElement) {
|
|
1074
|
+
const pos = canvasToScreen(trackpadCursorX, trackpadCursorY);
|
|
1075
|
+
onTouchCursorUpdate({ x: pos.x, y: pos.y, visible: true });
|
|
1076
|
+
}
|
|
1077
|
+
} else {
|
|
1078
|
+
onTouchCursorUpdate({ x: 0, y: 0, visible: false });
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// ── Trackpad (cursor) mode handlers ───────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
function handleTrackpadTouchStart(event: TouchEvent) {
|
|
1085
|
+
if (event.touches.length >= 2) {
|
|
1086
|
+
// Second finger joined → switch to two-finger mode
|
|
1087
|
+
if (!trackpadTwoFingerActive) {
|
|
1088
|
+
// Cancel any pending single-finger actions
|
|
1089
|
+
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
|
1090
|
+
if (touchLongPressed && dragStarted) {
|
|
1091
|
+
sendInteraction({ type: 'mouseup', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY), button: 'left' });
|
|
1092
|
+
}
|
|
1093
|
+
isMouseDown = false;
|
|
1094
|
+
dragStarted = false;
|
|
1095
|
+
touchLongPressed = false;
|
|
1096
|
+
}
|
|
1097
|
+
trackpadTwoFingerActive = true;
|
|
1098
|
+
trackpadTwoFingerStartTime = Date.now();
|
|
1099
|
+
trackpadTwoFingerTotalDist = 0;
|
|
1100
|
+
const t1 = event.touches[0];
|
|
1101
|
+
const t2 = event.touches[1];
|
|
1102
|
+
trackpadTwoFingerLastCenterX = (t1.clientX + t2.clientX) / 2;
|
|
1103
|
+
trackpadTwoFingerLastCenterY = (t1.clientY + t2.clientY) / 2;
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (trackpadTwoFingerActive) return; // Ignore until two-finger gesture fully ends
|
|
1108
|
+
|
|
1109
|
+
// Single finger
|
|
1110
|
+
const touch = event.touches[0];
|
|
1111
|
+
trackpadTouchStartClientX = touch.clientX;
|
|
1112
|
+
trackpadTouchStartClientY = touch.clientY;
|
|
1113
|
+
trackpadLastClientX = touch.clientX;
|
|
1114
|
+
trackpadLastClientY = touch.clientY;
|
|
1115
|
+
isMouseDown = true;
|
|
1116
|
+
mouseDownTime = Date.now();
|
|
1117
|
+
dragStarted = false;
|
|
1118
|
+
touchLongPressed = false;
|
|
1119
|
+
|
|
1120
|
+
// Long-press (600ms without movement) → drag mode
|
|
1121
|
+
longPressTimer = setTimeout(() => {
|
|
1122
|
+
if (!isMouseDown) return;
|
|
1123
|
+
const dist = Math.sqrt(
|
|
1124
|
+
Math.pow(trackpadLastClientX - trackpadTouchStartClientX, 2) +
|
|
1125
|
+
Math.pow(trackpadLastClientY - trackpadTouchStartClientY, 2)
|
|
1126
|
+
);
|
|
1127
|
+
if (dist < 8) {
|
|
1128
|
+
touchLongPressed = true;
|
|
1129
|
+
dragStarted = true;
|
|
1130
|
+
sendInteraction({ type: 'mousedown', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY), button: 'left' });
|
|
1131
|
+
}
|
|
1132
|
+
}, 600);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function handleTrackpadTouchMove(event: TouchEvent) {
|
|
1136
|
+
if (!canvasElement) return;
|
|
1137
|
+
|
|
1138
|
+
if (event.touches.length >= 2 && trackpadTwoFingerActive) {
|
|
1139
|
+
// Two-finger scroll
|
|
1140
|
+
const t1 = event.touches[0];
|
|
1141
|
+
const t2 = event.touches[1];
|
|
1142
|
+
const centerX = (t1.clientX + t2.clientX) / 2;
|
|
1143
|
+
const centerY = (t1.clientY + t2.clientY) / 2;
|
|
1144
|
+
const deltaX = trackpadTwoFingerLastCenterX - centerX;
|
|
1145
|
+
const deltaY = trackpadTwoFingerLastCenterY - centerY;
|
|
1146
|
+
trackpadTwoFingerLastCenterX = centerX;
|
|
1147
|
+
trackpadTwoFingerLastCenterY = centerY;
|
|
1148
|
+
trackpadTwoFingerTotalDist += Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
1149
|
+
if (Math.abs(deltaX) > 0.3 || Math.abs(deltaY) > 0.3) {
|
|
1150
|
+
const rect = canvasElement.getBoundingClientRect();
|
|
1151
|
+
const scale = canvasElement.width / rect.width;
|
|
1152
|
+
sendInteraction({ type: 'scroll', deltaX: deltaX * scale * 2, deltaY: deltaY * scale * 2 });
|
|
1153
|
+
}
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (event.touches.length !== 1 || !isMouseDown || trackpadTwoFingerActive) return;
|
|
1158
|
+
|
|
1159
|
+
const touch = event.touches[0];
|
|
1160
|
+
const deltaClientX = touch.clientX - trackpadLastClientX;
|
|
1161
|
+
const deltaClientY = touch.clientY - trackpadLastClientY;
|
|
1162
|
+
trackpadLastClientX = touch.clientX;
|
|
1163
|
+
trackpadLastClientY = touch.clientY;
|
|
1164
|
+
|
|
1165
|
+
// Cancel long-press if finger moved significantly
|
|
1166
|
+
const totalDist = Math.sqrt(
|
|
1167
|
+
Math.pow(touch.clientX - trackpadTouchStartClientX, 2) +
|
|
1168
|
+
Math.pow(touch.clientY - trackpadTouchStartClientY, 2)
|
|
1169
|
+
);
|
|
1170
|
+
if (totalDist > 8 && longPressTimer) {
|
|
1171
|
+
clearTimeout(longPressTimer);
|
|
1172
|
+
longPressTimer = null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Convert screen delta → canvas delta and move cursor
|
|
1176
|
+
const rect = canvasElement.getBoundingClientRect();
|
|
1177
|
+
const scale = canvasElement.width / rect.width;
|
|
1178
|
+
trackpadCursorX = Math.max(0, Math.min(canvasElement.width, trackpadCursorX + deltaClientX * scale));
|
|
1179
|
+
trackpadCursorY = Math.max(0, Math.min(canvasElement.height, trackpadCursorY + deltaClientY * scale));
|
|
1180
|
+
|
|
1181
|
+
// Send mousemove so the browser sees hover state changes
|
|
1182
|
+
sendInteraction({ type: 'mousemove', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY) });
|
|
1183
|
+
|
|
1184
|
+
// Update virtual cursor display
|
|
1185
|
+
const pos = canvasToScreen(trackpadCursorX, trackpadCursorY);
|
|
1186
|
+
onTouchCursorUpdate({ x: pos.x, y: pos.y, visible: true });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function handleTrackpadTouchEnd(event: TouchEvent) {
|
|
1190
|
+
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
|
1191
|
+
|
|
1192
|
+
const remainingTouches = event.touches.length;
|
|
1193
|
+
|
|
1194
|
+
if (trackpadTwoFingerActive) {
|
|
1195
|
+
if (remainingTouches === 0) {
|
|
1196
|
+
// All fingers lifted: check for two-finger tap → right click
|
|
1197
|
+
const duration = Date.now() - trackpadTwoFingerStartTime;
|
|
1198
|
+
if (duration < 300 && trackpadTwoFingerTotalDist < 20) {
|
|
1199
|
+
sendInteraction({ type: 'rightclick', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY) });
|
|
1200
|
+
}
|
|
1201
|
+
trackpadTwoFingerActive = false;
|
|
1202
|
+
} else if (remainingTouches === 1) {
|
|
1203
|
+
// One finger remains: transition back to single-finger tracking
|
|
1204
|
+
const touch = event.touches[0];
|
|
1205
|
+
trackpadLastClientX = touch.clientX;
|
|
1206
|
+
trackpadLastClientY = touch.clientY;
|
|
1207
|
+
trackpadTouchStartClientX = touch.clientX;
|
|
1208
|
+
trackpadTouchStartClientY = touch.clientY;
|
|
1209
|
+
isMouseDown = true;
|
|
1210
|
+
mouseDownTime = Date.now();
|
|
1211
|
+
trackpadTwoFingerActive = false;
|
|
1212
|
+
}
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (!isMouseDown) return;
|
|
1217
|
+
|
|
1218
|
+
if (touchLongPressed && dragStarted) {
|
|
1219
|
+
sendInteraction({ type: 'mouseup', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY), button: 'left' });
|
|
1220
|
+
} else {
|
|
1221
|
+
// Tap: short + minimal movement → left click at cursor position
|
|
1222
|
+
const duration = Date.now() - mouseDownTime;
|
|
1223
|
+
const moveDist = Math.sqrt(
|
|
1224
|
+
Math.pow(trackpadLastClientX - trackpadTouchStartClientX, 2) +
|
|
1225
|
+
Math.pow(trackpadLastClientY - trackpadTouchStartClientY, 2)
|
|
1226
|
+
);
|
|
1227
|
+
if (duration < 250 && moveDist < 10) {
|
|
1228
|
+
sendInteraction({ type: 'click', x: Math.round(trackpadCursorX), y: Math.round(trackpadCursorY) });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
isMouseDown = false;
|
|
1233
|
+
dragStarted = false;
|
|
1234
|
+
touchLongPressed = false;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ── Touch event handlers (dispatch to scroll or trackpad mode) ────────────
|
|
1238
|
+
|
|
922
1239
|
// Touch event handlers
|
|
923
1240
|
function handleTouchStart(event: TouchEvent, canvas: HTMLCanvasElement) {
|
|
924
1241
|
if (!sessionId || event.touches.length === 0) return;
|
|
925
1242
|
event.preventDefault();
|
|
926
1243
|
|
|
1244
|
+
if (touchMode === 'cursor') {
|
|
1245
|
+
handleTrackpadTouchStart(event);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// ── Scroll mode ──────────────────────────────────────────────────────────
|
|
1250
|
+
if (event.touches.length > 1) return;
|
|
1251
|
+
|
|
927
1252
|
const coords = getCanvasCoordinates(event, canvas);
|
|
928
1253
|
isMouseDown = true;
|
|
929
1254
|
mouseDownTime = Date.now();
|
|
930
1255
|
dragStartPos = { x: coords.x, y: coords.y };
|
|
931
1256
|
dragCurrentPos = { x: coords.x, y: coords.y };
|
|
932
|
-
dragStarted = false;
|
|
1257
|
+
dragStarted = false;
|
|
1258
|
+
touchLongPressed = false;
|
|
1259
|
+
lastTouchCoords = { x: coords.x, y: coords.y };
|
|
1260
|
+
|
|
1261
|
+
// Long-press detection: after 500ms without significant movement → drag mode
|
|
1262
|
+
longPressTimer = setTimeout(() => {
|
|
1263
|
+
if (!isMouseDown || !dragStartPos) return;
|
|
1264
|
+
const dist = dragCurrentPos
|
|
1265
|
+
? Math.sqrt(
|
|
1266
|
+
Math.pow(dragCurrentPos.x - dragStartPos.x, 2) +
|
|
1267
|
+
Math.pow(dragCurrentPos.y - dragStartPos.y, 2)
|
|
1268
|
+
)
|
|
1269
|
+
: 0;
|
|
1270
|
+
if (dist < 10) {
|
|
1271
|
+
touchLongPressed = true;
|
|
1272
|
+
dragStarted = true;
|
|
1273
|
+
sendInteraction({ type: 'mousedown', x: dragStartPos.x, y: dragStartPos.y, button: 'left' });
|
|
1274
|
+
}
|
|
1275
|
+
}, 500);
|
|
933
1276
|
}
|
|
934
1277
|
|
|
935
1278
|
function handleTouchMove(event: TouchEvent, canvas: HTMLCanvasElement) {
|
|
936
|
-
if (!sessionId || event.touches.length === 0
|
|
1279
|
+
if (!sessionId || event.touches.length === 0) return;
|
|
937
1280
|
event.preventDefault();
|
|
938
1281
|
|
|
1282
|
+
if (touchMode === 'cursor') {
|
|
1283
|
+
handleTrackpadTouchMove(event);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ── Scroll mode ──────────────────────────────────────────────────────────
|
|
1288
|
+
if (!isMouseDown || !dragStartPos) return;
|
|
1289
|
+
|
|
939
1290
|
const coords = getCanvasCoordinates(event, canvas);
|
|
940
1291
|
dragCurrentPos = { x: coords.x, y: coords.y };
|
|
941
1292
|
|
|
942
|
-
const
|
|
1293
|
+
const dist = Math.sqrt(
|
|
943
1294
|
Math.pow(coords.x - dragStartPos.x, 2) + Math.pow(coords.y - dragStartPos.y, 2)
|
|
944
1295
|
);
|
|
945
1296
|
|
|
946
|
-
if (
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
type: 'mousedown',
|
|
951
|
-
x: dragStartPos.x,
|
|
952
|
-
y: dragStartPos.y,
|
|
953
|
-
button: 'left'
|
|
954
|
-
});
|
|
955
|
-
dragStarted = true;
|
|
956
|
-
}
|
|
1297
|
+
if (dist > 10 && longPressTimer) {
|
|
1298
|
+
clearTimeout(longPressTimer);
|
|
1299
|
+
longPressTimer = null;
|
|
1300
|
+
}
|
|
957
1301
|
|
|
1302
|
+
if (touchLongPressed) {
|
|
958
1303
|
isDragging = true;
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
x
|
|
963
|
-
y
|
|
964
|
-
|
|
1304
|
+
sendInteraction({ type: 'mousemove', x: coords.x, y: coords.y });
|
|
1305
|
+
} else {
|
|
1306
|
+
if (lastTouchCoords) {
|
|
1307
|
+
const deltaX = lastTouchCoords.x - coords.x;
|
|
1308
|
+
const deltaY = lastTouchCoords.y - coords.y;
|
|
1309
|
+
sendInteraction({ type: 'scroll', deltaX, deltaY });
|
|
1310
|
+
}
|
|
1311
|
+
lastTouchCoords = { x: coords.x, y: coords.y };
|
|
965
1312
|
}
|
|
966
1313
|
}
|
|
967
1314
|
|
|
968
1315
|
function handleTouchEnd(event: TouchEvent, canvas: HTMLCanvasElement) {
|
|
969
|
-
if (!sessionId
|
|
1316
|
+
if (!sessionId) return;
|
|
970
1317
|
event.preventDefault();
|
|
971
1318
|
|
|
972
|
-
if (
|
|
973
|
-
|
|
1319
|
+
if (touchMode === 'cursor') {
|
|
1320
|
+
handleTrackpadTouchEnd(event);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
974
1323
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1324
|
+
// ── Scroll mode ──────────────────────────────────────────────────────────
|
|
1325
|
+
if (!isMouseDown) return;
|
|
1326
|
+
|
|
1327
|
+
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
|
1328
|
+
|
|
1329
|
+
if (touchLongPressed && dragStarted) {
|
|
1330
|
+
const endPos = dragCurrentPos || dragStartPos;
|
|
1331
|
+
if (endPos) sendInteraction({ type: 'mouseup', x: endPos.x, y: endPos.y, button: 'left' });
|
|
1332
|
+
} else if (!isDragging && dragStartPos) {
|
|
1333
|
+
const touchDuration = Date.now() - mouseDownTime;
|
|
1334
|
+
const dist = dragCurrentPos
|
|
1335
|
+
? Math.sqrt(
|
|
1336
|
+
Math.pow(dragCurrentPos.x - dragStartPos.x, 2) +
|
|
1337
|
+
Math.pow(dragCurrentPos.y - dragStartPos.y, 2)
|
|
1338
|
+
)
|
|
1339
|
+
: 0;
|
|
1340
|
+
if (touchDuration < 300 && dist < 15) {
|
|
985
1341
|
sendInteraction({ type: 'click', x: dragStartPos.x, y: dragStartPos.y });
|
|
986
1342
|
}
|
|
987
1343
|
}
|
|
@@ -991,6 +1347,8 @@
|
|
|
991
1347
|
dragStartPos = null;
|
|
992
1348
|
dragCurrentPos = null;
|
|
993
1349
|
dragStarted = false;
|
|
1350
|
+
touchLongPressed = false;
|
|
1351
|
+
lastTouchCoords = null;
|
|
994
1352
|
}
|
|
995
1353
|
|
|
996
1354
|
function getCanvasElement() {
|
|
@@ -1039,6 +1397,11 @@
|
|
|
1039
1397
|
|
|
1040
1398
|
onDestroy(() => {
|
|
1041
1399
|
stopHealthCheck(); // Stop health monitoring
|
|
1400
|
+
canvasSnapshots.clear(); // Free snapshot memory
|
|
1401
|
+
if (longPressTimer) {
|
|
1402
|
+
clearTimeout(longPressTimer);
|
|
1403
|
+
longPressTimer = null;
|
|
1404
|
+
}
|
|
1042
1405
|
if (webCodecsService) {
|
|
1043
1406
|
webCodecsService.destroy();
|
|
1044
1407
|
webCodecsService = null;
|