Skip to content
Merged
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
7 changes: 6 additions & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,12 @@ const config = [{
},

}, {
files: ["**/*.esm.js", "**/*test.js", "**/static/src/**/*.js"],
files: [
"**/*.esm.js",
"**/*test.js",
"**/static/src/**/*.js",
"**/static/tests/**/*.js",
],

languageOptions: {
ecmaVersion: 2024,
Expand Down
6 changes: 5 additions & 1 deletion mail_livekit/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Discuss - Livekit Integration",
"summary": "Integrate LiveKit video conferencing with Odoo Discuss",
"version": "18.0.1.0.2",
"version": "18.0.1.0.3",
"author": "Nitrokey GmbH, Solvti Sp. z o.o.",
"license": "LGPL-3",
"category": "Discuss",
Expand All @@ -25,6 +25,10 @@
"mail_livekit/static/lib/livekit/livekit-client.umd.js",
"mail_livekit/static/src/discuss/livekit_service.js",
"mail_livekit/static/src/discuss/livekit_adapter.js",
"mail_livekit/static/src/discuss/rtc_livekit_patch.js",
"mail_livekit/static/src/discuss/thread_actions_patch.js",
"mail_livekit/static/src/discuss/call_participant_video_patch.js",
"mail_livekit/static/src/discuss/call_context_menu_patch.js",
"mail_livekit/static/tests/**/*",
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ patch(CallParticipantVideo.prototype, {
// Subscribe to track rebind events
useExternalListener(this.env.bus, "LIVEKIT:TRACK:REBIND", (event) => {
const {sessionId, identity, type} = event.detail;
if (this.props.identity == identity && this.props.type === type) {
if (this.props.identity === identity && this.props.type === type) {
console.debug(
`LIVEKIT:TRACK:REBIND for identity ${identity}, type ${type}`
);
Expand Down
17 changes: 13 additions & 4 deletions mail_livekit/static/src/discuss/livekit_service.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class LivekitService {

let audioElement = null;

if (track.kind == "audio") {
if (track.kind === "audio") {
const audioElementId = this._formAudioElementId(participant.identity);
audioElement = document.getElementById(audioElementId);
audioElement?.remove();
Expand Down Expand Up @@ -130,6 +130,15 @@ class LivekitService {
const audioElementId = this._formAudioElementId(participant.identity);
const audioElement = document.getElementById(audioElementId);
audioElement?.remove();

// A graceful disconnection unsubscribes every track first, but an
// abrupt one does not: report all sources as gone so that nothing keeps
// showing (or focusing on) the tracks of a participant who left.
for (const source of [Source.CAMERA, Source.SCREEN, Source.MICROPHONE]) {
for (const listener of this.trackMutedListeners.values()) {
listener(participant.identity, source, null, true);
}
}
}

// Requires functions that accept info as parameter
Expand Down Expand Up @@ -351,13 +360,13 @@ class LivekitService {
log("Publishing new track for source:", source);
await this.room?.localParticipant.publishTrack(mediaStreamTrack, {
source,
simulcast: source !== Source.Microphone,
simulcast: source !== Source.MICROPHONE,
});
} else if (publication.track) {
log("Replacing track for source:", source);
await publication.track.replaceTrack(mediaStreamTrack);
if (
publication.track.source !== Source.Microphone ||
publication.track.source !== Source.MICROPHONE ||
mediaStreamTrack?.enabled
) {
publication?.track?.unmute();
Expand Down Expand Up @@ -389,7 +398,7 @@ class LivekitService {
async setMicrophoneMuted(muted) {
log("Setting microphone mute to:", muted);
const publication = this.room?.localParticipant.getTrackPublication(
Source.Microphone
Source.MICROPHONE
);
if (publication?.track && publication.track.isMuted !== muted) {
if (muted) {
Expand Down
88 changes: 75 additions & 13 deletions mail_livekit/static/src/discuss/rtc_livekit_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ patch(Rtc.prototype, {
);
}
if (
eventdata.detail.name == "info_change" &&
eventdata.detail.name === "info_change" &&
Object.keys(eventdata.detail.payload)[0].includes(":")
) {
const fixedIdentity = this.identityToSessionId(
Expand All @@ -95,18 +95,32 @@ patch(Rtc.prototype, {
},

async setAudioVolume(sessionId, element = null) {
const rtcSession = await this.store.RtcSession.getWhenReady(sessionId);
if (element) {
rtcSession.audioElement = element;
if (!element) {
return;
}
const volumeSetting = this.store.Volume.getForPartnerId(rtcSession.partnerId);
const volume = volumeSetting ? volumeSetting.volume / 100 : 1.0;
if (rtcSession.audioElement) {
rtcSession.audioElement.volume = volume;
const rtcSession = await this.store.RtcSession.getWhenReady(sessionId);
if (!rtcSession) {
// `identityToSessionId` falls back to the raw identity when the
// attendee has no session on this client yet: there is nothing to
// bind the element to.
return;
}
// `RtcSession.volume` reads back from `audioElement`, so the saved
// volume has to be resolved before the element is bound to the session,
// otherwise it resolves to the element's own default.
element.volume = this.store.settings.getVolume(rtcSession);
// LiveKit attaches the element on its own, after the fact, so it has to
// catch up with a deafening that already happened.
element.muted = Boolean(this.selfSession?.isDeaf);
rtcSession.audioElement = element;
},

async handleSetAudioVolume(eventdata) {
// The adapter notifies every listener of every event, so the ones that
// are not meant for this handler have to be filtered out.
if (eventdata.detail.name !== "setAudioVolume") {
return;
}
console.debug("LIVEKIT: Set audio volume event received", eventdata);
this.fixEventIds(eventdata);
return this.setAudioVolume(
Expand All @@ -127,6 +141,11 @@ patch(Rtc.prototype, {
);

const rtcSession = await this.store.RtcSession.getWhenReady(sessionId);
if (!rtcSession) {
// The attendee has no session on this client yet: their track
// is bound when it is replayed by `rebindExistingTracks`.
return;
}

// Store LiveKit track separately
rtcSession.livekitTracks.set(type, track);
Expand All @@ -136,6 +155,10 @@ patch(Rtc.prototype, {
rtcSession.videoStreams.set(type, dummyStream);
await rtcSession.updateStreamState(type, true);

// Raise the focus view on an incoming screen share, the way the
// standard `handleRemoteTrack` does for the other connection types.
this.updateActiveSession(rtcSession, type, {addVideo: true});

// Trigger bus event to notify CallParticipantVideo to attach track
this.store.env.bus.trigger("LIVEKIT:TRACK:REBIND", {
sessionId: rtcSession.id,
Expand All @@ -145,7 +168,33 @@ patch(Rtc.prototype, {
}
},

/**
* LiveKit reports the end of a track (screen share stopped, camera turned
* off, participant gone) as an inactive track instead of an actual
* MediaStreamTrack, so the standard handler cannot be used to remove it.
*/
async handleRemoteTrack({session, type, active = true}) {
if (active) {
return super.handleRemoteTrack(...arguments);
}
session.updateStreamState(type, false);
if (type === "camera" || type === "screen") {
session.livekitTracks?.delete(type);
this.removeVideoFromSession(session, {type, cleanup: false});
}
},

async _initConnection() {
// Which stream is on display is a local viewing preference, not call
// state: joining starts on the tiles, so that a focus left over from a
// previous call cannot point at a stream this client no longer holds.
// An attendee already sharing their screen focuses it back on rebind.
if (this.state.channel) {
this.state.channel.activeRtcSession = undefined;
for (const session of this.state.channel.rtcSessions) {
session.mainVideoStreamType = undefined;
}
}
this.selfSession.connectionState = "selecting network type";
await this.network?.disconnect();
this.network = new LiveKitAdapter();
Expand All @@ -156,6 +205,10 @@ patch(Rtc.prototype, {
"updateTrack",
this.handleTrackSubscribed.bind(this)
);
this.network.addEventListener(
"setAudioVolume",
this.handleSetAudioVolume.bind(this)
);

if (this.state.channel) {
await this.call();
Expand Down Expand Up @@ -217,13 +270,22 @@ patch(Rtc.prototype, {
// No-op
},

async leaveCall(...args) {
this.network?.disconnect();
return super.leaveCall(...args);
},

updateActiveSession(session, videoType, {addVideo = false} = {}) {
this.state.channel ??= session.channel;
const channel = this.state.channel;
if (
!addVideo &&
session.eq(channel?.activeRtcSession) &&
session.mainVideoStreamType === videoType
) {
// The stream on display is over: everyone goes back to the tile
// view, instead of falling back to another stream of the same
// participant (a screen share ending would otherwise leave everyone
// focused on the sharer's camera).
channel.activeRtcSession = undefined;
session.mainVideoStreamType = undefined;
return;
}
return super.updateActiveSession(session, videoType, {addVideo});
},

Expand Down
15 changes: 2 additions & 13 deletions mail_livekit/static/tests/livekit_adapter.test.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,10 @@
import {after, afterEach, describe, expect, test} from "@odoo/hoot";
import {Source, livekitService} from "@mail_livekit/discuss/livekit_service";
import {after, afterEach, describe, expect, test} from "@odoo/hoot";
import {LiveKitAdapter} from "@mail_livekit/discuss/livekit_adapter";
import {cleanupLivekitService} from "./livekit_test_helpers";

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 = {
Expand Down
Loading
Loading