From f6b7b98eb3abde369ddbdf317f87202f4262cc5e Mon Sep 17 00:00:00 2001 From: lalli-flores Date: Mon, 18 Nov 2019 23:27:25 -0800 Subject: [PATCH] feat(MeetingsJSONAdapter): add mute-audio meeting control --- src/adapters/MeetingsJSONAdapter.js | 139 +++++++++++++++++++---- src/adapters/MeetingsJSONAdapter.test.js | 131 ++++++++++++++++----- src/data/meetings.json | 12 ++ 3 files changed, 230 insertions(+), 52 deletions(-) diff --git a/src/adapters/MeetingsJSONAdapter.js b/src/adapters/MeetingsJSONAdapter.js index 83aa2b644..c1764541e 100644 --- a/src/adapters/MeetingsJSONAdapter.js +++ b/src/adapters/MeetingsJSONAdapter.js @@ -1,6 +1,10 @@ -import {from, Observable} from 'rxjs'; -import {flatMap, map} from 'rxjs/operators'; -import {MeetingsAdapter} from '@webex/component-adapter-interfaces'; +import {concat, from, fromEvent, Observable} from 'rxjs'; +import {filter, flatMap, map} from 'rxjs/operators'; +import {MeetingsAdapter, MeetingControlState} from '@webex/component-adapter-interfaces'; + +// Defined meeting controls in Meetings JSON Adapter +export const MUTE_AUDIO_CONTROL = 'mute-audio'; + /** * @typedef MeetingsJSON * @param {object} datasource An object that contains a set of meetings keyed by ID. @@ -22,9 +26,69 @@ import {MeetingsAdapter} from '@webex/component-adapter-interfaces'; */ /** - * Implements the MeetingsAdapter interface with a JSON object as its datasource. See @MeetingsJSON + * Implements the MeetingsAdapter interface with a JSON object as its datasource. + * + * @see {@link MeetingsJSON} for example datasource. + * @class + * @implements {MeetingsAdapter} */ export default class MeetingsJSONAdapter extends MeetingsAdapter { + /** + * Creates a new MeetingJSONAdapter. + * + * @param {object} datasource An object that contains a set of meetings keyed by ID. + * @hideconstructor + */ + constructor(datasource) { + super(datasource); + + this.meetingControls = {}; + + this.meetingControls[MUTE_AUDIO_CONTROL] = { + ID: MUTE_AUDIO_CONTROL, + action: this.toggleMuteAudio.bind(this), + display: this.muteAudioControl.bind(this), + }; + } + + /** + * Returns an observable that emits a Meeting object. + * Whenever there is an update to the meeting, the observable + * will emit a new updated Meeting object, if datasource permits. + * + * @param {string} ID ID of the meeting to get. + * @returns {Observable.} + * @memberof MeetingsJSONAdapter + */ + getMeeting(ID) { + const getMeeting$ = Observable.create((observer) => { + if (this.datasource[ID]) { + observer.next(this.datasource[ID]); + } else { + observer.error(new Error(`Could not find meeting with ID "${ID}"`)); + } + + observer.complete(); + }); + + // Attach local media stream if meeting `localVideo` property is not `null`. + // We can not attach the MediaStream object in a JSON module, the work needs + // to be done here. + const getMeetingWithLocalMedia$ = getMeeting$.pipe( + flatMap((meeting) => + from(this.getLocalVideo()).pipe(map((localVideo) => (meeting.localVideo ? {...meeting, localVideo} : meeting))) + ) + ); + + // Send updates on the meeting when a mute event is triggered + const muteEvent$ = fromEvent(document, MUTE_AUDIO_CONTROL).pipe( + filter((event) => event.detail.ID === ID), + map((event) => event.detail) + ); + + return concat(getMeetingWithLocalMedia$, muteEvent$); + } + /** * Returns a MediaStream object obtained from local user media. * @returns {MediaStream} @@ -47,18 +111,32 @@ export default class MeetingsJSONAdapter extends MeetingsAdapter { } /** - * Returns an observable that emits a Meeting object. - * Whenever there is an update to the meeting, the observable - * will emit a new updated Meeting object, if datasource permits. + * Returns an observable that emits the display data of a meeting control. * - * @param {string} ID ID of the meeting to get. - * @returns {Observable.} - * @memberof MeetingsJSONAdapter + * @param {string} ID ID of the meeting for which to update display + * @returns {Observable} + * @memberof MeetingJSONAdapter + * @private */ - getMeeting(ID) { - const getMeeting$ = Observable.create((observer) => { - if (this.datasource[ID]) { - observer.next(this.datasource[ID]); + muteAudioControl(ID) { + const unmuted = { + ID: MUTE_AUDIO_CONTROL, + icon: 'microphone', + tooltip: 'Mute', + state: MeetingControlState.INACTIVE, + }; + const muted = { + ID: MUTE_AUDIO_CONTROL, + icon: 'microphone-muted', + tooltip: 'Unmute', + state: MeetingControlState.ACTIVE, + }; + + const default$ = Observable.create((observer) => { + const meeting = this.datasource[ID]; + + if (meeting) { + observer.next(meeting.localAudio.muted ? muted : unmuted); } else { observer.error(new Error(`Could not find meeting with ID "${ID}"`)); } @@ -66,14 +144,31 @@ export default class MeetingsJSONAdapter extends MeetingsAdapter { observer.complete(); }); - return getMeeting$.pipe( - flatMap((meeting) => - // Attach the localMedia stream if meeting localVideo - // property is not `null`. Since we can not attach the - // MediaStream object in out JSON module, the work needs - // to be done here. - from(this.getLocalVideo()).pipe(map((localVideo) => (meeting.localVideo ? {...meeting, localVideo} : meeting))) - ) + const muteEvent$ = fromEvent(document, MUTE_AUDIO_CONTROL).pipe( + filter((event) => event.detail.ID === ID), + map((event) => (event.detail.localAudio.muted ? muted : unmuted)) ); + + return concat(default$, muteEvent$); + } + + /** + * Toggles muting the local audio media stream track. + * Used by "mute-audio" meeting control. + * + * @param {string} ID ID of the meeting for which to mute local audio. + * @memberof MeetingsJSONAdapter + * @private + */ + toggleMuteAudio(ID) { + if (this.datasource[ID]) { + const meeting = {...this.datasource[ID]}; // Copy meeting to modify mute + const muteEvent = new CustomEvent(MUTE_AUDIO_CONTROL, {detail: meeting}); + + if (meeting.localAudio) { + meeting.localAudio.muted = !meeting.localAudio.muted; + document.dispatchEvent(muteEvent); + } + } } } diff --git a/src/adapters/MeetingsJSONAdapter.test.js b/src/adapters/MeetingsJSONAdapter.test.js index 70d7f1642..75533c949 100644 --- a/src/adapters/MeetingsJSONAdapter.test.js +++ b/src/adapters/MeetingsJSONAdapter.test.js @@ -1,3 +1,6 @@ +import {from} from 'rxjs'; +import {tap, skip} from 'rxjs/operators'; + import meetings from './../data/meetings'; import MeetingsJSONAdapter from './MeetingsJSONAdapter'; @@ -10,44 +13,112 @@ describe('Meetings JSON Adapter', () => { meetingsJSONAdapter.getLocalVideo = jest.fn(() => Promise.resolve('mock-stream')); }); - test('getMeeting() returns an observable', () => { - expect(rxjs.isObservable(meetingsJSONAdapter.getMeeting())).toBeTruthy(); - }); - - test('getMeeting() returns a meeting data', (done) => { - meetingsJSONAdapter.getMeeting(meetingID).subscribe((data) => { - expect(data).toEqual(meetings[meetingID]); - done(); + describe('getMeeting()', () => { + test('returns an observable', () => { + expect(rxjs.isObservable(meetingsJSONAdapter.getMeeting())).toBeTruthy(); }); - }); - test('getMeeting() throws a proper error message', (done) => { - const wrongMeetingID = 'wrongMeetingID'; + test('returns meeting data', (done) => { + meetingsJSONAdapter.getMeeting(meetingID).subscribe((data) => { + expect(data).toEqual(meetings[meetingID]); + done(); + }); + }); - meetingsJSONAdapter.getMeeting(wrongMeetingID).subscribe( - () => {}, - (error) => { - expect(error.message).toBe(`Could not find meeting with ID "${wrongMeetingID}"`); + test('renders the local media if localVideo property is defined', (done) => { + meetingsJSONAdapter.getMeeting('localVideo').subscribe((data) => { + expect(data.localVideo).toEqual('mock-stream'); done(); - } - ); + }); + }); + + test('returns meeting data from mute event', (done) => { + meetingID = 'localAudio'; + + meetingsJSONAdapter + .getMeeting(meetingID) + .pipe(tap(() => meetingsJSONAdapter.toggleMuteAudio(meetingID))) + .subscribe((meeting) => { + expect(meeting.localAudio.muted).toBeTruthy(); + done(); + }); + }); + + test('throws a proper error message', (done) => { + const wrongMeetingID = 'wrongMeetingID'; + + meetingsJSONAdapter.getMeeting(wrongMeetingID).subscribe( + () => {}, + (error) => { + expect(error.message).toBe(`Could not find meeting with ID "${wrongMeetingID}"`); + done(); + } + ); + }); }); - test('getMeeting() completes the observable', (done) => { - meetingsJSONAdapter.getMeeting(meetingID).subscribe( - () => {}, - () => {}, - () => { - expect(true).toBeTruthy(); - done(); - } - ); + describe('muteAudioControl() returns', () => { + beforeEach(() => { + meetingID = 'localAudio'; + }); + + test('active control display values', (done) => { + rxjs.fromEvent = jest.fn(() => from([{detail: meetings[meetingID]}])); + + meetingsJSONAdapter + .muteAudioControl(meetingID) + .pipe(skip(1)) // Skip the "default" emission + .subscribe((display) => { + expect(display).toMatchObject({ + ID: 'mute-audio', + icon: 'microphone-muted', + tooltip: 'Unmute', + state: 'active', + }); + done(); + }); + }); + + test('inactive control display values', (done) => { + rxjs.fromEvent = jest.fn(() => { + const meeting = meetings[meetingID]; + + meeting.localAudio.muted = false; // Local audio is already muted + + return from([{detail: meeting}]); + }); + + meetingsJSONAdapter + .muteAudioControl(meetingID) + .pipe(skip(1)) // Skip the "default" emission + .subscribe((display) => { + expect(display).toMatchObject({ + ID: 'mute-audio', + icon: 'microphone', + tooltip: 'Mute', + state: 'inactive', + }); + done(); + }); + }); + + test('error on invalid meeting ID', (done) => { + meetingsJSONAdapter.muteAudioControl('invalid').subscribe( + () => {}, + (error) => { + expect(error.message).toEqual('Could not find meeting with ID "invalid"'); + done(); + } + ); + }); }); - test('getMeeting() renders the local media if localVideo property is defined', (done) => { - meetingsJSONAdapter.getMeeting('localVideo').subscribe((data) => { - expect(data.localVideo).toEqual('mock-stream'); - done(); + describe('toggleMuteAudio()', () => { + test('dispatches a "mute-audio" event', () => { + meetingID = 'localAudio'; + + meetingsJSONAdapter.toggleMuteAudio(meetingID); + expect(global.document.dispatchEvent).toHaveBeenCalled(); }); }); diff --git a/src/data/meetings.json b/src/data/meetings.json index b7f88d112..4ea0e8bee 100644 --- a/src/data/meetings.json +++ b/src/data/meetings.json @@ -61,5 +61,17 @@ "remoteAudio": null, "localShare": null, "remoteShare": null + }, + "localAudio": { + "ID": "localAudio", + "title": null, + "startTime": null, + "endTime": null, + "localVideo": null, + "remoteVideo": null, + "localAudio": {}, + "remoteAudio": null, + "localShare": null, + "remoteShare": null } }