Calling: Device Selection
This commit is contained in:
parent
8b34294c97
commit
8ab1013f70
17 changed files with 1038 additions and 135 deletions
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue