Skip to content

Commit

Permalink
Extend RequestAppAudioHandling to support for audio device selection …
Browse files Browse the repository at this point in the history
…sync (#2030)

* extend RequestAppAudioHandlingParams by adding audioDeviceSelectionChangedCallback

---------

Co-authored-by: Xukai Wu <[email protected]>
Co-authored-by: bingliang <[email protected]>
Co-authored-by: Trevor Harris <[email protected]>
Co-authored-by: AE ( ͡ಠ ʖ̯ ͡ಠ) <[email protected]>
  • Loading branch information
5 people authored Nov 30, 2023
1 parent 4c2800d commit 6f23f92
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 4 deletions.
23 changes: 23 additions & 0 deletions apps/teams-test-app/e2e-test-data/meeting.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,29 @@
"expectedAlertValue": "requestAppAudioHandling called",
"expectedTestAppValue": "requestAppAudioHandling() succeeded: isHostAudioless=true"
},
{
"title": "requestAppAudioHandling API Call - register audioDeviceSelectionChanged Handler - Success",
"type": "registerAndRaiseEvent",
"boxSelector": "#box_registerAudioDeviceSelectionChangedHandler",
"eventName": "audioDeviceSelectionChanged",
"eventData": {
"speaker": { "deviceLabel": "speaker_0" },
"microphone": { "deviceLabel": "microphone_0" }
},
"expectedAlertValueOnRegistration": "requestAppAudioHandling called with isAppHandlingAudio: true",
"expectedTestAppValue": "Received audioDeviceSelectionChanged event: ##JSON_EVENT_DATA##"
},
{
"title": "requestAppAudioHandling API Call - register audioDeviceSelectionChanged Handler - Error",
"type": "registerAndRaiseEvent",
"boxSelector": "#box_registerAudioDeviceSelectionChangedHandler",
"eventName": "audioDeviceSelectionChanged",
"eventData": {
"error": { "errorCode": 100, "message": "Not supported on platform." }
},
"expectedAlertValueOnRegistration": "requestAppAudioHandling called with isAppHandlingAudio: true",
"expectedTestAppValue": "Received audioDeviceSelectionChanged event: ##JSON_EVENT_DATA##"
},
{
"title": "updateMicState API Call - Success",
"type": "callResponse",
Expand Down
25 changes: 25 additions & 0 deletions apps/teams-test-app/src/components/MeetingAPIs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,30 @@ const RequestAppAudioHandling = (): React.ReactElement =>
},
});

const RegisterAudioDeviceSelectionChangedHandler = (): React.ReactElement =>
ApiWithoutInput({
name: 'registerAudioDeviceSelectionChangedHandler',
title: 'Register AudioDeviceSelectionChanged Handler',
onClick: async (setResult) => {
const audioDeviceSelectionChangedCallback = (
selectedDevicesInHost: meeting.AudioDeviceSelection | SdkError,
): void => {
setResult('Received audioDeviceSelectionChanged event: ' + JSON.stringify(selectedDevicesInHost));
};
meeting.requestAppAudioHandling(
{
isAppHandlingAudio: true,
micMuteStateChangedCallback: (micState) => Promise.resolve(micState),
audioDeviceSelectionChangedCallback: audioDeviceSelectionChangedCallback,
},
() => {
return;
},
);
return generateRegistrationMsg('audioDeviceSelectionChaged event is received');
},
});

const UpdateMicState = (): React.ReactElement =>
ApiWithTextInput<meeting.MicState>({
name: 'updateMicState',
Expand Down Expand Up @@ -398,6 +422,7 @@ const MeetingAPIs = (): ReactElement => (
<GetAppContentStageSharingState />
<SetOptions />
<RequestAppAudioHandling />
<RegisterAudioDeviceSelectionChangedHandler />
<UpdateMicState />
</ModuleWrapper>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Extended `RequestAppAudioHandlingParams` by adding `audioDeviceSelectionChangedCallback` for speaker selection updates",
"packageName": "@microsoft/teams-js",
"email": "[email protected]",
"dependentChangeType": "patch"
}
4 changes: 4 additions & 0 deletions packages/teams-js/src/public/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,10 @@ export interface SdkError {
message?: string;
}

export function isSdkError(err: unknown): err is SdkError {
return (err as SdkError)?.errorCode !== undefined;
}

/** Error codes used to identify different types of errors that can occur while developing apps. */
export enum ErrorCode {
/**
Expand Down
56 changes: 56 additions & 0 deletions packages/teams-js/src/public/meeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,53 @@ export namespace meeting {
* @returns A promise with the updated microphone state
*/
micMuteStateChangedCallback: (micState: MicState) => Promise<MicState>;
/**
* Callback for the host to tell the app to change its speaker selection
*/
audioDeviceSelectionChangedCallback?: (selectedDevices: AudioDeviceSelection | SdkError) => void;
}

/**
* Interface for AudioDeviceSelection from host selection.
* If the speaker or the microphone is undefined or don't have a device label, you can try to find the default devices
* by using
* ```ts
* const devices = await navigator.mediaDevices.enumerateDevices();
* const defaultSpeaker = devices.find((d) => d.deviceId === 'default' && d.kind === 'audiooutput');
* const defaultMic = devices.find((d) => d.deviceId === 'default' && d.kind === 'audioinput');
* ```
*
* @hidden
* Hide from docs.
*
* @internal
* Limited to Microsoft-internal use
*
* @beta
*/
export interface AudioDeviceSelection {
speaker?: AudioDeviceInfo;
microphone?: AudioDeviceInfo;
}

/**
* Interface for AudioDeviceInfo, includes a device label with the same format as {@link MediaDeviceInfo.label}
*
* Hosted app can use this label to compare it with the device info fetched from {@link navigator.mediaDevices.enumerateDevices()}.
* {@link MediaDeviceInfo} has {@link MediaDeviceInfo.deviceId} as an unique identifier, but that id is also unique to the origin
* of the calling application, so {@link MediaDeviceInfo.deviceId} cannot be used here as an identifier. Notice there are some cases
* that devices may have the same device label, but we don't have a better way to solve this, keep this as a known limitation for now.
*
* @hidden
* Hide from docs.
*
* @internal
* Limited to Microsoft-internal use
*
* @beta
*/
export interface AudioDeviceInfo {
deviceLabel: string;
}

/**
Expand Down Expand Up @@ -914,6 +961,11 @@ export namespace meeting {
};
registerHandler('meeting.micStateChanged', micStateChangedCallback);

const audioDeviceSelectionChangedCallback = (selectedDevicesInHost: AudioDeviceSelection): void => {
requestAppAudioHandlingParams.audioDeviceSelectionChangedCallback?.(selectedDevicesInHost);
};
registerHandler('meeting.audioDeviceSelectionChanged', audioDeviceSelectionChangedCallback);

callback(isHostAudioless);
};
sendMessageToParent(
Expand Down Expand Up @@ -942,6 +994,10 @@ export namespace meeting {
removeHandler('meeting.micStateChanged');
}

if (doesHandlerExist('meeting.audioDeviceSelectionChanged')) {
removeHandler('meeting.audioDeviceSelectionChanged');
}

callback(isHostAudioless);
};

Expand Down
108 changes: 108 additions & 0 deletions packages/teams-js/test/public/meeting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,63 @@ describe('meeting', () => {
});
}
});

describe('requestAppAudioHandling', () => {
const emptyMicStateCallback = (micState: meeting.MicState) => Promise.resolve(micState);
const waitForEventQueue = () => new Promise((resolve) => setTimeout(resolve, 0));

const allowedContexts = [FrameContexts.sidePanel, FrameContexts.meetingStage];
Object.values(FrameContexts).forEach((context) => {
if (allowedContexts.some((allowedContext) => allowedContext === context)) {
it(`should call meeting.audioDeviceSelectionChanged after meeting.requestAppAudioHandling. context: ${context}`, async () => {
await utils.initializeWithContext(context);

const requestIsHostAudioless: boolean | null = true;

let callbackPayload: meeting.AudioDeviceSelection | undefined = undefined;
const testCallback = (payload: meeting.AudioDeviceSelection) => {
callbackPayload = payload;
return Promise.resolve();
};

// call and respond to requestAppAudioHandling
meeting.requestAppAudioHandling(
{
isAppHandlingAudio: requestIsHostAudioless,
micMuteStateChangedCallback: (micState: meeting.MicState) => Promise.resolve(micState),
audioDeviceSelectionChangedCallback: testCallback,
},
(_result: boolean) => {},
);
const requestAppAudioHandlingMessage = utils.findMessageByFunc('meeting.requestAppAudioHandling');
expect(requestAppAudioHandlingMessage).not.toBeNull();

utils.respondToMessage(requestAppAudioHandlingMessage, null, requestIsHostAudioless);

// check that the registerHandler for audio device selection was called
const registerHandlerMessage = utils.findMessageByFunc('registerHandler', 1);
expect(registerHandlerMessage).not.toBeNull();
expect(registerHandlerMessage.args.length).toBe(1);
expect(registerHandlerMessage.args[0]).toBe('meeting.audioDeviceSelectionChanged');
});
} else {
it(`should not allow meeting.requestAppAudioHandling calls from ${context} context`, async () => {
await utils.initializeWithContext(context);

expect(() =>
meeting.requestAppAudioHandling(
{ isAppHandlingAudio: true, micMuteStateChangedCallback: emptyMicStateCallback },
emptyCallBack,
),
).toThrowError(
`This call is only allowed in following contexts: ${JSON.stringify(
allowedContexts,
)}. Current context: "${context}".`,
);
});
}
});
});
});
describe('frameless', () => {
let utils: Utils = new Utils();
Expand Down Expand Up @@ -1473,6 +1530,57 @@ describe('meeting', () => {
expect(micCallbackCalled).toBe(true);
});

it(`should call meeting.audioDeviceSelectionChanged after meeting.requestAppAudioHandling. context: ${context}`, async () => {
await utils.initializeWithContext(context);

const requestIsHostAudioless: boolean | null = true;

let callbackPayload: meeting.AudioDeviceSelection | undefined = undefined;
const testCallback = (payload: meeting.AudioDeviceSelection) => {
callbackPayload = payload;
return Promise.resolve();
};

// call and respond to requestAppAudioHandling
meeting.requestAppAudioHandling(
{
isAppHandlingAudio: requestIsHostAudioless,
micMuteStateChangedCallback: (micState: meeting.MicState) => Promise.resolve(micState),
audioDeviceSelectionChangedCallback: testCallback,
},
(_result: boolean) => {},
);
const requestAppAudioHandlingMessage = utils.findMessageByFunc('meeting.requestAppAudioHandling');
expect(requestAppAudioHandlingMessage).not.toBeNull();

const callbackId = requestAppAudioHandlingMessage.id;
utils.respondToFramelessMessage({
data: {
id: callbackId,
args: [null, requestIsHostAudioless],
},
} as DOMMessageEvent);

// check that the registerHandler for audio device selection was called
const registerHandlerMessage = utils.findMessageByFunc('registerHandler', 1);
expect(registerHandlerMessage).not.toBeNull();
expect(registerHandlerMessage.args.length).toBe(1);
expect(registerHandlerMessage.args[0]).toBe('meeting.audioDeviceSelectionChanged');

const mockPayload = {};

// respond to the registerHandler
utils.respondToFramelessMessage({
data: {
func: 'meeting.audioDeviceSelectionChanged',
args: [mockPayload],
},
} as DOMMessageEvent);
await waitForEventQueue();

expect(callbackPayload).toBe(mockPayload);
});

it(`should call meeting.updateMicState with HostInitiated reason when mic state matches. context: ${context}`, async () => {
await utils.initializeWithContext(context);

Expand Down
19 changes: 15 additions & 4 deletions packages/teams-js/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,21 @@ export class Utils {
return app.initialize(validMessageOrigins);
};

public findMessageByFunc = (func: string): MessageRequest | null => {
for (let i = 0; i < this.messages.length; i++) {
if (this.messages[i].func === func) {
return this.messages[i];
/**
* This function is used to find a message by function name.
* @param {string} func - The name of the function.
* @param {number | undefined} k - There could be multiple functions with that name,
* use this as a zero-based index to return the kth one. Default is 0, will return the first match.
* @returns {MessageRequest | null} The found message.
*/
public findMessageByFunc = (func: string, k = 0): MessageRequest | null => {
let countOfMatchedMessages = 0;
for (const message of this.messages) {
if (message.func === func) {
if (countOfMatchedMessages === k) {
return message;
}
countOfMatchedMessages++;
}
}
return null;
Expand Down

0 comments on commit 6f23f92

Please sign in to comment.