@myrialabs/clopen 0.2.9 → 0.2.11
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/README.md +61 -27
- package/backend/chat/stream-manager.ts +11 -7
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +17 -0
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +219 -34
- package/backend/preview/browser/browser-pool.ts +1 -1
- package/backend/preview/browser/browser-preview-service.ts +23 -34
- package/backend/preview/browser/browser-tab-manager.ts +16 -1
- package/backend/preview/browser/browser-video-capture.ts +15 -3
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/scripts/video-stream.ts +39 -4
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +35 -15
- package/backend/ws/preview/index.ts +8 -0
- package/frontend/components/chat/input/ChatInput.svelte +3 -3
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/files/FileNode.svelte +16 -58
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
- package/frontend/components/preview/browser/components/Canvas.svelte +158 -64
- package/frontend/components/preview/browser/components/Container.svelte +26 -8
- package/frontend/components/preview/browser/components/Toolbar.svelte +35 -18
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +26 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +66 -9
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- package/frontend/components/workspace/PanelHeader.svelte +8 -6
- package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
- package/frontend/services/chat/chat.service.ts +25 -3
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +277 -61
- package/package.json +2 -2
|
@@ -132,6 +132,10 @@ export class BrowserWebCodecsService {
|
|
|
132
132
|
// Navigation state - when true, DataChannel close is expected and recovery is suppressed
|
|
133
133
|
private isNavigating = false;
|
|
134
134
|
private navigationCleanupFn: (() => void) | null = null;
|
|
135
|
+
private navigationSafetyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
136
|
+
|
|
137
|
+
// SPA navigation frame freeze — holds last frame briefly during SPA transitions
|
|
138
|
+
private spaFreezeUntil = 0;
|
|
135
139
|
|
|
136
140
|
// WebSocket cleanup
|
|
137
141
|
private wsCleanupFunctions: Array<() => void> = [];
|
|
@@ -142,11 +146,32 @@ export class BrowserWebCodecsService {
|
|
|
142
146
|
// Bandwidth logging interval
|
|
143
147
|
private bandwidthLogIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
144
148
|
|
|
149
|
+
// User gesture listener for AudioContext resume (needed after page refresh)
|
|
150
|
+
private userGestureHandler: (() => void) | null = null;
|
|
151
|
+
|
|
145
152
|
constructor(projectId: string) {
|
|
146
153
|
if (!projectId) {
|
|
147
154
|
throw new Error('projectId is required for BrowserWebCodecsService');
|
|
148
155
|
}
|
|
149
156
|
this.projectId = projectId;
|
|
157
|
+
|
|
158
|
+
// Register a one-time user gesture listener to resume suspended AudioContext.
|
|
159
|
+
// After page refresh, AudioContext cannot resume without a user gesture.
|
|
160
|
+
// This listener fires on the first click/keydown and resumes it.
|
|
161
|
+
this.userGestureHandler = () => {
|
|
162
|
+
if (this.audioContext && this.audioContext.state === 'suspended') {
|
|
163
|
+
this.audioContext.resume().catch(() => {});
|
|
164
|
+
debug.log('webcodecs', 'AudioContext resumed via user gesture');
|
|
165
|
+
}
|
|
166
|
+
// Remove listeners after first successful gesture
|
|
167
|
+
if (this.userGestureHandler) {
|
|
168
|
+
document.removeEventListener('click', this.userGestureHandler);
|
|
169
|
+
document.removeEventListener('keydown', this.userGestureHandler);
|
|
170
|
+
this.userGestureHandler = null;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
document.addEventListener('click', this.userGestureHandler, { once: false });
|
|
174
|
+
document.addEventListener('keydown', this.userGestureHandler, { once: false });
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
/**
|
|
@@ -218,8 +243,10 @@ export class BrowserWebCodecsService {
|
|
|
218
243
|
this.setupEventListeners();
|
|
219
244
|
|
|
220
245
|
// Request server to start streaming and get offer
|
|
246
|
+
// Send explicit tabId to ensure backend targets the correct tab
|
|
247
|
+
// even if user switches tabs during the async negotiation
|
|
221
248
|
debug.log('webcodecs', `[DIAG] Sending preview:browser-stream-start for session: ${sessionId}`);
|
|
222
|
-
const response = await ws.http('preview:browser-stream-start', {}, 30000);
|
|
249
|
+
const response = await ws.http('preview:browser-stream-start', { tabId: sessionId }, 30000);
|
|
223
250
|
debug.log('webcodecs', `[DIAG] preview:browser-stream-start response: success=${response.success}, hasOffer=${!!response.offer}, message=${response.message}`);
|
|
224
251
|
|
|
225
252
|
if (!response.success) {
|
|
@@ -237,16 +264,32 @@ export class BrowserWebCodecsService {
|
|
|
237
264
|
sdp: response.offer.sdp
|
|
238
265
|
});
|
|
239
266
|
} else {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
267
|
+
// Offer not ready yet — peer may still be initializing. Retry with backoff.
|
|
268
|
+
debug.log('webcodecs', `[DIAG] No offer in stream-start response, retrying stream-offer with backoff`);
|
|
269
|
+
|
|
270
|
+
let offer: { type: string; sdp?: string } | undefined;
|
|
271
|
+
const offerMaxRetries = 5;
|
|
272
|
+
const offerRetryDelay = 200;
|
|
273
|
+
|
|
274
|
+
for (let attempt = 0; attempt < offerMaxRetries; attempt++) {
|
|
275
|
+
if (attempt > 0) {
|
|
276
|
+
await new Promise(resolve => setTimeout(resolve, offerRetryDelay * attempt));
|
|
277
|
+
}
|
|
278
|
+
debug.log('webcodecs', `[DIAG] stream-offer attempt ${attempt + 1}/${offerMaxRetries}`);
|
|
279
|
+
const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
|
|
280
|
+
if (offerResponse.offer) {
|
|
281
|
+
offer = offerResponse.offer;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (offer) {
|
|
244
287
|
await this.handleOffer({
|
|
245
|
-
type:
|
|
246
|
-
sdp:
|
|
288
|
+
type: offer.type as RTCSdpType,
|
|
289
|
+
sdp: offer.sdp
|
|
247
290
|
});
|
|
248
291
|
} else {
|
|
249
|
-
throw new Error('No offer received from server');
|
|
292
|
+
throw new Error('No offer received from server after retries');
|
|
250
293
|
}
|
|
251
294
|
}
|
|
252
295
|
|
|
@@ -289,6 +332,11 @@ export class BrowserWebCodecsService {
|
|
|
289
332
|
|
|
290
333
|
this.dataChannel.onclose = () => {
|
|
291
334
|
debug.log('webcodecs', 'DataChannel closed');
|
|
335
|
+
// Clear navigation safety timeout — DataChannel closed normally
|
|
336
|
+
if (this.navigationSafetyTimeout) {
|
|
337
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
338
|
+
this.navigationSafetyTimeout = null;
|
|
339
|
+
}
|
|
292
340
|
// Trigger recovery if channel closed while we were connected
|
|
293
341
|
// BUT skip recovery if we're navigating (expected behavior during page navigation)
|
|
294
342
|
if (this.isConnected && !this.isCleaningUp && !this.isNavigating && this.onConnectionFailed) {
|
|
@@ -300,8 +348,8 @@ export class BrowserWebCodecsService {
|
|
|
300
348
|
if (this.onReconnectingStart) {
|
|
301
349
|
this.onReconnectingStart();
|
|
302
350
|
}
|
|
303
|
-
// Backend
|
|
304
|
-
//
|
|
351
|
+
// Backend is already ready (navigation-complete fires after backend setup).
|
|
352
|
+
// Short delay to let state settle, then trigger fast reconnection.
|
|
305
353
|
setTimeout(() => {
|
|
306
354
|
if (this.isCleaningUp) return;
|
|
307
355
|
debug.log('webcodecs', '🔄 Triggering fast reconnection after navigation');
|
|
@@ -313,16 +361,22 @@ export class BrowserWebCodecsService {
|
|
|
313
361
|
// Fallback to regular recovery if no navigation handler
|
|
314
362
|
this.onConnectionFailed();
|
|
315
363
|
}
|
|
316
|
-
},
|
|
364
|
+
}, 100);
|
|
317
365
|
}
|
|
318
366
|
};
|
|
319
367
|
|
|
320
368
|
this.dataChannel.onerror = (error) => {
|
|
369
|
+
// Suppress error log during intentional cleanup (User-Initiated Abort is expected)
|
|
370
|
+
if (this.isCleaningUp) {
|
|
371
|
+
debug.log('webcodecs', 'DataChannel error during cleanup (expected, suppressed)');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
321
375
|
debug.error('webcodecs', 'DataChannel error:', error);
|
|
322
|
-
debug.log('webcodecs', `DataChannel error state: isConnected=${this.isConnected},
|
|
376
|
+
debug.log('webcodecs', `DataChannel error state: isConnected=${this.isConnected}, isNavigating=${this.isNavigating}`);
|
|
323
377
|
// Trigger recovery on DataChannel error
|
|
324
378
|
// BUT skip recovery if we're navigating (expected behavior during page navigation)
|
|
325
|
-
if (this.isConnected && !this.
|
|
379
|
+
if (this.isConnected && !this.isNavigating && this.onConnectionFailed) {
|
|
326
380
|
debug.warn('webcodecs', 'DataChannel error - triggering recovery');
|
|
327
381
|
this.onConnectionFailed();
|
|
328
382
|
} else if (this.isNavigating) {
|
|
@@ -338,16 +392,21 @@ export class BrowserWebCodecsService {
|
|
|
338
392
|
// Handle ICE candidates
|
|
339
393
|
this.peerConnection.onicecandidate = (event) => {
|
|
340
394
|
if (event.candidate && this.sessionId) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}).catch((error) => {
|
|
395
|
+
const candidateInit: RTCIceCandidateInit = {
|
|
396
|
+
candidate: event.candidate.candidate,
|
|
397
|
+
sdpMid: event.candidate.sdpMid,
|
|
398
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
ws.http('preview:browser-stream-ice', { candidate: candidateInit, tabId: this.sessionId }).catch((error) => {
|
|
349
402
|
debug.warn('webcodecs', 'Failed to send ICE candidate:', error);
|
|
350
403
|
});
|
|
404
|
+
|
|
405
|
+
// Also send loopback version for VPN compatibility (same-machine peers)
|
|
406
|
+
const loopback = this.createLoopbackCandidate(candidateInit);
|
|
407
|
+
if (loopback) {
|
|
408
|
+
ws.http('preview:browser-stream-ice', { candidate: loopback, tabId: this.sessionId }).catch(() => {});
|
|
409
|
+
}
|
|
351
410
|
}
|
|
352
411
|
};
|
|
353
412
|
|
|
@@ -420,12 +479,12 @@ export class BrowserWebCodecsService {
|
|
|
420
479
|
debug.log('webcodecs', 'Local description set');
|
|
421
480
|
|
|
422
481
|
if (this.sessionId) {
|
|
423
|
-
// Backend uses active tab automatically
|
|
424
482
|
await ws.http('preview:browser-stream-answer', {
|
|
425
483
|
answer: {
|
|
426
484
|
type: answer.type,
|
|
427
485
|
sdp: answer.sdp
|
|
428
|
-
}
|
|
486
|
+
},
|
|
487
|
+
tabId: this.sessionId
|
|
429
488
|
});
|
|
430
489
|
}
|
|
431
490
|
}
|
|
@@ -548,6 +607,11 @@ export class BrowserWebCodecsService {
|
|
|
548
607
|
output: (frame) => this.handleDecodedVideoFrame(frame),
|
|
549
608
|
error: (e) => {
|
|
550
609
|
debug.error('webcodecs', 'VideoDecoder error:', e);
|
|
610
|
+
// Null out so next keyframe triggers reinitialization.
|
|
611
|
+
// Without this, the decoder stays in 'closed' state but non-null,
|
|
612
|
+
// causing handleVideoChunk's `!this.videoDecoder` check to never fire
|
|
613
|
+
// → all subsequent frames permanently dropped (stuck frames bug).
|
|
614
|
+
this.videoDecoder = null;
|
|
551
615
|
}
|
|
552
616
|
});
|
|
553
617
|
|
|
@@ -579,6 +643,8 @@ export class BrowserWebCodecsService {
|
|
|
579
643
|
output: (frame) => this.handleDecodedAudioFrame(frame),
|
|
580
644
|
error: (e) => {
|
|
581
645
|
debug.error('webcodecs', 'AudioDecoder error:', e);
|
|
646
|
+
// Null out so next chunk triggers reinitialization.
|
|
647
|
+
this.audioDecoder = null;
|
|
582
648
|
}
|
|
583
649
|
});
|
|
584
650
|
|
|
@@ -624,6 +690,17 @@ export class BrowserWebCodecsService {
|
|
|
624
690
|
return;
|
|
625
691
|
}
|
|
626
692
|
|
|
693
|
+
// During SPA navigation freeze, skip rendering to hold the last frame
|
|
694
|
+
// This prevents brief white flashes during SPA page transitions
|
|
695
|
+
if (this.spaFreezeUntil > 0 && Date.now() < this.spaFreezeUntil) {
|
|
696
|
+
frame.close();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Auto-reset freeze after it expires
|
|
700
|
+
if (this.spaFreezeUntil > 0) {
|
|
701
|
+
this.spaFreezeUntil = 0;
|
|
702
|
+
}
|
|
703
|
+
|
|
627
704
|
try {
|
|
628
705
|
// Update stats
|
|
629
706
|
this.stats.videoFramesDecoded++;
|
|
@@ -658,6 +735,10 @@ export class BrowserWebCodecsService {
|
|
|
658
735
|
if (this.isNavigating) {
|
|
659
736
|
debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
|
|
660
737
|
this.isNavigating = false;
|
|
738
|
+
if (this.navigationSafetyTimeout) {
|
|
739
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
740
|
+
this.navigationSafetyTimeout = null;
|
|
741
|
+
}
|
|
661
742
|
}
|
|
662
743
|
}
|
|
663
744
|
|
|
@@ -701,6 +782,10 @@ export class BrowserWebCodecsService {
|
|
|
701
782
|
this.isRenderingFrame = false;
|
|
702
783
|
|
|
703
784
|
if (!this.pendingFrame || this.isCleaningUp || !this.canvas || !this.ctx) {
|
|
785
|
+
if (this.pendingFrame) {
|
|
786
|
+
this.pendingFrame.close();
|
|
787
|
+
this.pendingFrame = null;
|
|
788
|
+
}
|
|
704
789
|
return;
|
|
705
790
|
}
|
|
706
791
|
|
|
@@ -710,15 +795,15 @@ export class BrowserWebCodecsService {
|
|
|
710
795
|
const frameTimestamp = (this.pendingFrame.timestamp - this.firstFrameTimestamp) / 1000; // convert to ms
|
|
711
796
|
const elapsedTime = timestamp - this.startTime;
|
|
712
797
|
|
|
713
|
-
// Check if we're ahead of schedule (frame came too early)
|
|
714
|
-
// If so, we might want to delay rendering, but for low-latency
|
|
715
|
-
// we render immediately to minimize lag
|
|
716
798
|
const timeDrift = elapsedTime - frameTimestamp;
|
|
717
799
|
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
800
|
+
// Auto-reset timing when drift exceeds 2 seconds.
|
|
801
|
+
// This handles stale timing state after reconnects or rapid tab switches
|
|
802
|
+
// where startTime/firstFrameTimestamp were not properly reset.
|
|
803
|
+
if (Math.abs(timeDrift) > 2000 && this.startTime > 0) {
|
|
804
|
+
debug.warn('webcodecs', `Frame timing drift reset (was ${timeDrift.toFixed(0)}ms)`);
|
|
805
|
+
this.startTime = timestamp;
|
|
806
|
+
this.firstFrameTimestamp = this.pendingFrame.timestamp;
|
|
722
807
|
}
|
|
723
808
|
|
|
724
809
|
// Render to canvas with optimal settings
|
|
@@ -849,9 +934,16 @@ export class BrowserWebCodecsService {
|
|
|
849
934
|
|
|
850
935
|
const cleanupNavComplete = ws.on('preview:browser-navigation', (data) => {
|
|
851
936
|
if (data.sessionId === this.sessionId) {
|
|
852
|
-
// Keep isNavigating true for a short period to allow reconnection
|
|
853
|
-
// Will be reset when new frames arrive or reconnection completes
|
|
854
937
|
debug.log('webcodecs', `Navigation completed (direct WS) for session ${data.sessionId}`);
|
|
938
|
+
|
|
939
|
+
// If isNavigating was NOT set by navigation-loading (SPA-like case where
|
|
940
|
+
// framenavigated fires without a document request), set it now so the
|
|
941
|
+
// subsequent DataChannel close triggers fast reconnect instead of full recovery
|
|
942
|
+
if (!this.isNavigating) {
|
|
943
|
+
this.isNavigating = true;
|
|
944
|
+
debug.log('webcodecs', '✅ Set isNavigating=true on navigation complete (no loading event preceded)');
|
|
945
|
+
}
|
|
946
|
+
|
|
855
947
|
// Signal reconnecting state IMMEDIATELY when navigation completes
|
|
856
948
|
// This eliminates the gap between isNavigating=false and DataChannel close
|
|
857
949
|
// ensuring the overlay stays visible continuously
|
|
@@ -859,14 +951,62 @@ export class BrowserWebCodecsService {
|
|
|
859
951
|
debug.log('webcodecs', '🔄 Pre-emptive reconnecting state on navigation complete');
|
|
860
952
|
this.onReconnectingStart();
|
|
861
953
|
}
|
|
954
|
+
|
|
955
|
+
// Fast safety timeout: if DataChannel doesn't close within 100ms,
|
|
956
|
+
// force a full recovery (stop + start fresh stream). Sometimes
|
|
957
|
+
// the old WebRTC connection lingers for 10-16s before ICE timeout.
|
|
958
|
+
// Using onConnectionFailed triggers attemptRecovery (full stop+start),
|
|
959
|
+
// which is the same path as tab switching and takes ~100ms to first frame.
|
|
960
|
+
if (this.navigationSafetyTimeout) {
|
|
961
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
962
|
+
}
|
|
963
|
+
this.navigationSafetyTimeout = setTimeout(() => {
|
|
964
|
+
this.navigationSafetyTimeout = null;
|
|
965
|
+
if (this.isCleaningUp || !this.isNavigating) return;
|
|
966
|
+
debug.warn('webcodecs', '⏰ Navigation safety timeout - forcing full recovery');
|
|
967
|
+
this.isNavigating = false;
|
|
968
|
+
if (this.onConnectionFailed) {
|
|
969
|
+
this.onConnectionFailed();
|
|
970
|
+
}
|
|
971
|
+
}, 100);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// Listen for SPA navigation events (pushState/replaceState/hash changes)
|
|
976
|
+
// Reset isNavigating if it was set by a preceding navigation-loading event
|
|
977
|
+
// that the SPA router intercepted (cancelled the full navigation)
|
|
978
|
+
const cleanupNavSpa = ws.on('preview:browser-navigation-spa', (data) => {
|
|
979
|
+
if (data.sessionId === this.sessionId && this.isNavigating) {
|
|
980
|
+
debug.log('webcodecs', '🔄 SPA navigation received - resetting isNavigating (no stream restart needed)');
|
|
981
|
+
this.isNavigating = false;
|
|
862
982
|
}
|
|
863
983
|
});
|
|
864
984
|
|
|
865
|
-
this.wsCleanupFunctions = [cleanupIce, cleanupState, cleanupCursor, cleanupNavLoading, cleanupNavComplete];
|
|
985
|
+
this.wsCleanupFunctions = [cleanupIce, cleanupState, cleanupCursor, cleanupNavLoading, cleanupNavComplete, cleanupNavSpa];
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Create a loopback (127.0.0.1) copy of a host ICE candidate.
|
|
990
|
+
* Ensures WebRTC connects via loopback when VPN (e.g. Cloudflare WARP)
|
|
991
|
+
* interferes with host candidate connectivity between same-machine peers.
|
|
992
|
+
*/
|
|
993
|
+
private createLoopbackCandidate(candidate: RTCIceCandidateInit): RTCIceCandidateInit | null {
|
|
994
|
+
if (!candidate.candidate) return null;
|
|
995
|
+
if (!candidate.candidate.includes('typ host')) return null;
|
|
996
|
+
|
|
997
|
+
const parts = candidate.candidate.split(' ');
|
|
998
|
+
if (parts.length < 8) return null;
|
|
999
|
+
|
|
1000
|
+
// Index 4 is the address field in ICE candidate format
|
|
1001
|
+
const address = parts[4];
|
|
1002
|
+
if (address === '127.0.0.1' || address === '::1') return null;
|
|
1003
|
+
|
|
1004
|
+
parts[4] = '127.0.0.1';
|
|
1005
|
+
return { ...candidate, candidate: parts.join(' ') };
|
|
866
1006
|
}
|
|
867
1007
|
|
|
868
1008
|
/**
|
|
869
|
-
* Add ICE candidate
|
|
1009
|
+
* Add ICE candidate (+ loopback variant for VPN compatibility)
|
|
870
1010
|
*/
|
|
871
1011
|
private async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
|
872
1012
|
if (!this.peerConnection) return;
|
|
@@ -876,6 +1016,16 @@ export class BrowserWebCodecsService {
|
|
|
876
1016
|
} catch (error) {
|
|
877
1017
|
debug.warn('webcodecs', 'Add ICE candidate error:', error);
|
|
878
1018
|
}
|
|
1019
|
+
|
|
1020
|
+
// Also try loopback version for VPN compatibility (same-machine peers)
|
|
1021
|
+
const loopback = this.createLoopbackCandidate(candidate);
|
|
1022
|
+
if (loopback) {
|
|
1023
|
+
try {
|
|
1024
|
+
await this.peerConnection.addIceCandidate(new RTCIceCandidate(loopback));
|
|
1025
|
+
} catch {
|
|
1026
|
+
// Expected to fail if loopback is not applicable
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
879
1029
|
}
|
|
880
1030
|
|
|
881
1031
|
/**
|
|
@@ -1014,7 +1164,7 @@ export class BrowserWebCodecsService {
|
|
|
1014
1164
|
await this.createPeerConnection();
|
|
1015
1165
|
|
|
1016
1166
|
// Get offer from backend's existing peer (don't start new streaming)
|
|
1017
|
-
const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
|
|
1167
|
+
const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
|
|
1018
1168
|
if (offerResponse.offer) {
|
|
1019
1169
|
await this.handleOffer({
|
|
1020
1170
|
type: offerResponse.offer.type as RTCSdpType,
|
|
@@ -1057,13 +1207,19 @@ export class BrowserWebCodecsService {
|
|
|
1057
1207
|
}
|
|
1058
1208
|
|
|
1059
1209
|
// Cleanup WebSocket listeners
|
|
1060
|
-
|
|
1210
|
+
try {
|
|
1211
|
+
this.wsCleanupFunctions.forEach((cleanup) => cleanup());
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
debug.warn('webcodecs', 'Error in ws cleanup:', e);
|
|
1214
|
+
}
|
|
1061
1215
|
this.wsCleanupFunctions = [];
|
|
1062
1216
|
|
|
1063
|
-
// Close decoders
|
|
1217
|
+
// Close decoders immediately (reset + close, no flush).
|
|
1218
|
+
// Flushing processes all queued frames which is slow and can fire
|
|
1219
|
+
// stale callbacks during rapid tab switching.
|
|
1064
1220
|
if (this.videoDecoder) {
|
|
1065
1221
|
try {
|
|
1066
|
-
|
|
1222
|
+
this.videoDecoder.reset();
|
|
1067
1223
|
this.videoDecoder.close();
|
|
1068
1224
|
} catch (e) {}
|
|
1069
1225
|
this.videoDecoder = null;
|
|
@@ -1071,7 +1227,7 @@ export class BrowserWebCodecsService {
|
|
|
1071
1227
|
|
|
1072
1228
|
if (this.audioDecoder) {
|
|
1073
1229
|
try {
|
|
1074
|
-
|
|
1230
|
+
this.audioDecoder.reset();
|
|
1075
1231
|
this.audioDecoder.close();
|
|
1076
1232
|
} catch (e) {}
|
|
1077
1233
|
this.audioDecoder = null;
|
|
@@ -1144,22 +1300,22 @@ export class BrowserWebCodecsService {
|
|
|
1144
1300
|
}
|
|
1145
1301
|
|
|
1146
1302
|
// Cleanup WebSocket listeners
|
|
1147
|
-
|
|
1303
|
+
try {
|
|
1304
|
+
this.wsCleanupFunctions.forEach((cleanup) => cleanup());
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
debug.warn('webcodecs', 'Error in ws cleanup:', e);
|
|
1307
|
+
}
|
|
1148
1308
|
this.wsCleanupFunctions = [];
|
|
1149
1309
|
|
|
1150
|
-
// Notify server
|
|
1310
|
+
// Notify server with explicit tabId (fire-and-forget for speed during rapid switching)
|
|
1151
1311
|
if (this.sessionId) {
|
|
1152
|
-
|
|
1153
|
-
await ws.http('preview:browser-stream-stop', {});
|
|
1154
|
-
} catch (error) {
|
|
1155
|
-
debug.warn('webcodecs', 'Failed to notify server:', error);
|
|
1156
|
-
}
|
|
1312
|
+
ws.http('preview:browser-stream-stop', { tabId: this.sessionId }).catch(() => {});
|
|
1157
1313
|
}
|
|
1158
1314
|
|
|
1159
|
-
// Close decoders
|
|
1315
|
+
// Close decoders immediately (reset + close, no flush for speed)
|
|
1160
1316
|
if (this.videoDecoder) {
|
|
1161
1317
|
try {
|
|
1162
|
-
|
|
1318
|
+
this.videoDecoder.reset();
|
|
1163
1319
|
this.videoDecoder.close();
|
|
1164
1320
|
} catch (e) {}
|
|
1165
1321
|
this.videoDecoder = null;
|
|
@@ -1167,17 +1323,16 @@ export class BrowserWebCodecsService {
|
|
|
1167
1323
|
|
|
1168
1324
|
if (this.audioDecoder) {
|
|
1169
1325
|
try {
|
|
1170
|
-
|
|
1326
|
+
this.audioDecoder.reset();
|
|
1171
1327
|
this.audioDecoder.close();
|
|
1172
1328
|
} catch (e) {}
|
|
1173
1329
|
this.audioDecoder = null;
|
|
1174
1330
|
}
|
|
1175
1331
|
|
|
1176
|
-
//
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
}
|
|
1332
|
+
// Keep AudioContext alive across stop/start cycles — it was created during
|
|
1333
|
+
// a user gesture in startStreaming() and closing it means we can't resume
|
|
1334
|
+
// without another user gesture. Only destroy() should close it.
|
|
1335
|
+
// Just reset audio playback scheduling state below.
|
|
1181
1336
|
|
|
1182
1337
|
// Close data channel
|
|
1183
1338
|
if (this.dataChannel) {
|
|
@@ -1247,6 +1402,10 @@ export class BrowserWebCodecsService {
|
|
|
1247
1402
|
|
|
1248
1403
|
// Reset navigation state
|
|
1249
1404
|
this.isNavigating = false;
|
|
1405
|
+
if (this.navigationSafetyTimeout) {
|
|
1406
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
1407
|
+
this.navigationSafetyTimeout = null;
|
|
1408
|
+
}
|
|
1250
1409
|
|
|
1251
1410
|
if (this.onConnectionChange) {
|
|
1252
1411
|
this.onConnectionChange(false);
|
|
@@ -1276,12 +1435,16 @@ export class BrowserWebCodecsService {
|
|
|
1276
1435
|
this.pendingFrame = null;
|
|
1277
1436
|
}
|
|
1278
1437
|
|
|
1279
|
-
|
|
1438
|
+
try {
|
|
1439
|
+
this.wsCleanupFunctions.forEach((cleanup) => cleanup());
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
debug.warn('webcodecs', 'Error in ws cleanup:', e);
|
|
1442
|
+
}
|
|
1280
1443
|
this.wsCleanupFunctions = [];
|
|
1281
1444
|
|
|
1282
1445
|
if (this.videoDecoder) {
|
|
1283
1446
|
try {
|
|
1284
|
-
|
|
1447
|
+
this.videoDecoder.reset();
|
|
1285
1448
|
this.videoDecoder.close();
|
|
1286
1449
|
} catch (e) {}
|
|
1287
1450
|
this.videoDecoder = null;
|
|
@@ -1289,16 +1452,13 @@ export class BrowserWebCodecsService {
|
|
|
1289
1452
|
|
|
1290
1453
|
if (this.audioDecoder) {
|
|
1291
1454
|
try {
|
|
1292
|
-
|
|
1455
|
+
this.audioDecoder.reset();
|
|
1293
1456
|
this.audioDecoder.close();
|
|
1294
1457
|
} catch (e) {}
|
|
1295
1458
|
this.audioDecoder = null;
|
|
1296
1459
|
}
|
|
1297
1460
|
|
|
1298
|
-
|
|
1299
|
-
await this.audioContext.close().catch(() => {});
|
|
1300
|
-
this.audioContext = null;
|
|
1301
|
-
}
|
|
1461
|
+
// Keep AudioContext alive — same rationale as cleanup()
|
|
1302
1462
|
|
|
1303
1463
|
if (this.dataChannel) {
|
|
1304
1464
|
this.dataChannel.close();
|
|
@@ -1313,6 +1473,10 @@ export class BrowserWebCodecsService {
|
|
|
1313
1473
|
this.isConnected = false;
|
|
1314
1474
|
this.sessionId = null;
|
|
1315
1475
|
|
|
1476
|
+
// Reset stats (prevent stale firstFrameRendered from skipping timing reset)
|
|
1477
|
+
this.stats.firstFrameRendered = false;
|
|
1478
|
+
this.stats.isConnected = false;
|
|
1479
|
+
|
|
1316
1480
|
// Reset audio playback state
|
|
1317
1481
|
this.nextAudioPlayTime = 0;
|
|
1318
1482
|
this.audioBufferQueue = [];
|
|
@@ -1334,6 +1498,12 @@ export class BrowserWebCodecsService {
|
|
|
1334
1498
|
this.lastVideoTimestamp = 0;
|
|
1335
1499
|
this.lastVideoRealTime = 0;
|
|
1336
1500
|
|
|
1501
|
+
// Clear navigation safety timeout
|
|
1502
|
+
if (this.navigationSafetyTimeout) {
|
|
1503
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
1504
|
+
this.navigationSafetyTimeout = null;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1337
1507
|
this.isCleaningUp = false;
|
|
1338
1508
|
}
|
|
1339
1509
|
|
|
@@ -1361,6 +1531,24 @@ export class BrowserWebCodecsService {
|
|
|
1361
1531
|
return this.isConnected;
|
|
1362
1532
|
}
|
|
1363
1533
|
|
|
1534
|
+
/**
|
|
1535
|
+
* Immediately block frame rendering without closing the WebRTC connection.
|
|
1536
|
+
* Call this as soon as a tab switch is detected to prevent the old session's
|
|
1537
|
+
* frames from painting onto the canvas after it has been cleared/snapshot-restored.
|
|
1538
|
+
* The cleanup + new connection will complete asynchronously via stopStreaming/startStreaming.
|
|
1539
|
+
*/
|
|
1540
|
+
pauseRendering(): void {
|
|
1541
|
+
this.isCleaningUp = true;
|
|
1542
|
+
if (this.renderFrameId !== null) {
|
|
1543
|
+
cancelAnimationFrame(this.renderFrameId);
|
|
1544
|
+
this.renderFrameId = null;
|
|
1545
|
+
}
|
|
1546
|
+
if (this.pendingFrame) {
|
|
1547
|
+
this.pendingFrame.close();
|
|
1548
|
+
this.pendingFrame = null;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1364
1552
|
// Event handlers
|
|
1365
1553
|
setConnectionChangeHandler(handler: (connected: boolean) => void): void {
|
|
1366
1554
|
this.onConnectionChange = handler;
|
|
@@ -1382,6 +1570,15 @@ export class BrowserWebCodecsService {
|
|
|
1382
1570
|
this.onFirstFrame = handler;
|
|
1383
1571
|
}
|
|
1384
1572
|
|
|
1573
|
+
/**
|
|
1574
|
+
* Freeze frame rendering briefly during SPA navigation.
|
|
1575
|
+
* Holds the current canvas content to prevent white flash during
|
|
1576
|
+
* SPA page transitions (pushState/replaceState).
|
|
1577
|
+
*/
|
|
1578
|
+
freezeForSpaNavigation(durationMs = 150): void {
|
|
1579
|
+
this.spaFreezeUntil = Date.now() + durationMs;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1385
1582
|
setErrorHandler(handler: (error: Error) => void): void {
|
|
1386
1583
|
this.onError = handler;
|
|
1387
1584
|
}
|
|
@@ -1401,6 +1598,11 @@ export class BrowserWebCodecsService {
|
|
|
1401
1598
|
*/
|
|
1402
1599
|
setNavigating(navigating: boolean): void {
|
|
1403
1600
|
this.isNavigating = navigating;
|
|
1601
|
+
// Clear safety timeout when navigation state is externally reset
|
|
1602
|
+
if (!navigating && this.navigationSafetyTimeout) {
|
|
1603
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
1604
|
+
this.navigationSafetyTimeout = null;
|
|
1605
|
+
}
|
|
1404
1606
|
debug.log('webcodecs', `Navigation state set: ${navigating}`);
|
|
1405
1607
|
}
|
|
1406
1608
|
|
|
@@ -1416,6 +1618,13 @@ export class BrowserWebCodecsService {
|
|
|
1416
1618
|
*/
|
|
1417
1619
|
destroy(): void {
|
|
1418
1620
|
this.cleanup();
|
|
1621
|
+
|
|
1622
|
+
// Close AudioContext only on full destroy (not reused after this)
|
|
1623
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
1624
|
+
this.audioContext.close().catch(() => {});
|
|
1625
|
+
this.audioContext = null;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1419
1628
|
this.onConnectionChange = null;
|
|
1420
1629
|
this.onConnectionFailed = null;
|
|
1421
1630
|
this.onNavigationReconnect = null;
|
|
@@ -1423,5 +1632,12 @@ export class BrowserWebCodecsService {
|
|
|
1423
1632
|
this.onError = null;
|
|
1424
1633
|
this.onStats = null;
|
|
1425
1634
|
this.onCursorChange = null;
|
|
1635
|
+
|
|
1636
|
+
// Remove user gesture listener
|
|
1637
|
+
if (this.userGestureHandler) {
|
|
1638
|
+
document.removeEventListener('click', this.userGestureHandler);
|
|
1639
|
+
document.removeEventListener('keydown', this.userGestureHandler);
|
|
1640
|
+
this.userGestureHandler = null;
|
|
1641
|
+
}
|
|
1426
1642
|
}
|
|
1427
1643
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"type": "git",
|
|
14
14
|
"url": "https://github.com/myrialabs/clopen.git"
|
|
15
15
|
},
|
|
16
|
-
"homepage": "https://
|
|
16
|
+
"homepage": "https://clopen.myrialabs.dev",
|
|
17
17
|
"bugs": {
|
|
18
18
|
"url": "https://github.com/myrialabs/clopen/issues"
|
|
19
19
|
},
|