@100mslive/hms-video-store
Advanced tools
Comparing version 0.12.20 to 0.12.21-alpha.0
@@ -7,3 +7,3 @@ export type { IStoreReadOnly, IHMSStore, IHMSStoreReadOnly as HMSStoreWrapper, IHMSStatsStore, IHMSStatsStoreReadOnly as HMSStatsStoreWrapper, } from './IHMSStore'; | ||
export * from './webrtc-stats'; | ||
export { HMSAudioMode, HMSLogLevel, HMSAudioPluginType, HMSVideoPluginType, HMSVideoPluginCanvasContextType, parsedUserAgent, simulcastMapping, DeviceType, HMSPeerType, } from './internal'; | ||
export { HMSAudioMode, HMSLogLevel, HMSAudioPluginType, HMSVideoPluginType, HMSVideoPluginCanvasContextType, parsedUserAgent, simulcastMapping, DeviceType, HMSPeerType, getAudioDeviceCategory, } from './internal'; | ||
export type { HMSConfig, HMSPreviewConfig, HMSConfigInitialSettings, HMSAudioTrackSettings, HMSVideoTrackSettings, RTMPRecordingConfig, HMSPeerStats, HMSTrackStats, HMSLocalTrackStats, HMSRemoteTrackStats, HLSConfig, HLSMeetingURLVariant, HMSScreenShareConfig, ScreenCaptureHandle, HMSPreferredSimulcastLayer, TokenRequest, TokenRequestOptions, RID, HMSPoll, HMSPollStates, HMSPollState, HMSPollCreateParams, HMSPollQuestionCreateParams, HMSPollQuestionAnswer, HMSPollQuestion, HMSPollQuestionType, HMSPollQuestionOptionCreateParams, HMSPollQuestionOption, HMSQuizLeaderboardResponse, HMSQuizLeaderboardSummary, HMSTranscriptionInfo, HMSICEServer, } from './internal'; | ||
@@ -10,0 +10,0 @@ export { EventBus } from './events/EventBus'; |
@@ -34,2 +34,3 @@ import { ServerError } from './internal'; | ||
isEffectsEnabled?: boolean; | ||
disableNoneLayerRequest?: boolean; | ||
isVBEnabled?: boolean; | ||
@@ -36,0 +37,0 @@ effectsKey?: string; |
@@ -12,3 +12,4 @@ import { HMSVideoTrack } from './HMSVideoTrack'; | ||
private bizTrackId; | ||
constructor(stream: HMSRemoteStream, track: MediaStreamTrack, source?: string); | ||
private disableNoneLayerRequest; | ||
constructor(stream: HMSRemoteStream, track: MediaStreamTrack, source?: string, disableNoneLayerRequest?: boolean); | ||
setTrackId(trackId: string): void; | ||
@@ -15,0 +16,0 @@ get trackId(): string; |
@@ -40,2 +40,3 @@ import { HMSPeerID } from './peer'; | ||
isEffectsEnabled?: boolean; | ||
disableNoneLayerRequest?: boolean; | ||
isVBEnabled?: boolean; | ||
@@ -42,0 +43,0 @@ effectsKey?: string; |
@@ -18,2 +18,3 @@ import { HMSHLS, HMSRecording, HMSRoom, HMSRTMP, HMSTranscriptionInfo } from '../../interfaces/room'; | ||
isEffectsEnabled?: boolean; | ||
disableNoneLayerRequest?: boolean; | ||
isVBEnabled?: boolean; | ||
@@ -20,0 +21,0 @@ effectsKey?: string; |
@@ -58,3 +58,4 @@ /** | ||
FLAG_NOISE_CANCELLATION = "noiseCancellation", | ||
FLAG_SCALE_SCREENSHARE_BASED_ON_PIXELS = "scaleScreenshareBasedOnPixels" | ||
FLAG_SCALE_SCREENSHARE_BASED_ON_PIXELS = "scaleScreenshareBasedOnPixels", | ||
FLAG_DISABLE_NONE_LAYER_REQUEST = "disableNoneLayerRequest" | ||
} |
@@ -21,3 +21,3 @@ import { TransportFailureCategory as TFC } from './models/TransportFailureCategory'; | ||
originalState: TransportState; | ||
maxFailedRetries?: number; | ||
maxRetryTime?: number; | ||
changeState?: boolean; | ||
@@ -32,7 +32,6 @@ } | ||
constructor(onStateChange: (state: TransportState, error?: HMSException) => Promise<void>, sendEvent: (error: HMSException, category: TFC) => void); | ||
schedule({ category, error, task, originalState, maxFailedRetries, changeState, }: ScheduleTaskParams): Promise<void>; | ||
schedule({ category, error, task, originalState, maxRetryTime, changeState, }: ScheduleTaskParams): Promise<void>; | ||
reset(): void; | ||
isTaskInProgress(category: TFC): boolean; | ||
private scheduleTask; | ||
private getBaseDelayForTask; | ||
private getDelayForRetryCount; | ||
@@ -39,0 +38,0 @@ private setTimeoutPromise; |
@@ -5,3 +5,3 @@ export declare const RENEGOTIATION_CALLBACK_ID = "renegotiation-callback-id"; | ||
/** | ||
* Maximum number of retries that transport-layer will try | ||
* Maximum time that transport-layer will try | ||
* before giving up on the connection and returning a failure | ||
@@ -11,4 +11,3 @@ * | ||
*/ | ||
export declare const MAX_TRANSPORT_RETRIES = 5; | ||
export declare const MAX_TRANSPORT_RETRY_DELAY = 60; | ||
export declare const MAX_TRANSPORT_RETRY_TIME = 60000; | ||
export declare const DEFAULT_SIGNAL_PING_TIMEOUT = 12000; | ||
@@ -15,0 +14,0 @@ export declare const DEFAULT_SIGNAL_PING_INTERVAL = 3000; |
@@ -15,2 +15,3 @@ export declare function getLocalStream(constraints: MediaStreamConstraints): Promise<MediaStream>; | ||
export declare const HMSAudioContextHandler: HMSAudioContext; | ||
export declare const getAudioDeviceCategory: (deviceLabel: string) => "bluetooth" | "speakerhone" | "wired" | "earpiece" | "speakerphone"; | ||
export {}; |
{ | ||
"version": "0.12.20", | ||
"version": "0.12.21-alpha.0", | ||
"license": "MIT", | ||
@@ -76,3 +76,3 @@ "repository": { | ||
], | ||
"gitHead": "da50781c2357d6201da1c94dcfef574a0081e2dc" | ||
"gitHead": "5b08acb39d9c077ca4c28c4bef65e0f7190a13d9" | ||
} |
@@ -101,4 +101,7 @@ import EventEmitter from 'eventemitter2'; | ||
const remote = this.remoteStreams.get(streamId)!; | ||
const TrackCls = e.track.kind === 'audio' ? HMSRemoteAudioTrack : HMSRemoteVideoTrack; | ||
const track = new TrackCls(remote, e.track); | ||
const isAudioTrack = e.track.kind === 'audio'; | ||
const TrackCls = isAudioTrack ? HMSRemoteAudioTrack : HMSRemoteVideoTrack; | ||
const track = isAudioTrack | ||
? new TrackCls(remote, e.track) | ||
: new TrackCls(remote, e.track, undefined, this.isFlagEnabled(InitFlags.FLAG_DISABLE_NONE_LAYER_REQUEST)); | ||
// reset the simulcast layer to none when new video tracks are added, UI will subscribe when required | ||
@@ -105,0 +108,0 @@ if (e.track.kind === 'video') { |
@@ -7,3 +7,3 @@ import { DeviceStorageManager } from './DeviceStorage'; | ||
import { DeviceMap, HMSDeviceChangeEvent, SelectedDevices } from '../interfaces'; | ||
import { isIOS } from '../internal'; | ||
import { getAudioDeviceCategory, isIOS } from '../internal'; | ||
import { HMSAudioTrackSettingsBuilder, HMSVideoTrackSettingsBuilder } from '../media/settings'; | ||
@@ -263,3 +263,3 @@ import { HMSLocalAudioTrack, HMSLocalTrack, HMSLocalVideoTrack } from '../media/tracks'; | ||
// false positives when device is removed, because the other available device | ||
// get's the deviceId as default once this device is removed | ||
// gets the deviceId as default once this device is removed | ||
const nextDevice = this.audioInput.find(device => { | ||
@@ -448,10 +448,10 @@ return device.deviceId !== 'default' && defaultDevice.label.includes(device.label); | ||
for (const device of this.audioInput) { | ||
const label = device.label.toLowerCase(); | ||
if (label.includes('speakerphone')) { | ||
const deviceCategory = getAudioDeviceCategory(device.label); | ||
if (deviceCategory === 'speakerphone') { | ||
speakerPhone = device; | ||
} else if (label.includes('wired')) { | ||
} else if (deviceCategory === 'wired') { | ||
wired = device; | ||
} else if (/airpods|buds|wireless|bluetooth/gi.test(label)) { | ||
} else if (deviceCategory === 'bluetooth') { | ||
bluetoothDevice = device; | ||
} else if (label.includes('earpiece')) { | ||
} else if (deviceCategory === 'speakerhone') { | ||
earpiece = device; | ||
@@ -458,0 +458,0 @@ } |
@@ -23,2 +23,3 @@ export type { | ||
HMSPeerType, | ||
getAudioDeviceCategory, | ||
} from './internal'; | ||
@@ -25,0 +26,0 @@ |
@@ -37,2 +37,3 @@ import { ServerError } from './internal'; | ||
isEffectsEnabled?: boolean; | ||
disableNoneLayerRequest?: boolean; | ||
isVBEnabled?: boolean; | ||
@@ -39,0 +40,0 @@ effectsKey?: string; |
@@ -187,4 +187,4 @@ import isEqual from 'lodash.isequal'; | ||
// Replace silent empty track with an actual audio track, if enabled. | ||
if (value && isEmptyTrack(this.nativeTrack)) { | ||
// Replace silent empty track or muted track(happens when microphone is disabled from address bar in iOS) with an actual audio track, if enabled. | ||
if (value && (isEmptyTrack(this.nativeTrack) || this.nativeTrack.muted)) { | ||
await this.replaceTrackWith(this.settings); | ||
@@ -191,0 +191,0 @@ } |
@@ -569,8 +569,3 @@ import isEqual from 'lodash.isequal'; | ||
if (this.enabled) { | ||
const track = await this.replaceTrackWithBlank(); | ||
await this.replaceSender(track, this.enabled); | ||
this.nativeTrack?.stop(); | ||
this.nativeTrack = track; | ||
} else { | ||
await this.replaceSender(this.nativeTrack, false); | ||
await this.setEnabled(false); | ||
} | ||
@@ -585,8 +580,5 @@ // started interruption event | ||
} else { | ||
HMSLogger.d(this.TAG, 'visibility visibile, restoring track state', this.enabledStateBeforeBackground); | ||
HMSLogger.d(this.TAG, 'visibility visible, restoring track state', this.enabledStateBeforeBackground); | ||
if (this.enabledStateBeforeBackground) { | ||
await this.setEnabled(true); | ||
} else { | ||
this.nativeTrack.enabled = this.enabledStateBeforeBackground; | ||
await this.replaceSender(this.nativeTrack, this.enabledStateBeforeBackground); | ||
} | ||
@@ -601,4 +593,3 @@ // ended interruption event | ||
} | ||
this.eventBus.localVideoEnabled.publish({ enabled: this.nativeTrack.enabled, track: this }); | ||
}; | ||
} |
@@ -21,5 +21,7 @@ import { HMSVideoTrack } from './HMSVideoTrack'; | ||
private bizTrackId!: string; | ||
private disableNoneLayerRequest = false; | ||
constructor(stream: HMSRemoteStream, track: MediaStreamTrack, source?: string) { | ||
constructor(stream: HMSRemoteStream, track: MediaStreamTrack, source?: string, disableNoneLayerRequest?: boolean) { | ||
super(stream, track, source); | ||
this.disableNoneLayerRequest = !!disableNoneLayerRequest; | ||
this.setVideoHandler(new VideoElementManager(this)); | ||
@@ -174,3 +176,6 @@ } | ||
private async updateLayer(source: string) { | ||
const newLayer = this.degraded || !this.enabled || !this.hasSinks() ? HMSSimulcastLayer.NONE : this.preferredLayer; | ||
const newLayer = | ||
(this.degraded || !this.enabled || !this.hasSinks()) && !this.disableNoneLayerRequest | ||
? HMSSimulcastLayer.NONE | ||
: this.preferredLayer; | ||
if (!this.shouldSendVideoLayer(newLayer, source)) { | ||
@@ -177,0 +182,0 @@ return; |
@@ -22,3 +22,3 @@ import { HMSRemoteVideoTrack } from './HMSRemoteVideoTrack'; | ||
nativeTrack = { id: trackId, kind: 'video', enabled: true } as MediaStreamTrack; | ||
track = new HMSRemoteVideoTrack(stream, nativeTrack, 'regular'); | ||
track = new HMSRemoteVideoTrack(stream, nativeTrack, 'regular', false); | ||
window.MediaStream = jest.fn().mockImplementation(() => ({ | ||
@@ -160,1 +160,61 @@ addTrack: jest.fn(), | ||
}); | ||
describe('HMSRemoteVideoTrack with disableNoneLayerRequest', () => { | ||
let stream: HMSRemoteStream; | ||
let sendOverApiDataChannelWithResponse: jest.Mock; | ||
let track: HMSRemoteVideoTrack; | ||
let nativeTrack: MediaStreamTrack; | ||
let videoElement: HTMLVideoElement; | ||
const trackId = 'test-track-id'; | ||
beforeEach(() => { | ||
videoElement = document.createElement('video'); | ||
sendOverApiDataChannelWithResponse = jest.fn(); | ||
const connection = { sendOverApiDataChannelWithResponse } as unknown as HMSSubscribeConnection; | ||
const nativeStream = new MediaStream(); | ||
stream = new HMSRemoteStream(nativeStream, connection); | ||
nativeTrack = { id: trackId, kind: 'video', enabled: true } as MediaStreamTrack; | ||
track = new HMSRemoteVideoTrack(stream, nativeTrack, 'regular', true); // disableNoneLayerRequest flag is set | ||
track.setTrackId(trackId); | ||
window.MediaStream = jest.fn().mockImplementation(() => ({ | ||
addTrack: jest.fn(), | ||
})); | ||
}); | ||
const expectLayersSent = (layers: HMSSimulcastLayer[]) => { | ||
const allCalls = sendOverApiDataChannelWithResponse.mock.calls; | ||
expect(allCalls.length).toBe(layers.length); | ||
for (let i = 0; i < allCalls.length; i++) { | ||
const data = allCalls[i][0]; | ||
expect(data.params.max_spatial_layer).toBe(layers[i]); | ||
} | ||
}; | ||
const sfuDegrades = () => { | ||
track.setLayerFromServer({ | ||
subscriber_degraded: true, | ||
expected_layer: HMSSimulcastLayer.HIGH, | ||
current_layer: HMSSimulcastLayer.NONE, | ||
publisher_degraded: false, | ||
track_id: trackId, | ||
}); | ||
}; | ||
test('disableNoneLayerRequest - degradation', async () => { | ||
await track.addSink(videoElement); | ||
expectLayersSent([HMSSimulcastLayer.HIGH]); | ||
sfuDegrades(); | ||
expectLayersSent([HMSSimulcastLayer.HIGH]); | ||
}); | ||
test('disableNoneLayerRequest - mute and removeSink', async () => { | ||
await track.addSink(videoElement); | ||
track.setEnabled(false); | ||
expectLayersSent([HMSSimulcastLayer.HIGH]); | ||
await track.removeSink(videoElement); | ||
expectLayersSent([HMSSimulcastLayer.HIGH]); | ||
}); | ||
}); |
@@ -70,3 +70,8 @@ import { TrackManager } from './TrackManager'; | ||
emptyTrack.enabled = !trackInfo.mute; | ||
const track = new HMSRemoteVideoTrack(remoteStream, emptyTrack, trackInfo.source); | ||
const track = new HMSRemoteVideoTrack( | ||
remoteStream, | ||
emptyTrack, | ||
trackInfo.source, | ||
this.store.getRoom()?.disableNoneLayerRequest, | ||
); | ||
track.setTrackId(trackInfo.track_id); | ||
@@ -73,0 +78,0 @@ track.peerId = hmsPeer.peerId; |
@@ -162,2 +162,3 @@ import { areArraysEqual } from './sdkUtils/storeMergeUtils'; | ||
isEffectsEnabled: sdkRoom.isEffectsEnabled, | ||
disableNoneLayerRequest: sdkRoom.disableNoneLayerRequest, | ||
isVBEnabled: sdkRoom.isVBEnabled, | ||
@@ -164,0 +165,0 @@ effectsKey: sdkRoom.effectsKey, |
@@ -43,2 +43,3 @@ import { HMSPeerID } from './peer'; | ||
isEffectsEnabled?: boolean; | ||
disableNoneLayerRequest?: boolean; | ||
isVBEnabled?: boolean; | ||
@@ -45,0 +46,0 @@ effectsKey?: string; |
@@ -19,2 +19,3 @@ import { HMSHLS, HMSRecording, HMSRoom, HMSRTMP, HMSTranscriptionInfo } from '../../interfaces/room'; | ||
isEffectsEnabled?: boolean; | ||
disableNoneLayerRequest?: boolean; | ||
isVBEnabled?: boolean; | ||
@@ -21,0 +22,0 @@ effectsKey?: string; |
@@ -65,2 +65,3 @@ /** | ||
FLAG_SCALE_SCREENSHARE_BASED_ON_PIXELS = 'scaleScreenshareBasedOnPixels', | ||
FLAG_DISABLE_NONE_LAYER_REQUEST = 'disableNoneLayerRequest', | ||
} |
@@ -39,3 +39,2 @@ import { JoinParameters } from './models/JoinParameters'; | ||
ICE_DISCONNECTION_TIMEOUT, | ||
MAX_TRANSPORT_RETRIES, | ||
PROTOCOL_SPEC, | ||
@@ -356,3 +355,2 @@ PROTOCOL_VERSION, | ||
originalState: this.state, | ||
maxFailedRetries: MAX_TRANSPORT_RETRIES, | ||
changeState: false, | ||
@@ -928,3 +926,2 @@ }); | ||
originalState: TransportState.Joined, | ||
maxFailedRetries: 3, | ||
changeState: false, | ||
@@ -1093,3 +1090,2 @@ }); | ||
originalState: TransportState.Joined, | ||
maxFailedRetries: 1, | ||
}); | ||
@@ -1116,2 +1112,3 @@ } | ||
room.isEffectsEnabled = this.isFlagEnabled(InitFlags.FLAG_EFFECTS_SDK_ENABLED); | ||
room.disableNoneLayerRequest = this.isFlagEnabled(InitFlags.FLAG_DISABLE_NONE_LAYER_REQUEST); | ||
room.isVBEnabled = this.isFlagEnabled(InitFlags.FLAG_VB_ENABLED); | ||
@@ -1118,0 +1115,0 @@ room.isHipaaEnabled = this.isFlagEnabled(InitFlags.FLAG_HIPAA_ENABLED); |
import { Dependencies as TFCDependencies, TransportFailureCategory as TFC } from './models/TransportFailureCategory'; | ||
import { TransportState } from './models/TransportState'; | ||
import { HMSException } from '../error/HMSException'; | ||
import { MAX_TRANSPORT_RETRIES, MAX_TRANSPORT_RETRY_DELAY } from '../utils/constants'; | ||
import { MAX_TRANSPORT_RETRY_TIME } from '../utils/constants'; | ||
import HMSLogger from '../utils/logger'; | ||
@@ -26,3 +26,3 @@ import { PromiseWithCallbacks } from '../utils/promise'; | ||
originalState: TransportState; | ||
maxFailedRetries?: number; | ||
maxRetryTime?: number; | ||
changeState?: boolean; | ||
@@ -46,6 +46,6 @@ } | ||
originalState, | ||
maxFailedRetries = MAX_TRANSPORT_RETRIES, | ||
maxRetryTime = MAX_TRANSPORT_RETRY_TIME, | ||
changeState = true, | ||
}: ScheduleTaskParams) { | ||
await this.scheduleTask({ category, error, changeState, task, originalState, maxFailedRetries }); | ||
await this.scheduleTask({ category, error, changeState, task, originalState, maxRetryTime, failedAt: Date.now() }); | ||
} | ||
@@ -70,5 +70,6 @@ | ||
originalState, | ||
maxFailedRetries = MAX_TRANSPORT_RETRIES, | ||
failedAt, | ||
maxRetryTime = MAX_TRANSPORT_RETRY_TIME, | ||
failedRetryCount = 0, | ||
}: ScheduleTaskParams & { failedRetryCount?: number }): Promise<void> { | ||
}: ScheduleTaskParams & { failedAt: number; failedRetryCount?: number }): Promise<void> { | ||
HMSLogger.d(this.TAG, 'schedule: ', { category: TFC[category], error }); | ||
@@ -119,4 +120,5 @@ | ||
if (failedRetryCount >= maxFailedRetries || hasFailedDependency) { | ||
error.description += `. [${TFC[category]}] Could not recover after ${failedRetryCount} tries`; | ||
const timeElapsedSinceError = Date.now() - failedAt; | ||
if (timeElapsedSinceError >= maxRetryTime || hasFailedDependency) { | ||
error.description += `. [${TFC[category]}] Could not recover after ${timeElapsedSinceError} milliseconds`; | ||
@@ -151,3 +153,3 @@ if (hasFailedDependency) { | ||
const delay = this.getDelayForRetryCount(category, failedRetryCount); | ||
const delay = this.getDelayForRetryCount(category); | ||
@@ -179,3 +181,6 @@ HMSLogger.d( | ||
} | ||
HMSLogger.d(this.TAG, `schedule: [${TFC[category]}] [failedRetryCount=${failedRetryCount}] Recovered ♻️`); | ||
HMSLogger.d( | ||
this.TAG, | ||
`schedule: [${TFC[category]}] [failedRetryCount=${failedRetryCount}] Recovered ♻️ after ${timeElapsedSinceError}ms`, | ||
); | ||
} else { | ||
@@ -188,3 +193,4 @@ await this.scheduleTask({ | ||
originalState, | ||
maxFailedRetries, | ||
maxRetryTime, | ||
failedAt, | ||
failedRetryCount: failedRetryCount + 1, | ||
@@ -195,17 +201,14 @@ }); | ||
private getBaseDelayForTask(category: TFC, n: number) { | ||
private getDelayForRetryCount(category: TFC) { | ||
const jitter = category === TFC.JoinWSMessageFailed ? Math.random() * 2 : Math.random(); | ||
let delaySeconds = 0; | ||
if (category === TFC.JoinWSMessageFailed) { | ||
// linear backoff(2 + jitter for every retry) | ||
return 2; | ||
delaySeconds = 2 + jitter; | ||
} else if (category === TFC.SignalDisconnect) { | ||
delaySeconds = 1; | ||
} | ||
// exponential backoff | ||
return Math.pow(2, n); | ||
return delaySeconds * 1000; | ||
} | ||
private getDelayForRetryCount(category: TFC, n: number) { | ||
const delay = this.getBaseDelayForTask(category, n); | ||
const jitter = category === TFC.JoinWSMessageFailed ? Math.random() * 2 : Math.random(); | ||
return Math.round(Math.min(delay + jitter, MAX_TRANSPORT_RETRY_DELAY) * 1000); | ||
} | ||
private async setTimeoutPromise<T>(task: () => Promise<T>, delay: number): Promise<T> { | ||
@@ -212,0 +215,0 @@ return new Promise((resolve, reject) => { |
@@ -6,3 +6,3 @@ export const RENEGOTIATION_CALLBACK_ID = 'renegotiation-callback-id'; | ||
/** | ||
* Maximum number of retries that transport-layer will try | ||
* Maximum time that transport-layer will try | ||
* before giving up on the connection and returning a failure | ||
@@ -12,4 +12,3 @@ * | ||
*/ | ||
export const MAX_TRANSPORT_RETRIES = 5; | ||
export const MAX_TRANSPORT_RETRY_DELAY = 60; | ||
export const MAX_TRANSPORT_RETRY_TIME = 60_000; | ||
@@ -16,0 +15,0 @@ export const DEFAULT_SIGNAL_PING_TIMEOUT = 12_000; |
@@ -66,1 +66,15 @@ import HMSLogger from './logger'; | ||
}; | ||
export const getAudioDeviceCategory = (deviceLabel: string) => { | ||
const label = deviceLabel.toLowerCase(); | ||
if (label.includes('speakerphone')) { | ||
return 'speakerhone'; | ||
} else if (label.includes('wired')) { | ||
return 'wired'; | ||
} else if (/airpods|buds|wireless|bluetooth/gi.test(label)) { | ||
return 'bluetooth'; | ||
} else if (label.includes('earpiece')) { | ||
return 'earpiece'; | ||
} | ||
return 'speakerphone'; | ||
}; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
4802374
40825