diff --git a/.changeset/dry-crabs-agree.md b/.changeset/dry-crabs-agree.md new file mode 100644 index 000000000..5a2a444e0 --- /dev/null +++ b/.changeset/dry-crabs-agree.md @@ -0,0 +1,5 @@ +--- +"@whereby.com/media": patch +--- + +Add custom Safari17 mediasoup device hanlder diff --git a/packages/core/src/redux/slices/rtcConnection/index.ts b/packages/core/src/redux/slices/rtcConnection/index.ts index 4abbfe4ef..dfc7f0f6e 100644 --- a/packages/core/src/redux/slices/rtcConnection/index.ts +++ b/packages/core/src/redux/slices/rtcConnection/index.ts @@ -13,7 +13,7 @@ import { createReactor, startAppListening } from "../../listenerMiddleware"; import { selectRemoteClients, streamStatusUpdated } from "../remoteParticipants"; import { StreamState } from "../../../RoomParticipant"; import { selectAppIsNodeSdk, selectAppIsActive, doAppStop } from "../app"; -import { Safari12 as MediasoupDeviceHandler } from "mediasoup-client/lib/handlers/Safari12.js"; + import { selectIsCameraEnabled, selectIsMicrophoneEnabled, @@ -197,7 +197,6 @@ export const doConnectRtc = createAppThunk(() => (dispatch, getState) => { vp9On: false, h264On: false, simulcastScreenshareOn: false, - deviceHandlerFactory: isNodeSdk ? MediasoupDeviceHandler.createFactory() : undefined, }, }); diff --git a/packages/core/src/redux/tests/store/rtcConnection.spec.ts b/packages/core/src/redux/tests/store/rtcConnection.spec.ts index 633895d41..afac17ba7 100644 --- a/packages/core/src/redux/tests/store/rtcConnection.spec.ts +++ b/packages/core/src/redux/tests/store/rtcConnection.spec.ts @@ -72,7 +72,7 @@ describe("actions", () => { }); describe("when isNodeSdk is true", () => { - it("uses a custom mediasoup device", () => { + it("initializes the RtcManagerDispatcher with that feature", () => { const store = createStore({ withSignalConnection: true, initialState: { @@ -97,7 +97,7 @@ describe("actions", () => { expect(RtcManagerDispatcher).toHaveBeenCalledTimes(1); expect(RtcManagerDispatcher).toHaveBeenCalledWith( expect.objectContaining({ - features: expect.objectContaining({ deviceHandlerFactory: expect.any(Function) }), + features: expect.objectContaining({ isNodeSdk: true }), }), ); expect(diff(before, after)).toEqual({ diff --git a/packages/media/package.json b/packages/media/package.json index 76376a6d8..9fa8d4f9b 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -49,6 +49,7 @@ "dist/**/*.d.cts" ], "dependencies": { + "@types/ua-parser-js": "^0.7.39", "check-ip": "^1.1.1", "events": "^3.3.0", "ip-address": "^9.0.5", @@ -58,6 +59,7 @@ "sdp-transform": "^2.14.2", "socket.io-client": "4.7.2", "typescript": "^5.3.3", + "ua-parser-js": "^1.0.38", "uuid": "^9.0.1", "uuid-validate": "^0.0.3", "webrtc-adapter": "^9.0.1" diff --git a/packages/media/src/utils/Safari17Handler.ts b/packages/media/src/utils/Safari17Handler.ts new file mode 100644 index 000000000..a4fff2151 --- /dev/null +++ b/packages/media/src/utils/Safari17Handler.ts @@ -0,0 +1,1018 @@ +import * as sdpTransform from "sdp-transform"; +import { Logger } from "mediasoup-client/lib/Logger"; +import * as utils from "mediasoup-client/lib/utils"; +import * as ortc from "mediasoup-client/lib/ortc"; +import * as sdpCommonUtils from "mediasoup-client/lib/handlers/sdp/commonUtils"; +import * as sdpUnifiedPlanUtils from "mediasoup-client/lib/handlers/sdp/unifiedPlanUtils"; +import * as ortcUtils from "mediasoup-client/lib/handlers/ortc/utils"; +import { InvalidStateError } from "mediasoup-client/lib/errors"; +import { + HandlerFactory, + HandlerInterface, + HandlerRunOptions, + HandlerSendOptions, + HandlerSendResult, + HandlerReceiveOptions, + HandlerReceiveResult, + HandlerSendDataChannelOptions, + HandlerSendDataChannelResult, + HandlerReceiveDataChannelOptions, + HandlerReceiveDataChannelResult, +} from "mediasoup-client/lib/handlers/HandlerInterface"; +import { RemoteSdp } from "mediasoup-client/lib/handlers/sdp/RemoteSdp"; +import { parse as parseScalabilityMode } from "mediasoup-client/lib/scalabilityModes"; +import { IceParameters, DtlsRole } from "mediasoup-client/lib/Transport"; +import { RtpCapabilities, RtpParameters } from "mediasoup-client/lib/RtpParameters"; +import { SctpCapabilities, SctpStreamParameters } from "mediasoup-client/lib/SctpParameters"; + +const logger = new Logger("Safari17"); + +const NAME = "Safari17"; +const SCTP_NUM_STREAMS = { OS: 1024, MIS: 1024 }; + +export class Safari17 extends HandlerInterface { + // Closed flag. + private _closed = false; + // Handler direction. + private _direction?: "send" | "recv"; + // Remote SDP handler. + private _remoteSdp?: RemoteSdp; + // Generic sending RTP parameters for audio and video. + private _sendingRtpParametersByKind?: { [key: string]: RtpParameters }; + // Generic sending RTP parameters for audio and video suitable for the SDP + // remote answer. + private _sendingRemoteRtpParametersByKind?: { [key: string]: RtpParameters }; + // Initial server side DTLS role. If not 'auto', it will force the opposite + // value in client side. + private _forcedLocalDtlsRole?: DtlsRole; + // RTCPeerConnection instance. + private _pc: any; + // Map of RTCTransceivers indexed by MID. + private readonly _mapMidTransceiver: Map = new Map(); + // Local stream for sending. + private readonly _sendStream = new MediaStream(); + // Whether a DataChannel m=application section has been created. + private _hasDataChannelMediaSection = false; + // Sending DataChannel id value counter. Incremented for each new DataChannel. + private _nextSendSctpStreamId = 0; + // Got transport local and remote parameters. + private _transportReady = false; + + /** + * Creates a factory function. + */ + static createFactory(): HandlerFactory { + return (): Safari17 => new Safari17(); + } + + constructor() { + super(); + } + + get name(): string { + return NAME; + } + + close(): void { + logger.debug("close()"); + + if (this._closed) { + return; + } + + this._closed = true; + + // Close RTCPeerConnection. + if (this._pc) { + try { + this._pc.close(); + } catch (error) {} + } + + this.emit("@close"); + } + + async getNativeRtpCapabilities(): Promise { + logger.debug("getNativeRtpCapabilities()"); + + const pc = new (RTCPeerConnection as any)({ + iceServers: [], + iceTransportPolicy: "all", + bundlePolicy: "max-bundle", + rtcpMuxPolicy: "require", + }); + + try { + pc.addTransceiver("audio"); + pc.addTransceiver("video"); + + const offer = await pc.createOffer(); + + try { + pc.close(); + } catch (error) {} + + const sdpObject = sdpTransform.parse(offer.sdp); + const nativeRtpCapabilities = sdpCommonUtils.extractRtpCapabilities({ + sdpObject, + }); + + // libwebrtc supports NACK for OPUS but doesn't announce it. + ortcUtils.addNackSuppportForOpus(nativeRtpCapabilities); + + return nativeRtpCapabilities; + } catch (error) { + try { + pc.close(); + } catch (error2) {} + + throw error; + } + } + + async getNativeSctpCapabilities(): Promise { + logger.debug("getNativeSctpCapabilities()"); + + return { + numStreams: SCTP_NUM_STREAMS, + }; + } + + run({ + direction, + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + iceServers, + iceTransportPolicy, + additionalSettings, + proprietaryConstraints, + extendedRtpCapabilities, + }: HandlerRunOptions): void { + this.assertNotClosed(); + + logger.debug("run()"); + + this._direction = direction; + + this._remoteSdp = new RemoteSdp({ + iceParameters, + iceCandidates, + dtlsParameters, + sctpParameters, + }); + + this._sendingRtpParametersByKind = { + audio: ortc.getSendingRtpParameters("audio", extendedRtpCapabilities), + video: ortc.getSendingRtpParameters("video", extendedRtpCapabilities), + }; + + this._sendingRemoteRtpParametersByKind = { + audio: ortc.getSendingRemoteRtpParameters("audio", extendedRtpCapabilities), + video: ortc.getSendingRemoteRtpParameters("video", extendedRtpCapabilities), + }; + + if (dtlsParameters.role && dtlsParameters.role !== "auto") { + this._forcedLocalDtlsRole = dtlsParameters.role === "server" ? "client" : "server"; + } + + this._pc = new (RTCPeerConnection as any)( + { + iceServers: iceServers ?? [], + iceTransportPolicy: iceTransportPolicy ?? "all", + bundlePolicy: "max-bundle", + rtcpMuxPolicy: "require", + ...additionalSettings, + }, + proprietaryConstraints + ); + + this._pc.addEventListener("icegatheringstatechange", () => { + this.emit("@icegatheringstatechange", this._pc.iceGatheringState); + }); + + if (this._pc.connectionState) { + this._pc.addEventListener("connectionstatechange", () => { + this.emit("@connectionstatechange", this._pc.connectionState); + }); + } else { + this._pc.addEventListener("iceconnectionstatechange", () => { + logger.warn("run() | pc.connectionState not supported, using pc.iceConnectionState"); + + switch (this._pc.iceConnectionState) { + case "checking": { + this.emit("@connectionstatechange", "connecting"); + + break; + } + + case "connected": + case "completed": { + this.emit("@connectionstatechange", "connected"); + + break; + } + + case "failed": { + this.emit("@connectionstatechange", "failed"); + + break; + } + + case "disconnected": { + this.emit("@connectionstatechange", "disconnected"); + + break; + } + + case "closed": { + this.emit("@connectionstatechange", "closed"); + + break; + } + } + }); + } + } + + async updateIceServers(iceServers: RTCIceServer[]): Promise { + this.assertNotClosed(); + + logger.debug("updateIceServers()"); + + const configuration = this._pc.getConfiguration(); + + configuration.iceServers = iceServers; + + this._pc.setConfiguration(configuration); + } + + async restartIce(iceParameters: IceParameters): Promise { + this.assertNotClosed(); + + logger.debug("restartIce()"); + + // Provide the remote SDP handler with new remote ICE parameters. + this._remoteSdp!.updateIceParameters(iceParameters); + + if (!this._transportReady) { + return; + } + + if (this._direction === "send") { + const offer = await this._pc.createOffer({ iceRestart: true }); + + logger.debug("restartIce() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("restartIce() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + } else { + const offer = { type: "offer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("restartIce() | calling pc.setRemoteDescription() [offer:%o]", offer); + + await this._pc.setRemoteDescription(offer); + + const answer = await this._pc.createAnswer(); + + logger.debug("restartIce() | calling pc.setLocalDescription() [answer:%o]", answer); + + await this._pc.setLocalDescription(answer); + } + } + + async getTransportStats(): Promise { + this.assertNotClosed(); + + return this._pc.getStats(); + } + + async send({ track, encodings, codecOptions, codec, onRtpSender }: HandlerSendOptions): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + logger.debug("send() [kind:%s, track.id:%s]", track.kind, track.id); + + const sendingRtpParameters = utils.clone(this._sendingRtpParametersByKind![track.kind]); + + // This may throw. + sendingRtpParameters.codecs = ortc.reduceCodecs(sendingRtpParameters.codecs, codec); + + if (encodings && encodings.length > 1) { + // Set rid and verify scalabilityMode in each encoding. + // NOTE: Even if WebRTC allows different scalabilityMode (different number + // of temporal layers) per simulcast stream, we need that those are the + // same in all them, so let's pick up the highest value. + // NOTE: If scalabilityMode is not given, Chrome will use L1T3. + let maxTemporalLayers = 1; + + for (const encoding of encodings) { + const temporalLayers = encoding.scalabilityMode + ? parseScalabilityMode(encoding.scalabilityMode).temporalLayers + : 3; + + if (temporalLayers > maxTemporalLayers) { + maxTemporalLayers = temporalLayers; + } + } + + encodings.forEach((encoding, idx: number) => { + encoding.rid = `r${idx}`; + encoding.scalabilityMode = `L1T${maxTemporalLayers}`; + }); + } + + const sendingRemoteRtpParameters = utils.clone( + this._sendingRemoteRtpParametersByKind![track.kind] + ); + + // This may throw. + sendingRemoteRtpParameters.codecs = ortc.reduceCodecs(sendingRemoteRtpParameters.codecs, codec); + + const mediaSectionIdx = this._remoteSdp!.getNextMediaSectionIdx(); + const transceiver = this._pc.addTransceiver(track, { + direction: "sendonly", + streams: [this._sendStream], + sendEncodings: encodings, + }); + + if (onRtpSender) { + onRtpSender(transceiver.sender); + } + + const offer = await this._pc.createOffer(); + let localSdpObject = sdpTransform.parse(offer.sdp); + + if (!this._transportReady) { + await this.setupTransport({ + localDtlsRole: this._forcedLocalDtlsRole ?? "client", + localSdpObject, + }); + } + + const layers = parseScalabilityMode((encodings ?? [{}])[0].scalabilityMode); + + logger.debug("send() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + // We can now get the transceiver.mid. + const localId = transceiver.mid; + + // Set MID. + sendingRtpParameters.mid = localId; + + localSdpObject = sdpTransform.parse(this._pc.localDescription.sdp); + const offerMediaObject = localSdpObject.media[mediaSectionIdx.idx]; + + // Set RTCP CNAME. + sendingRtpParameters.rtcp!.cname = sdpCommonUtils.getCname({ + offerMediaObject, + }); + + // Set RTP encodings by parsing the SDP offer if no encodings are given. + if (!encodings) { + sendingRtpParameters.encodings = sdpUnifiedPlanUtils.getRtpEncodings({ + offerMediaObject, + }); + } + // Set RTP encodings by parsing the SDP offer and complete them with given + // one if just a single encoding has been given. + else if (encodings.length === 1) { + const newEncodings = sdpUnifiedPlanUtils.getRtpEncodings({ + offerMediaObject, + }); + + Object.assign(newEncodings[0], encodings[0]); + + sendingRtpParameters.encodings = newEncodings; + } + // Otherwise if more than 1 encoding are given use them verbatim. + else { + sendingRtpParameters.encodings = encodings; + } + + // If VP8 or H264 and there is effective simulcast, add scalabilityMode to + // each encoding. + if ( + sendingRtpParameters.encodings.length > 1 && + (sendingRtpParameters.codecs[0].mimeType.toLowerCase() === "video/vp8" || + sendingRtpParameters.codecs[0].mimeType.toLowerCase() === "video/h264") + ) { + for (const encoding of sendingRtpParameters.encodings) { + if (encoding.scalabilityMode) { + encoding.scalabilityMode = `L1T${layers.temporalLayers}`; + } else { + encoding.scalabilityMode = "L1T3"; + } + } + } + + this._remoteSdp!.send({ + offerMediaObject, + reuseMid: mediaSectionIdx.reuseMid, + offerRtpParameters: sendingRtpParameters, + answerRtpParameters: sendingRemoteRtpParameters, + codecOptions, + }); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("send() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + + // Store in the map. + this._mapMidTransceiver.set(localId, transceiver); + + return { + localId, + rtpParameters: sendingRtpParameters, + rtpSender: transceiver.sender, + }; + } + + async stopSending(localId: string): Promise { + this.assertSendDirection(); + + if (this._closed) { + return; + } + + logger.debug("stopSending() [localId:%s]", localId); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + void transceiver.sender.replaceTrack(null); + + this._pc.removeTrack(transceiver.sender); + + const mediaSectionClosed = this._remoteSdp!.closeMediaSection(transceiver.mid!); + + if (mediaSectionClosed) { + try { + transceiver.stop(); + } catch (error) {} + } + + const offer = await this._pc.createOffer(); + + logger.debug("stopSending() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("stopSending() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + + this._mapMidTransceiver.delete(localId); + } + + async pauseSending(localId: string): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + logger.debug("pauseSending() [localId:%s]", localId); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + transceiver.direction = "inactive"; + this._remoteSdp!.pauseMediaSection(localId); + + const offer = await this._pc.createOffer(); + + logger.debug("pauseSending() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("pauseSending() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + } + + async resumeSending(localId: string): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + logger.debug("resumeSending() [localId:%s]", localId); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + transceiver.direction = "sendonly"; + this._remoteSdp!.resumeSendingMediaSection(localId); + + const offer = await this._pc.createOffer(); + + logger.debug("resumeSending() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("resumeSending() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + } + + async replaceTrack(localId: string, track: MediaStreamTrack | null): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + if (track) { + logger.debug("replaceTrack() [localId:%s, track.id:%s]", localId, track.id); + } else { + logger.debug("replaceTrack() [localId:%s, no track]", localId); + } + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + await transceiver.sender.replaceTrack(track); + } + + async setMaxSpatialLayer(localId: string, spatialLayer: number): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + logger.debug("setMaxSpatialLayer() [localId:%s, spatialLayer:%s]", localId, spatialLayer); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + const parameters = transceiver.sender.getParameters(); + + parameters.encodings.forEach((encoding: RTCRtpEncodingParameters, idx: number) => { + if (idx <= spatialLayer) { + encoding.active = true; + } else { + encoding.active = false; + } + }); + + await transceiver.sender.setParameters(parameters); + + this._remoteSdp!.muxMediaSectionSimulcast(localId, parameters.encodings); + + const offer = await this._pc.createOffer(); + + logger.debug("setMaxSpatialLayer() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("setMaxSpatialLayer() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + } + + async setRtpEncodingParameters(localId: string, params: any): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + logger.debug("setRtpEncodingParameters() [localId:%s, params:%o]", localId, params); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + const parameters = transceiver.sender.getParameters(); + + parameters.encodings.forEach((encoding: RTCRtpEncodingParameters, idx: number) => { + parameters.encodings[idx] = { ...encoding, ...params }; + }); + + await transceiver.sender.setParameters(parameters); + + this._remoteSdp!.muxMediaSectionSimulcast(localId, parameters.encodings); + + const offer = await this._pc.createOffer(); + + logger.debug("setRtpEncodingParameters() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("setRtpEncodingParameters() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + } + + async getSenderStats(localId: string): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + return transceiver.sender.getStats(); + } + + async sendDataChannel({ + ordered, + maxPacketLifeTime, + maxRetransmits, + label, + protocol, + }: HandlerSendDataChannelOptions): Promise { + this.assertNotClosed(); + this.assertSendDirection(); + + const options = { + negotiated: true, + id: this._nextSendSctpStreamId, + ordered, + maxPacketLifeTime, + maxRetransmits, + protocol, + }; + + logger.debug("sendDataChannel() [options:%o]", options); + + const dataChannel = this._pc.createDataChannel(label, options); + + // Increase next id. + this._nextSendSctpStreamId = ++this._nextSendSctpStreamId % SCTP_NUM_STREAMS.MIS; + + // If this is the first DataChannel we need to create the SDP answer with + // m=application section. + if (!this._hasDataChannelMediaSection) { + const offer = await this._pc.createOffer(); + const localSdpObject = sdpTransform.parse(offer.sdp); + const offerMediaObject = localSdpObject.media.find((m: any) => m.type === "application"); + + if (!this._transportReady) { + await this.setupTransport({ + localDtlsRole: this._forcedLocalDtlsRole ?? "client", + localSdpObject, + }); + } + + logger.debug("sendDataChannel() | calling pc.setLocalDescription() [offer:%o]", offer); + + await this._pc.setLocalDescription(offer); + + this._remoteSdp!.sendSctpAssociation({ offerMediaObject }); + + const answer = { type: "answer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("sendDataChannel() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setRemoteDescription(answer); + + this._hasDataChannelMediaSection = true; + } + + const sctpStreamParameters: SctpStreamParameters = { + streamId: options.id, + ordered: options.ordered, + maxPacketLifeTime: options.maxPacketLifeTime, + maxRetransmits: options.maxRetransmits, + }; + + return { dataChannel, sctpStreamParameters }; + } + + async receive(optionsList: HandlerReceiveOptions[]): Promise { + this.assertNotClosed(); + this.assertRecvDirection(); + + const results: HandlerReceiveResult[] = []; + const mapLocalId: Map = new Map(); + + for (const options of optionsList) { + const { trackId, kind, rtpParameters, streamId } = options; + + logger.debug("receive() [trackId:%s, kind:%s]", trackId, kind); + + const localId = rtpParameters.mid ?? String(this._mapMidTransceiver.size); + + mapLocalId.set(trackId, localId); + + this._remoteSdp!.receive({ + mid: localId, + kind, + offerRtpParameters: rtpParameters, + streamId: streamId ?? rtpParameters.rtcp!.cname!, + trackId, + }); + } + + const offer = { type: "offer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("receive() | calling pc.setRemoteDescription() [offer:%o]", offer); + + await this._pc.setRemoteDescription(offer); + + for (const options of optionsList) { + const { trackId, onRtpReceiver } = options; + + if (onRtpReceiver) { + const localId = mapLocalId.get(trackId); + const transceiver = this._pc.getTransceivers().find((t: RTCRtpTransceiver) => t.mid === localId); + + if (!transceiver) { + throw new Error("transceiver not found"); + } + + onRtpReceiver(transceiver.receiver); + } + } + + let answer = await this._pc.createAnswer(); + const localSdpObject = sdpTransform.parse(answer.sdp); + + for (const options of optionsList) { + const { trackId, rtpParameters } = options; + const localId = mapLocalId.get(trackId); + const answerMediaObject = localSdpObject.media.find((m: any) => String(m.mid) === localId); + + // May need to modify codec parameters in the answer based on codec + // parameters in the offer. + sdpCommonUtils.applyCodecParameters({ + offerRtpParameters: rtpParameters, + answerMediaObject, + }); + } + + answer = { type: "answer", sdp: sdpTransform.write(localSdpObject) }; + + if (!this._transportReady) { + await this.setupTransport({ + localDtlsRole: this._forcedLocalDtlsRole ?? "client", + localSdpObject, + }); + } + + logger.debug("receive() | calling pc.setLocalDescription() [answer:%o]", answer); + + await this._pc.setLocalDescription(answer); + + for (const options of optionsList) { + const { trackId } = options; + const localId = mapLocalId.get(trackId)!; + const transceiver = this._pc.getTransceivers().find((t: RTCRtpTransceiver) => t.mid === localId); + + if (!transceiver) { + throw new Error("new RTCRtpTransceiver not found"); + } + + // Store in the map. + this._mapMidTransceiver.set(localId, transceiver); + + results.push({ + localId, + track: transceiver.receiver.track, + rtpReceiver: transceiver.receiver, + }); + } + + return results; + } + + async stopReceiving(localIds: string[]): Promise { + this.assertRecvDirection(); + + if (this._closed) { + return; + } + + for (const localId of localIds) { + logger.debug("stopReceiving() [localId:%s]", localId); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + this._remoteSdp!.closeMediaSection(transceiver.mid!); + } + + const offer = { type: "offer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("stopReceiving() | calling pc.setRemoteDescription() [offer:%o]", offer); + + await this._pc.setRemoteDescription(offer); + + const answer = await this._pc.createAnswer(); + + logger.debug("stopReceiving() | calling pc.setLocalDescription() [answer:%o]", answer); + + await this._pc.setLocalDescription(answer); + + for (const localId of localIds) { + this._mapMidTransceiver.delete(localId); + } + } + + async pauseReceiving(localIds: string[]): Promise { + this.assertNotClosed(); + this.assertRecvDirection(); + + for (const localId of localIds) { + logger.debug("pauseReceiving() [localId:%s]", localId); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + transceiver.direction = "inactive"; + this._remoteSdp!.pauseMediaSection(localId); + } + + const offer = { type: "offer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("pauseReceiving() | calling pc.setRemoteDescription() [offer:%o]", offer); + + await this._pc.setRemoteDescription(offer); + + const answer = await this._pc.createAnswer(); + + logger.debug("pauseReceiving() | calling pc.setLocalDescription() [answer:%o]", answer); + + await this._pc.setLocalDescription(answer); + } + + async resumeReceiving(localIds: string[]): Promise { + this.assertNotClosed(); + this.assertRecvDirection(); + + for (const localId of localIds) { + logger.debug("resumeReceiving() [localId:%s]", localId); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + transceiver.direction = "recvonly"; + this._remoteSdp!.resumeReceivingMediaSection(localId); + } + + const offer = { type: "offer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("resumeReceiving() | calling pc.setRemoteDescription() [offer:%o]", offer); + + await this._pc.setRemoteDescription(offer); + + const answer = await this._pc.createAnswer(); + + logger.debug("resumeReceiving() | calling pc.setLocalDescription() [answer:%o]", answer); + + await this._pc.setLocalDescription(answer); + } + + async getReceiverStats(localId: string): Promise { + this.assertNotClosed(); + this.assertRecvDirection(); + + const transceiver = this._mapMidTransceiver.get(localId); + + if (!transceiver) { + throw new Error("associated RTCRtpTransceiver not found"); + } + + return transceiver.receiver.getStats(); + } + + async receiveDataChannel({ + sctpStreamParameters, + label, + protocol, + }: HandlerReceiveDataChannelOptions): Promise { + this.assertNotClosed(); + this.assertRecvDirection(); + + const { streamId, ordered, maxPacketLifeTime, maxRetransmits }: SctpStreamParameters = sctpStreamParameters; + + const options = { + negotiated: true, + id: streamId, + ordered, + maxPacketLifeTime, + maxRetransmits, + protocol, + }; + + logger.debug("receiveDataChannel() [options:%o]", options); + + const dataChannel = this._pc.createDataChannel(label, options); + + // If this is the first DataChannel we need to create the SDP offer with + // m=application section. + if (!this._hasDataChannelMediaSection) { + this._remoteSdp!.receiveSctpAssociation(); + + const offer = { type: "offer", sdp: this._remoteSdp!.getSdp() }; + + logger.debug("receiveDataChannel() | calling pc.setRemoteDescription() [offer:%o]", offer); + + await this._pc.setRemoteDescription(offer); + + const answer = await this._pc.createAnswer(); + + if (!this._transportReady) { + const localSdpObject = sdpTransform.parse(answer.sdp); + + await this.setupTransport({ + localDtlsRole: this._forcedLocalDtlsRole ?? "client", + localSdpObject, + }); + } + + logger.debug("receiveDataChannel() | calling pc.setRemoteDescription() [answer:%o]", answer); + + await this._pc.setLocalDescription(answer); + + this._hasDataChannelMediaSection = true; + } + + return { dataChannel }; + } + + private async setupTransport({ + localDtlsRole, + localSdpObject, + }: { + localDtlsRole: DtlsRole; + localSdpObject?: any; + }): Promise { + if (!localSdpObject) { + localSdpObject = sdpTransform.parse(this._pc.localDescription.sdp); + } + + // Get our local DTLS parameters. + const dtlsParameters = sdpCommonUtils.extractDtlsParameters({ + sdpObject: localSdpObject, + }); + + // Set our DTLS role. + dtlsParameters.role = localDtlsRole; + + // Update the remote DTLS role in the SDP. + this._remoteSdp!.updateDtlsRole(localDtlsRole === "client" ? "server" : "client"); + + // Need to tell the remote transport about our parameters. + await new Promise((resolve, reject) => { + this.safeEmit("@connect", { dtlsParameters }, resolve, reject); + }); + + this._transportReady = true; + } + + private assertNotClosed(): void { + if (this._closed) { + throw new InvalidStateError("method called in a closed handler"); + } + } + + private assertSendDirection(): void { + if (this._direction !== "send") { + throw new Error('method can just be called for handlers with "send" direction'); + } + } + + private assertRecvDirection(): void { + if (this._direction !== "recv") { + throw new Error('method can just be called for handlers with "recv" direction'); + } + } +} diff --git a/packages/media/src/utils/__tests__/getMediasoupDevice.spec.ts b/packages/media/src/utils/__tests__/getMediasoupDevice.spec.ts new file mode 100644 index 000000000..dbb0d7dd3 --- /dev/null +++ b/packages/media/src/utils/__tests__/getMediasoupDevice.spec.ts @@ -0,0 +1,83 @@ +import { getMediasoupDevice } from "../getMediasoupDevice"; + +jest.mock("mediasoup-client", () => ({ + Device: jest.fn(), + detectDevice: jest.fn(), +})); +const mediasoupClient = jest.requireMock("mediasoup-client"); +jest.mock("../Safari17Handler"); +const { Safari17 } = jest.requireMock("../Safari17Handler"); + +const safari17UserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"; +const safari18UserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15"; + +describe("getMediasoupClient", () => { + const features = {}; + (global as any).userAgent = jest.spyOn(navigator, "userAgent", "get"); + + it("returns the resolved handler from mediasoup-client", () => { + mediasoupClient.detectDevice.mockImplementationOnce(() => "Chrome111"); + + getMediasoupDevice(features); + + expect(mediasoupClient.Device).toHaveBeenCalledWith({ handlerName: "Chrome111" }); + }); + + describe("when the browser version is Safari12", () => { + beforeEach(() => { + mediasoupClient.detectDevice.mockImplementationOnce(() => "Safari12"); + }); + + it("returns the resolved handler from mediasoup-client", () => { + getMediasoupDevice(features); + + expect(mediasoupClient.Device).toHaveBeenCalledWith({ handlerName: "Safari12" }); + }); + + describe.each([ + { version: 17, userAgent: safari17UserAgent }, + { version: 18, userAgent: safari18UserAgent }, + ])("when the user agent version is $version", ({ userAgent }) => { + beforeEach(() => { + (global as any).userAgent.mockReturnValue(userAgent); + }); + + it("returns the resolved handler from mediasoup-client", () => { + getMediasoupDevice(features); + + expect(mediasoupClient.Device).toHaveBeenCalledWith({ handlerName: "Safari12" }); + }); + + describe("when the safari17HandlerOn feature is enabled", () => { + it("returns a Safari17 device", () => { + const factory = jest.fn(); + Safari17.createFactory.mockImplementation(() => factory); + + getMediasoupDevice({ safari17HandlerOn: true }); + + expect(mediasoupClient.Device).toHaveBeenCalledWith({ handlerFactory: factory }); + }); + }); + }); + }); + + describe.each(["AppleCoreMedia", "AppleWebkit", "Safari", "iphone", "ipad"])( + "when the userAgent matches %s", + (userAgent) => { + beforeEach(() => { + (global as any).userAgent.mockReturnValue(userAgent); + }); + + it("returns a Safari17 device", () => { + const factory = jest.fn(); + Safari17.createFactory.mockImplementation(() => factory); + + getMediasoupDevice({ safari17HandlerOn: true }); + + expect(mediasoupClient.Device).toHaveBeenCalledWith({ handlerFactory: factory }); + }); + }, + ); +}); diff --git a/packages/media/src/utils/getHandler.ts b/packages/media/src/utils/getHandler.ts deleted file mode 100644 index b5c1c5c24..000000000 --- a/packages/media/src/utils/getHandler.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { detectDevice } from "mediasoup-client"; - -export const getHandler = () => { - let handlerName = - detectDevice() || (/applecoremedia|applewebkit|safari/i.test(navigator.userAgent) ? "Safari12" : undefined); - - // Since custom browsers on iOS/iPadOS are using webkit under the hood, we must use - // the Safari handler even if detected as something else (like Chrome) - if (/iphone|ipad/i.test(navigator.userAgent)) handlerName = "Safari12"; - - return handlerName; -}; diff --git a/packages/media/src/utils/getMediasoupDevice.ts b/packages/media/src/utils/getMediasoupDevice.ts new file mode 100644 index 000000000..6877feb9a --- /dev/null +++ b/packages/media/src/utils/getMediasoupDevice.ts @@ -0,0 +1,37 @@ +import { detectDevice, Device } from "mediasoup-client"; +import { BuiltinHandlerName } from "mediasoup-client/lib/types"; +import { UAParser } from "ua-parser-js"; +import { Safari17 } from "./Safari17Handler"; +// of the provided ones, this seems to work best in NodeJS +import { Safari12 as NodeDeviceHandler } from "mediasoup-client/lib/handlers/Safari12.js"; + +type SupportedDevice = BuiltinHandlerName | "NodeJS" | "Safari17" | undefined; +export const getMediasoupDevice = (features: Record): Device => { + if (features.isNodeSdk) { + return new Device({ handlerFactory: NodeDeviceHandler.createFactory() }); + } + + let handlerName: SupportedDevice = + detectDevice() || (/applecoremedia|applewebkit|safari/i.test(navigator.userAgent) ? "Safari17" : undefined); + + if (handlerName === "Safari12" && typeof navigator === "object" && typeof navigator.userAgent === "string") { + const uaParser = new UAParser(navigator.userAgent); + const browser = uaParser.getBrowser(); + const browserVersion = parseInt(browser.major ?? "0"); + + if (browserVersion >= 17 && features.safari17HandlerOn) { + handlerName = "Safari17"; + } + } + + // Since custom browsers on iOS/iPadOS are using webkit under the hood, we must use + // the Safari handler even if detected as something else (like Chrome) + if (/iphone|ipad/i.test(navigator.userAgent)) handlerName = "Safari17"; + + if (handlerName === "Safari17") { + // we use a custom patched version of the Safari handler that fixes simulcast bandwidth limiting + return new Device({ handlerFactory: Safari17.createFactory() }); + } + + return new Device({ handlerName }); +}; diff --git a/packages/media/src/utils/index.ts b/packages/media/src/utils/index.ts index 8f49513cc..0c006fe07 100644 --- a/packages/media/src/utils/index.ts +++ b/packages/media/src/utils/index.ts @@ -1,6 +1,6 @@ export { default as assert } from "./assert"; export * from "./bandwidthTestUtils"; -export * from "./getHandler"; +export * from "./getMediasoupDevice"; export { default as ipRegex } from "./ipRegex"; export { default as Logger } from "./Logger"; export * from "./mediaSettings"; diff --git a/packages/media/src/webrtc/BandwidthTester.ts b/packages/media/src/webrtc/BandwidthTester.ts index 3d3097fd2..b2bd466b0 100644 --- a/packages/media/src/webrtc/BandwidthTester.ts +++ b/packages/media/src/webrtc/BandwidthTester.ts @@ -1,9 +1,9 @@ -import EventEmitter from "events"; import { Device } from "mediasoup-client"; +import EventEmitter from "events"; import VegaConnection from "./VegaConnection"; import { getMediaSettings, modifyMediaCapabilities } from "../utils/mediaSettings"; -import { getHandler } from "../utils/getHandler"; import Logger from "../utils/Logger"; +import { getMediasoupDevice } from "../utils/getMediasoupDevice"; const logger = new Logger(); @@ -11,7 +11,7 @@ export default class BandwidthTester extends EventEmitter { closed: boolean; _features: any; _vegaConnection: any; - _mediasoupDevice: any; + _mediasoupDevice: Device | null; _routerRtpCapabilities: any; _sendTransport: any; _receiveTransport: any; @@ -37,7 +37,8 @@ export default class BandwidthTester extends EventEmitter { this._vegaConnection = null; - this._mediasoupDevice = new Device({ handlerName: getHandler() }); + this._mediasoupDevice = getMediasoupDevice(features); + this._routerRtpCapabilities = null; this._sendTransport = null; @@ -155,11 +156,11 @@ export default class BandwidthTester extends EventEmitter { modifyMediaCapabilities(routerRtpCapabilities, this._features); this._routerRtpCapabilities = routerRtpCapabilities; - await this._mediasoupDevice.load({ routerRtpCapabilities }); + await this._mediasoupDevice?.load({ routerRtpCapabilities }); } this._vegaConnection.message("setCapabilities", { - rtpCapabilities: this._mediasoupDevice.rtpCapabilities, + rtpCapabilities: this._mediasoupDevice?.rtpCapabilities, }); await Promise.all([this._createTransport(true), this._createTransport(false)]); @@ -184,7 +185,7 @@ export default class BandwidthTester extends EventEmitter { () => { this._reportResults(); }, - this._runTime * 1000 - this._mediaEstablishedTime + this._runTime * 1000 - this._mediaEstablishedTime, ); } catch (error) { logger.error("_start() [error:%o]", error); @@ -213,9 +214,9 @@ export default class BandwidthTester extends EventEmitter { transportOptions.iceServers = [{ urls: "stun:any.turn.whereby.com" }]; - const transport = this._mediasoupDevice[creator](transportOptions); + const transport = this._mediasoupDevice?.[creator](transportOptions); - transport.on("connect", ({ dtlsParameters }: { dtlsParameters: any }, callback: any) => { + transport?.on("connect", ({ dtlsParameters }: { dtlsParameters: any }, callback: any) => { this._vegaConnection.message("connectTransport", { transportId: transport.id, dtlsParameters, @@ -225,7 +226,7 @@ export default class BandwidthTester extends EventEmitter { }); if (send) { - transport.on("produce", async ({ kind, rtpParameters, appData }: any, callback: any, errback: any) => { + transport?.on("produce", async ({ kind, rtpParameters, appData }: any, callback: any, errback: any) => { try { const { paused } = appData; diff --git a/packages/media/src/webrtc/RtcManagerDispatcher.ts b/packages/media/src/webrtc/RtcManagerDispatcher.ts index f2ea030c4..b7feab146 100644 --- a/packages/media/src/webrtc/RtcManagerDispatcher.ts +++ b/packages/media/src/webrtc/RtcManagerDispatcher.ts @@ -34,7 +34,6 @@ export default class RtcManagerDispatcher { webrtcProvider, features, eventClaim, - deviceHandlerFactory: features?.deviceHandlerFactory, }; const isSfu = !!room.sfuServer; if (this.currentManager) { diff --git a/packages/media/src/webrtc/VegaRtcManager/__tests__/index.spec.ts b/packages/media/src/webrtc/VegaRtcManager/__tests__/index.spec.ts index fd3f497d6..07b447989 100644 --- a/packages/media/src/webrtc/VegaRtcManager/__tests__/index.spec.ts +++ b/packages/media/src/webrtc/VegaRtcManager/__tests__/index.spec.ts @@ -1,5 +1,3 @@ -import * as mediasoupClient from "mediasoup-client"; - import VegaRtcManager from "../"; import * as CONNECTION_STATUS from "../../../model/connectionStatusConstants"; @@ -10,6 +8,9 @@ import WS from "jest-websocket-mock"; import Logger from "../../../utils/Logger"; import { setTimeout } from "timers/promises"; +jest.mock("../../../utils/getMediasoupDevice"); +const { getMediasoupDevice } = jest.requireMock("../../../utils/getMediasoupDevice"); + const logger = new Logger(); jest.mock("webrtc-adapter", () => { @@ -19,7 +20,6 @@ jest.mock("webrtc-adapter", () => { }); const originalNavigator = global.navigator; -const originalMediasoupDevice = mediasoupClient.Device; describe("VegaRtcManager", () => { let navigator: any; @@ -62,16 +62,12 @@ describe("VegaRtcManager", () => { value: navigator, }); - Object.defineProperty(mediasoupClient, "Device", { - value: jest.fn().mockImplementation(() => { - return { - load: jest.fn(), - rtpCapabilities: {}, - createSendTransport: () => mockSendTransport, - createRecvTransport: () => new MockTransport(), - }; - }), - }); + getMediasoupDevice.mockImplementation(() => ({ + load: jest.fn(), + rtpCapabilities: {}, + createSendTransport: () => mockSendTransport, + createRecvTransport: () => new MockTransport(), + })); rtcManager = new VegaRtcManager({ selfId: helpers.randomString("client-"), @@ -94,27 +90,28 @@ describe("VegaRtcManager", () => { Object.defineProperty(global, "navigator", { value: originalNavigator, }); - Object.defineProperty(mediasoupClient, "Device", { - value: originalMediasoupDevice, - }); }); describe("constructor", () => { const selfId = helpers.randomString("client-"); const room = { name: helpers.randomString("/room-"), iceServers: {} }; - it("handles custom device handler factories", () => { - const deviceHandlerFactory = function () {}; + it("gets a mediasoup device", () => { + const device = jest.fn(); + getMediasoupDevice.mockImplementation(() => device); + //eslint-disable-next-line no-new - new VegaRtcManager({ + const rtcManager = new VegaRtcManager({ selfId, room, emitter, serverSocket, webrtcProvider, - deviceHandlerFactory, + features: { isNodeSdk: true }, }); - expect(mediasoupClient.Device).toHaveBeenCalledWith({ handlerFactory: deviceHandlerFactory }); + + expect(getMediasoupDevice).toHaveBeenCalledWith({ isNodeSdk: true }); + expect(rtcManager._mediasoupDevice).toEqual(device); }); }); diff --git a/packages/media/src/webrtc/VegaRtcManager/index.ts b/packages/media/src/webrtc/VegaRtcManager/index.ts index 96915ea22..7c54de981 100644 --- a/packages/media/src/webrtc/VegaRtcManager/index.ts +++ b/packages/media/src/webrtc/VegaRtcManager/index.ts @@ -1,6 +1,8 @@ import { Device } from "mediasoup-client"; import adapterRaw from "webrtc-adapter"; import { v4 as uuidv4 } from "uuid"; +// of the provided ones, this seems to work best in NodeJS +import { Safari12 as NodeDeviceHandler } from "mediasoup-client/lib/handlers/Safari12.js"; import rtcManagerEvents from "../rtcManagerEvents"; import rtcStats from "../rtcStatsService"; @@ -13,7 +15,7 @@ import { MEDIA_JITTER_BUFFER_TARGET } from "../constants"; import { PROTOCOL_EVENTS, PROTOCOL_REQUESTS, PROTOCOL_RESPONSES } from "../../model/protocol"; import * as CONNECTION_STATUS from "../../model/connectionStatusConstants"; import { getMediaSettings, modifyMediaCapabilities } from "../../utils/mediaSettings"; -import { getHandler } from "../../utils/getHandler"; +import { getMediasoupDevice } from "../../utils/getMediasoupDevice"; import { maybeTurnOnly } from "../../utils/transportSettings"; import Logger from "../../utils/Logger"; import { getLayers, getNumberOfActiveVideos, getNumberOfTemporalLayers } from "./utils"; @@ -45,7 +47,7 @@ export default class VegaRtcManager implements RtcManager { _vegaConnection: any; _micAnalyser: any; _micAnalyserDebugger: any; - _mediasoupDevice: any; + _mediasoupDevice: Device | null; _routerRtpCapabilities: any; _sendTransport: any; _receiveTransport: any; @@ -97,7 +99,6 @@ export default class VegaRtcManager implements RtcManager { webrtcProvider, features, eventClaim, - deviceHandlerFactory, }: { selfId: any; room: any; @@ -106,7 +107,6 @@ export default class VegaRtcManager implements RtcManager { webrtcProvider: any; features?: any; eventClaim?: string; - deviceHandlerFactory?: any; }) { const { session, iceServers, sfuServer, mediaserverConfigTtlSeconds } = room; @@ -124,11 +124,7 @@ export default class VegaRtcManager implements RtcManager { this._micAnalyser = null; this._micAnalyserDebugger = null; - if (deviceHandlerFactory) { - this._mediasoupDevice = new Device({ handlerFactory: deviceHandlerFactory }); - } else { - this._mediasoupDevice = new Device({ handlerName: getHandler() }); - } + this._mediasoupDevice = getMediasoupDevice(features); this._routerRtpCapabilities = null; @@ -339,11 +335,11 @@ export default class VegaRtcManager implements RtcManager { modifyMediaCapabilities(routerRtpCapabilities, this._features); this._routerRtpCapabilities = routerRtpCapabilities; - await this._mediasoupDevice.load({ routerRtpCapabilities }); + await this._mediasoupDevice?.load({ routerRtpCapabilities }); } this._vegaConnection.message("setCapabilities", { - rtpCapabilities: this._mediasoupDevice.rtpCapabilities, + rtpCapabilities: this._mediasoupDevice?.rtpCapabilities, }); if (this._colocation) this._vegaConnection.message("setColocation", { colocation: this._colocation }); @@ -392,7 +388,7 @@ export default class VegaRtcManager implements RtcManager { maybeTurnOnly(transportOptions, this._features); - const transport = this._mediasoupDevice[creator](transportOptions); + const transport = this._mediasoupDevice?.[creator](transportOptions); const onConnectionStateListener = async (connectionState: any) => { logger.info(`Transport ConnectionStateChanged ${connectionState}`); if (connectionState !== "disconnected" && connectionState !== "failed") { @@ -415,7 +411,7 @@ export default class VegaRtcManager implements RtcManager { } }; transport - .on("connect", ({ dtlsParameters }: { dtlsParameters: any }, callback: any) => { + ?.on("connect", ({ dtlsParameters }: { dtlsParameters: any }, callback: any) => { this._vegaConnection?.message("connectTransport", { transportId: transport.id, dtlsParameters, @@ -425,12 +421,12 @@ export default class VegaRtcManager implements RtcManager { }) .on("connectionstatechange", onConnectionStateListener); - transport.observer.once("close", () => { - transport.removeListener(onConnectionStateListener); + transport?.observer.once("close", () => { + transport.removeListener("connectionstatechange", onConnectionStateListener); }); if (send) { - transport.on( + transport?.on( "produce", async ( { @@ -462,7 +458,7 @@ export default class VegaRtcManager implements RtcManager { } }, ); - transport.on( + transport?.on( "producedata", async ( { @@ -1213,7 +1209,7 @@ export default class VegaRtcManager implements RtcManager { this._stopProducer(this._screenAudioProducer); this._screenAudioProducer = null; this._screenAudioTrack = null; - + if (this._features.lowBandwidth) { this._webcamProducer?.setMaxSpatialLayer(Number.MAX_VALUE); } diff --git a/packages/media/tests/webrtc/RtcManagerDispatcher.spec.ts b/packages/media/tests/webrtc/RtcManagerDispatcher.spec.ts index 6fb4ec801..f67d9398c 100644 --- a/packages/media/tests/webrtc/RtcManagerDispatcher.spec.ts +++ b/packages/media/tests/webrtc/RtcManagerDispatcher.spec.ts @@ -68,19 +68,6 @@ describe("RtcManagerDispatcher", () => { const rtcManager = mockEmitRoomJoined({ sfuServer: { url: helpers.randomString("sfu-") + ":443" } }); expect(rtcManager).toBeInstanceOf(VegaRtcManager); }); - it("allows custom device handler factories when sfuServer", () => { - features.deviceHandlerFactory = function () {}; - jest.mock("../../src/webrtc/VegaRtcManager", () => { - return { - default: jest.fn(), - }; - }); - const rtcManager = mockEmitRoomJoined({ sfuServer: { url: helpers.randomString("sfu-") + ":443" } }); - expect(rtcManager).toBeInstanceOf(VegaRtcManager); - expect(mediasoupClient.Device).toHaveBeenCalledWith({ - handlerFactory: features.deviceHandlerFactory, - }); - }); it("emits nothing when error is set", () => { const rtcManager = mockEmitRoomJoined({ error: "yo" }); diff --git a/yarn.lock b/yarn.lock index 2ec8c2abc..588831fdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6857,6 +6857,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/ua-parser-js@^0.7.39": + version "0.7.39" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz#832c58e460c9435e4e34bb866e85e9146e12cdbb" + integrity sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.2.tgz#6dd61e43ef60b34086287f83683a5c1b2dc53d20"