diff --git a/src/components/CallView/shared/LocalAudioControlButton.vue b/src/components/CallView/shared/LocalAudioControlButton.vue index 43bf424c831..0242ff1e021 100644 --- a/src/components/CallView/shared/LocalAudioControlButton.vue +++ b/src/components/CallView/shared/LocalAudioControlButton.vue @@ -330,11 +330,13 @@ export default { }, mounted() { - this.subscribeToDevices(this.conversation.permissions) + // The button only needs the device list, not a live video preview, so + // it does not keep the camera grabbed during the call (see spreed#4008). + this.subscribeToDevices(this.conversation.permissions, { videoPreview: false }) }, beforeUnmount() { - this.unsubscribeFromDevices(this.conversation.permissions) + this.unsubscribeFromDevices(this.conversation.permissions, { videoPreview: false }) }, methods: { diff --git a/src/components/CallView/shared/LocalVideoControlButton.vue b/src/components/CallView/shared/LocalVideoControlButton.vue index 367054cfbd9..8ac010f8aaa 100644 --- a/src/components/CallView/shared/LocalVideoControlButton.vue +++ b/src/components/CallView/shared/LocalVideoControlButton.vue @@ -237,11 +237,13 @@ export default { }, mounted() { - this.subscribeToDevices(this.conversation.permissions) + // The button only needs the device list, not a live video preview, so + // it does not keep the camera grabbed during the call (see spreed#4008). + this.subscribeToDevices(this.conversation.permissions, { videoPreview: false }) }, beforeUnmount() { - this.unsubscribeFromDevices(this.conversation.permissions) + this.unsubscribeFromDevices(this.conversation.permissions, { videoPreview: false }) }, methods: { diff --git a/src/composables/useDevices.js b/src/composables/useDevices.js index c2d423d8505..f76fb9c72ad 100644 --- a/src/composables/useDevices.js +++ b/src/composables/useDevices.js @@ -15,6 +15,14 @@ import { callParticipantsAudioPlayer, mediaDevicesManager } from '../utils/webrt /** Permissions bitmask of each active subscriber */ const subscribersPermissions = [] +/** + * Number of active subscribers that request a live video preview stream. + * In-call control buttons only need the device list, not a live preview, so + * they subscribe without one; keeping a live camera track just for them would + * keep the camera (and its hardware light) on during the whole call even when + * the video is disabled (see spreed#4008). + */ +let videoPreviewSubscribersCount = 0 const videoElement = ref(null) /** @@ -143,10 +151,15 @@ export const useDevices = createSharedComposable(function() { * Streams are started based on the combined (OR) permissions of all subscribers * * @param {number} permissions - requested permission (for call - attendee check, for device preview - MAX_DEFAULT) + * @param {object} [options] - subscription options + * @param {boolean} [options.videoPreview] - whether a live video preview stream is needed (false for in-call control buttons) * @public */ - function subscribeToDevices(permissions = PARTICIPANT.PERMISSIONS.MAX_DEFAULT) { + function subscribeToDevices(permissions = PARTICIPANT.PERMISSIONS.MAX_DEFAULT, { videoPreview = true } = {}) { subscribersPermissions.push(permissions) + if (videoPreview) { + videoPreviewSubscribersCount++ + } if (!initialized) { initializeDevices() } else { @@ -161,9 +174,11 @@ export const useDevices = createSharedComposable(function() { * If reduced permissions no longer allow a stream, that stream should be stopped * * @param {number} permissions bitmask, must match the value passed to subscribeToDevices + * @param {object} [options] - subscription options, must match the value passed to subscribeToDevices + * @param {boolean} [options.videoPreview] - whether a live video preview stream was requested * @public */ - function unsubscribeFromDevices(permissions = PARTICIPANT.PERMISSIONS.MAX_DEFAULT) { + function unsubscribeFromDevices(permissions = PARTICIPANT.PERMISSIONS.MAX_DEFAULT, { videoPreview = true } = {}) { const index = subscribersPermissions.indexOf(permissions) if (index === -1) { console.error('Attempt to unsubscribe from devices with unknown permissions') @@ -171,12 +186,15 @@ export const useDevices = createSharedComposable(function() { } subscribersPermissions.splice(index, 1) + if (videoPreview && videoPreviewSubscribersCount > 0) { + videoPreviewSubscribersCount-- + } if (subscribersPermissions.length === 0) { stopDevices() return } - // Stop streams no longer permitted by any remaining subscriber + // Stop streams no longer permitted (or no longer previewed) by any remaining subscriber stopForbiddenStreams() } @@ -213,7 +231,7 @@ export const useDevices = createSharedComposable(function() { if (hasAudioPermission() && !audioStream.value && !pendingGetUserMediaAudioCount) { updateAudioStream() } - if (hasVideoPermission() && !videoStream.value && !pendingGetUserMediaVideoCount) { + if (shouldGrabVideoPreview() && !videoStream.value && !pendingGetUserMediaVideoCount) { updateVideoStream() } } @@ -225,7 +243,7 @@ export const useDevices = createSharedComposable(function() { if (!hasAudioPermission()) { stopAudioStream() } - if (!hasVideoPermission()) { + if (!shouldGrabVideoPreview()) { stopVideoStream() } } @@ -251,6 +269,16 @@ export const useDevices = createSharedComposable(function() { return !!(getEffectivePermissions() & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO) } + /** + * Checks whether a live video preview stream should be grabbed: at least one + * subscriber must both allow video and actually request a preview. In-call + * control buttons do not request a preview, so the camera is not grabbed + * just for them (see spreed#4008). + */ + function shouldGrabVideoPreview() { + return hasVideoPermission() && videoPreviewSubscribersCount > 0 + } + /** * Start tracking device events (audio and video) * @@ -516,7 +544,7 @@ export const useDevices = createSharedComposable(function() { return } - if (!hasVideoPermission()) { + if (!shouldGrabVideoPreview()) { return } diff --git a/src/utils/media/pipeline/MediaDevicesSource.js b/src/utils/media/pipeline/MediaDevicesSource.js index a3b235603d6..a763f7cfdc6 100644 --- a/src/utils/media/pipeline/MediaDevicesSource.js +++ b/src/utils/media/pipeline/MediaDevicesSource.js @@ -46,6 +46,14 @@ export default class MediaDevicesSource extends TrackSource { this._audioAllowed = true this._videoAllowed = true + // Whether the video device should be actively grabbed. Unlike + // "_videoAllowed" (which reflects whether video is permitted at all, e.g. + // based on participant permissions), this reflects whether the video is + // currently enabled by the user. When the video is disabled the device + // is fully released (rather than just disabling the track) so the camera + // hardware light turns off (see spreed#4008). + this._videoActive = true + this._active = false } @@ -57,6 +65,10 @@ export default class MediaDevicesSource extends TrackSource { return this._videoAllowed } + isVideoActive() { + return this._videoActive + } + setAudioAllowed(audioAllowed) { if (this._audioAllowed === audioAllowed) { return @@ -103,6 +115,33 @@ export default class MediaDevicesSource extends TrackSource { this._setOutputTrack('video', null) } + setVideoActive(videoActive) { + if (this._videoActive === videoActive) { + return + } + + this._videoActive = videoActive + + if (!videoActive) { + // Fully stop the video track to release the camera (and thus turn + // off the camera hardware light) rather than just disabling it. + if (this.getOutputTrack('video')) { + this.getOutputTrack('video').stop() + } + this._setOutputTrack('video', null) + + return + } + + // Re-grabbing the camera only makes sense when the source is active and + // video is allowed. + if (!this._active || !this._videoAllowed) { + return + } + + this._handleVideoInputIdChangedBound(mediaDevicesManager, mediaDevicesManager.get('videoInputId')) + } + async start(retryNoVideoCallback) { this._active = true @@ -116,7 +155,7 @@ export default class MediaDevicesSource extends TrackSource { const constraints = { audio: this._audioAllowed, - video: this._videoAllowed, + video: this._videoAllowed && this._videoActive, } let stream @@ -321,6 +360,12 @@ export default class MediaDevicesSource extends TrackSource { return } + // While the video is disabled the camera is kept released, so device + // changes should not grab it again until the video is enabled. + if (!this._videoActive) { + return + } + if (this._pendingVideoInputIdChangedCount) { this._pendingVideoInputIdChangedCount++ diff --git a/src/utils/media/pipeline/MediaDevicesSource.spec.js b/src/utils/media/pipeline/MediaDevicesSource.spec.js index 40501388afe..7099306c144 100644 --- a/src/utils/media/pipeline/MediaDevicesSource.spec.js +++ b/src/utils/media/pipeline/MediaDevicesSource.spec.js @@ -882,6 +882,119 @@ describe('MediaDevicesSource', () => { }) }) + describe('activate and deactivate video', () => { + beforeEach(() => { + getUserMediaAudioTrack = newMediaStreamTrackMock('audio', 'audio') + getUserMediaVideoTrack = newMediaStreamTrackMock('video', 'video') + }) + + test('video is active by default', () => { + expect(mediaDevicesSource.isVideoActive()).toBe(true) + }) + + test('after modifying the active state', () => { + mediaDevicesSource.setVideoActive(false) + expect(mediaDevicesSource.isVideoActive()).toBe(false) + + mediaDevicesSource.setVideoActive(true) + expect(mediaDevicesSource.isVideoActive()).toBe(true) + }) + + test('video is not grabbed on start when deactivated', async () => { + mediaDevicesManager.set('audioInputId', 'audio-device') + mediaDevicesManager.set('videoInputId', 'video-device') + + mediaDevicesSource.setVideoActive(false) + + await mediaDevicesSource.start(retryNoVideoCallback) + + expect(mediaDevicesSource.getOutputTrack('audio')).toBe(getUserMediaAudioTrack) + expect(mediaDevicesSource.getOutputTrack('video')).toBe(null) + expect(getUserMediaVideoTrack.stop).not.toHaveBeenCalled() + }) + + test('deactivate while active releases the camera', async () => { + mediaDevicesManager.set('videoInputId', 'video-device') + + await mediaDevicesSource.start(retryNoVideoCallback) + + mediaDevicesSource.setVideoActive(false) + + expect(mediaDevicesSource.getOutputTrack('video')).toBe(null) + expect(getUserMediaVideoTrack.stop).toHaveBeenCalledTimes(1) + }) + + test('deactivate again while active', async () => { + mediaDevicesManager.set('videoInputId', 'video-device') + + await mediaDevicesSource.start(retryNoVideoCallback) + + mediaDevicesSource.setVideoActive(false) + mediaDevicesSource.setVideoActive(false) + + expect(mediaDevicesSource.getOutputTrack('video')).toBe(null) + expect(getUserMediaVideoTrack.stop).toHaveBeenCalledTimes(1) + }) + + test('activate again while active re-grabs the camera', async () => { + mediaDevicesManager.set('videoInputId', 'video-device') + + mediaDevicesSource.setVideoActive(false) + + await mediaDevicesSource.start(retryNoVideoCallback) + + expect(mediaDevicesSource.getOutputTrack('video')).toBe(null) + + mediaDevicesSource.setVideoActive(true) + + // Wait until getUserMedia(), internally called by MediaDevicesSource + // when activating the video, finishes. + await new Promise(process.nextTick) + + expect(mediaDevicesSource.getOutputTrack('video')).toBe(getUserMediaVideoTrack) + }) + + test('deactivate and activate again before starting does not grab', () => { + mediaDevicesManager.set('videoInputId', 'video-device') + + mediaDevicesSource.setVideoActive(false) + mediaDevicesSource.setVideoActive(true) + + expect(mediaDevicesManager.getUserMedia).not.toHaveBeenCalled() + }) + + test('activate while not allowed does not grab the camera', async () => { + mediaDevicesManager.set('audioInputId', 'audio-device') + mediaDevicesManager.set('videoInputId', 'video-device') + + mediaDevicesSource.setVideoAllowed(false) + mediaDevicesSource.setVideoActive(false) + + await mediaDevicesSource.start(retryNoVideoCallback) + + mediaDevicesSource.setVideoActive(true) + + await new Promise(process.nextTick) + + expect(mediaDevicesSource.getOutputTrack('video')).toBe(null) + }) + + test('device change while deactivated does not grab the camera', async () => { + mediaDevicesManager.set('videoInputId', 'video-device') + + mediaDevicesSource.setVideoActive(false) + + await mediaDevicesSource.start(retryNoVideoCallback) + + mediaDevicesManager.getUserMedia.mockClear() + + mediaDevicesManager.set('videoInputId', 'video-device-2') + + expect(mediaDevicesManager.getUserMedia).not.toHaveBeenCalled() + expect(mediaDevicesSource.getOutputTrack('video')).toBe(null) + }) + }) + describe('stop', () => { test('with audio and video tracks', async () => { getUserMediaAudioTrack = newMediaStreamTrackMock('audio', 'audio') diff --git a/src/utils/webrtc/models/LocalMediaModel.js b/src/utils/webrtc/models/LocalMediaModel.js index 069e9e3e854..4c265d58172 100644 --- a/src/utils/webrtc/models/LocalMediaModel.js +++ b/src/utils/webrtc/models/LocalMediaModel.js @@ -196,6 +196,28 @@ LocalMediaModel.prototype = { this._updateMediaAvailability(localStream) }, + /** + * Returns whether the video track is currently absent because the camera + * was intentionally released while the video is disabled (see spreed#4008), + * as opposed to the camera being missing or unusable. + * + * In that case the camera is still available (a camera input device is + * selected and it was released on purpose while the video is disabled), so + * it can be enabled again. This keeps the video controls usable and matches + * the model state of a regular disabled ("muted") video, even when the call + * was joined with the video already disabled. + * + * @return {boolean} true if the camera was released while the video is + * disabled, false otherwise + */ + _isVideoReleasedWhileDisabled() { + return Boolean(this._webRtc) + && Boolean(this._webRtc.webrtc) + && typeof this._webRtc.webrtc.isVideoActive === 'function' + && !this._webRtc.webrtc.isVideoActive() + && this._webRtc.webrtc.isVideoInputAvailable() + }, + _updateMediaAvailability(localStream) { if (localStream && localStream.getAudioTracks().length > 0) { this.set('audioAvailable', true) @@ -214,6 +236,15 @@ LocalMediaModel.prototype = { if (localStream && localStream.getVideoTracks().length > 0) { this.set('videoAvailable', true) this.set('videoEnabled', localStream.getVideoTracks()[0].enabled) + } else if (this._isVideoReleasedWhileDisabled()) { + // The video track is not present because the camera was + // intentionally released while the video is disabled, so the camera + // hardware light turns off (see spreed#4008). The camera is still + // available and can be enabled again, so "videoAvailable" is kept + // true and only the disabled state is reflected. This matches the + // model state of a regular disabled ("muted") video. + this.set('videoAvailable', true) + this.set('videoEnabled', false) } else { this.disableVideo() // "videoEnabled" needs to be explicitly set to false, as there is diff --git a/src/utils/webrtc/simplewebrtc/localmedia.js b/src/utils/webrtc/simplewebrtc/localmedia.js index 754d2806a48..5e3dbf72c4e 100644 --- a/src/utils/webrtc/simplewebrtc/localmedia.js +++ b/src/utils/webrtc/simplewebrtc/localmedia.js @@ -390,13 +390,36 @@ LocalMedia.prototype.allowVideo = function() { LocalMedia.prototype.pauseVideo = function() { this._setVideoEnabled(false) + // Fully release the camera while the video is disabled so the camera + // hardware light turns off (see spreed#4008), rather than just disabling + // the track (which keeps the camera grabbed in Chromium). + this._mediaDevicesSource.setVideoActive(false) this.emit('videoOff') } LocalMedia.prototype.resumeVideo = function() { + // Enable the track enabler before grabbing the camera again so the newly + // grabbed track is not disabled when it flows through the pipeline. this._setVideoEnabled(true) + this._mediaDevicesSource.setVideoActive(true) this.emit('videoOn') } +LocalMedia.prototype.isVideoActive = function() { + return this._mediaDevicesSource.isVideoActive() +} + +/** + * Returns whether a camera input device is currently selected, and thus the + * video can be enabled, even if the camera is not grabbed at the moment (for + * example while the video is disabled and the camera is released, see + * spreed#4008). Returns false if "no camera" ("None") is selected. + * + * @return {boolean} true if a camera input device is selected + */ +LocalMedia.prototype.isVideoInputAvailable = function() { + return mediaDevicesManager.get('videoInputId') !== null +} + LocalMedia.prototype.enableNoiseSuppression = function() { this._noiseSuppressor.setEnabled(true) this.emit('noiseSuppressionOn')