diff --git a/src/utils/media/pipeline/TrackEnabler.js b/src/utils/media/pipeline/TrackEnabler.js index 6b7dca755c3..552903547c1 100644 --- a/src/utils/media/pipeline/TrackEnabler.js +++ b/src/utils/media/pipeline/TrackEnabler.js @@ -65,11 +65,15 @@ export default class TrackEnabler extends TrackSinkSource { } _handleInputTrack(trackId, track) { - this._setOutputTrack('default', track) - + // Ignore the enabled state of the input and force the desired state by + // the node. The state must be forced before setting the output track to + // ensure that it will have the desired state from the start (and thus + // "_setOutputTrackEnabled" can not be used). if (track && track.enabled !== this._enabled) { - this._setOutputTrackEnabled('default', this._enabled) + track.enabled = this._enabled } + + this._setOutputTrack('default', track) } _handleInputTrackEnabled(trackId, enabled) { diff --git a/src/utils/media/pipeline/TrackEnabler.spec.js b/src/utils/media/pipeline/TrackEnabler.spec.js new file mode 100644 index 00000000000..7cafe2a9aa7 --- /dev/null +++ b/src/utils/media/pipeline/TrackEnabler.spec.js @@ -0,0 +1,443 @@ +/** + * + * @copyright Copyright (c) 2022, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import TrackEnabler from './TrackEnabler' + +/** + * Helper function to create MediaStreamTrack mocks with just the attributes and + * methods used by TrackEnabler. + * + * @param {string} id the ID of the track + */ +function newMediaStreamTrackMock(id) { + /** + * MediaStreamTrackMock constructor. + */ + function MediaStreamTrackMock() { + this.id = id + this.enabled = true + // "ended" event is not being tested, so there is no need to add even a + // stub for the event listener methods. + this.addEventListener = jest.fn() + this.removeEventListener = jest.fn() + } + return new MediaStreamTrackMock() +} + +describe('TrackToStream', () => { + let trackEnabler + let outputTrackSetHandler + let outputTrackEnabledHandler + let expectedTrackEnabledStateInOutputTrackSetEvent + + beforeEach(() => { + trackEnabler = new TrackEnabler() + + expectedTrackEnabledStateInOutputTrackSetEvent = undefined + + outputTrackSetHandler = jest.fn((trackEnabler, trackId, track) => { + if (expectedTrackEnabledStateInOutputTrackSetEvent !== undefined) { + expect(track.enabled).toBe(expectedTrackEnabledStateInOutputTrackSetEvent) + } + }) + outputTrackEnabledHandler = jest.fn() + + trackEnabler.on('outputTrackSet', outputTrackSetHandler) + trackEnabler.on('outputTrackEnabled', outputTrackEnabledHandler) + }) + + test('is enabled by default', () => { + expect(trackEnabler.isEnabled()).toBe(true) + }) + + describe('enable/disable node', () => { + test('does nothing if disabled when there is no input track', () => { + trackEnabler.setEnabled(false) + + expect(trackEnabler.isEnabled()).toBe(false) + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('does nothing if enabled when there is no input track', () => { + trackEnabler.setEnabled(false) + trackEnabler.setEnabled(true) + + expect(trackEnabler.isEnabled()).toBe(true) + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + }) + }) + + describe('set input track', () => { + test('sets enabled input track as its output track', () => { + const inputTrack = newMediaStreamTrackMock('input') + + expectedTrackEnabledStateInOutputTrackSetEvent = true + + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('sets disabled input track as its output track', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + expectedTrackEnabledStateInOutputTrackSetEvent = false + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + + test('sets disabled input track as its output track enabling it when node is enabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + expectedTrackEnabledStateInOutputTrackSetEvent = true + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('sets enabled input track as its output track disabling it when node is disabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + expectedTrackEnabledStateInOutputTrackSetEvent = false + + inputTrack.enabled = true + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + }) + + describe('enable/disable node after setting input track', () => { + test('enables input track when node is enabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + trackEnabler.setEnabled(true) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(1) + expect(outputTrackEnabledHandler).toHaveBeenCalledWith(trackEnabler, 'default', true) + expect(inputTrack.enabled).toBe(true) + }) + + test('disables input track when node is disabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + trackEnabler.setEnabled(false) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(1) + expect(outputTrackEnabledHandler).toHaveBeenCalledWith(trackEnabler, 'default', false) + expect(inputTrack.enabled).toBe(false) + }) + }) + + describe('enable/disable input track', () => { + test('enables input track again if input track is disabled when node is enabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + inputTrack.enabled = false + trackEnabler._setInputTrackEnabled('default', false) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(1) + expect(outputTrackEnabledHandler).toHaveBeenCalledWith(trackEnabler, 'default', true) + expect(inputTrack.enabled).toBe(true) + }) + + test('disables input track again if input track is enabled when node is disabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + inputTrack.enabled = true + trackEnabler._setInputTrackEnabled('default', true) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(1) + expect(outputTrackEnabledHandler).toHaveBeenCalledWith(trackEnabler, 'default', false) + expect(inputTrack.enabled).toBe(false) + }) + + test('does nothing if input track is enabled again when node is enabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + trackEnabler._setInputTrackEnabled('default', true) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('does nothing if input track is disabled again when node is disabled', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + trackEnabler._setInputTrackEnabled('default', false) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(0) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + }) + + describe('remove input track', () => { + test('removes output track when removing input track', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + trackEnabler._setInputTrack('default', null) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', null) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + }) + }) + + describe('update input track', () => { + test('sets input track as its output track when setting same enabled input track again', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = true + + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('sets input track as its output track when setting same disabled input track again', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = false + + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + + test('sets input track as its output track enabling it when setting same now disabled input track again', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = true + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('sets input track as its output track disabling it when setting same now enabled input track again', () => { + const inputTrack = newMediaStreamTrackMock('input') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = false + + inputTrack.enabled = true + trackEnabler._setInputTrack('default', inputTrack) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + + test('sets input track as its output track when setting another enabled input track', () => { + const inputTrack = newMediaStreamTrackMock('input') + const inputTrack2 = newMediaStreamTrackMock('input2') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = true + + trackEnabler._setInputTrack('default', inputTrack2) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack2) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('sets input track as its output track when setting another disabled input track', () => { + const inputTrack = newMediaStreamTrackMock('input') + const inputTrack2 = newMediaStreamTrackMock('input2') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = false + + inputTrack2.enabled = false + trackEnabler._setInputTrack('default', inputTrack2) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack2) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + + test('sets input track as its output track enabling it when setting another now disabled input track', () => { + const inputTrack = newMediaStreamTrackMock('input') + const inputTrack2 = newMediaStreamTrackMock('input2') + + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = true + + inputTrack2.enabled = false + trackEnabler._setInputTrack('default', inputTrack2) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack2) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(true) + }) + + test('sets input track as its output track disabling it when setting another now enabled input track', () => { + const inputTrack = newMediaStreamTrackMock('input') + const inputTrack2 = newMediaStreamTrackMock('input2') + + trackEnabler.setEnabled(false) + + inputTrack.enabled = false + trackEnabler._setInputTrack('default', inputTrack) + + outputTrackSetHandler.mockClear() + outputTrackEnabledHandler.mockClear() + + expectedTrackEnabledStateInOutputTrackSetEvent = false + + inputTrack2.enabled = true + trackEnabler._setInputTrack('default', inputTrack2) + + expect(outputTrackSetHandler).toHaveBeenCalledTimes(1) + expect(outputTrackSetHandler).toHaveBeenCalledWith(trackEnabler, 'default', inputTrack2) + expect(outputTrackEnabledHandler).toHaveBeenCalledTimes(0) + expect(inputTrack.enabled).toBe(false) + }) + }) +}) diff --git a/src/utils/media/pipeline/TrackToStream.js b/src/utils/media/pipeline/TrackToStream.js index 562cc3e5035..d2b773f88b9 100644 --- a/src/utils/media/pipeline/TrackToStream.js +++ b/src/utils/media/pipeline/TrackToStream.js @@ -56,6 +56,8 @@ export default class TrackToStream extends TrackSink { this._superEmitterMixin() this._stream = null + + this._trackEnabledStates = {} } addInputTrackSlot(trackId) { @@ -69,6 +71,13 @@ export default class TrackToStream extends TrackSink { _handleInputTrack(trackId, newTrack, oldTrack) { // Only constraints changed, nothing to do if (newTrack === oldTrack) { + // But trigger "trackEnabled" if the state changed + if (newTrack && this._trackEnabledStates[trackId] !== newTrack.enabled) { + this._trackEnabledStates[trackId] = newTrack.enabled + + this._trigger('trackEnabled', [newTrack, newTrack.enabled]) + } + return } @@ -86,6 +95,8 @@ export default class TrackToStream extends TrackSink { this._stream.addTrack(newTrack) } + this._trackEnabledStates[trackId] = newTrack?.enabled + this._trigger('trackReplaced', [newTrack, oldTrack]) if (this._stream && this._stream.getTracks().length === 0) { @@ -98,6 +109,8 @@ export default class TrackToStream extends TrackSink { } _handleInputTrackEnabled(trackId, enabled) { + this._trackEnabledStates[trackId] = enabled + this._trigger('trackEnabled', [this.getInputTrack(trackId), enabled]) } diff --git a/src/utils/media/pipeline/TrackToStream.spec.js b/src/utils/media/pipeline/TrackToStream.spec.js new file mode 100644 index 00000000000..007a5e28b51 --- /dev/null +++ b/src/utils/media/pipeline/TrackToStream.spec.js @@ -0,0 +1,403 @@ +/** + * + * @copyright Copyright (c) 2022, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import TrackToStream from './TrackToStream' + +/** + * Helper function to create MediaStreamTrack mocks with just the attributes + * used by TrackToStream. + * + * @param {string} id the ID of the track + */ +function newMediaStreamTrackMock(id) { + /** + * MediaStreamTrackMock constructor. + */ + function MediaStreamTrackMock() { + this.id = id + this.enabled = true + } + return new MediaStreamTrackMock() +} + +describe('TrackToStream', () => { + let trackToStream + let streamSetHandler + let trackReplacedHandler + let trackEnabledHandler + + beforeAll(() => { + // MediaStream is used in TrackToStream but not implemented in jsdom, so + // a stub is needed. + window.MediaStream = function() { + this._tracks = [] + + this.addTrack = jest.fn((track) => { + const index = this._tracks.indexOf(track) + if (index >= 0) { + console.error('Tried to add again track already added to stream') + return + } + this._tracks.push(track) + }) + this.removeTrack = jest.fn((track) => { + const index = this._tracks.indexOf(track) + if (index < 0) { + console.error('Tried to delete track not added to stream') + return + } + this._tracks.splice(index, 1) + }) + this.getTracks = jest.fn(() => { + return this._tracks + }) + } + }) + + beforeEach(() => { + trackToStream = new TrackToStream() + trackToStream.addInputTrackSlot('audio') + trackToStream.addInputTrackSlot('video') + + streamSetHandler = jest.fn() + trackReplacedHandler = jest.fn() + trackEnabledHandler = jest.fn() + + trackToStream.on('streamSet', streamSetHandler) + trackToStream.on('trackReplaced', trackReplacedHandler) + trackToStream.on('trackEnabled', trackEnabledHandler) + }) + + test('has no stream by default', () => { + expect(trackToStream.getStream()).toBe(null) + }) + + describe('set input track', () => { + test('creates stream and adds track when setting first input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + trackToStream._setInputTrack('audio', audioTrack) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(1) + expect(streamSetHandler).toHaveBeenCalledWith(trackToStream, trackToStream.getStream(), null) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, audioTrack, null) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('adds track to existing stream when setting second input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + const videoTrack = newMediaStreamTrackMock('video') + + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + trackToStream._setInputTrack('video', videoTrack) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(2) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(trackToStream.getStream().getTracks()).toContain(videoTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(1) + expect(streamSetHandler).toHaveBeenCalledWith(trackToStream, trackToStream.getStream(), null) + expect(trackReplacedHandler).toHaveBeenCalledTimes(2) + expect(trackReplacedHandler).toHaveBeenNthCalledWith(1, trackToStream, audioTrack, null) + expect(trackReplacedHandler).toHaveBeenNthCalledWith(2, trackToStream, videoTrack, null) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('does not trigger trackEnabled when setting disabled input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + audioTrack.enabled = false + trackToStream._setInputTrack('audio', audioTrack) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(1) + expect(streamSetHandler).toHaveBeenCalledWith(trackToStream, trackToStream.getStream(), null) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, audioTrack, null) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('creates another stream when setting first input track again after removing it', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + trackToStream._setInputTrack('audio', null) + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + trackToStream._setInputTrack('audio', audioTrack) + + expect(trackToStream.getStream()).not.toBe(stream) + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(1) + expect(streamSetHandler).toHaveBeenCalledWith(trackToStream, trackToStream.getStream(), null) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, audioTrack, null) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + }) + + describe('enable/disable input track', () => { + test('triggers event if input track is disabled', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + audioTrack.enabled = true + trackToStream._setInputTrack('audio', audioTrack) + + audioTrack.enabled = false + trackToStream._setInputTrackEnabled('audio', false) + + expect(trackEnabledHandler).toHaveBeenCalledTimes(1) + expect(trackEnabledHandler).toHaveBeenCalledWith(trackToStream, audioTrack, false) + }) + + test('triggers event if input track is enabled', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + audioTrack.enabled = false + trackToStream._setInputTrack('audio', audioTrack) + + audioTrack.enabled = true + trackToStream._setInputTrackEnabled('audio', true) + + expect(trackEnabledHandler).toHaveBeenCalledTimes(1) + expect(trackEnabledHandler).toHaveBeenCalledWith(trackToStream, audioTrack, true) + }) + }) + + describe('remove input track', () => { + test('removes track from existing stream when removing input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + const videoTrack = newMediaStreamTrackMock('video') + + trackToStream._setInputTrack('audio', audioTrack) + trackToStream._setInputTrack('video', videoTrack) + + const stream = trackToStream.getStream() + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + trackToStream._setInputTrack('audio', null) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).not.toContain(audioTrack) + expect(trackToStream.getStream().getTracks()).toContain(videoTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, null, audioTrack) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('removes track and stream when removing remaining input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + const videoTrack = newMediaStreamTrackMock('video') + + trackToStream._setInputTrack('audio', audioTrack) + trackToStream._setInputTrack('video', videoTrack) + + const stream = trackToStream.getStream() + expect(stream).not.toBe(null) + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + trackToStream._setInputTrack('audio', null) + trackToStream._setInputTrack('video', null) + + expect(trackToStream.getStream()).toBe(null) + expect(streamSetHandler).toHaveBeenCalledTimes(1) + expect(streamSetHandler).toHaveBeenCalledWith(trackToStream, null, stream) + expect(trackReplacedHandler).toHaveBeenCalledTimes(2) + expect(trackReplacedHandler).toHaveBeenNthCalledWith(1, trackToStream, null, audioTrack) + expect(trackReplacedHandler).toHaveBeenNthCalledWith(2, trackToStream, null, videoTrack) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + }) + + describe('update input track', () => { + test('does nothing when setting same input track again', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + trackToStream._setInputTrack('audio', audioTrack) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(0) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('triggers event when setting same now disabled input track again', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + audioTrack.enabled = false + trackToStream._setInputTrack('audio', audioTrack) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(0) + expect(trackEnabledHandler).toHaveBeenCalledTimes(1) + expect(trackEnabledHandler).toHaveBeenCalledWith(trackToStream, audioTrack, false) + }) + + test('triggers event when setting same now enabled input track again', () => { + const audioTrack = newMediaStreamTrackMock('audio') + + audioTrack.enabled = false + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + audioTrack.enabled = true + trackToStream._setInputTrack('audio', audioTrack) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(0) + expect(trackEnabledHandler).toHaveBeenCalledTimes(1) + expect(trackEnabledHandler).toHaveBeenCalledWith(trackToStream, audioTrack, true) + }) + + test('replaces track in existing stream when setting another input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + const audioTrack2 = newMediaStreamTrackMock('audio2') + + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + trackToStream._setInputTrack('audio', audioTrack2) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack2) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, audioTrack2, audioTrack) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('does not trigger trackEnabled when setting another now disabled input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + const audioTrack2 = newMediaStreamTrackMock('audio2') + + trackToStream._setInputTrack('audio', audioTrack) + + const stream = trackToStream.getStream() + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + audioTrack2.enabled = false + trackToStream._setInputTrack('audio', audioTrack2) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack2) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, audioTrack2, audioTrack) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + + test('does not trigger trackEnabled when setting another now enabled input track', () => { + const audioTrack = newMediaStreamTrackMock('audio') + const audioTrack2 = newMediaStreamTrackMock('audio2') + + audioTrack.enabled = false + trackToStream._setInputTrack('audio', audioTrack) + + streamSetHandler.mockClear() + trackReplacedHandler.mockClear() + trackEnabledHandler.mockClear() + + const stream = trackToStream.getStream() + + trackToStream._setInputTrack('audio', audioTrack2) + + expect(trackToStream.getStream()).not.toBe(null) + expect(trackToStream.getStream()).toBe(stream) + expect(trackToStream.getStream().getTracks().length).toBe(1) + expect(trackToStream.getStream().getTracks()).toContain(audioTrack2) + expect(streamSetHandler).toHaveBeenCalledTimes(0) + expect(trackReplacedHandler).toHaveBeenCalledTimes(1) + expect(trackReplacedHandler).toHaveBeenCalledWith(trackToStream, audioTrack2, audioTrack) + expect(trackEnabledHandler).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js index 7963edc50c0..8ff284cbb5e 100644 --- a/src/utils/webrtc/index.js +++ b/src/utils/webrtc/index.js @@ -193,26 +193,26 @@ async function signalingJoinCall(token, flags) { const enableVideo = !localStorage.getItem('videoDisabled_' + token) const enableVirtualBackground = !!localStorage.getItem('virtualBackgroundEnabled_' + token) + if (enableAudio) { + localMediaModel.enableAudio() + } else { + localMediaModel.disableAudio() + } + if (enableVideo) { + localMediaModel.enableVideo() + } else { + localMediaModel.disableVideo() + } + if (enableVirtualBackground) { + localMediaModel.enableVirtualBackground() + } else { + localMediaModel.disableVirtualBackground() + } + const startCallOnceLocalMediaStarted = (configuration) => { webRtc.off('localMediaStarted', startCallOnceLocalMediaStarted) webRtc.off('localMediaError', startCallOnceLocalMediaError) - if (enableAudio) { - localMediaModel.enableAudio() - } else { - localMediaModel.disableAudio() - } - if (enableVideo) { - localMediaModel.enableVideo() - } else { - localMediaModel.disableVideo() - } - if (enableVirtualBackground) { - localMediaModel.enableVirtualBackground() - } else { - localMediaModel.disableVirtualBackground() - } - startCall(_signaling, configuration) } const startCallOnceLocalMediaError = () => { diff --git a/src/utils/webrtc/models/LocalMediaModel.js b/src/utils/webrtc/models/LocalMediaModel.js index 3f87eb8c262..09313597121 100644 --- a/src/utils/webrtc/models/LocalMediaModel.js +++ b/src/utils/webrtc/models/LocalMediaModel.js @@ -52,16 +52,13 @@ export default function LocalMediaModel() { this._handleLocalStreamRequestFailedRetryNoVideoBound = this._handleLocalStreamRequestFailedRetryNoVideo.bind(this) this._handleLocalStreamRequestFailedBound = this._handleLocalStreamRequestFailed.bind(this) this._handleLocalStreamChangedBound = this._handleLocalStreamChanged.bind(this) + this._handleLocalTrackEnabledChangedBound = this._handleLocalTrackEnabledChanged.bind(this) this._handleLocalStreamStoppedBound = this._handleLocalStreamStopped.bind(this) - this._handleAudioOnBound = this._handleAudioOn.bind(this) - this._handleAudioOffBound = this._handleAudioOff.bind(this) this._handleVolumeChangeBound = this._handleVolumeChange.bind(this) this._handleSpeakingBound = this._handleSpeaking.bind(this) this._handleStoppedSpeakingBound = this._handleStoppedSpeaking.bind(this) this._handleSpeakingWhileMutedBound = this._handleSpeakingWhileMuted.bind(this) this._handleStoppedSpeakingWhileMutedBound = this._handleStoppedSpeakingWhileMuted.bind(this) - this._handleVideoOnBound = this._handleVideoOn.bind(this) - this._handleVideoOffBound = this._handleVideoOff.bind(this) this._handleVirtualBackgroundLoadFailedBound = this._handleVirtualBackgroundLoadFailed.bind(this) this._handleVirtualBackgroundOnBound = this._handleVirtualBackgroundOn.bind(this) this._handleVirtualBackgroundOffBound = this._handleVirtualBackgroundOff.bind(this) @@ -97,16 +94,13 @@ LocalMediaModel.prototype = { this._webRtc.webrtc.off('localStreamRequestFailedRetryNoVideo', this._handleLocalStreamRequestFailedBound) this._webRtc.webrtc.off('localStreamRequestFailed', this._handleLocalStreamRequestFailedBound) this._webRtc.webrtc.off('localStreamChanged', this._handleLocalStreamChangedBound) + this._webRtc.webrtc.off('localTrackEnabledChanged', this._handleLocalTrackEnabledChangedBound) this._webRtc.webrtc.off('localStreamStopped', this._handleLocalStreamStoppedBound) - this._webRtc.webrtc.off('audioOn', this._handleAudioOnBound) - this._webRtc.webrtc.off('audioOff', this._handleAudioOffBound) this._webRtc.webrtc.off('volumeChange', this._handleVolumeChangeBound) this._webRtc.webrtc.off('speaking', this._handleSpeakingBound) this._webRtc.webrtc.off('stoppedSpeaking', this._handleStoppedSpeakingBound) this._webRtc.webrtc.off('speakingWhileMuted', this._handleSpeakingWhileMutedBound) this._webRtc.webrtc.off('stoppedSpeakingWhileMuted', this._handleStoppedSpeakingWhileMutedBound) - this._webRtc.webrtc.off('videoOn', this._handleVideoOnBound) - this._webRtc.webrtc.off('videoOff', this._handleVideoOffBound) this._webRtc.webrtc.off('virtualBackgroundLoadFailed', this._handleVirtualBackgroundLoadFailedBound) this._webRtc.webrtc.off('virtualBackgroundOn', this._handleVirtualBackgroundOnBound) this._webRtc.webrtc.off('virtualBackgroundOff', this._handleVirtualBackgroundOffBound) @@ -134,16 +128,13 @@ LocalMediaModel.prototype = { this._webRtc.webrtc.on('localStreamRequestFailedRetryNoVideo', this._handleLocalStreamRequestFailedRetryNoVideoBound) this._webRtc.webrtc.on('localStreamRequestFailed', this._handleLocalStreamRequestFailedBound) this._webRtc.webrtc.on('localStreamChanged', this._handleLocalStreamChangedBound) + this._webRtc.webrtc.on('localTrackEnabledChanged', this._handleLocalTrackEnabledChangedBound) this._webRtc.webrtc.on('localStreamStopped', this._handleLocalStreamStoppedBound) - this._webRtc.webrtc.on('audioOn', this._handleAudioOnBound) - this._webRtc.webrtc.on('audioOff', this._handleAudioOffBound) this._webRtc.webrtc.on('volumeChange', this._handleVolumeChangeBound) this._webRtc.webrtc.on('speaking', this._handleSpeakingBound) this._webRtc.webrtc.on('stoppedSpeaking', this._handleStoppedSpeakingBound) this._webRtc.webrtc.on('speakingWhileMuted', this._handleSpeakingWhileMutedBound) this._webRtc.webrtc.on('stoppedSpeakingWhileMuted', this._handleStoppedSpeakingWhileMutedBound) - this._webRtc.webrtc.on('videoOn', this._handleVideoOnBound) - this._webRtc.webrtc.on('videoOff', this._handleVideoOffBound) this._webRtc.webrtc.on('virtualBackgroundLoadFailed', this._handleVirtualBackgroundLoadFailedBound) this._webRtc.webrtc.on('virtualBackgroundOn', this._handleVirtualBackgroundOnBound) this._webRtc.webrtc.on('virtualBackgroundOff', this._handleVirtualBackgroundOffBound) @@ -170,9 +161,7 @@ LocalMediaModel.prototype = { // local stream will be active at the same time. this.set('localStream', localStream) - this.set('token', store.getters.getToken()) - - this._setInitialMediaState(configuration) + this._setInitialState(localStream) }, _handleLocalStreamRequestFailedRetryNoVideo(constraints, error) { @@ -186,33 +175,13 @@ LocalMediaModel.prototype = { _handleLocalStreamRequestFailed() { this.set('localStream', null) - this._setInitialMediaState({ audio: false, video: false }) + this._setInitialState(null) }, - _setInitialMediaState(configuration) { - if (configuration.audio !== false) { - this.set('audioAvailable', true) - if (this.get('audioEnabled')) { - this.enableAudio() - } else { - this.disableAudio() - } - } else { - this.set('audioEnabled', false) - this.set('audioAvailable', false) - } + _setInitialState(localStream) { + this.set('token', store.getters.getToken()) - if (configuration.video !== false) { - this.set('videoAvailable', true) - if (this.get('videoEnabled')) { - this.enableVideo() - } else { - this.disableVideo() - } - } else { - this.set('videoEnabled', false) - this.set('videoAvailable', false) - } + this._updateMediaAvailability(localStream) this.set('raisedHand', { state: false, timestamp: Date.now() }) }, @@ -227,19 +196,41 @@ LocalMediaModel.prototype = { _updateMediaAvailability(localStream) { if (localStream && localStream.getAudioTracks().length > 0) { this.set('audioAvailable', true) + this.set('audioEnabled', localStream.getAudioTracks()[0].enabled) } else { this.disableAudio() + // "audioEnabled" needs to be explicitly set to false, as there is + // no audio track and thus disabling the audio will not trigger the + // handler for "localTrackEnabledChanged"; calling "disableAudio()" + // just ensures that the audio will be initially disabled if it + // becomes available again later. + this.set('audioEnabled', false) this.set('audioAvailable', false) } if (localStream && localStream.getVideoTracks().length > 0) { this.set('videoAvailable', true) + this.set('videoEnabled', localStream.getVideoTracks()[0].enabled) } else { this.disableVideo() + // "videoEnabled" needs to be explicitly set to false, as there is + // no video track and thus disabling the video will not trigger the + // handler for "localTrackEnabledChanged"; calling "disableVideo()" + // just ensures that the video will be initially disabled if it + // becomes available again later. + this.set('videoEnabled', false) this.set('videoAvailable', false) } }, + _handleLocalTrackEnabledChanged(track, stream) { + if (track.kind === 'audio') { + this.set('audioEnabled', track.enabled) + } else if (track.kind === 'video') { + this.set('videoEnabled', track.enabled) + } + }, + _handleLocalStreamStopped(localStream) { if (this.get('localStream') !== localStream) { return @@ -253,22 +244,6 @@ LocalMediaModel.prototype = { this.set('videoAvailable', false) }, - _handleAudioOn() { - if (!this.get('audioAvailable')) { - return - } - - this.set('audioEnabled', true) - }, - - _handleAudioOff() { - if (!this.get('audioAvailable')) { - return - } - - this.set('audioEnabled', false) - }, - _handleVolumeChange(currentVolume, volumeThreshold) { if (!this.get('audioAvailable')) { return @@ -310,22 +285,6 @@ LocalMediaModel.prototype = { this.set('speakingWhileMuted', false) }, - _handleVideoOn() { - if (!this.get('videoAvailable')) { - return - } - - this.set('videoEnabled', true) - }, - - _handleVideoOff() { - if (!this.get('videoAvailable')) { - return - } - - this.set('videoEnabled', false) - }, - _handleVirtualBackgroundLoadFailed() { this.set('virtualBackgroundAvailable', false) }, @@ -352,9 +311,6 @@ LocalMediaModel.prototype = { } localStorage.removeItem('audioDisabled_' + this.get('token')) - if (!this.get('audioAvailable')) { - return - } this._webRtc.unmute() }, @@ -365,12 +321,6 @@ LocalMediaModel.prototype = { } localStorage.setItem('audioDisabled_' + this.get('token'), 'true') - if (!this.get('audioAvailable')) { - // Ensure that the audio will be disabled once available. - this.set('audioEnabled', false) - - return - } this._webRtc.mute() }, @@ -381,9 +331,6 @@ LocalMediaModel.prototype = { } localStorage.removeItem('videoDisabled_' + this.get('token')) - if (!this.get('videoAvailable')) { - return - } this._webRtc.resumeVideo() }, @@ -394,12 +341,6 @@ LocalMediaModel.prototype = { } localStorage.setItem('videoDisabled_' + this.get('token'), 'true') - if (!this.get('videoAvailable')) { - // Ensure that the video will be disabled once available. - this.set('videoEnabled', false) - - return - } this._webRtc.pauseVideo() },