bigbluebutton-Github/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js

688 lines
22 KiB
JavaScript
Raw Normal View History

import browser from 'browser-detect';
import BaseAudioBridge from './base';
import logger from '/imports/startup/client/logger';
import { fetchStunTurnServers } from '/imports/utils/fetchStunTurnServers';
2019-06-04 02:54:30 +08:00
import {
2019-12-19 04:49:35 +08:00
isUnifiedPlan,
toUnifiedPlan,
toPlanB,
stripMDnsCandidates,
analyzeSdp,
logSelectedCandidate,
2019-06-04 02:54:30 +08:00
} from '/imports/utils/sdpUtils';
import { Tracker } from 'meteor/tracker';
import VoiceCallStates from '/imports/api/voice-call-states';
import CallStateOptions from '/imports/api/voice-call-states/utils/callStates';
import Auth from '/imports/ui/services/auth';
2017-08-01 04:54:18 +08:00
2017-10-18 03:16:42 +08:00
const MEDIA = Meteor.settings.public.media;
const MEDIA_TAG = MEDIA.mediaTag;
const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
2017-10-27 01:14:56 +08:00
const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
const RELAY_ONLY_ON_RECONNECT = MEDIA.relayOnlyOnReconnect;
const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain;
const ICE_NEGOTIATION_FAILED = ['iceConnectionFailed'];
const CALL_CONNECT_TIMEOUT = 20000;
2019-04-13 06:23:22 +08:00
const ICE_NEGOTIATION_TIMEOUT = 20000;
2019-11-14 07:57:42 +08:00
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
const getAudioSessionNumber = () => {
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
if (!currItem) {
currItem = 0;
}
currItem += 1;
sessionStorage.setItem(AUDIO_SESSION_NUM_KEY, currItem);
return currItem;
};
2017-10-12 05:04:10 +08:00
class SIPSession {
2019-11-14 07:57:42 +08:00
constructor(user, userData, protocol, hostname,
baseCallStates, baseErrorCodes, reconnectAttempt) {
this.user = user;
this.userData = userData;
this.protocol = protocol;
this.hostname = hostname;
this.baseCallStates = baseCallStates;
2019-06-25 04:41:19 +08:00
this.baseErrorCodes = baseErrorCodes;
this.reconnectAttempt = reconnectAttempt;
2017-09-29 21:38:10 +08:00
}
2017-07-24 22:15:46 +08:00
2017-09-30 04:42:34 +08:00
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
2017-10-05 04:49:11 +08:00
return new Promise((resolve, reject) => {
const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge;
2017-07-24 22:15:46 +08:00
2017-09-29 21:38:10 +08:00
const callback = (message) => {
// There will sometimes we erroneous errors put out like timeouts and improper shutdowns,
// but only the first error ever matters
if (this.alreadyErrored) {
logger.info({
logCode: 'sip_js_absorbing_callback_message',
extraInfo: { message },
}, 'Absorbing a redundant callback message.');
return;
}
if (message.status === this.baseCallStates.failed) {
this.alreadyErrored = true;
}
2017-10-12 05:04:10 +08:00
managerCallback(message).then(resolve);
2017-09-30 04:42:34 +08:00
};
2017-07-24 22:15:46 +08:00
2017-10-12 05:30:38 +08:00
this.callback = callback;
// If there's an extension passed it means that we're joining the echo test first
this.inEchoTest = !!extension;
2017-10-27 01:14:56 +08:00
return this.doCall({ callExtension, isListenOnly, inputStream })
.catch((reason) => {
reject(reason);
});
2017-09-30 04:42:34 +08:00
});
2017-07-24 22:15:46 +08:00
}
2017-10-20 18:11:51 +08:00
doCall(options) {
const {
isListenOnly,
} = options;
const {
userId,
name,
sessionToken,
} = this.user;
const callerIdName = [
2019-11-14 07:57:42 +08:00
`${userId}_${getAudioSessionNumber()}`,
2017-10-20 18:11:51 +08:00
'bbbID',
isListenOnly ? `LISTENONLY-${name}` : name,
].join('-').replace(/"/g, "'");
2017-10-20 18:11:51 +08:00
this.user.callerIdName = callerIdName;
2017-10-20 18:11:51 +08:00
this.callOptions = options;
return fetchStunTurnServers(sessionToken)
.then(this.createUserAgent.bind(this))
.then(this.inviteUserAgent.bind(this))
.then(this.setupEventHandlers.bind(this));
2017-10-20 18:11:51 +08:00
}
2017-10-18 03:16:42 +08:00
transferCall(onTransferSuccess) {
return new Promise((resolve, reject) => {
this.inEchoTest = false;
let trackerControl = null;
const timeout = setTimeout(() => {
trackerControl.stop();
logger.error({ logCode: 'sip_js_transfer_timed_out' }, 'Timeout on transferring from echo test to conference');
2019-05-10 05:01:34 +08:00
this.callback({
status: this.baseCallStates.failed,
error: 1008,
bridgeError: 'Timeout on call transfer',
});
this.exitAudio();
2019-05-10 05:01:34 +08:00
reject(this.baseErrorCodes.REQUEST_TIMEOUT);
2017-10-18 03:16:42 +08:00
}, CALL_TRANSFER_TIMEOUT);
// This is is the call transfer code ask @chadpilkey
this.currentSession.dtmf(1);
Tracker.autorun((c) => {
trackerControl = c;
const selector = { meetingId: Auth.meetingID, userId: Auth.userID };
const query = VoiceCallStates.find(selector);
query.observeChanges({
changed: (id, fields) => {
if (fields.callState === CallStateOptions.IN_CONFERENCE) {
clearTimeout(timeout);
onTransferSuccess();
c.stop();
resolve();
}
},
});
});
});
}
2017-09-29 21:38:10 +08:00
exitAudio() {
2017-10-27 01:14:56 +08:00
return new Promise((resolve, reject) => {
let hangupRetries = 0;
let hangup = false;
this.userRequestedHangup = true;
if (this.currentSession) {
const { mediaHandler } = this.currentSession;
// Removing termination events to avoid triggering an error
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e));
}
2017-10-27 01:14:56 +08:00
const tryHangup = () => {
if ((this.currentSession && this.currentSession.endTime)
|| (this.userAgent && this.userAgent.status === SIP.UA.C.STATUS_USER_CLOSED)) {
hangup = true;
return resolve();
}
if (this.currentSession) this.currentSession.bye();
if (this.userAgent) this.userAgent.stop();
2017-10-27 01:14:56 +08:00
hangupRetries += 1;
setTimeout(() => {
if (hangupRetries > CALL_HANGUP_MAX_RETRIES) {
this.callback({
status: this.baseCallStates.failed,
error: 1006,
2017-10-27 01:14:56 +08:00
bridgeError: 'Timeout on call hangup',
});
return reject(this.baseErrorCodes.REQUEST_TIMEOUT);
}
if (!hangup) return tryHangup();
return resolve();
}, CALL_HANGUP_TIMEOUT);
};
if (this.currentSession) {
this.currentSession.on('bye', () => {
hangup = true;
resolve();
});
}
2017-10-27 01:14:56 +08:00
return tryHangup();
2017-09-30 04:42:34 +08:00
});
}
2017-10-18 03:16:42 +08:00
createUserAgent({ stun, turn }) {
2017-09-30 04:42:34 +08:00
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
2017-10-18 03:16:42 +08:00
const {
hostname,
protocol,
} = this;
const {
callerIdName,
} = this.user;
// WebView safari needs a transceiver to be added. Made it a SIP.js hack.
// Don't like the UA picking though, we should straighten everything to user
// transceivers - prlanzarin 2019/05/21
const browserUA = window.navigator.userAgent.toLocaleLowerCase();
const isSafariWebview = ((browserUA.indexOf('iphone') > -1
2019-06-04 02:54:30 +08:00
|| browserUA.indexOf('ipad') > -1) && browserUA.indexOf('safari') === -1);
// Second UA check to get all Safari browsers to enable Unified Plan <-> PlanB
// translation
const isSafari = browser().name === 'safari';
2019-07-03 03:56:25 +08:00
logger.debug({ logCode: 'sip_js_creating_user_agent' }, 'Creating the user agent');
if (this.userAgent && this.userAgent.isConnected()) {
if (this.userAgent.configuration.hostPortParams === this.hostname) {
2019-07-03 03:56:25 +08:00
logger.debug({ logCode: 'sip_js_reusing_user_agent' }, 'Reusing the user agent');
resolve(this.userAgent);
return;
}
2019-07-03 03:56:25 +08:00
logger.debug({ logCode: 'sip_js_different_host_name' }, 'Different host name. need to kill');
}
const localSdpCallback = (sdp) => {
// For now we just need to call the utils function to parse and log the different pieces.
// In the future we're going to want to be tracking whether there were TURN candidates
// and IPv4 candidates to make informed decisions about what to do on fallbacks/reconnects.
analyzeSdp(sdp);
};
2019-12-19 04:49:35 +08:00
const remoteSdpCallback = (sdp) => {
// We have have to find the candidate that FS sends back to us to determine if the client
// is connecting with IPv4 or IPv6
const sdpInfo = analyzeSdp(sdp, false);
this.protocolIsIpv6 = sdpInfo.v6Info.found;
};
let userAgentConnected = false;
this.userAgent = new window.SIP.UA({
2017-10-18 03:16:42 +08:00
uri: `sip:${encodeURIComponent(callerIdName)}@${hostname}`,
wsServers: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws`,
displayName: callerIdName,
2017-09-30 04:42:34 +08:00
register: false,
traceSip: true,
autostart: false,
userAgentString: 'BigBlueButton',
stunServers: stun,
turnServers: turn,
hackPlanBUnifiedPlanTranslation: isSafari,
hackAddAudioTransceiver: isSafariWebview,
relayOnlyOnReconnect: this.reconnectAttempt && RELAY_ONLY_ON_RECONNECT,
localSdpCallback,
2019-12-19 04:49:35 +08:00
remoteSdpCallback,
2017-09-30 04:42:34 +08:00
});
2017-10-18 03:16:42 +08:00
const handleUserAgentConnection = () => {
userAgentConnected = true;
resolve(this.userAgent);
2017-10-18 03:16:42 +08:00
};
const handleUserAgentDisconnection = () => {
if (this.userAgent) {
this.userAgent.removeAllListeners();
this.userAgent.stop();
}
let error;
let bridgeError;
if (this.userRequestedHangup) return;
if (userAgentConnected) {
error = 1001;
bridgeError = 'Websocket disconnected';
} else {
error = 1002;
bridgeError = 'Websocket failed to connect';
}
2017-10-18 03:16:42 +08:00
this.callback({
status: this.baseCallStates.failed,
2018-06-27 21:56:03 +08:00
error,
bridgeError,
});
2017-10-20 18:11:51 +08:00
reject(this.baseErrorCodes.CONNECTION_ERROR);
2017-10-18 03:16:42 +08:00
};
2017-09-30 04:42:34 +08:00
this.userAgent.on('connected', handleUserAgentConnection);
this.userAgent.on('disconnected', handleUserAgentDisconnection);
2017-09-30 04:42:34 +08:00
this.userAgent.start();
2017-09-30 04:42:34 +08:00
});
}
2017-10-18 03:16:42 +08:00
inviteUserAgent(userAgent) {
if (this.userRequestedHangup === true) Promise.reject();
2017-10-18 03:16:42 +08:00
const {
hostname,
} = this;
const {
inputStream,
callExtension,
} = this.callOptions;
const options = {
media: {
stream: inputStream,
constraints: {
audio: true,
video: false,
},
render: {
remote: document.querySelector(MEDIA_TAG),
},
},
RTCConstraints: {
2018-12-22 01:14:05 +08:00
offerToReceiveAudio: true,
offerToReceiveVideo: false,
2017-10-18 03:16:42 +08:00
},
};
2017-10-18 03:16:42 +08:00
return userAgent.invite(`sip:${callExtension}@${hostname}`, options);
2017-09-30 04:42:34 +08:00
}
2017-10-18 03:16:42 +08:00
setupEventHandlers(currentSession) {
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
2018-04-04 00:49:45 +08:00
const { mediaHandler } = currentSession;
2017-10-27 01:14:56 +08:00
let iceCompleted = false;
let fsReady = false;
this.currentSession = currentSession;
let connectionCompletedEvents = ['iceConnectionCompleted', 'iceConnectionConnected'];
// Edge sends a connected first and then a completed, but the call isn't ready until
// the completed comes in. Due to the way that we have the listeners set up, the only
// way to ignore one status is to not listen for it.
if (browser().name === 'edge') {
connectionCompletedEvents = ['iceConnectionCompleted'];
}
2019-05-10 05:01:34 +08:00
const checkIfCallReady = () => {
if (this.userRequestedHangup === true) {
this.exitAudio();
resolve();
}
if (iceCompleted && fsReady) {
this.webrtcConnected = true;
2019-05-10 05:01:34 +08:00
this.callback({ status: this.baseCallStates.started });
resolve();
}
};
// Sometimes FreeSWITCH just won't respond with anything and hangs. This timeout is to
// avoid that issue
const callTimeout = setTimeout(() => {
this.callback({
status: this.baseCallStates.failed,
error: 1006,
bridgeError: `Call timed out on start after ${CALL_CONNECT_TIMEOUT / 1000}s`,
});
this.exitAudio();
}, CALL_CONNECT_TIMEOUT);
let iceNegotiationTimeout;
const handleSessionAccepted = () => {
logger.info({ logCode: 'sip_js_session_accepted' }, 'Audio call session accepted');
clearTimeout(callTimeout);
currentSession.off('accepted', handleSessionAccepted);
// If ICE isn't connected yet then start timeout waiting for ICE to finish
if (!iceCompleted) {
iceNegotiationTimeout = setTimeout(() => {
this.callback({
status: this.baseCallStates.failed,
error: 1010,
bridgeError: `ICE negotiation timeout after ${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
});
this.exitAudio();
}, ICE_NEGOTIATION_TIMEOUT);
}
};
currentSession.on('accepted', handleSessionAccepted);
const handleSessionProgress = (update) => {
logger.info({ logCode: 'sip_js_session_progress' }, 'Audio call session progress update');
clearTimeout(callTimeout);
currentSession.off('progress', handleSessionProgress);
};
currentSession.on('progress', handleSessionProgress);
const handleConnectionCompleted = (peer) => {
logger.info({
logCode: 'sip_js_ice_connection_success',
extraInfo: { currentState: peer.iceConnectionState },
}, `ICE connection success. Current state - ${peer.iceConnectionState}`);
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
2018-04-04 00:49:45 +08:00
connectionCompletedEvents.forEach(e => mediaHandler.off(e, handleConnectionCompleted));
iceCompleted = true;
2019-05-10 05:01:34 +08:00
2019-12-19 04:49:35 +08:00
logSelectedCandidate(peer, this.protocolIsIpv6);
2019-05-10 05:01:34 +08:00
checkIfCallReady();
2017-10-18 03:16:42 +08:00
};
2018-04-04 00:49:45 +08:00
connectionCompletedEvents.forEach(e => mediaHandler.on(e, handleConnectionCompleted));
2017-10-18 03:16:42 +08:00
const handleSessionTerminated = (message, cause) => {
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
currentSession.off('terminated', handleSessionTerminated);
if (!message && !cause && !!this.userRequestedHangup) {
2017-10-18 03:16:42 +08:00
return this.callback({
status: this.baseCallStates.ended,
});
}
logger.error({
logCode: 'sip_js_call_terminated',
extraInfo: { cause },
}, `Audio call terminated. cause=${cause}`);
let mappedCause;
if (!iceCompleted) {
mappedCause = '1004';
} else {
mappedCause = '1005';
}
2017-10-20 18:11:51 +08:00
2017-10-18 03:16:42 +08:00
return this.callback({
status: this.baseCallStates.failed,
error: mappedCause,
bridgeError: cause,
});
};
currentSession.on('terminated', handleSessionTerminated);
const handleIceNegotiationFailed = (peer) => {
if (iceCompleted) {
logger.error({ logCode: 'sipjs_ice_failed_after' }, 'ICE connection failed after success');
2019-05-10 05:01:34 +08:00
} else {
logger.error({ logCode: 'sipjs_ice_failed_before' }, 'ICE connection failed before success');
2019-05-10 05:01:34 +08:00
}
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e, handleIceNegotiationFailed));
this.callback({
status: this.baseCallStates.failed,
error: 1007,
bridgeError: `ICE negotiation failed. Current state - ${peer.iceConnectionState}`,
});
};
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.on(e, handleIceNegotiationFailed));
const handleIceConnectionTerminated = (peer) => {
['iceConnectionClosed'].forEach(e => mediaHandler.off(e, handleIceConnectionTerminated));
if (!this.userRequestedHangup) {
logger.error({ logCode: 'sipjs_ice_closed' }, 'ICE connection closed');
}
/*
2018-04-04 00:49:45 +08:00
this.callback({
status: this.baseCallStates.failed,
error: 1012,
bridgeError: "ICE connection closed. Current state - " + peer.iceConnectionState,
2018-04-04 00:49:45 +08:00
});
*/
2018-04-04 00:49:45 +08:00
};
['iceConnectionClosed'].forEach(e => mediaHandler.on(e, handleIceConnectionTerminated));
Tracker.autorun((c) => {
const selector = { meetingId: Auth.meetingID, userId: Auth.userID };
const query = VoiceCallStates.find(selector);
query.observeChanges({
changed: (id, fields) => {
if (fields.callState === CallStateOptions.IN_ECHO_TEST) {
fsReady = true;
checkIfCallReady();
c.stop();
}
},
});
});
});
}
}
2019-05-10 05:01:34 +08:00
export default class SIPBridge extends BaseAudioBridge {
constructor(userData) {
super(userData);
const {
userId,
username,
sessionToken,
} = userData;
this.user = {
userId,
sessionToken,
name: username,
};
this.media = {
inputDevice: {},
};
this.protocol = window.document.location.protocol;
this.hostname = window.document.location.hostname;
// SDP conversion utilitary methods to be used inside SIP.js
window.isUnifiedPlan = isUnifiedPlan;
window.toUnifiedPlan = toUnifiedPlan;
window.toPlanB = toPlanB;
window.stripMDnsCandidates = stripMDnsCandidates;
}
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== '';
return new Promise((resolve, reject) => {
let { hostname } = this;
this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
hostname, this.baseCallStates, this.baseErrorCodes, false);
const callback = (message) => {
if (message.status === this.baseCallStates.failed) {
let shouldTryReconnect = false;
// Try and get the call to clean up and end on an error
this.activeSession.exitAudio().catch(() => {});
if (this.activeSession.webrtcConnected) {
// webrtc was able to connect so just try again
message.silenceNotifications = true;
callback({ status: this.baseCallStates.reconnecting });
shouldTryReconnect = true;
} else if (hasFallbackDomain === true && hostname !== IPV4_FALLBACK_DOMAIN) {
message.silenceNotifications = true;
logger.info({ logCode: 'sip_js_attempt_ipv4_fallback' }, 'Attempting to fallback to IPv4 domain for audio');
hostname = IPV4_FALLBACK_DOMAIN;
shouldTryReconnect = true;
}
if (shouldTryReconnect) {
const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined;
this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
hostname, this.baseCallStates, this.baseErrorCodes, true);
this.activeSession.joinAudio({ isListenOnly, extension: fallbackExtension, inputStream }, callback)
.then((value) => {
resolve(value);
}).catch((reason) => {
reject(reason);
});
}
}
return managerCallback(message);
};
this.activeSession.joinAudio({ isListenOnly, extension, inputStream }, callback)
.then((value) => {
resolve(value);
}).catch((reason) => {
reject(reason);
});
2017-10-12 05:04:10 +08:00
});
}
transferCall(onTransferSuccess) {
return this.activeSession.transferCall(onTransferSuccess);
}
exitAudio() {
return this.activeSession.exitAudio();
}
setDefaultInputDevice() {
const handleMediaSuccess = (mediaStream) => {
const deviceLabel = mediaStream.getAudioTracks()[0].label;
window.defaultInputStream = mediaStream.getTracks();
return navigator.mediaDevices.enumerateDevices().then((mediaDevices) => {
const device = mediaDevices.find(d => d.label === deviceLabel);
return this.changeInputDevice(device.deviceId, deviceLabel);
});
};
2017-10-27 01:14:56 +08:00
return navigator.mediaDevices.getUserMedia({ audio: true }).then(handleMediaSuccess);
2017-10-27 01:14:56 +08:00
}
changeInputDevice(deviceId, deviceLabel) {
2017-10-18 03:16:42 +08:00
const {
media,
} = this;
if (media.inputDevice.audioContext) {
const handleAudioContextCloseSuccess = () => {
2017-10-18 03:16:42 +08:00
media.inputDevice.audioContext = null;
media.inputDevice.scriptProcessor = null;
media.inputDevice.source = null;
return this.changeInputDevice(deviceId);
};
return media.inputDevice.audioContext.close().then(handleAudioContextCloseSuccess);
2017-10-18 03:16:42 +08:00
}
2017-10-12 05:04:10 +08:00
2017-10-18 03:16:42 +08:00
if ('AudioContext' in window) {
media.inputDevice.audioContext = new window.AudioContext();
} else {
media.inputDevice.audioContext = new window.webkitAudioContext();
2017-10-12 05:04:10 +08:00
}
media.inputDevice.id = deviceId;
media.inputDevice.label = deviceLabel;
2017-10-18 03:16:42 +08:00
media.inputDevice.scriptProcessor = media.inputDevice.audioContext
.createScriptProcessor(2048, 1, 1);
2017-10-18 03:16:42 +08:00
media.inputDevice.source = null;
const constraints = {
audio: {
deviceId,
2017-10-18 03:16:42 +08:00
},
};
2017-10-12 05:04:10 +08:00
const handleMediaSuccess = (mediaStream) => {
media.inputDevice.stream = mediaStream;
media.inputDevice.source = media.inputDevice.audioContext
.createMediaStreamSource(mediaStream);
media.inputDevice.source.connect(media.inputDevice.scriptProcessor);
media.inputDevice.scriptProcessor.connect(media.inputDevice.audioContext.destination);
return this.media.inputDevice;
};
2017-10-18 03:16:42 +08:00
return navigator.mediaDevices.getUserMedia(constraints).then(handleMediaSuccess);
2017-10-18 03:16:42 +08:00
}
2017-10-27 01:14:56 +08:00
async changeOutputDevice(value) {
2017-10-18 03:16:42 +08:00
const audioContext = document.querySelector(MEDIA_TAG);
if (audioContext.setSinkId) {
2017-10-27 01:14:56 +08:00
try {
2018-10-25 04:26:20 +08:00
audioContext.srcObject = null;
2017-10-27 01:14:56 +08:00
await audioContext.setSinkId(value);
this.media.outputDeviceId = value;
} catch (err) {
logger.error({
logCode: 'audio_sip_changeoutputdevice_error',
extraInfo: { error: err },
}, 'Change Output Device error');
2017-10-27 01:14:56 +08:00
throw new Error(this.baseErrorCodes.MEDIA_ERROR);
}
2017-10-18 03:16:42 +08:00
}
2017-11-17 19:52:48 +08:00
return this.media.outputDeviceId || value;
2017-07-24 22:15:46 +08:00
}
}