diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index e98b753c66d..39973b40f11 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -724,6 +724,7 @@ export default class Meeting extends StatelessWebexPlugin { allowMediaInLobby: boolean; localShareInstanceId: string; remoteShareInstanceId: string; + acceptedContentHandoffPreviousShare: any; shareCAEventSentStatus: { transmitStart: boolean; transmitStop: boolean; @@ -1521,6 +1522,7 @@ export default class Meeting extends StatelessWebexPlugin { * @memberof Meeting */ this.remoteShareInstanceId = null; + this.acceptedContentHandoffPreviousShare = null; /** * Status used for ensuring we do not oversend metrics @@ -3206,13 +3208,23 @@ export default class Meeting extends StatelessWebexPlugin { newShareStatus = SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE; } } - // or if content share is either released or null and whiteboard share is either released or null, no one is sharing + // Preserve active content sharing while another participant's content floor is only ACCEPTED. + // The final GRANTED update must still see the previous active share status so steal handling + // can unpublish local streams or update the remote presenter without emitting a stop event. else if ( - ((previousContentShare && contentShare.disposition === FLOOR_ACTION.RELEASED) || - contentShare.disposition === null) && - ((previousWhiteboardShare && whiteboardShare.disposition === FLOOR_ACTION.RELEASED) || - whiteboardShare.disposition === null) + (this.shareStatus === SHARE_STATUS.LOCAL_SHARE_ACTIVE || + this.shareStatus === SHARE_STATUS.REMOTE_SHARE_ACTIVE) && + previousContentShare?.disposition === FLOOR_ACTION.GRANTED && + contentShare.disposition === FLOOR_ACTION.ACCEPTED ) { + this.acceptedContentHandoffPreviousShare = previousContentShare; + newShareStatus = this.shareStatus; + } + // Otherwise, neither content nor whiteboard floor is GRANTED (covers + // RELEASED, null, and intermediate dispositions such as ACCEPTED), so no + // one is currently sharing. Active content shares are preserved above until + // another participant receives the final GRANTED floor update. + else { newShareStatus = SHARE_STATUS.NO_SHARE; } @@ -3220,6 +3232,26 @@ export default class Meeting extends StatelessWebexPlugin { `Meeting:index#setUpLocusInfoMediaInactiveListener --> this.shareStatus=${this.shareStatus} newShareStatus=${newShareStatus}` ); + let mediaSharesUpdatePayload = payload; + + if ( + this.acceptedContentHandoffPreviousShare && + contentShare.disposition === FLOOR_ACTION.GRANTED && + payload.previous?.content?.disposition === FLOOR_ACTION.ACCEPTED + ) { + mediaSharesUpdatePayload = { + ...payload, + previous: { + ...payload.previous, + content: this.acceptedContentHandoffPreviousShare, + }, + }; + } + + if (contentShare.disposition !== FLOOR_ACTION.ACCEPTED) { + this.acceptedContentHandoffPreviousShare = null; + } + if (newShareStatus !== this.shareStatus) { const oldShareStatus = this.shareStatus; @@ -3393,8 +3425,11 @@ export default class Meeting extends StatelessWebexPlugin { break; } - this.members.locusMediaSharesUpdate(payload); - } else if (newShareStatus === SHARE_STATUS.REMOTE_SHARE_ACTIVE) { + this.members.locusMediaSharesUpdate(mediaSharesUpdatePayload); + } else if ( + newShareStatus === SHARE_STATUS.REMOTE_SHARE_ACTIVE && + contentShare.disposition !== FLOOR_ACTION.ACCEPTED + ) { // if we got here, then some remote participant has stolen // the presentation from another remote participant this.remoteShareInstanceId = contentShare.shareInstanceId; @@ -3416,7 +3451,7 @@ export default class Meeting extends StatelessWebexPlugin { resourceType: contentShare.resourceType, } ); - this.members.locusMediaSharesUpdate(payload); + this.members.locusMediaSharesUpdate(mediaSharesUpdatePayload); } else if (newShareStatus === SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE) { // if we got here, then some remote participant has stolen // the presentation from another remote participant @@ -3442,7 +3477,7 @@ export default class Meeting extends StatelessWebexPlugin { meetingId: this.id, }, }); - this.members.locusMediaSharesUpdate(payload); + this.members.locusMediaSharesUpdate(mediaSharesUpdatePayload); } }); } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 6e0211a46db..c555e1377f6 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -14400,10 +14400,10 @@ describe('plugin-meetings', () => { } if (isAccepting) { - eventTrigger.share.push({ - eventName: EVENT_TRIGGERS.MEETING_STOPPED_SHARING_WHITEBOARD, - functionName: 'stopWhiteboardShare', - }); + // After the fix, an intermediate locus delta with whiteboard RELEASED + // and content ACCEPTED resolves to NO_SHARE, so by the time content + // becomes GRANTED the previous state is NO_SHARE — no STOP_WHITEBOARD + // event needs to fire here. } // Web client is sharing locally @@ -14544,32 +14544,19 @@ describe('plugin-meetings', () => { newPayload.current.whiteboard.disposition = FLOOR_ACTION.RELEASED; if (isAccepting) { + // Whiteboard floor RELEASED while content floor is in the + // intermediate ACCEPTED state. After the fix this transitions to + // NO_SHARE and only emits MEETING_STOPPED_SHARING_WHITEBOARD; the + // listener no longer re-emits MEETING_STARTED_SHARING_WHITEBOARD. newPayload.current.content.disposition = FLOOR_ACTION.ACCEPTED; newPayload.current.content.beneficiaryId = otherBeneficiaryId; - eventTrigger.share.push( - meeting.webinar.selfIsAttendee - ? { - eventName: EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE, - functionName: 'remoteShare', - eventPayload: { - memberId: beneficiaryId, - url, - shareInstanceId, - annotationInfo: undefined, - resourceType: undefined, - }, - } - : { - eventName: EVENT_TRIGGERS.MEETING_STARTED_SHARING_WHITEBOARD, - functionName: 'startWhiteboardShare', - eventPayload: {resourceUrl, memberId: beneficiaryId}, - } - ); + eventTrigger.share.push({ + eventName: EVENT_TRIGGERS.MEETING_STOPPED_SHARING_WHITEBOARD, + functionName: 'stopWhiteboardShare', + }); - shareStatus = meeting.webinar.selfIsAttendee - ? SHARE_STATUS.REMOTE_SHARE_ACTIVE - : SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE; + shareStatus = SHARE_STATUS.NO_SHARE; } else { eventTrigger.share.push({ eventName: EVENT_TRIGGERS.MEETING_STOPPED_SHARING_WHITEBOARD, @@ -15461,6 +15448,475 @@ describe('plugin-meetings', () => { }); }); + describe('Whiteboard --> File Share (intermediate ACCEPTED disposition)', () => { + // Regression test for the "cannot receive sharing" issue where + // setUpLocusMediaSharesListener would re-emit MEETING_STARTED_SHARING_WHITEBOARD + // when receiving an intermediate locus delta in which the whiteboard floor was + // already RELEASED but the new content floor was still in ACCEPTED (not yet GRANTED) + // state. The fix turns the final disposition branch into a default `else` so that + // any non-GRANTED content disposition (RELEASED, null, ACCEPTED, ...) combined with + // a non-GRANTED whiteboard disposition resolves to NO_SHARE. + it('transitions whiteboard_share_active -> no_share -> remote_share_active without re-emitting whiteboard start', () => { + // Step 1: whiteboard A is GRANTED (start whiteboard share) + const step1 = { + previous: { + content: generateContent(), + whiteboard: generateWhiteboard(), + }, + current: { + content: generateContent(), + whiteboard: generateWhiteboard( + USER_IDS.REMOTE_A, + FLOOR_ACTION.GRANTED, + RESOURCE_URLS.WHITEBOARD_A + ), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step1 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE); + + // Reset trigger and metric spies so we can check exactly which events fire + // on the intermediate locus delta. + TriggerProxy.trigger.resetHistory(); + webex.internal.newMetrics.submitClientEvent.resetHistory(); + + // Step 2: intermediate locus delta — whiteboard RELEASED, content ACCEPTED. + // Before the fix this used to re-emit MEETING_STARTED_SHARING_WHITEBOARD. + const step2 = { + previous: cloneDeep(step1.current), + current: { + content: generateContent( + USER_IDS.REMOTE_B, + FLOOR_ACTION.ACCEPTED, + DEVICE_URL.REMOTE_B, + undefined, + SHARE_TYPE.FILE + ), + whiteboard: generateWhiteboard( + USER_IDS.REMOTE_A, + FLOOR_ACTION.RELEASED, + RESOURCE_URLS.WHITEBOARD_A + ), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step2 + ); + + // The listener should now resolve to NO_SHARE (whiteboard stopped, content + // not yet granted) and emit only MEETING_STOPPED_SHARING_WHITEBOARD. + assert.equal(meeting.shareStatus, SHARE_STATUS.NO_SHARE); + + const triggeredShareEvents = TriggerProxy.trigger + .getCalls() + .map((call) => call.args[2]); + + assert.include( + triggeredShareEvents, + EVENT_TRIGGERS.MEETING_STOPPED_SHARING_WHITEBOARD, + 'expected MEETING_STOPPED_SHARING_WHITEBOARD to fire once' + ); + assert.notInclude( + triggeredShareEvents, + EVENT_TRIGGERS.MEETING_STARTED_SHARING_WHITEBOARD, + 'MEETING_STARTED_SHARING_WHITEBOARD must not be re-emitted on ACCEPTED' + ); + + // The whiteboard floor-granted CA event must not be submitted on this + // intermediate transition. + const whiteboardFloorGrantedSubmitted = + webex.internal.newMetrics.submitClientEvent + .getCalls() + .some( + (call) => + call.args[0]?.name === 'client.share.floor-granted.local' && + call.args[0]?.payload?.mediaType === 'whiteboard' + ); + assert.isFalse( + whiteboardFloorGrantedSubmitted, + 'client.share.floor-granted.local for whiteboard must not be submitted on ACCEPTED' + ); + + // Step 3: content becomes GRANTED → REMOTE_SHARE_ACTIVE, exactly one + // MEETING_STARTED_SHARING_REMOTE event fires. + TriggerProxy.trigger.resetHistory(); + + const step3 = { + previous: cloneDeep(step2.current), + current: { + content: generateContent( + USER_IDS.REMOTE_B, + FLOOR_ACTION.GRANTED, + DEVICE_URL.REMOTE_B, + undefined, + SHARE_TYPE.FILE + ), + whiteboard: generateWhiteboard( + USER_IDS.REMOTE_A, + FLOOR_ACTION.RELEASED, + RESOURCE_URLS.WHITEBOARD_A + ), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step3 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE); + + const remoteStartCount = TriggerProxy.trigger + .getCalls() + .filter((call) => call.args[2] === EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE) + .length; + assert.equal( + remoteStartCount, + 1, + 'MEETING_STARTED_SHARING_REMOTE should fire exactly once on GRANTED' + ); + }); + }); + + + describe('Whiteboard as Remote Share --> File Share (intermediate ACCEPTED disposition)', () => { + const remoteWhiteboardCases = [ + { + name: 'webinar attendee', + setup: () => { + meeting.webinar = {selfIsAttendee: true}; + meeting.locusInfo.info.isWebinar = true; + }, + }, + { + name: 'guest', + setup: () => { + meeting.guest = true; + }, + }, + ]; + + remoteWhiteboardCases.forEach(({name, setup}) => { + it(`stops the released whiteboard for ${name} instead of preserving remote share state on ACCEPTED`, () => { + setup(); + + const step1 = { + previous: { + content: generateContent(), + whiteboard: generateWhiteboard(), + }, + current: { + content: generateContent(), + whiteboard: generateWhiteboard( + USER_IDS.REMOTE_A, + FLOOR_ACTION.GRANTED, + RESOURCE_URLS.WHITEBOARD_A + ), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step1 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE); + TriggerProxy.trigger.resetHistory(); + + const step2 = { + previous: cloneDeep(step1.current), + current: { + content: generateContent( + USER_IDS.REMOTE_B, + FLOOR_ACTION.ACCEPTED, + DEVICE_URL.REMOTE_B, + undefined, + SHARE_TYPE.FILE + ), + whiteboard: generateWhiteboard( + USER_IDS.REMOTE_A, + FLOOR_ACTION.RELEASED, + RESOURCE_URLS.WHITEBOARD_A + ), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step2 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.NO_SHARE); + + const acceptedEvents = TriggerProxy.trigger.getCalls().map((call) => call.args[2]); + + assert.include( + acceptedEvents, + EVENT_TRIGGERS.MEETING_STOPPED_SHARING_REMOTE, + 'released whiteboard viewed as remote share should stop on ACCEPTED' + ); + assert.notInclude( + acceptedEvents, + EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE, + 'accepted content should not start remote sharing before final GRANTED' + ); + }); + }); + }); + + describe('Local Share --> Remote Share (intermediate ACCEPTED disposition)', () => { + it('preserves local share state on ACCEPTED so final GRANTED unpublishes local streams', async () => { + const shareVideoStream = {id: 'local-share-video'}; + const shareAudioStream = {id: 'local-share-audio'}; + + meeting.mediaProperties.mediaDirection = { + ...meeting.mediaProperties.mediaDirection, + sendShare: true, + }; + meeting.mediaProperties.shareVideoStream = shareVideoStream; + meeting.mediaProperties.shareAudioStream = shareAudioStream; + sandbox.stub(meeting, 'unpublishStreams').resolves(); + + const step1 = { + previous: { + content: generateContent(), + whiteboard: generateWhiteboard(), + }, + current: { + content: generateContent( + USER_IDS.ME, + FLOOR_ACTION.GRANTED, + DEVICE_URL.LOCAL_WEB, + undefined, + SHARE_TYPE.DESKTOP + ), + whiteboard: generateWhiteboard(), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step1 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.LOCAL_SHARE_ACTIVE); + TriggerProxy.trigger.resetHistory(); + + const step2 = { + previous: cloneDeep(step1.current), + current: { + content: generateContent( + USER_IDS.REMOTE_A, + FLOOR_ACTION.ACCEPTED, + DEVICE_URL.REMOTE_A, + undefined, + SHARE_TYPE.DESKTOP + ), + whiteboard: generateWhiteboard(), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step2 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.LOCAL_SHARE_ACTIVE); + assert.notCalled(meeting.unpublishStreams); + + const acceptedEvents = TriggerProxy.trigger.getCalls().map((call) => call.args[2]); + + assert.notInclude( + acceptedEvents, + EVENT_TRIGGERS.MEETING_STOPPED_SHARING_LOCAL, + 'local share should not stop while another participant is only ACCEPTED' + ); + + TriggerProxy.trigger.resetHistory(); + + const step3 = { + previous: cloneDeep(step2.current), + current: { + content: generateContent( + USER_IDS.REMOTE_A, + FLOOR_ACTION.GRANTED, + DEVICE_URL.REMOTE_A, + undefined, + SHARE_TYPE.DESKTOP + ), + whiteboard: generateWhiteboard(), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step3 + ); + + await Promise.resolve(); + + assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE); + assert.calledOnceWithExactly(meeting.unpublishStreams, [ + shareVideoStream, + shareAudioStream, + ]); + + const remoteStartCount = TriggerProxy.trigger + .getCalls() + .filter((call) => call.args[2] === EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE) + .length; + + assert.equal( + remoteStartCount, + 1, + 'MEETING_STARTED_SHARING_REMOTE should fire exactly once after local streams unpublish' + ); + + const memberUpdateCalls = TriggerProxy.trigger + .getCalls() + .filter((call) => call.args[2] === EVENT_TRIGGERS.MEMBERS_CONTENT_UPDATE); + + assert.lengthOf(memberUpdateCalls, 1); + assert.deepEqual(memberUpdateCalls[0].args[3], { + activeSharingId: USER_IDS.REMOTE_A, + endedSharingId: USER_IDS.ME, + }); + assert.equal(meeting.members.mediaShareContentId, USER_IDS.REMOTE_A); + }); + }); + + describe('Remote Share --> Remote Share (intermediate ACCEPTED disposition)', () => { + it('preserves remote share state on ACCEPTED so final GRANTED uses the remote-steal path', () => { + const step1 = { + previous: { + content: generateContent(), + whiteboard: generateWhiteboard(), + }, + current: { + content: generateContent( + USER_IDS.REMOTE_A, + FLOOR_ACTION.GRANTED, + DEVICE_URL.REMOTE_A, + undefined, + SHARE_TYPE.DESKTOP + ), + whiteboard: generateWhiteboard(), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step1 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE); + TriggerProxy.trigger.resetHistory(); + + const step2 = { + previous: cloneDeep(step1.current), + current: { + content: generateContent( + USER_IDS.REMOTE_B, + FLOOR_ACTION.ACCEPTED, + DEVICE_URL.REMOTE_B, + undefined, + SHARE_TYPE.DESKTOP + ), + whiteboard: generateWhiteboard(), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step2 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE); + + const acceptedEvents = TriggerProxy.trigger.getCalls().map((call) => call.args[2]); + + assert.notInclude( + acceptedEvents, + EVENT_TRIGGERS.MEETING_STOPPED_SHARING_REMOTE, + 'remote share should not stop while another participant is only ACCEPTED' + ); + assert.notInclude( + acceptedEvents, + EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE, + 'remote share should not restart until another participant is GRANTED' + ); + + TriggerProxy.trigger.resetHistory(); + + const step3 = { + previous: cloneDeep(step2.current), + current: { + content: generateContent( + USER_IDS.REMOTE_B, + FLOOR_ACTION.GRANTED, + DEVICE_URL.REMOTE_B, + undefined, + SHARE_TYPE.DESKTOP + ), + whiteboard: generateWhiteboard(), + }, + }; + + meeting.locusInfo.emit( + {function: 'test', file: 'test'}, + EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, + step3 + ); + + assert.equal(meeting.shareStatus, SHARE_STATUS.REMOTE_SHARE_ACTIVE); + + const remoteStartCalls = TriggerProxy.trigger + .getCalls() + .filter((call) => call.args[2] === EVENT_TRIGGERS.MEETING_STARTED_SHARING_REMOTE); + + assert.lengthOf( + remoteStartCalls, + 1, + 'MEETING_STARTED_SHARING_REMOTE should fire exactly once on final GRANTED' + ); + assert.deepEqual(remoteStartCalls[0].args[3], { + memberId: USER_IDS.REMOTE_B, + url: undefined, + shareInstanceId: undefined, + annotationInfo: undefined, + resourceType: SHARE_TYPE.DESKTOP, + }); + + const memberUpdateCalls = TriggerProxy.trigger + .getCalls() + .filter((call) => call.args[2] === EVENT_TRIGGERS.MEMBERS_CONTENT_UPDATE); + + assert.lengthOf(memberUpdateCalls, 1); + assert.deepEqual(memberUpdateCalls[0].args[3], { + activeSharingId: USER_IDS.REMOTE_B, + endedSharingId: USER_IDS.REMOTE_A, + }); + assert.equal(meeting.members.mediaShareContentId, USER_IDS.REMOTE_B); + }); + }); + describe('handleShareVideoStreamMuteStateChange', () => { it('should emit MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE event with correct fields', () => { meeting.isMultistream = true;