Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/CallView/shared/LocalAudioControlButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intention is right, and there's an open issue in #15991.

If you want to continue, I'd suggest starting with it first. Maybe useGetMessagesProvider and useGetMessages will be a good example to based approach on how to split them - so buttons do not subscribe at all.
This might be a better approach, than to construct another layer of flags to listen

},

beforeUnmount() {
this.unsubscribeFromDevices(this.conversation.permissions)
this.unsubscribeFromDevices(this.conversation.permissions, { videoPreview: false })
},

methods: {
Expand Down
6 changes: 4 additions & 2 deletions src/components/CallView/shared/LocalVideoControlButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
40 changes: 34 additions & 6 deletions src/composables/useDevices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

/**
Expand Down Expand Up @@ -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 {
Expand All @@ -161,22 +174,27 @@ 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')
return
}

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()
}

Expand Down Expand Up @@ -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()
}
}
Expand All @@ -225,7 +243,7 @@ export const useDevices = createSharedComposable(function() {
if (!hasAudioPermission()) {
stopAudioStream()
}
if (!hasVideoPermission()) {
if (!shouldGrabVideoPreview()) {
stopVideoStream()
}
}
Expand All @@ -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)
*
Expand Down Expand Up @@ -516,7 +544,7 @@ export const useDevices = createSharedComposable(function() {
return
}

if (!hasVideoPermission()) {
if (!shouldGrabVideoPreview()) {
return
}

Expand Down
47 changes: 46 additions & 1 deletion src/utils/media/pipeline/MediaDevicesSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -57,6 +65,10 @@ export default class MediaDevicesSource extends TrackSource {
return this._videoAllowed
}

isVideoActive() {
return this._videoActive
}

setAudioAllowed(audioAllowed) {
if (this._audioAllowed === audioAllowed) {
return
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pipeline is the most important here, behaviour should not change (e.g. BlackVideoEnforcer should keep sending the black frame, to keep the peer connection alive). Ideally it should replicate selecting 'camera => None', but I see call flags being updated on every camera mute (might be side-effect of this PR, might be original behaviour - need more time to debug it)

// 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

Expand All @@ -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
Expand Down Expand Up @@ -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++

Expand Down
113 changes: 113 additions & 0 deletions src/utils/media/pipeline/MediaDevicesSource.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
31 changes: 31 additions & 0 deletions src/utils/webrtc/models/LocalMediaModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading