Calling: Device Selection

This commit is contained in:
Josh Perez 2020-08-26 20:03:42 -04:00 committed by Josh Perez
parent 8b34294c97
commit 8ab1013f70
17 changed files with 1038 additions and 135 deletions

View file

@ -5,12 +5,13 @@ import {
CallLogLevel,
CallSettings,
CallState,
CanvasVideoRenderer,
DeviceId,
GumVideoCapturer,
RingRTC,
UserId,
VideoCapturer,
VideoRenderer,
} from 'ringrtc';
import {
ActionsType as UxActionsType,
CallDetailsType,
@ -18,6 +19,7 @@ import {
import { CallingMessageClass, EnvelopeClass } from '../textsecure.d';
import { ConversationModelType } from '../model-types.d';
import is from '@sindresorhus/is';
import { AudioDevice, MediaDeviceSettings } from '../types/Calling';
export {
CallState,
@ -36,7 +38,16 @@ export type CallHistoryDetailsType = {
};
export class CallingClass {
readonly videoCapturer: GumVideoCapturer;
readonly videoRenderer: CanvasVideoRenderer;
private uxActions?: UxActionsType;
private lastMediaDeviceSettings?: MediaDeviceSettings;
private deviceReselectionTimer?: NodeJS.Timeout;
constructor() {
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
this.videoRenderer = new CanvasVideoRenderer();
}
initialize(uxActions: UxActionsType): void {
this.uxActions = uxActions;
@ -80,11 +91,6 @@ export class CallingClass {
return;
}
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
window.log.info('Call already in progress, new call not allowed.');
return;
}
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
if (!remoteUserId || !this.localDeviceId) {
window.log.error('Missing identifier, new call not allowed.');
@ -97,15 +103,26 @@ export class CallingClass {
return;
}
const callSettings = await this.getCallSettings(conversation);
// Check state after awaiting to debounce call button.
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
window.log.info('Call already in progress, new call not allowed.');
return;
}
// We could make this faster by getting the call object
// from the RingRTC before we lookup the ICE servers.
const call = RingRTC.startOutgoingCall(
remoteUserId,
isVideoCall,
this.localDeviceId,
await this.getCallSettings(conversation)
callSettings
);
await this.startDeviceReselectionTimer();
RingRTC.setVideoCapturer(call.callId, this.videoCapturer);
RingRTC.setVideoRenderer(call.callId, this.videoRenderer);
this.attachToCall(conversation, call);
this.uxActions.outgoingCall({
@ -116,6 +133,9 @@ export class CallingClass {
async accept(callId: CallId, asVideoCall: boolean) {
const haveMediaPermissions = await this.requestPermissions(asVideoCall);
if (haveMediaPermissions) {
await this.startDeviceReselectionTimer();
RingRTC.setVideoCapturer(callId, this.videoCapturer);
RingRTC.setVideoRenderer(callId, this.videoRenderer);
RingRTC.accept(callId, asVideoCall);
} else {
window.log.info('Permissions were denied, call not allowed, hanging up.');
@ -139,12 +159,230 @@ export class CallingClass {
RingRTC.setOutgoingVideo(callId, enabled);
}
setVideoCapturer(callId: CallId, capturer: VideoCapturer | null) {
RingRTC.setVideoCapturer(callId, capturer);
private async startDeviceReselectionTimer(): Promise<void> {
// Poll once
await this.pollForMediaDevices();
// Start the timer
if (!this.deviceReselectionTimer) {
this.deviceReselectionTimer = setInterval(async () => {
await this.pollForMediaDevices();
}, 3000);
}
}
setVideoRenderer(callId: CallId, renderer: VideoRenderer | null) {
RingRTC.setVideoRenderer(callId, renderer);
private stopDeviceReselectionTimer() {
if (this.deviceReselectionTimer) {
clearInterval(this.deviceReselectionTimer);
this.deviceReselectionTimer = undefined;
}
}
// tslint:disable-next-line cyclomatic-complexity
private mediaDeviceSettingsEqual(
a?: MediaDeviceSettings,
b?: MediaDeviceSettings
): boolean {
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
if (
a.availableCameras.length !== b.availableCameras.length ||
a.availableMicrophones.length !== b.availableMicrophones.length ||
a.availableSpeakers.length !== b.availableSpeakers.length
) {
return false;
}
for (let i = 0; i < a.availableCameras.length; i++) {
if (
a.availableCameras[i].deviceId !== b.availableCameras[i].deviceId ||
a.availableCameras[i].groupId !== b.availableCameras[i].groupId ||
a.availableCameras[i].label !== b.availableCameras[i].label
) {
return false;
}
}
for (let i = 0; i < a.availableMicrophones.length; i++) {
if (
a.availableMicrophones[i].name !== b.availableMicrophones[i].name ||
a.availableMicrophones[i].unique_id !==
b.availableMicrophones[i].unique_id ||
a.availableMicrophones[i].same_name_index !==
b.availableMicrophones[i].same_name_index
) {
return false;
}
}
for (let i = 0; i < a.availableSpeakers.length; i++) {
if (
a.availableSpeakers[i].name !== b.availableSpeakers[i].name ||
a.availableSpeakers[i].unique_id !== b.availableSpeakers[i].unique_id ||
a.availableSpeakers[i].same_name_index !==
b.availableSpeakers[i].same_name_index
) {
return false;
}
}
if (
(a.selectedCamera && !b.selectedCamera) ||
(!a.selectedCamera && b.selectedCamera) ||
(a.selectedMicrophone && !b.selectedMicrophone) ||
(!a.selectedMicrophone && b.selectedMicrophone) ||
(a.selectedSpeaker && !b.selectedSpeaker) ||
(!a.selectedSpeaker && b.selectedSpeaker)
) {
return false;
}
if (
a.selectedCamera &&
b.selectedCamera &&
a.selectedCamera !== b.selectedCamera
) {
return false;
}
if (
a.selectedMicrophone &&
b.selectedMicrophone &&
a.selectedMicrophone.index !== b.selectedMicrophone.index
) {
return false;
}
if (
a.selectedSpeaker &&
b.selectedSpeaker &&
a.selectedSpeaker.index !== b.selectedSpeaker.index
) {
return false;
}
return true;
}
private async pollForMediaDevices(): Promise<void> {
const newSettings = await this.getMediaDeviceSettings();
if (
!this.mediaDeviceSettingsEqual(this.lastMediaDeviceSettings, newSettings)
) {
window.log.info(
'MediaDevice: available devices changed (from->to)',
this.lastMediaDeviceSettings,
newSettings
);
await this.selectPreferredMediaDevices(newSettings);
this.lastMediaDeviceSettings = newSettings;
this.uxActions?.refreshIODevices(newSettings);
}
}
async getMediaDeviceSettings(): Promise<MediaDeviceSettings> {
const availableMicrophones = RingRTC.getAudioInputs();
const preferredMicrophone = window.storage.get(
'preferred-audio-input-device'
);
const selectedMicIndex = this.findBestMatchingDeviceIndex(
availableMicrophones,
preferredMicrophone
);
const selectedMicrophone =
selectedMicIndex !== undefined
? availableMicrophones[selectedMicIndex]
: undefined;
const availableSpeakers = RingRTC.getAudioOutputs();
const preferredSpeaker = window.storage.get(
'preferred-audio-output-device'
);
const selectedSpeakerIndex = this.findBestMatchingDeviceIndex(
availableSpeakers,
preferredSpeaker
);
const selectedSpeaker =
selectedSpeakerIndex !== undefined
? availableSpeakers[selectedSpeakerIndex]
: undefined;
const availableCameras = await window.Signal.Services.calling.videoCapturer.enumerateDevices();
const preferredCamera = window.storage.get('preferred-video-input-device');
const selectedCamera = this.findBestMatchingCamera(
availableCameras,
preferredCamera
);
return {
availableMicrophones,
availableSpeakers,
selectedMicrophone,
selectedSpeaker,
availableCameras,
selectedCamera,
};
}
findBestMatchingDeviceIndex(
available: Array<AudioDevice>,
preferred: AudioDevice | undefined
): number | undefined {
if (!preferred) {
// No preference stored
return undefined;
}
// Match by UUID first, if available
if (preferred.unique_id) {
const matchIndex = available.findIndex(
d => d.unique_id === preferred.unique_id
);
if (matchIndex !== -1) {
return matchIndex;
}
}
// Match by name second, and if there are multiple such names - by instance index.
const matchingNames = available.filter(d => d.name === preferred.name);
if (matchingNames.length > preferred.same_name_index) {
return matchingNames[preferred.same_name_index].index;
}
if (matchingNames.length > 0) {
return matchingNames[0].index;
}
// Nothing matches; take the first device if there are any
return available.length > 0 ? 0 : undefined;
}
findBestMatchingCamera(
available: Array<MediaDeviceInfo>,
preferred?: string
): string | undefined {
const matchingId = available.filter(d => d.deviceId === preferred);
const nonInfrared = available.filter(d => !d.label.includes('IR Camera'));
/// By default, pick the first non-IR camera (but allow the user to pick the infrared if they so desire)
if (matchingId.length > 0) {
return matchingId[0].deviceId;
} else if (nonInfrared.length > 0) {
return nonInfrared[0].deviceId;
} else {
return undefined;
}
}
setPreferredMicrophone(device: AudioDevice) {
window.log.info('MediaDevice: setPreferredMicrophone', device);
window.storage.put('preferred-audio-input-device', device);
RingRTC.setAudioInput(device.index);
}
setPreferredSpeaker(device: AudioDevice) {
window.log.info('MediaDevice: setPreferredSpeaker', device);
window.storage.put('preferred-audio-output-device', device);
RingRTC.setAudioOutput(device.index);
}
async setPreferredCamera(device: string) {
window.log.info('MediaDevice: setPreferredCamera', device);
window.storage.put('preferred-video-input-device', device);
await this.videoCapturer.setPreferredDevice(device);
}
async handleCallingMessage(
@ -176,6 +414,30 @@ export class CallingClass {
);
}
private async selectPreferredMediaDevices(
settings: MediaDeviceSettings
): Promise<void> {
// Assume that the MediaDeviceSettings have been obtained very recently and the index is still valid (no devices have been plugged in in between).
if (settings.selectedMicrophone) {
window.log.info(
'MediaDevice: selecting microphone',
settings.selectedMicrophone
);
RingRTC.setAudioInput(settings.selectedMicrophone.index);
}
if (settings.selectedSpeaker) {
window.log.info(
'MediaDevice: selecting speaker',
settings.selectedMicrophone
);
RingRTC.setAudioOutput(settings.selectedSpeaker.index);
}
if (settings.selectedCamera) {
window.log.info('MediaDevice: selecting camera', settings.selectedCamera);
await this.videoCapturer.setPreferredDevice(settings.selectedCamera);
}
}
private async requestCameraPermissions(): Promise<boolean> {
const cameraPermission = await window.getMediaCameraPermissions();
if (!cameraPermission) {
@ -325,6 +587,8 @@ export class CallingClass {
acceptedTime = Date.now();
} else if (call.state === CallState.Ended) {
this.addCallHistoryForEndedCall(conversation, call, acceptedTime);
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
}
uxActions.callStateChange({
callState: call.state,