@myrialabs/clopen 0.2.10 → 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 +5 -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 +31 -3
- package/backend/preview/browser/browser-preview-service.ts +0 -34
- package/backend/preview/browser/browser-video-capture.ts +13 -1
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/types.ts +7 -6
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- 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/components/Canvas.svelte +119 -42
- package/frontend/components/preview/browser/components/Container.svelte +18 -3
- package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- 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 +170 -46
- package/package.json +2 -2
|
@@ -132,6 +132,7 @@ 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;
|
|
135
136
|
|
|
136
137
|
// SPA navigation frame freeze — holds last frame briefly during SPA transitions
|
|
137
138
|
private spaFreezeUntil = 0;
|
|
@@ -145,11 +146,32 @@ export class BrowserWebCodecsService {
|
|
|
145
146
|
// Bandwidth logging interval
|
|
146
147
|
private bandwidthLogIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
147
148
|
|
|
149
|
+
// User gesture listener for AudioContext resume (needed after page refresh)
|
|
150
|
+
private userGestureHandler: (() => void) | null = null;
|
|
151
|
+
|
|
148
152
|
constructor(projectId: string) {
|
|
149
153
|
if (!projectId) {
|
|
150
154
|
throw new Error('projectId is required for BrowserWebCodecsService');
|
|
151
155
|
}
|
|
152
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 });
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/**
|
|
@@ -221,8 +243,10 @@ export class BrowserWebCodecsService {
|
|
|
221
243
|
this.setupEventListeners();
|
|
222
244
|
|
|
223
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
|
|
224
248
|
debug.log('webcodecs', `[DIAG] Sending preview:browser-stream-start for session: ${sessionId}`);
|
|
225
|
-
const response = await ws.http('preview:browser-stream-start', {}, 30000);
|
|
249
|
+
const response = await ws.http('preview:browser-stream-start', { tabId: sessionId }, 30000);
|
|
226
250
|
debug.log('webcodecs', `[DIAG] preview:browser-stream-start response: success=${response.success}, hasOffer=${!!response.offer}, message=${response.message}`);
|
|
227
251
|
|
|
228
252
|
if (!response.success) {
|
|
@@ -252,7 +276,7 @@ export class BrowserWebCodecsService {
|
|
|
252
276
|
await new Promise(resolve => setTimeout(resolve, offerRetryDelay * attempt));
|
|
253
277
|
}
|
|
254
278
|
debug.log('webcodecs', `[DIAG] stream-offer attempt ${attempt + 1}/${offerMaxRetries}`);
|
|
255
|
-
const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
|
|
279
|
+
const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
|
|
256
280
|
if (offerResponse.offer) {
|
|
257
281
|
offer = offerResponse.offer;
|
|
258
282
|
break;
|
|
@@ -308,6 +332,11 @@ export class BrowserWebCodecsService {
|
|
|
308
332
|
|
|
309
333
|
this.dataChannel.onclose = () => {
|
|
310
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
|
+
}
|
|
311
340
|
// Trigger recovery if channel closed while we were connected
|
|
312
341
|
// BUT skip recovery if we're navigating (expected behavior during page navigation)
|
|
313
342
|
if (this.isConnected && !this.isCleaningUp && !this.isNavigating && this.onConnectionFailed) {
|
|
@@ -319,8 +348,8 @@ export class BrowserWebCodecsService {
|
|
|
319
348
|
if (this.onReconnectingStart) {
|
|
320
349
|
this.onReconnectingStart();
|
|
321
350
|
}
|
|
322
|
-
// Backend
|
|
323
|
-
//
|
|
351
|
+
// Backend is already ready (navigation-complete fires after backend setup).
|
|
352
|
+
// Short delay to let state settle, then trigger fast reconnection.
|
|
324
353
|
setTimeout(() => {
|
|
325
354
|
if (this.isCleaningUp) return;
|
|
326
355
|
debug.log('webcodecs', '🔄 Triggering fast reconnection after navigation');
|
|
@@ -332,16 +361,22 @@ export class BrowserWebCodecsService {
|
|
|
332
361
|
// Fallback to regular recovery if no navigation handler
|
|
333
362
|
this.onConnectionFailed();
|
|
334
363
|
}
|
|
335
|
-
},
|
|
364
|
+
}, 100);
|
|
336
365
|
}
|
|
337
366
|
};
|
|
338
367
|
|
|
339
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
|
+
|
|
340
375
|
debug.error('webcodecs', 'DataChannel error:', error);
|
|
341
|
-
debug.log('webcodecs', `DataChannel error state: isConnected=${this.isConnected},
|
|
376
|
+
debug.log('webcodecs', `DataChannel error state: isConnected=${this.isConnected}, isNavigating=${this.isNavigating}`);
|
|
342
377
|
// Trigger recovery on DataChannel error
|
|
343
378
|
// BUT skip recovery if we're navigating (expected behavior during page navigation)
|
|
344
|
-
if (this.isConnected && !this.
|
|
379
|
+
if (this.isConnected && !this.isNavigating && this.onConnectionFailed) {
|
|
345
380
|
debug.warn('webcodecs', 'DataChannel error - triggering recovery');
|
|
346
381
|
this.onConnectionFailed();
|
|
347
382
|
} else if (this.isNavigating) {
|
|
@@ -363,15 +398,14 @@ export class BrowserWebCodecsService {
|
|
|
363
398
|
sdpMLineIndex: event.candidate.sdpMLineIndex
|
|
364
399
|
};
|
|
365
400
|
|
|
366
|
-
|
|
367
|
-
ws.http('preview:browser-stream-ice', { candidate: candidateInit }).catch((error) => {
|
|
401
|
+
ws.http('preview:browser-stream-ice', { candidate: candidateInit, tabId: this.sessionId }).catch((error) => {
|
|
368
402
|
debug.warn('webcodecs', 'Failed to send ICE candidate:', error);
|
|
369
403
|
});
|
|
370
404
|
|
|
371
405
|
// Also send loopback version for VPN compatibility (same-machine peers)
|
|
372
406
|
const loopback = this.createLoopbackCandidate(candidateInit);
|
|
373
407
|
if (loopback) {
|
|
374
|
-
ws.http('preview:browser-stream-ice', { candidate: loopback }).catch(() => {});
|
|
408
|
+
ws.http('preview:browser-stream-ice', { candidate: loopback, tabId: this.sessionId }).catch(() => {});
|
|
375
409
|
}
|
|
376
410
|
}
|
|
377
411
|
};
|
|
@@ -445,12 +479,12 @@ export class BrowserWebCodecsService {
|
|
|
445
479
|
debug.log('webcodecs', 'Local description set');
|
|
446
480
|
|
|
447
481
|
if (this.sessionId) {
|
|
448
|
-
// Backend uses active tab automatically
|
|
449
482
|
await ws.http('preview:browser-stream-answer', {
|
|
450
483
|
answer: {
|
|
451
484
|
type: answer.type,
|
|
452
485
|
sdp: answer.sdp
|
|
453
|
-
}
|
|
486
|
+
},
|
|
487
|
+
tabId: this.sessionId
|
|
454
488
|
});
|
|
455
489
|
}
|
|
456
490
|
}
|
|
@@ -573,6 +607,11 @@ export class BrowserWebCodecsService {
|
|
|
573
607
|
output: (frame) => this.handleDecodedVideoFrame(frame),
|
|
574
608
|
error: (e) => {
|
|
575
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;
|
|
576
615
|
}
|
|
577
616
|
});
|
|
578
617
|
|
|
@@ -604,6 +643,8 @@ export class BrowserWebCodecsService {
|
|
|
604
643
|
output: (frame) => this.handleDecodedAudioFrame(frame),
|
|
605
644
|
error: (e) => {
|
|
606
645
|
debug.error('webcodecs', 'AudioDecoder error:', e);
|
|
646
|
+
// Null out so next chunk triggers reinitialization.
|
|
647
|
+
this.audioDecoder = null;
|
|
607
648
|
}
|
|
608
649
|
});
|
|
609
650
|
|
|
@@ -694,6 +735,10 @@ export class BrowserWebCodecsService {
|
|
|
694
735
|
if (this.isNavigating) {
|
|
695
736
|
debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
|
|
696
737
|
this.isNavigating = false;
|
|
738
|
+
if (this.navigationSafetyTimeout) {
|
|
739
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
740
|
+
this.navigationSafetyTimeout = null;
|
|
741
|
+
}
|
|
697
742
|
}
|
|
698
743
|
}
|
|
699
744
|
|
|
@@ -737,6 +782,10 @@ export class BrowserWebCodecsService {
|
|
|
737
782
|
this.isRenderingFrame = false;
|
|
738
783
|
|
|
739
784
|
if (!this.pendingFrame || this.isCleaningUp || !this.canvas || !this.ctx) {
|
|
785
|
+
if (this.pendingFrame) {
|
|
786
|
+
this.pendingFrame.close();
|
|
787
|
+
this.pendingFrame = null;
|
|
788
|
+
}
|
|
740
789
|
return;
|
|
741
790
|
}
|
|
742
791
|
|
|
@@ -746,15 +795,15 @@ export class BrowserWebCodecsService {
|
|
|
746
795
|
const frameTimestamp = (this.pendingFrame.timestamp - this.firstFrameTimestamp) / 1000; // convert to ms
|
|
747
796
|
const elapsedTime = timestamp - this.startTime;
|
|
748
797
|
|
|
749
|
-
// Check if we're ahead of schedule (frame came too early)
|
|
750
|
-
// If so, we might want to delay rendering, but for low-latency
|
|
751
|
-
// we render immediately to minimize lag
|
|
752
798
|
const timeDrift = elapsedTime - frameTimestamp;
|
|
753
799
|
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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;
|
|
758
807
|
}
|
|
759
808
|
|
|
760
809
|
// Render to canvas with optimal settings
|
|
@@ -902,6 +951,24 @@ export class BrowserWebCodecsService {
|
|
|
902
951
|
debug.log('webcodecs', '🔄 Pre-emptive reconnecting state on navigation complete');
|
|
903
952
|
this.onReconnectingStart();
|
|
904
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);
|
|
905
972
|
}
|
|
906
973
|
});
|
|
907
974
|
|
|
@@ -1097,7 +1164,7 @@ export class BrowserWebCodecsService {
|
|
|
1097
1164
|
await this.createPeerConnection();
|
|
1098
1165
|
|
|
1099
1166
|
// Get offer from backend's existing peer (don't start new streaming)
|
|
1100
|
-
const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
|
|
1167
|
+
const offerResponse = await ws.http('preview:browser-stream-offer', { tabId: sessionId }, 10000);
|
|
1101
1168
|
if (offerResponse.offer) {
|
|
1102
1169
|
await this.handleOffer({
|
|
1103
1170
|
type: offerResponse.offer.type as RTCSdpType,
|
|
@@ -1140,13 +1207,19 @@ export class BrowserWebCodecsService {
|
|
|
1140
1207
|
}
|
|
1141
1208
|
|
|
1142
1209
|
// Cleanup WebSocket listeners
|
|
1143
|
-
|
|
1210
|
+
try {
|
|
1211
|
+
this.wsCleanupFunctions.forEach((cleanup) => cleanup());
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
debug.warn('webcodecs', 'Error in ws cleanup:', e);
|
|
1214
|
+
}
|
|
1144
1215
|
this.wsCleanupFunctions = [];
|
|
1145
1216
|
|
|
1146
|
-
// 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.
|
|
1147
1220
|
if (this.videoDecoder) {
|
|
1148
1221
|
try {
|
|
1149
|
-
|
|
1222
|
+
this.videoDecoder.reset();
|
|
1150
1223
|
this.videoDecoder.close();
|
|
1151
1224
|
} catch (e) {}
|
|
1152
1225
|
this.videoDecoder = null;
|
|
@@ -1154,7 +1227,7 @@ export class BrowserWebCodecsService {
|
|
|
1154
1227
|
|
|
1155
1228
|
if (this.audioDecoder) {
|
|
1156
1229
|
try {
|
|
1157
|
-
|
|
1230
|
+
this.audioDecoder.reset();
|
|
1158
1231
|
this.audioDecoder.close();
|
|
1159
1232
|
} catch (e) {}
|
|
1160
1233
|
this.audioDecoder = null;
|
|
@@ -1227,22 +1300,22 @@ export class BrowserWebCodecsService {
|
|
|
1227
1300
|
}
|
|
1228
1301
|
|
|
1229
1302
|
// Cleanup WebSocket listeners
|
|
1230
|
-
|
|
1303
|
+
try {
|
|
1304
|
+
this.wsCleanupFunctions.forEach((cleanup) => cleanup());
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
debug.warn('webcodecs', 'Error in ws cleanup:', e);
|
|
1307
|
+
}
|
|
1231
1308
|
this.wsCleanupFunctions = [];
|
|
1232
1309
|
|
|
1233
|
-
// Notify server
|
|
1310
|
+
// Notify server with explicit tabId (fire-and-forget for speed during rapid switching)
|
|
1234
1311
|
if (this.sessionId) {
|
|
1235
|
-
|
|
1236
|
-
await ws.http('preview:browser-stream-stop', {});
|
|
1237
|
-
} catch (error) {
|
|
1238
|
-
debug.warn('webcodecs', 'Failed to notify server:', error);
|
|
1239
|
-
}
|
|
1312
|
+
ws.http('preview:browser-stream-stop', { tabId: this.sessionId }).catch(() => {});
|
|
1240
1313
|
}
|
|
1241
1314
|
|
|
1242
|
-
// Close decoders
|
|
1315
|
+
// Close decoders immediately (reset + close, no flush for speed)
|
|
1243
1316
|
if (this.videoDecoder) {
|
|
1244
1317
|
try {
|
|
1245
|
-
|
|
1318
|
+
this.videoDecoder.reset();
|
|
1246
1319
|
this.videoDecoder.close();
|
|
1247
1320
|
} catch (e) {}
|
|
1248
1321
|
this.videoDecoder = null;
|
|
@@ -1250,17 +1323,16 @@ export class BrowserWebCodecsService {
|
|
|
1250
1323
|
|
|
1251
1324
|
if (this.audioDecoder) {
|
|
1252
1325
|
try {
|
|
1253
|
-
|
|
1326
|
+
this.audioDecoder.reset();
|
|
1254
1327
|
this.audioDecoder.close();
|
|
1255
1328
|
} catch (e) {}
|
|
1256
1329
|
this.audioDecoder = null;
|
|
1257
1330
|
}
|
|
1258
1331
|
|
|
1259
|
-
//
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
}
|
|
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.
|
|
1264
1336
|
|
|
1265
1337
|
// Close data channel
|
|
1266
1338
|
if (this.dataChannel) {
|
|
@@ -1330,6 +1402,10 @@ export class BrowserWebCodecsService {
|
|
|
1330
1402
|
|
|
1331
1403
|
// Reset navigation state
|
|
1332
1404
|
this.isNavigating = false;
|
|
1405
|
+
if (this.navigationSafetyTimeout) {
|
|
1406
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
1407
|
+
this.navigationSafetyTimeout = null;
|
|
1408
|
+
}
|
|
1333
1409
|
|
|
1334
1410
|
if (this.onConnectionChange) {
|
|
1335
1411
|
this.onConnectionChange(false);
|
|
@@ -1359,12 +1435,16 @@ export class BrowserWebCodecsService {
|
|
|
1359
1435
|
this.pendingFrame = null;
|
|
1360
1436
|
}
|
|
1361
1437
|
|
|
1362
|
-
|
|
1438
|
+
try {
|
|
1439
|
+
this.wsCleanupFunctions.forEach((cleanup) => cleanup());
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
debug.warn('webcodecs', 'Error in ws cleanup:', e);
|
|
1442
|
+
}
|
|
1363
1443
|
this.wsCleanupFunctions = [];
|
|
1364
1444
|
|
|
1365
1445
|
if (this.videoDecoder) {
|
|
1366
1446
|
try {
|
|
1367
|
-
|
|
1447
|
+
this.videoDecoder.reset();
|
|
1368
1448
|
this.videoDecoder.close();
|
|
1369
1449
|
} catch (e) {}
|
|
1370
1450
|
this.videoDecoder = null;
|
|
@@ -1372,16 +1452,13 @@ export class BrowserWebCodecsService {
|
|
|
1372
1452
|
|
|
1373
1453
|
if (this.audioDecoder) {
|
|
1374
1454
|
try {
|
|
1375
|
-
|
|
1455
|
+
this.audioDecoder.reset();
|
|
1376
1456
|
this.audioDecoder.close();
|
|
1377
1457
|
} catch (e) {}
|
|
1378
1458
|
this.audioDecoder = null;
|
|
1379
1459
|
}
|
|
1380
1460
|
|
|
1381
|
-
|
|
1382
|
-
await this.audioContext.close().catch(() => {});
|
|
1383
|
-
this.audioContext = null;
|
|
1384
|
-
}
|
|
1461
|
+
// Keep AudioContext alive — same rationale as cleanup()
|
|
1385
1462
|
|
|
1386
1463
|
if (this.dataChannel) {
|
|
1387
1464
|
this.dataChannel.close();
|
|
@@ -1396,6 +1473,10 @@ export class BrowserWebCodecsService {
|
|
|
1396
1473
|
this.isConnected = false;
|
|
1397
1474
|
this.sessionId = null;
|
|
1398
1475
|
|
|
1476
|
+
// Reset stats (prevent stale firstFrameRendered from skipping timing reset)
|
|
1477
|
+
this.stats.firstFrameRendered = false;
|
|
1478
|
+
this.stats.isConnected = false;
|
|
1479
|
+
|
|
1399
1480
|
// Reset audio playback state
|
|
1400
1481
|
this.nextAudioPlayTime = 0;
|
|
1401
1482
|
this.audioBufferQueue = [];
|
|
@@ -1417,6 +1498,12 @@ export class BrowserWebCodecsService {
|
|
|
1417
1498
|
this.lastVideoTimestamp = 0;
|
|
1418
1499
|
this.lastVideoRealTime = 0;
|
|
1419
1500
|
|
|
1501
|
+
// Clear navigation safety timeout
|
|
1502
|
+
if (this.navigationSafetyTimeout) {
|
|
1503
|
+
clearTimeout(this.navigationSafetyTimeout);
|
|
1504
|
+
this.navigationSafetyTimeout = null;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1420
1507
|
this.isCleaningUp = false;
|
|
1421
1508
|
}
|
|
1422
1509
|
|
|
@@ -1444,6 +1531,24 @@ export class BrowserWebCodecsService {
|
|
|
1444
1531
|
return this.isConnected;
|
|
1445
1532
|
}
|
|
1446
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
|
+
|
|
1447
1552
|
// Event handlers
|
|
1448
1553
|
setConnectionChangeHandler(handler: (connected: boolean) => void): void {
|
|
1449
1554
|
this.onConnectionChange = handler;
|
|
@@ -1493,6 +1598,11 @@ export class BrowserWebCodecsService {
|
|
|
1493
1598
|
*/
|
|
1494
1599
|
setNavigating(navigating: boolean): void {
|
|
1495
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
|
+
}
|
|
1496
1606
|
debug.log('webcodecs', `Navigation state set: ${navigating}`);
|
|
1497
1607
|
}
|
|
1498
1608
|
|
|
@@ -1508,6 +1618,13 @@ export class BrowserWebCodecsService {
|
|
|
1508
1618
|
*/
|
|
1509
1619
|
destroy(): void {
|
|
1510
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
|
+
|
|
1511
1628
|
this.onConnectionChange = null;
|
|
1512
1629
|
this.onConnectionFailed = null;
|
|
1513
1630
|
this.onNavigationReconnect = null;
|
|
@@ -1515,5 +1632,12 @@ export class BrowserWebCodecsService {
|
|
|
1515
1632
|
this.onError = null;
|
|
1516
1633
|
this.onStats = null;
|
|
1517
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
|
+
}
|
|
1518
1642
|
}
|
|
1519
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
|
},
|