diff --git a/addons/mail/static/src/rtc/rtc_service.js b/addons/mail/static/src/rtc/rtc_service.js index ca26a8e646a..b6023c298c3 100644 --- a/addons/mail/static/src/rtc/rtc_service.js +++ b/addons/mail/static/src/rtc/rtc_service.js @@ -136,7 +136,7 @@ export class Rtc { pttReleaseTimeout: undefined, sourceCameraStream: null, }); - this.blur = undefined; + this.blurManager = undefined; this.ringingThreads = reactive([], () => this.onRingingThreadsChange()); void this.ringingThreads.length; this.store.ringingThreads = this.ringingThreads; @@ -1110,7 +1110,7 @@ export class Rtc { let sourceStream; try { if (type === "camera") { - if (this.state.sourceCameraStream) { + if (this.state.sourceCameraStream && this.state.sendCamera) { sourceStream = this.state.sourceCameraStream; } else { sourceStream = await browser.navigator.mediaDevices.getUserMedia({ @@ -1190,22 +1190,13 @@ export class Rtc { } else { transceiver = getTransceiver(session.peerConnection, trackKind); } - if (track) { - try { - await transceiver.sender.replaceTrack(track); - transceiver.direction = transceiverDirection; - } catch { - // ignored, the track is probably already on the peerConnection. - } - return; - } try { - await transceiver.sender.replaceTrack(null); + await transceiver.sender.replaceTrack(track || null); transceiver.direction = transceiverDirection; } catch { - // ignored, the transceiver is probably already removed + this.log(session, `failed to update ${trackKind} transceiver`); } - if (trackKind === "video") { + if (!track && trackKind === "video") { this.notify([session], "trackChange", { type: "video", state: { isSendingVideo: false }, @@ -1218,7 +1209,7 @@ export class Rtc { this.state.audioTrack.stop(); this.state.audioTrack = undefined; } - if (!this.state.channel.id) { + if (!this.state.channel) { return; } if (force) { diff --git a/addons/mail/static/tests/call/call_tests.js b/addons/mail/static/tests/call/call_tests.js index 43eaf1a2587..5e7434da5db 100644 --- a/addons/mail/static/tests/call/call_tests.js +++ b/addons/mail/static/tests/call/call_tests.js @@ -1,12 +1,19 @@ /** @odoo-module **/ -import { afterNextRender, click, start, startServer } from "@mail/../tests/helpers/test_utils"; +import { + afterNextRender, + click, + start, + startServer, + mockGetMedia, +} from "@mail/../tests/helpers/test_utils"; import { editInput, nextTick, patchWithCleanup, triggerEvent } from "@web/../tests/helpers/utils"; import { browser } from "@web/core/browser/browser"; QUnit.module("call"); QUnit.test("basic rendering", async (assert) => { + mockGetMedia(); const pyEnv = await startServer(); const channelId = pyEnv["mail.channel"].create({ name: "General", @@ -54,9 +61,10 @@ QUnit.test("should not display call UI when no more members (self disconnect)", }); QUnit.test("show call UI in chat window when in call", async (assert) => { + mockGetMedia(); const pyEnv = await startServer(); pyEnv["mail.channel"].create({ name: "General" }); - const { env } = await start(); + await start(); await click(".o_menu_systray i[aria-label='Messages']"); await click(".o-mail-NotificationItem:contains(General)"); assert.containsOnce($, ".o-mail-ChatWindow"); @@ -72,17 +80,13 @@ QUnit.test("show call UI in chat window when in call", async (assert) => { $, ".o-mail-ChatWindow-header .o-mail-ChatWindow-command[title='Start a Call']" ); - /** - * during the tests, the browser is not really closed, - * so we need to end the call manually to avoid memory leaks. - */ - env.services["mail.rtc"]?.endCall(); }); QUnit.test("should disconnect when closing page while in call", async (assert) => { + mockGetMedia(); const pyEnv = await startServer(); const channelId = pyEnv["mail.channel"].create({ name: "General" }); - const { openDiscuss, env } = await start(); + const { openDiscuss } = await start(); await openDiscuss(channelId); patchWithCleanup(browser, { navigator: { @@ -104,11 +108,6 @@ QUnit.test("should disconnect when closing page while in call", async (assert) = await afterNextRender(() => window.dispatchEvent(new Event("pagehide"), { bubble: true })); await nextTick(); assert.verifySteps(["sendBeacon_leave_call"]); - /** - * during the tests, the browser is not really closed, - * so we need to end the call manually to avoid memory leaks. - */ - env.services["mail.rtc"]?.endCall(); }); QUnit.test("no default rtc after joining a chat conversation", async (assert) => { @@ -205,3 +204,33 @@ QUnit.test("should display invitations", async (assert) => { assert.containsNone($, ".o-mail-CallInvitation"); assert.verifySteps(["pause_sound_effect"]); }); + +QUnit.test("can share screen", async (assert) => { + mockGetMedia(); + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ + name: "General", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Start a Call']"); + await click(".o-mail-CallActionList button[title='Share screen']"); + assert.containsOnce($, ".o-mail-CallParticipantCard video"); + await click(".o-mail-CallActionList button[title='Stop screen sharing']"); + assert.containsNone($, ".o-mail-CallParticipantCard video"); +}); + +QUnit.test("can share user camera", async (assert) => { + mockGetMedia(); + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ + name: "General", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Start a Call']"); + await click(".o-mail-CallActionList button[title='Turn camera on']"); + assert.containsOnce($, ".o-mail-CallParticipantCard video"); + await click(".o-mail-CallActionList button[title='Stop camera']"); + assert.containsNone($, ".o-mail-CallParticipantCard video"); +}); diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js index b6cb0c1295b..428995eb2c0 100644 --- a/addons/mail/static/tests/helpers/test_utils.js +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -583,6 +583,77 @@ function isScrolledTo(el, scrollTop) { return Math.abs(el.scrollTop - scrollTop) <= 1; } //------------------------------------------------------------------------------ +// Public: web API utilities +//------------------------------------------------------------------------------ + +/** + * Mocks the browser's `navigator.mediaDevices.getUserMedia` and `navigator.mediaDevices.getDisplayMedia` + */ +export function mockGetMedia() { + class MockMediaStreamTrack extends EventTarget { + enabled = true; + readyState = "live"; + constructor(kind) { + super(); + this.kind = kind; + } + stop() { + this.readyState = "ended"; + } + } + /** + * The audio streams are mocked as there is no way to create a MediaStream + * with an audio track without really requesting it from the device. + */ + class MockAudioMediaStream extends MediaStream { + mockTracks = [new MockMediaStreamTrack("audio")]; + getTracks() { + return this.mockTracks; + } + getAudioTracks() { + return this.mockTracks; + } + getVideoTracks() { + return []; + } + } + const streams = []; + /** + * The video streams are real MediaStreams created from a 1x1 canvas at 1fps. + */ + const createVideoStream = (constraints) => { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const stream = canvas.captureStream(1); + return stream; + }; + patchWithCleanup(browser.navigator.mediaDevices, { + getUserMedia(constraints) { + let stream; + if (constraints.audio) { + stream = new MockAudioMediaStream(); + } else { + // The video streams are real MediaStreams + stream = createVideoStream(); + } + streams.push(stream); + return stream; + }, + getDisplayMedia() { + const stream = createVideoStream(); + streams.push(stream); + return stream; + }, + }); + registerCleanup(() => { + // stop all streams as some tests may not do actions that lead to the ending of tracks + streams.forEach((stream) => { + stream.getTracks().forEach((track) => track.stop()); + }); + }); +} +//------------------------------------------------------------------------------ // Export //------------------------------------------------------------------------------