Skip to content

Commit

Permalink
feat(MeetingsJSONAdapter): add mute-audio meeting control
Browse files Browse the repository at this point in the history
  • Loading branch information
lalli-flores committed Nov 27, 2019
1 parent c9bdf18 commit f6b7b98
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 52 deletions.
139 changes: 117 additions & 22 deletions src/adapters/MeetingsJSONAdapter.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.<Meeting>}
* @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}
Expand All @@ -47,33 +111,64 @@ 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.<Meeting>}
* @memberof MeetingsJSONAdapter
* @param {string} ID ID of the meeting for which to update display
* @returns {Observable<MeetingControlDisplay>}
* @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}"`));
}

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);
}
}
}
}
131 changes: 101 additions & 30 deletions src/adapters/MeetingsJSONAdapter.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import {from} from 'rxjs';
import {tap, skip} from 'rxjs/operators';

import meetings from './../data/meetings';
import MeetingsJSONAdapter from './MeetingsJSONAdapter';

Expand All @@ -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();
});
});

Expand Down
12 changes: 12 additions & 0 deletions src/data/meetings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

0 comments on commit f6b7b98

Please sign in to comment.