diff --git a/mail_livekit/__manifest__.py b/mail_livekit/__manifest__.py index d7e0157f..d127e52f 100644 --- a/mail_livekit/__manifest__.py +++ b/mail_livekit/__manifest__.py @@ -21,6 +21,12 @@ "mail_livekit/static/src/discuss/call_participant_video_patch.js", "mail_livekit/static/src/discuss/call_context_menu_patch.js", ], + "web.assets_unit_tests": [ + "mail_livekit/static/lib/livekit/livekit-client.umd.min.js", + "mail_livekit/static/src/discuss/livekit_service.js", + "mail_livekit/static/src/discuss/livekit_adapter.js", + "mail_livekit/static/tests/**/*", + ], }, "external_dependencies": { "python": ["livekit-api"], diff --git a/mail_livekit/static/src/discuss/livekit_adapter.js b/mail_livekit/static/src/discuss/livekit_adapter.js index f0d5575b..54420aa7 100644 --- a/mail_livekit/static/src/discuss/livekit_adapter.js +++ b/mail_livekit/static/src/discuss/livekit_adapter.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + import {Source, livekitService} from "./livekit_service"; export class LiveKitAdapter { @@ -29,12 +31,13 @@ export class LiveKitAdapter { } async updateUpload(source, track) { - const livekitSource = - source === "audio" - ? Source.MICROPHONE - : source === "camera" - ? Source.CAMERA - : Source.SCREEN; + const livekitSource = Object.values(Source).includes(source) + ? source + : source === "audio" + ? Source.MICROPHONE + : source === "camera" + ? Source.CAMERA + : Source.SCREEN; await livekitService?.setTrackEnabled(livekitSource, Boolean(track), track); } @@ -87,14 +90,14 @@ export class LiveKitAdapter { }); livekitService.subscribeToTrackSubscribed( "adapter", - (participantId, source, track) => { + (participantId, source, track, audioElement = null) => { console.debug( "received Track subscribed event:", participantId, source, track ); - this.handleTrackSubscribed(participantId, source, track); + this.handleTrackSubscribed(participantId, source, track, audioElement); } ); livekitService.subscribeToTrackMuted( @@ -113,8 +116,13 @@ export class LiveKitAdapter { } async connect(livekit_url, token) { - await livekitService?.connect(livekit_url, token); this.addLivekitListeners(); + try { + await livekitService?.connect(livekit_url, token); + } catch (error) { + await livekitService?.disconnect(); + throw error; + } } async disconnect() { diff --git a/mail_livekit/static/src/discuss/livekit_service.js b/mail_livekit/static/src/discuss/livekit_service.js index 243cb2dc..875d5eca 100644 --- a/mail_livekit/static/src/discuss/livekit_service.js +++ b/mail_livekit/static/src/discuss/livekit_service.js @@ -1,3 +1,5 @@ +/** @odoo-module */ + const {Room, VideoPresets, RoomEvent} = window.LivekitClient; // This script should be the only contact point with Livekit SDK @@ -151,8 +153,8 @@ class LivekitService { // Add this method to the LivekitService class async rebindExistingTracks() { - if (!this.room || !this.connected) { - log("Cannot rebind - not connected to room"); + if (!this.room) { + log("Cannot rebind - no LiveKit room"); return; } diff --git a/mail_livekit/static/tests/livekit_adapter.test.js b/mail_livekit/static/tests/livekit_adapter.test.js new file mode 100644 index 00000000..9c255ae3 --- /dev/null +++ b/mail_livekit/static/tests/livekit_adapter.test.js @@ -0,0 +1,110 @@ +import {after, afterEach, describe, expect, test} from "@odoo/hoot"; +import {Source, livekitService} from "@mail_livekit/discuss/livekit_service"; +import {LiveKitAdapter} from "@mail_livekit/discuss/livekit_adapter"; + +const originalLivekitClient = window.LivekitClient; + +function cleanupLivekitService() { + livekitService.infoChangeListeners.clear(); + livekitService.trackSubscribedListeners.clear(); + livekitService.trackMutedListeners.clear(); + livekitService.room = null; + livekitService.connected = false; + livekitService.initiated = false; + document + .querySelectorAll(`.${livekitService.audioElementClass}`) + .forEach((element) => element.remove()); +} + +function makeRemoteAudioTrack(identity) { + const audioElement = document.createElement("audio"); + const track = { + kind: "audio", + attach: () => audioElement, + detach: () => { + // Detach the audio element + }, + }; + const publication = { + source: Source.MICROPHONE, + track, + isSubscribed: true, + }; + const participant = { + identity, + sid: `${identity}-sid`, + audioTrackPublications: new Map([["microphone", publication]]), + videoTrackPublications: new Map(), + }; + return {audioElement, participant, publication, track}; +} + +describe("mail_livekit livekit adapter", () => { + after(() => { + window.LivekitClient = originalLivekitClient; + }); + + afterEach(() => { + cleanupLivekitService(); + }); + + test("connect does not drop audio subscribed while the room connection is starting", async () => { + const adapter = new LiveKitAdapter(); + const emittedPayloads = []; + const {audioElement, participant, publication, track} = + makeRemoteAudioTrack("partner:8"); + const originalConnect = livekitService.connect; + + livekitService.connect = async () => { + livekitService.handleTrackSubscribed(track, publication, participant); + }; + + adapter.addEventListener("setAudioVolume", (event) => { + emittedPayloads.push(event.detail.payload); + }); + + try { + await adapter.connect("wss://livekit.example", "token"); + } finally { + livekitService.connect = originalConnect; + } + + expect(emittedPayloads).toEqual([ + { + element: audioElement, + identity: "partner:8", + }, + ]); + }); + + test("rebindExistingTracks replays audio already subscribed during connection startup", async () => { + const {audioElement, participant, track} = makeRemoteAudioTrack("partner:9"); + const subscribedTracks = []; + + livekitService.room = { + remoteParticipants: new Map([[participant.identity, participant]]), + }; + livekitService.connected = false; + livekitService.subscribeToTrackSubscribed( + "adapter", + (identity, source, subscribedTrack, element) => { + subscribedTracks.push({ + element, + identity, + source, + track: subscribedTrack, + }); + } + ); + + await livekitService.rebindExistingTracks(); + + expect(subscribedTracks.length).toBe(1); + expect(subscribedTracks[0]).toEqual({ + element: audioElement, + identity: "partner:9", + source: Source.MICROPHONE, + track, + }); + }); +});