agora-appbuilder-core 4.1.24 → 4.1.25-beta.2
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/package.json +1 -1
- package/template/defaultConfig.js +2 -2
- package/template/src/components/__tests__/pinnedLayoutState.test.ts +16 -8
- package/template/src/components/participants/UserActionMenuOptions.tsx +19 -9
- package/template/src/components/pinnedLayoutState.ts +19 -3
- package/template/src/subComponents/caption/__tests__/transcriptStreamListener.test.ts +47 -0
- package/template/src/subComponents/caption/transcriptJourney.ts +1 -0
- package/template/src/subComponents/caption/transcriptStreamListener.ts +50 -0
- package/template/src/subComponents/caption/useCaption.tsx +130 -31
- package/template/src/subComponents/screenshare/ScreenshareConfigure.native.tsx +65 -6
- package/template/src/subComponents/screenshare/ScreenshareConfigure.tsx +71 -6
- package/template/src/subComponents/screenshare/__tests__/screenshareJourney.test.ts +43 -0
- package/template/src/subComponents/screenshare/screenshareJourney.ts +29 -0
- package/template/src/utils/__tests__/activeSpeakerState.test.ts +23 -0
- package/template/src/utils/activeSpeakerState.ts +25 -0
- package/template/src/utils/useActiveSpeaker.ts +21 -4
- package/template/src/utils/useFindActiveSpeaker.ts +4 -0
package/package.json
CHANGED
|
@@ -77,8 +77,8 @@ const DefaultConfig = {
|
|
|
77
77
|
CHAT_ORG_NAME: "",
|
|
78
78
|
CHAT_APP_NAME: "",
|
|
79
79
|
CHAT_URL: "",
|
|
80
|
-
CLI_VERSION: "3.1.
|
|
81
|
-
CORE_VERSION: "4.1.
|
|
80
|
+
CLI_VERSION: "3.1.25-beta.2",
|
|
81
|
+
CORE_VERSION: "4.1.25-beta.2",
|
|
82
82
|
DISABLE_LANDSCAPE_MODE: false,
|
|
83
83
|
STT_AUTO_START: false,
|
|
84
84
|
CLOUD_RECORDING_AUTO_START: false,
|
|
@@ -26,16 +26,24 @@ describe('pinned layout state', () => {
|
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it('does not offer View in large for either pinned position', () => {
|
|
30
|
-
expect(canViewInLarge(101, 101, 100)).toBe(false);
|
|
31
|
-
expect(canViewInLarge(100, 101, 100)).toBe(false);
|
|
32
|
-
expect(canViewInLarge(200, 101, 100)).toBe(true);
|
|
29
|
+
it('does not offer View in large for either pinned position while in pinned layout', () => {
|
|
30
|
+
expect(canViewInLarge(101, 101, 100, undefined, true)).toBe(false);
|
|
31
|
+
expect(canViewInLarge(100, 101, 100, undefined, true)).toBe(false);
|
|
32
|
+
expect(canViewInLarge(200, 101, 100, undefined, true)).toBe(true);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it('does not offer View in large for the fallback maximized UID', () => {
|
|
36
|
-
expect(canViewInLarge(101, undefined, 100, 101)).toBe(false);
|
|
37
|
-
expect(canViewInLarge(100, undefined, 100, 101)).toBe(false);
|
|
38
|
-
expect(canViewInLarge(200, undefined, 100, 101)).toBe(true);
|
|
35
|
+
it('does not offer View in large for the fallback maximized UID while in pinned layout', () => {
|
|
36
|
+
expect(canViewInLarge(101, undefined, 100, 101, true)).toBe(false);
|
|
37
|
+
expect(canViewInLarge(100, undefined, 100, 101, true)).toBe(false);
|
|
38
|
+
expect(canViewInLarge(200, undefined, 100, 101, true)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('still offers View in large in Grid even when pin/fallback state remains from pinned layout', () => {
|
|
42
|
+
// Reproduction: View in Large for 101, then switch to Grid without clearing pin.
|
|
43
|
+
expect(canViewInLarge(101, 101, undefined, 101, false)).toBe(true);
|
|
44
|
+
// Reproduction: pin cleared but UserPin left 101 at activeUids[0].
|
|
45
|
+
expect(canViewInLarge(101, undefined, undefined, 101, false)).toBe(true);
|
|
46
|
+
expect(canViewInLarge(101, 0, undefined, 101, false)).toBe(true);
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
it('identifies explicit and fallback maximized UIDs for tile actions', () => {
|
|
@@ -208,17 +208,26 @@ export default function UserActionMenuOptionsOptions(
|
|
|
208
208
|
)
|
|
209
209
|
) {
|
|
210
210
|
if (enablePinForMe) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
211
|
+
const isPinnedLayout = currentLayout === getPinnedLayoutName();
|
|
212
|
+
// Fallback maximized UID is only meaningful in pinned/sidebar layout.
|
|
213
|
+
// Passing activeUids[0] in Grid incorrectly hides View in Large for the
|
|
214
|
+
// user who was previously promoted (UserPin leaves them at index 0).
|
|
215
|
+
const fallbackMaximizedUid = isPinnedLayout
|
|
216
|
+
? activeUids?.[0]
|
|
217
|
+
: undefined;
|
|
218
|
+
const allowViewInLarge = canViewInLarge(
|
|
219
|
+
user.uid,
|
|
220
|
+
pinnedUid,
|
|
221
|
+
secondaryPinnedUid,
|
|
222
|
+
fallbackMaximizedUid,
|
|
223
|
+
isPinnedLayout,
|
|
224
|
+
);
|
|
225
|
+
if (allowViewInLarge) {
|
|
219
226
|
const viewInLargeKey = ActionMenuKeys.VIEW_IN_LARGE;
|
|
220
227
|
const viewInLargeConfig = userActionMenuItems?.[viewInLargeKey] ?? {};
|
|
221
|
-
|
|
228
|
+
// In Grid, a leftover pinnedUid must not suppress View in Large —
|
|
229
|
+
// Grid has no large tile, so the action should re-promote the user.
|
|
230
|
+
const isPinned = isPinnedLayout && pinnedUid === user.uid;
|
|
222
231
|
const isWhiteboard = user.uid === getWhiteboardUid();
|
|
223
232
|
const isOnlyOneActive = activeUids?.length === 1;
|
|
224
233
|
|
|
@@ -766,6 +775,7 @@ export default function UserActionMenuOptionsOptions(
|
|
|
766
775
|
disableChatUids,
|
|
767
776
|
secondaryPinnedUid,
|
|
768
777
|
currentLayout,
|
|
778
|
+
activeUids,
|
|
769
779
|
spotlightUid,
|
|
770
780
|
extraMenuItems,
|
|
771
781
|
setActionMenuVisible,
|
|
@@ -33,11 +33,27 @@ export const isUidMaximized = (
|
|
|
33
33
|
fallbackMaximizedUid?: UidType,
|
|
34
34
|
) => uid === (pinnedUid || fallbackMaximizedUid);
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether the tile action menu may offer "View in large".
|
|
38
|
+
*
|
|
39
|
+
* Pinned-layout rules (explicit pin, pin-to-top, fallback max at activeUids[0])
|
|
40
|
+
* only apply while the current layout is pinned/sidebar. In Grid there is no
|
|
41
|
+
* large tile, so the action must stay available even if pinnedUid or
|
|
42
|
+
* activeUids[0] still point at this user from a prior pinned session.
|
|
43
|
+
*/
|
|
36
44
|
export const canViewInLarge = (
|
|
37
45
|
uid: UidType,
|
|
38
46
|
pinnedUid?: UidType,
|
|
39
47
|
secondaryPinnedUid?: UidType,
|
|
40
48
|
fallbackMaximizedUid?: UidType,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
isPinnedLayout: boolean = true,
|
|
50
|
+
) => {
|
|
51
|
+
if (!isPinnedLayout) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
!isUidMaximized(uid, pinnedUid, fallbackMaximizedUid) &&
|
|
57
|
+
uid !== secondaryPinnedUid
|
|
58
|
+
);
|
|
59
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerTranscriptStreamListener,
|
|
3
|
+
removeTranscriptStreamListener,
|
|
4
|
+
TranscriptStreamEngine,
|
|
5
|
+
} from '../transcriptStreamListener';
|
|
6
|
+
|
|
7
|
+
describe('transcript stream listener lifecycle', () => {
|
|
8
|
+
it('tracks and removes a native listener when addListener returns void', () => {
|
|
9
|
+
const listener = jest.fn();
|
|
10
|
+
const engine: TranscriptStreamEngine = {
|
|
11
|
+
addListener: jest.fn(),
|
|
12
|
+
removeListener: jest.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const registration = registerTranscriptStreamListener(engine, listener);
|
|
16
|
+
|
|
17
|
+
expect(registration).toEqual({subscription: null});
|
|
18
|
+
expect(engine.addListener).toHaveBeenCalledTimes(1);
|
|
19
|
+
expect(engine.addListener).toHaveBeenCalledWith(
|
|
20
|
+
'onStreamMessage',
|
|
21
|
+
listener,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
removeTranscriptStreamListener(engine, listener, registration, false);
|
|
25
|
+
|
|
26
|
+
expect(engine.removeListener).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(engine.removeListener).toHaveBeenCalledWith(
|
|
28
|
+
'onStreamMessage',
|
|
29
|
+
listener,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('uses the subscription returned by the web bridge', () => {
|
|
34
|
+
const listener = jest.fn();
|
|
35
|
+
const remove = jest.fn();
|
|
36
|
+
const engine: TranscriptStreamEngine = {
|
|
37
|
+
addListener: jest.fn(() => ({remove})),
|
|
38
|
+
removeListener: jest.fn(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const registration = registerTranscriptStreamListener(engine, listener);
|
|
42
|
+
removeTranscriptStreamListener(engine, listener, registration, true);
|
|
43
|
+
|
|
44
|
+
expect(remove).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(engine.removeListener).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const TRANSCRIPT_JOURNEY = '[TRANSCRIPT_JOURNEY]';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type TranscriptStreamListener = (...args: any[]) => void;
|
|
2
|
+
|
|
3
|
+
type TranscriptStreamSubscription = {
|
|
4
|
+
remove: () => void;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type TranscriptStreamEngine = {
|
|
8
|
+
addListener: (
|
|
9
|
+
event: 'onStreamMessage',
|
|
10
|
+
listener: TranscriptStreamListener,
|
|
11
|
+
) => unknown;
|
|
12
|
+
removeListener: (
|
|
13
|
+
event: 'onStreamMessage',
|
|
14
|
+
listener: TranscriptStreamListener,
|
|
15
|
+
) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TranscriptStreamListenerRegistration = {
|
|
19
|
+
subscription: TranscriptStreamSubscription | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const registerTranscriptStreamListener = (
|
|
23
|
+
engine: TranscriptStreamEngine,
|
|
24
|
+
listener: TranscriptStreamListener,
|
|
25
|
+
): TranscriptStreamListenerRegistration => {
|
|
26
|
+
const result = engine.addListener('onStreamMessage', listener);
|
|
27
|
+
const subscription =
|
|
28
|
+
result &&
|
|
29
|
+
typeof (result as TranscriptStreamSubscription).remove === 'function'
|
|
30
|
+
? (result as TranscriptStreamSubscription)
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
// Native Agora returns void from addListener. The registration object is our
|
|
34
|
+
// platform-independent proof that registration completed successfully.
|
|
35
|
+
return {subscription};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const removeTranscriptStreamListener = (
|
|
39
|
+
engine: TranscriptStreamEngine,
|
|
40
|
+
listener: TranscriptStreamListener,
|
|
41
|
+
registration: TranscriptStreamListenerRegistration,
|
|
42
|
+
web: boolean,
|
|
43
|
+
) => {
|
|
44
|
+
if (web) {
|
|
45
|
+
registration.subscription?.remove();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
engine.removeListener('onStreamMessage', listener);
|
|
50
|
+
};
|
|
@@ -21,6 +21,13 @@ import {useRoomInfo} from '../../components/room-info/useRoomInfo';
|
|
|
21
21
|
import {useContent, useRtc} from 'customization-api';
|
|
22
22
|
import useStreamMessageUtils from './useStreamMessageUtils';
|
|
23
23
|
import {isWebInternal} from '../../utils/common';
|
|
24
|
+
import {TRANSCRIPT_JOURNEY} from './transcriptJourney';
|
|
25
|
+
import {
|
|
26
|
+
registerTranscriptStreamListener,
|
|
27
|
+
removeTranscriptStreamListener,
|
|
28
|
+
TranscriptStreamEngine,
|
|
29
|
+
TranscriptStreamListenerRegistration,
|
|
30
|
+
} from './transcriptStreamListener';
|
|
24
31
|
|
|
25
32
|
// Types
|
|
26
33
|
type GlobalSttState = {
|
|
@@ -320,22 +327,20 @@ const CaptionProvider: React.FC<CaptionProviderProps> = ({
|
|
|
320
327
|
prevSpeakerRef,
|
|
321
328
|
selectedTranslationLanguageRef,
|
|
322
329
|
});
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
[streamMessageCallback],
|
|
338
|
-
);
|
|
330
|
+
const streamMessageCallbackRef = React.useRef(streamMessageCallback);
|
|
331
|
+
streamMessageCallbackRef.current = streamMessageCallback;
|
|
332
|
+
const transcriptListenerRegistrationRef =
|
|
333
|
+
React.useRef<TranscriptStreamListenerRegistration | null>(null);
|
|
334
|
+
|
|
335
|
+
const handleStreamMessageCallback = React.useCallback((...args: any[]) => {
|
|
336
|
+
if (isWebInternal()) {
|
|
337
|
+
const [uid, data] = args;
|
|
338
|
+
streamMessageCallbackRef.current([uid, data]);
|
|
339
|
+
} else {
|
|
340
|
+
const [, uid, , data] = args;
|
|
341
|
+
streamMessageCallbackRef.current([uid, data]);
|
|
342
|
+
}
|
|
343
|
+
}, []);
|
|
339
344
|
|
|
340
345
|
// Register once for the lifetime of the active call. Keeping collection in
|
|
341
346
|
// the provider makes it independent of whether caption/transcript UI is open.
|
|
@@ -343,24 +348,73 @@ const CaptionProvider: React.FC<CaptionProviderProps> = ({
|
|
|
343
348
|
if (
|
|
344
349
|
!$config.ENABLE_STT ||
|
|
345
350
|
!callActive ||
|
|
346
|
-
|
|
351
|
+
transcriptListenerRegistrationRef.current
|
|
347
352
|
) {
|
|
348
353
|
return;
|
|
349
354
|
}
|
|
350
355
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
356
|
+
logger.log(
|
|
357
|
+
LogSource.Internals,
|
|
358
|
+
'TRANSCRIPT',
|
|
359
|
+
`${TRANSCRIPT_JOURNEY} stream listener registration requested`,
|
|
360
|
+
{stage: 'listener_registration', outcome: 'started'},
|
|
354
361
|
);
|
|
355
|
-
|
|
356
|
-
|
|
362
|
+
try {
|
|
363
|
+
transcriptListenerRegistrationRef.current =
|
|
364
|
+
registerTranscriptStreamListener(
|
|
365
|
+
RtcEngineUnsafe as unknown as TranscriptStreamEngine,
|
|
366
|
+
handleStreamMessageCallback,
|
|
367
|
+
);
|
|
368
|
+
setIsSTTListenerAdded(true);
|
|
369
|
+
logger.log(
|
|
370
|
+
LogSource.Internals,
|
|
371
|
+
'TRANSCRIPT',
|
|
372
|
+
`${TRANSCRIPT_JOURNEY} stream listener registered`,
|
|
373
|
+
{stage: 'listener_registration', outcome: 'success'},
|
|
374
|
+
);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
logger.error(
|
|
377
|
+
LogSource.Internals,
|
|
378
|
+
'TRANSCRIPT',
|
|
379
|
+
`${TRANSCRIPT_JOURNEY} stream listener registration failed`,
|
|
380
|
+
{
|
|
381
|
+
stage: 'listener_registration',
|
|
382
|
+
outcome: 'failure',
|
|
383
|
+
error: error ?? null,
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
357
388
|
|
|
358
|
-
React.useEffect(() => {
|
|
359
389
|
return () => {
|
|
360
|
-
|
|
361
|
-
|
|
390
|
+
logger.log(
|
|
391
|
+
LogSource.Internals,
|
|
392
|
+
'TRANSCRIPT',
|
|
393
|
+
`${TRANSCRIPT_JOURNEY} stream listener cleanup requested`,
|
|
394
|
+
{
|
|
395
|
+
stage: 'listener_cleanup',
|
|
396
|
+
outcome: 'started',
|
|
397
|
+
listenerRegistered: !!transcriptListenerRegistrationRef.current,
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
if (transcriptListenerRegistrationRef.current) {
|
|
401
|
+
removeTranscriptStreamListener(
|
|
402
|
+
RtcEngineUnsafe as unknown as TranscriptStreamEngine,
|
|
403
|
+
handleStreamMessageCallback,
|
|
404
|
+
transcriptListenerRegistrationRef.current,
|
|
405
|
+
isWebInternal(),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
transcriptListenerRegistrationRef.current = null;
|
|
409
|
+
setIsSTTListenerAdded(false);
|
|
410
|
+
logger.log(
|
|
411
|
+
LogSource.Internals,
|
|
412
|
+
'TRANSCRIPT',
|
|
413
|
+
`${TRANSCRIPT_JOURNEY} stream listener removed and local references cleared`,
|
|
414
|
+
{stage: 'listener_cleanup', outcome: 'completed'},
|
|
415
|
+
);
|
|
362
416
|
};
|
|
363
|
-
}, []);
|
|
417
|
+
}, [RtcEngineUnsafe, callActive, handleStreamMessageCallback]);
|
|
364
418
|
|
|
365
419
|
const flushPendingTranscript = React.useCallback(async () => {
|
|
366
420
|
await flushStreamMessageQueue();
|
|
@@ -393,12 +447,17 @@ const CaptionProvider: React.FC<CaptionProviderProps> = ({
|
|
|
393
447
|
const updateErrorLabel = useString(sttUpdateError)();
|
|
394
448
|
|
|
395
449
|
// --- Derived readiness flag for STT ---
|
|
450
|
+
const localUidReady = !!localUid;
|
|
451
|
+
const localBotUidReady = !!localBotUid;
|
|
452
|
+
const rtmJoined = !!hasUserJoinedRTM;
|
|
453
|
+
const activeCallReady = !!callActive;
|
|
454
|
+
const roomInfoReady = !!(roomId?.host || roomId?.attendee);
|
|
396
455
|
const sttDepsReady =
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
456
|
+
localUidReady &&
|
|
457
|
+
localBotUidReady &&
|
|
458
|
+
rtmJoined &&
|
|
459
|
+
activeCallReady &&
|
|
460
|
+
roomInfoReady;
|
|
402
461
|
|
|
403
462
|
const sttStartGuardRef = React.useRef(false);
|
|
404
463
|
const sttAutoStartGuardRef = React.useRef(false);
|
|
@@ -409,6 +468,46 @@ const CaptionProvider: React.FC<CaptionProviderProps> = ({
|
|
|
409
468
|
sttDepsReadyRef.current = sttDepsReady;
|
|
410
469
|
}, [sttDepsReady]);
|
|
411
470
|
|
|
471
|
+
React.useEffect(() => {
|
|
472
|
+
const missingDependencies = [
|
|
473
|
+
!localUidReady && 'localUid',
|
|
474
|
+
!localBotUidReady && 'localBotUid',
|
|
475
|
+
!rtmJoined && 'RTM',
|
|
476
|
+
!activeCallReady && 'activeCall',
|
|
477
|
+
!roomInfoReady && 'roomInfo',
|
|
478
|
+
!isSTTListenerAdded && 'streamListener',
|
|
479
|
+
].filter(Boolean);
|
|
480
|
+
const transcriptReady = sttDepsReady && isSTTListenerAdded;
|
|
481
|
+
|
|
482
|
+
logger.log(
|
|
483
|
+
LogSource.Internals,
|
|
484
|
+
'TRANSCRIPT',
|
|
485
|
+
`${TRANSCRIPT_JOURNEY} STT readiness: ${
|
|
486
|
+
transcriptReady
|
|
487
|
+
? 'READY'
|
|
488
|
+
: `WAITING (missing: ${missingDependencies.join(', ')})`
|
|
489
|
+
}`,
|
|
490
|
+
{
|
|
491
|
+
stage: 'stt_readiness',
|
|
492
|
+
outcome: transcriptReady ? 'ready' : 'waiting',
|
|
493
|
+
localUidReady,
|
|
494
|
+
localBotUidReady,
|
|
495
|
+
rtmJoined,
|
|
496
|
+
callActive: activeCallReady,
|
|
497
|
+
roomInfoReady,
|
|
498
|
+
listenerRegistered: isSTTListenerAdded,
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
}, [
|
|
502
|
+
activeCallReady,
|
|
503
|
+
isSTTListenerAdded,
|
|
504
|
+
localBotUidReady,
|
|
505
|
+
localUidReady,
|
|
506
|
+
roomInfoReady,
|
|
507
|
+
rtmJoined,
|
|
508
|
+
sttDepsReady,
|
|
509
|
+
]);
|
|
510
|
+
|
|
412
511
|
React.useEffect(() => {
|
|
413
512
|
if (sttDepsReadyRef.current && !hasFlushedSttQueueRef.current) {
|
|
414
513
|
hasFlushedSttQueueRef.current = true;
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
getScreenshareRecoveryDecision,
|
|
58
58
|
ScreenshareRecoveryCandidate,
|
|
59
59
|
} from './screenshareInterruptionRecovery';
|
|
60
|
+
import {getScreenshareLayoutSwitchLog} from './screenshareJourney';
|
|
60
61
|
|
|
61
62
|
export const ScreenshareContextConsumer = ScreenshareContext.Consumer;
|
|
62
63
|
|
|
@@ -119,7 +120,7 @@ export const ScreenshareConfigure = (props: {children: React.ReactNode}) => {
|
|
|
119
120
|
|
|
120
121
|
const triggerChangeLayout = useCallback(
|
|
121
122
|
(pinned: boolean, screenShareUid?: UidType, parentUid?: UidType) => {
|
|
122
|
-
|
|
123
|
+
const fromLayout = currentLayoutRef.current.currentLayout;
|
|
123
124
|
//screenshare is started set the layout to Pinned View
|
|
124
125
|
if (pinned && screenShareUid) {
|
|
125
126
|
isPinned.current = screenShareUid;
|
|
@@ -141,11 +142,51 @@ export const ScreenshareConfigure = (props: {children: React.ReactNode}) => {
|
|
|
141
142
|
value: [parentUid],
|
|
142
143
|
});
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
+
const toLayout = getPinnedLayoutName();
|
|
146
|
+
const layoutSwitched = fromLayout !== toLayout;
|
|
147
|
+
if (layoutSwitched) {
|
|
148
|
+
setPinnedLayout();
|
|
149
|
+
}
|
|
150
|
+
const layoutSwitchLog = getScreenshareLayoutSwitchLog(
|
|
151
|
+
'screenshare start',
|
|
152
|
+
fromLayout,
|
|
153
|
+
toLayout,
|
|
154
|
+
);
|
|
155
|
+
logger.log(
|
|
156
|
+
LogSource.Internals,
|
|
157
|
+
'SCREENSHARE',
|
|
158
|
+
layoutSwitchLog.message,
|
|
159
|
+
{
|
|
160
|
+
action: 'start',
|
|
161
|
+
...layoutSwitchLog.fields,
|
|
162
|
+
screenShareUid,
|
|
163
|
+
parentUid: parentUid || null,
|
|
164
|
+
},
|
|
165
|
+
);
|
|
145
166
|
} else {
|
|
146
167
|
isPinned.current = 0;
|
|
147
168
|
//screenshare is stopped set the layout Grid View
|
|
148
|
-
|
|
169
|
+
const toLayout = getGridLayoutName();
|
|
170
|
+
const layoutSwitched = fromLayout !== toLayout;
|
|
171
|
+
if (layoutSwitched) {
|
|
172
|
+
changeLayout();
|
|
173
|
+
}
|
|
174
|
+
const layoutSwitchLog = getScreenshareLayoutSwitchLog(
|
|
175
|
+
'screenshare stop',
|
|
176
|
+
fromLayout,
|
|
177
|
+
toLayout,
|
|
178
|
+
);
|
|
179
|
+
logger.log(
|
|
180
|
+
LogSource.Internals,
|
|
181
|
+
'SCREENSHARE',
|
|
182
|
+
layoutSwitchLog.message,
|
|
183
|
+
{
|
|
184
|
+
action: 'stop',
|
|
185
|
+
...layoutSwitchLog.fields,
|
|
186
|
+
screenShareUid: screenShareUid || null,
|
|
187
|
+
parentUid: parentUid || null,
|
|
188
|
+
},
|
|
189
|
+
);
|
|
149
190
|
}
|
|
150
191
|
},
|
|
151
192
|
[changeLayout, dispatch, setPinnedLayout],
|
|
@@ -207,13 +248,31 @@ export const ScreenshareConfigure = (props: {children: React.ReactNode}) => {
|
|
|
207
248
|
} else if (decision === 'restore') {
|
|
208
249
|
isPinned.current = candidate.uid;
|
|
209
250
|
dispatch({type: 'UserPin', value: [candidate.uid]});
|
|
210
|
-
|
|
251
|
+
const fromLayout = currentLayout;
|
|
252
|
+
const toLayout = getPinnedLayoutName();
|
|
253
|
+
const layoutSwitched =
|
|
211
254
|
candidate.previousLayout === getPinnedLayoutName() &&
|
|
212
|
-
|
|
213
|
-
) {
|
|
255
|
+
fromLayout !== toLayout;
|
|
256
|
+
if (layoutSwitched) {
|
|
214
257
|
setPinnedLayout();
|
|
215
258
|
}
|
|
216
259
|
recoveryCandidateRef.current = null;
|
|
260
|
+
const layoutSwitchLog = getScreenshareLayoutSwitchLog(
|
|
261
|
+
'screenshare recover',
|
|
262
|
+
fromLayout,
|
|
263
|
+
layoutSwitched ? toLayout : fromLayout,
|
|
264
|
+
);
|
|
265
|
+
logger.log(
|
|
266
|
+
LogSource.Internals,
|
|
267
|
+
'SCREENSHARE',
|
|
268
|
+
layoutSwitchLog.message,
|
|
269
|
+
{
|
|
270
|
+
action: 'recover',
|
|
271
|
+
...layoutSwitchLog.fields,
|
|
272
|
+
screenShareUid: candidate.uid,
|
|
273
|
+
restoredPinnedUid: candidate.previousPinnedUid,
|
|
274
|
+
},
|
|
275
|
+
);
|
|
217
276
|
} else if (decision.startsWith('cancel_')) {
|
|
218
277
|
recoveryCandidateRef.current = null;
|
|
219
278
|
}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
getScreenshareSessionBoundaryMessage,
|
|
48
48
|
getScreenshareSessionId,
|
|
49
49
|
getScreenshareError,
|
|
50
|
+
getScreenshareLayoutSwitchLog,
|
|
50
51
|
isUserCancelOrPermissionDenied,
|
|
51
52
|
SCREENSHARE_JOURNEY,
|
|
52
53
|
} from './screenshareJourney';
|
|
@@ -184,7 +185,7 @@ export const ScreenshareConfigure = (props: {
|
|
|
184
185
|
screenShareUid?: UidType,
|
|
185
186
|
parentUid?: UidType,
|
|
186
187
|
) => {
|
|
187
|
-
|
|
188
|
+
const fromLayout = currentLayoutRef.current.currentLayout;
|
|
188
189
|
//screenshare is started set the layout to Pinned View
|
|
189
190
|
if (pinned && screenShareUid) {
|
|
190
191
|
isPinned.current = screenShareUid;
|
|
@@ -206,11 +207,55 @@ export const ScreenshareConfigure = (props: {
|
|
|
206
207
|
value: [parentUid],
|
|
207
208
|
});
|
|
208
209
|
}
|
|
209
|
-
|
|
210
|
+
const toLayout = getPinnedLayoutName();
|
|
211
|
+
const layoutSwitched = fromLayout !== toLayout;
|
|
212
|
+
if (layoutSwitched) {
|
|
213
|
+
setPinnedLayout();
|
|
214
|
+
}
|
|
215
|
+
const layoutSwitchLog = getScreenshareLayoutSwitchLog(
|
|
216
|
+
'screenshare start',
|
|
217
|
+
fromLayout,
|
|
218
|
+
toLayout,
|
|
219
|
+
);
|
|
220
|
+
logger.log(
|
|
221
|
+
LogSource.Internals,
|
|
222
|
+
'SCREENSHARE',
|
|
223
|
+
layoutSwitchLog.message,
|
|
224
|
+
{
|
|
225
|
+
action: 'start',
|
|
226
|
+
...layoutSwitchLog.fields,
|
|
227
|
+
screenshareSessionId:
|
|
228
|
+
activeScreenshareSessionIdRef.current || 'unknown-session',
|
|
229
|
+
screenShareUid,
|
|
230
|
+
parentUid: parentUid || null,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
210
233
|
} else {
|
|
211
234
|
isPinned.current = 0;
|
|
212
235
|
//screenshare is stopped set the layout Grid View
|
|
213
|
-
|
|
236
|
+
const toLayout = getGridLayoutName();
|
|
237
|
+
const layoutSwitched = fromLayout !== toLayout;
|
|
238
|
+
if (layoutSwitched) {
|
|
239
|
+
changeLayout();
|
|
240
|
+
}
|
|
241
|
+
const layoutSwitchLog = getScreenshareLayoutSwitchLog(
|
|
242
|
+
'screenshare stop',
|
|
243
|
+
fromLayout,
|
|
244
|
+
toLayout,
|
|
245
|
+
);
|
|
246
|
+
logger.log(
|
|
247
|
+
LogSource.Internals,
|
|
248
|
+
'SCREENSHARE',
|
|
249
|
+
layoutSwitchLog.message,
|
|
250
|
+
{
|
|
251
|
+
action: 'stop',
|
|
252
|
+
...layoutSwitchLog.fields,
|
|
253
|
+
screenshareSessionId:
|
|
254
|
+
activeScreenshareSessionIdRef.current || 'unknown-session',
|
|
255
|
+
screenShareUid: screenShareUid || null,
|
|
256
|
+
parentUid: parentUid || null,
|
|
257
|
+
},
|
|
258
|
+
);
|
|
214
259
|
}
|
|
215
260
|
};
|
|
216
261
|
|
|
@@ -281,13 +326,20 @@ export const ScreenshareConfigure = (props: {
|
|
|
281
326
|
// newer user pin or screen-share session cancels this path below.
|
|
282
327
|
isPinned.current = candidate.uid;
|
|
283
328
|
dispatch({type: 'UserPin', value: [candidate.uid]});
|
|
284
|
-
|
|
329
|
+
const fromLayout = currentLayout;
|
|
330
|
+
const toLayout = getPinnedLayoutName();
|
|
331
|
+
const layoutSwitched =
|
|
285
332
|
candidate.previousLayout === getPinnedLayoutName() &&
|
|
286
|
-
|
|
287
|
-
) {
|
|
333
|
+
fromLayout !== toLayout;
|
|
334
|
+
if (layoutSwitched) {
|
|
288
335
|
setPinnedLayout();
|
|
289
336
|
}
|
|
290
337
|
recoveryCandidateRef.current = null;
|
|
338
|
+
const layoutSwitchLog = getScreenshareLayoutSwitchLog(
|
|
339
|
+
'screenshare recover',
|
|
340
|
+
fromLayout,
|
|
341
|
+
layoutSwitched ? toLayout : fromLayout,
|
|
342
|
+
);
|
|
291
343
|
logger.log(
|
|
292
344
|
LogSource.Internals,
|
|
293
345
|
'SCREENSHARE',
|
|
@@ -304,6 +356,19 @@ export const ScreenshareConfigure = (props: {
|
|
|
304
356
|
elapsedMs: Date.now() - candidate.detectedAt,
|
|
305
357
|
},
|
|
306
358
|
);
|
|
359
|
+
logger.log(
|
|
360
|
+
LogSource.Internals,
|
|
361
|
+
'SCREENSHARE',
|
|
362
|
+
layoutSwitchLog.message,
|
|
363
|
+
{
|
|
364
|
+
action: 'recover',
|
|
365
|
+
...layoutSwitchLog.fields,
|
|
366
|
+
screenshareSessionId:
|
|
367
|
+
activeScreenshareSessionIdRef.current || 'unknown-session',
|
|
368
|
+
screenShareUid: candidate.uid,
|
|
369
|
+
restoredPinnedUid: candidate.previousPinnedUid,
|
|
370
|
+
},
|
|
371
|
+
);
|
|
307
372
|
} else if (decision.startsWith('cancel_')) {
|
|
308
373
|
// An authoritative stop, replacement share, or user layout choice
|
|
309
374
|
// takes precedence over automatic interruption recovery.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getScreenshareError,
|
|
3
|
+
getScreenshareLayoutSwitchLog,
|
|
3
4
|
getScreenshareReleaseOrigin,
|
|
4
5
|
getScreenshareSessionBoundaryMessage,
|
|
5
6
|
getScreenshareSessionId,
|
|
@@ -12,6 +13,48 @@ describe('screenshare journey logging', () => {
|
|
|
12
13
|
expect(SCREENSHARE_JOURNEY).toBe('[SCREENSHARE_JOURNEY]');
|
|
13
14
|
});
|
|
14
15
|
|
|
16
|
+
it('describes screenshare-driven layout switches for Datadog', () => {
|
|
17
|
+
expect(
|
|
18
|
+
getScreenshareLayoutSwitchLog('screenshare start', 'grid', 'pinned'),
|
|
19
|
+
).toEqual({
|
|
20
|
+
message:
|
|
21
|
+
'[SCREENSHARE_JOURNEY] layout switched from grid to pinned (screenshare start)',
|
|
22
|
+
fields: {
|
|
23
|
+
stage: 'layout_update',
|
|
24
|
+
reason: 'screenshare start',
|
|
25
|
+
fromLayout: 'grid',
|
|
26
|
+
toLayout: 'pinned',
|
|
27
|
+
layoutSwitched: true,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
expect(
|
|
31
|
+
getScreenshareLayoutSwitchLog('screenshare stop', 'pinned', 'grid'),
|
|
32
|
+
).toEqual({
|
|
33
|
+
message:
|
|
34
|
+
'[SCREENSHARE_JOURNEY] layout switched from pinned to grid (screenshare stop)',
|
|
35
|
+
fields: {
|
|
36
|
+
stage: 'layout_update',
|
|
37
|
+
reason: 'screenshare stop',
|
|
38
|
+
fromLayout: 'pinned',
|
|
39
|
+
toLayout: 'grid',
|
|
40
|
+
layoutSwitched: true,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
expect(
|
|
44
|
+
getScreenshareLayoutSwitchLog('screenshare stop', 'grid', 'grid'),
|
|
45
|
+
).toEqual({
|
|
46
|
+
message:
|
|
47
|
+
'[SCREENSHARE_JOURNEY] layout unchanged at grid (screenshare stop)',
|
|
48
|
+
fields: {
|
|
49
|
+
stage: 'layout_update',
|
|
50
|
+
reason: 'screenshare stop',
|
|
51
|
+
fromLayout: 'grid',
|
|
52
|
+
toLayout: 'grid',
|
|
53
|
+
layoutSwitched: false,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
15
58
|
it('puts the session ID directly in start and end boundary messages', () => {
|
|
16
59
|
expect(getScreenshareSessionBoundaryMessage('start', 'session-123')).toBe(
|
|
17
60
|
'----- [SCREENSHARE_JOURNEY] SCREEN SHARE SESSION START | sessionId=session-123 -----',
|
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
export const SCREENSHARE_JOURNEY = '[SCREENSHARE_JOURNEY]';
|
|
2
2
|
|
|
3
|
+
export type ScreenshareLayoutSwitchReason =
|
|
4
|
+
| 'screenshare start'
|
|
5
|
+
| 'screenshare stop'
|
|
6
|
+
| 'screenshare recover';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Message + fields for Datadog correlation of screenshare-driven layout changes.
|
|
10
|
+
* Search: `[SCREENSHARE_JOURNEY] layout switched` or `layout unchanged`.
|
|
11
|
+
*/
|
|
12
|
+
export const getScreenshareLayoutSwitchLog = (
|
|
13
|
+
reason: ScreenshareLayoutSwitchReason,
|
|
14
|
+
fromLayout: string,
|
|
15
|
+
toLayout: string,
|
|
16
|
+
) => {
|
|
17
|
+
const layoutSwitched = fromLayout !== toLayout;
|
|
18
|
+
return {
|
|
19
|
+
message: layoutSwitched
|
|
20
|
+
? `${SCREENSHARE_JOURNEY} layout switched from ${fromLayout} to ${toLayout} (${reason})`
|
|
21
|
+
: `${SCREENSHARE_JOURNEY} layout unchanged at ${fromLayout} (${reason})`,
|
|
22
|
+
fields: {
|
|
23
|
+
stage: 'layout_update' as const,
|
|
24
|
+
reason,
|
|
25
|
+
fromLayout,
|
|
26
|
+
toLayout,
|
|
27
|
+
layoutSwitched,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
3
32
|
export const getScreenshareSessionBoundaryMessage = (
|
|
4
33
|
boundary: 'start' | 'end',
|
|
5
34
|
screenshareSessionId: string,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentActiveSpeaker,
|
|
3
|
+
resetCurrentActiveSpeakerForTests,
|
|
4
|
+
setCurrentActiveSpeaker,
|
|
5
|
+
} from '../activeSpeakerState';
|
|
6
|
+
|
|
7
|
+
describe('activeSpeakerState', () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
resetCurrentActiveSpeakerForTests();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('stores the current active speaker for remount hydration', () => {
|
|
13
|
+
expect(getCurrentActiveSpeaker()).toBeUndefined();
|
|
14
|
+
expect(setCurrentActiveSpeaker(101)).toBe(101);
|
|
15
|
+
expect(getCurrentActiveSpeaker()).toBe(101);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('clears the cache when nobody is speaking (uid 0)', () => {
|
|
19
|
+
setCurrentActiveSpeaker(101);
|
|
20
|
+
expect(setCurrentActiveSpeaker(0)).toBeUndefined();
|
|
21
|
+
expect(getCurrentActiveSpeaker()).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {UidType} from '../../agora-rn-uikit';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Last known active-speaker uid shared across tile mounts.
|
|
5
|
+
* Video tiles remount when moving into the large slot; without this cache
|
|
6
|
+
* useActiveSpeaker would start as undefined and miss the current speaker
|
|
7
|
+
* until the next ACTIVE_SPEAKER event (which is deduped for the same uid).
|
|
8
|
+
*/
|
|
9
|
+
let currentActiveSpeaker: UidType | undefined = undefined;
|
|
10
|
+
|
|
11
|
+
export const getCurrentActiveSpeaker = (): UidType | undefined =>
|
|
12
|
+
currentActiveSpeaker;
|
|
13
|
+
|
|
14
|
+
export const setCurrentActiveSpeaker = (
|
|
15
|
+
uid: UidType | undefined,
|
|
16
|
+
): UidType | undefined => {
|
|
17
|
+
// 0 means nobody is speaking — normalize to undefined for consumers.
|
|
18
|
+
currentActiveSpeaker = uid ? uid : undefined;
|
|
19
|
+
return currentActiveSpeaker;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** @internal test helper */
|
|
23
|
+
export const resetCurrentActiveSpeakerForTests = () => {
|
|
24
|
+
currentActiveSpeaker = undefined;
|
|
25
|
+
};
|
|
@@ -13,16 +13,31 @@ import {useEffect, useState} from 'react';
|
|
|
13
13
|
import LocalEventEmitter, {
|
|
14
14
|
LocalEventsEnum,
|
|
15
15
|
} from '../rtm-events-api/LocalEvents';
|
|
16
|
+
import {
|
|
17
|
+
getCurrentActiveSpeaker,
|
|
18
|
+
setCurrentActiveSpeaker,
|
|
19
|
+
} from './activeSpeakerState';
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* Returns active speaker uid or undefined if nobody speaking
|
|
19
|
-
*
|
|
22
|
+
* Returns active speaker uid or undefined if nobody speaking.
|
|
23
|
+
* Hydrates from the shared last-known uid so remounted tiles (e.g. large slot)
|
|
24
|
+
* keep the highlight without waiting for a duplicate ACTIVE_SPEAKER emit.
|
|
20
25
|
*/
|
|
21
26
|
function useActiveSpeaker() {
|
|
22
|
-
const [activeSpeaker, setActiveSpeaker] = useState(
|
|
27
|
+
const [activeSpeaker, setActiveSpeaker] = useState(() =>
|
|
28
|
+
getCurrentActiveSpeaker(),
|
|
29
|
+
);
|
|
30
|
+
|
|
23
31
|
useEffect(() => {
|
|
32
|
+
const hydrated = getCurrentActiveSpeaker();
|
|
33
|
+
// Re-sync if an emit landed between first render and effect attach.
|
|
34
|
+
if (hydrated !== activeSpeaker) {
|
|
35
|
+
setActiveSpeaker(hydrated);
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
const listenActiveSpeaker = data => {
|
|
25
|
-
|
|
39
|
+
const next = setCurrentActiveSpeaker(data);
|
|
40
|
+
setActiveSpeaker(next);
|
|
26
41
|
};
|
|
27
42
|
LocalEventEmitter.on(LocalEventsEnum.ACTIVE_SPEAKER, listenActiveSpeaker);
|
|
28
43
|
return () => {
|
|
@@ -31,7 +46,9 @@ function useActiveSpeaker() {
|
|
|
31
46
|
listenActiveSpeaker,
|
|
32
47
|
);
|
|
33
48
|
};
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only hydrate + subscribe
|
|
34
50
|
}, []);
|
|
51
|
+
|
|
35
52
|
return activeSpeaker;
|
|
36
53
|
}
|
|
37
54
|
|
|
@@ -14,6 +14,7 @@ import LocalEventEmitter, {
|
|
|
14
14
|
LocalEventsEnum,
|
|
15
15
|
} from '../rtm-events-api/LocalEvents';
|
|
16
16
|
import {LogSource, logger} from '../logger/AppBuilderLogger';
|
|
17
|
+
import {setCurrentActiveSpeaker} from './activeSpeakerState';
|
|
17
18
|
|
|
18
19
|
enum volumeEnum {
|
|
19
20
|
IS_SPEAKING = 'IS_SPEAKING',
|
|
@@ -37,6 +38,9 @@ const useFindActiveSpeaker = () => {
|
|
|
37
38
|
const timenow = Date.now();
|
|
38
39
|
if (uid !== activeSpeakerUid.current) {
|
|
39
40
|
activeSpeakerUid.current = uid;
|
|
41
|
+
// Keep shared state in sync before emit so remounted tiles can hydrate
|
|
42
|
+
// even when this uid is not re-emitted later (deduped while still speaking).
|
|
43
|
+
setCurrentActiveSpeaker(uid);
|
|
40
44
|
uid
|
|
41
45
|
? logger.log(
|
|
42
46
|
LogSource.Internals,
|