diff --git a/addons/mail/models/discuss/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py index 1f5e9021f7e..d579cd2419e 100644 --- a/addons/mail/models/discuss/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -721,7 +721,7 @@ class Channel(models.Model): return [] channel_infos = [] # sudo: discuss.channel.rtc.session - reading sessions of accessible channel is acceptable - rtc_sessions_by_channel = self.sudo().rtc_session_ids._mail_rtc_session_format_by_channel() + rtc_sessions_by_channel = self.sudo().rtc_session_ids._mail_rtc_session_format_by_channel(extra=True) current_partner, current_guest = self.env["res.partner"]._get_current_persona() self.env['discuss.channel'].flush_model() self.env['discuss.channel.member'].flush_model() diff --git a/addons/mail/models/discuss/discuss_channel_rtc_session.py b/addons/mail/models/discuss/discuss_channel_rtc_session.py index ab355a37589..9f31cdb9c4b 100644 --- a/addons/mail/models/discuss/discuss_channel_rtc_session.py +++ b/addons/mail/models/discuss/discuss_channel_rtc_session.py @@ -60,9 +60,13 @@ class MailRtcSession(models.Model): of the change. """ valid_values = {'is_screen_sharing_on', 'is_camera_on', 'is_muted', 'is_deaf'} - self.write({key: values[key] for key in valid_values if key in valid_values}) - session_data = self._mail_rtc_session_format() - self.env['bus.bus']._sendone(self.channel_id, 'mail.record/insert', {'RtcSession': session_data}) + self.write({key: values[key] for key in valid_values if key in values}) + session_data = self._mail_rtc_session_format(extra=True) + self.env["bus.bus"]._sendone( + self.channel_id, + "discuss.channel.rtc.session/update_and_broadcast", + {"data": session_data, "channelId": self.channel_id.id}, + ) @api.autovacuum def _gc_inactive_sessions(self): @@ -95,29 +99,31 @@ class MailRtcSession(models.Model): payload_by_target[target]['notifications'].append(content) return self.env['bus.bus']._sendmany([(target, 'discuss.channel.rtc.session/peer_notification', payload) for target, payload in payload_by_target.items()]) - def _mail_rtc_session_format(self, fields=None): + def _mail_rtc_session_format(self, extra=False): self.ensure_one() - if not fields: - fields = {'id': True, 'channelMember': {'id': True, 'channel': {}, 'persona': {'partner': {'id', 'name', 'im_status'}, 'guest': {'id', 'name', 'im_status'}}}, 'isCameraOn': True, 'isDeaf': True, 'isSelfMuted': True, 'isScreenSharingOn': True} - vals = {} - if 'id' in fields: - vals['id'] = self.id - if 'channelMember' in fields: - vals['channelMember'] = self.channel_member_id._discuss_channel_member_format(fields=fields.get('channelMember')).get(self.channel_member_id) - if 'isCameraOn' in fields: - vals['isCameraOn'] = self.is_camera_on - if 'isDeaf' in fields: - vals['isDeaf'] = self.is_deaf - if 'isSelfMuted' in fields: - vals['isSelfMuted'] = self.is_muted - if 'isScreenSharingOn' in fields: - vals['isScreenSharingOn'] = self.is_screen_sharing_on + vals = { + "id": self.id, + "channelMember": self.channel_member_id._discuss_channel_member_format( + fields={ + "id": True, + "channel": {}, + "persona": {"partner": {"id", "name", "im_status"}, "guest": {"id", "name", "im_status"}}, + } + ).get(self.channel_member_id), + } + if extra: + vals.update({ + "isCameraOn": self.is_camera_on, + "isDeaf": self.is_deaf, + "isSelfMuted": self.is_muted, + "isScreenSharingOn": self.is_screen_sharing_on, + }) return vals - def _mail_rtc_session_format_by_channel(self): + def _mail_rtc_session_format_by_channel(self, extra=False): data = {} for rtc_session in self: - data.setdefault(rtc_session.channel_id, []).append(rtc_session._mail_rtc_session_format()) + data.setdefault(rtc_session.channel_id, []).append(rtc_session._mail_rtc_session_format(extra=extra)) return data @api.model diff --git a/addons/mail/static/src/core/common/thread_model.js b/addons/mail/static/src/core/common/thread_model.js index e0ffb6874ae..6c9cc69f430 100644 --- a/addons/mail/static/src/core/common/thread_model.js +++ b/addons/mail/static/src/core/common/thread_model.js @@ -487,9 +487,8 @@ export class Thread extends Record { } get videoCount() { - return Object.values(this._store.RtcSession.records).filter( - (session) => session.videoStreams.size - ).length; + return Object.values(this._store.RtcSession.records).filter((session) => session.hasVideo) + .length; } get lastInterestDateTime() { diff --git a/addons/mail/static/src/discuss/call/common/call.js b/addons/mail/static/src/discuss/call/common/call.js index b521067bd2a..852de70f423 100644 --- a/addons/mail/static/src/discuss/call/common/call.js +++ b/addons/mail/static/src/discuss/call/common/call.js @@ -90,19 +90,25 @@ export class Call extends Component { const filterVideos = this.props.thread.showOnlyVideo && this.props.thread.videoCount > 0; for (const session of this.props.thread.rtcSessions) { const target = session.raisingHand ? raisingHandCards : sessionCards; - const cameraStream = session.videoStreams.get("camera"); + const cameraStream = session.isCameraOn + ? session.videoStreams.get("camera") + : undefined; if (!filterVideos || cameraStream) { target.push({ key: "session_main_" + session.id, session, + type: "camera", videoStream: cameraStream, }); } - const screenStream = session.videoStreams.get("screen"); + const screenStream = session.isScreenSharingOn + ? session.videoStreams.get("screen") + : undefined; if (screenStream) { target.push({ key: "session_secondary_" + session.id, session, + type: "screen", videoStream: screenStream, }); } @@ -134,53 +140,34 @@ export class Call extends Component { /** @returns {CardData[]} */ get visibleMainCards() { const activeSession = this.props.thread.activeRtcSession; + this.state.insetCard = undefined; if (!activeSession) { - this.state.insetCard = undefined; return this.visibleCards; } - switch (activeSession.videoStreams.size) { - case 1: - if (activeSession.videoStreams.get("screen") === activeSession.mainVideoStream) { - this.setInset(activeSession); - } - if ( - activeSession.videoStreams.get("camera") && - activeSession.videoStreams.get("camera") === activeSession.mainVideoStream - ) { - this.state.insetCard = undefined; - } - if (!activeSession.mainVideoStream && activeSession.videoStreams.get("screen")) { - this.setInset(activeSession, "screen"); - } - break; - case 2: { - const videoType = - activeSession.videoStreams.get("screen") === activeSession.mainVideoStream - ? "camera" - : "screen"; - this.setInset(activeSession, videoType); - break; - } + const type = activeSession.mainVideoStreamType; + if (type === "screen" || activeSession.isScreenSharingOn) { + this.setInset(activeSession, type === "camera" ? "screen" : "camera"); } - return [ { key: "session_" + activeSession.id, session: activeSession, - videoStream: activeSession.mainVideoStream, + type, + videoStream: activeSession.getStream(type), }, ]; } /** * @param {RtcSession} session - * @param {String} videoType + * @param {String} [videoType] */ setInset(session, videoType) { this.state.insetCard = { key: "session_" + session.id, session, - videoStream: session.videoStreams.get(videoType), + type: videoType, + videoStream: session.getStream(videoType), }; } diff --git a/addons/mail/static/src/discuss/call/common/call_participant_card.js b/addons/mail/static/src/discuss/call/common/call_participant_card.js index 48a839e2940..6368090f80e 100644 --- a/addons/mail/static/src/discuss/call/common/call_participant_card.js +++ b/addons/mail/static/src/discuss/call/common/call_participant_card.js @@ -119,20 +119,16 @@ export class CallParticipantCard extends Component { } if (this.rtcSession) { const channel = this.rtcSession.channel; + this.rtcSession.mainVideoStreamType = this.props.cardData.type; if (this.rtcSession.eq(channel.activeRtcSession) && !this.props.inset) { channel.activeRtcSession = undefined; - this.rtcSession.mainVideoStream = undefined; + this.rtcSession.mainVideoStreamType = undefined; } else { const activeRtcSession = channel.activeRtcSession; - const mainVideoStream = this.rtcSession.mainVideoStream; + const currentMainVideoType = this.rtcSession.mainVideoStreamType; channel.activeRtcSession = this.rtcSession; - this.rtcSession.mainVideoStream = this.props.cardData.videoStream; if (this.props.inset && activeRtcSession) { - const videoType = - activeRtcSession.videoStreams.get("camera") === mainVideoStream - ? "camera" - : "screen"; - this.props.inset(activeRtcSession, videoType); + this.props.inset(activeRtcSession, currentMainVideoType); } } return; diff --git a/addons/mail/static/src/discuss/call/common/rtc_service.js b/addons/mail/static/src/discuss/call/common/rtc_service.js index afebf60a8e4..5a1ced30d35 100644 --- a/addons/mail/static/src/discuss/call/common/rtc_service.js +++ b/addons/mail/static/src/discuss/call/common/rtc_service.js @@ -273,6 +273,7 @@ export class Rtc { */ endCall(channel = this.state.channel) { channel.rtcInvitingSession = undefined; + channel.activeRtcSession = undefined; if (channel.eq(this.state.channel)) { this.clear(); this.soundEffectsService.play("channel-leave"); @@ -442,6 +443,7 @@ export class Rtc { */ this.updateActiveSession(session, payload.type); session.videoStreams.delete(payload.type); + session.updateStreamState(payload.type, false); break; } } @@ -634,12 +636,11 @@ export class Rtc { }; peerConnection.ontrack = ({ transceiver, track }) => { this.log(session, `received ${track.kind} track`); - const videoType = this.getVideoType(session.peerConnection, transceiver); - this.updateStream(session, track, { - mute: this.state.selfSession.isDeaf, - videoType, + this.handleRemoteTrack({ + session, + track, + type: this.getTransceiverType(session.peerConnection, transceiver), }); - this.updateActiveSession(session, videoType, { addVideo: true }); }; const dataChannel = peerConnection.createDataChannel("notifications", { negotiated: true, @@ -685,11 +686,26 @@ export class Rtc { return peerConnection; } + /** + * @param {import("@mail/discuss/call/common/rtc_session_model").RtcSession} session + * @param {MediaStreamTrack} track + * @param {"audio" | "screen" | "camera"} type + * @param {boolean} active false if the track is muted/disabled + */ + async handleRemoteTrack({ session, track, type, active = true }) { + session.updateStreamState(type, active); + await this.updateStream(session, track, { + mute: this.state.selfSession.isDeaf, + videoType: type, + }); + this.updateActiveSession(session, type, { addVideo: true }); + } + /** * @param {RTCPeerConnection} peerConnection * @param {RTCRtpTransceiver} transceiver */ - getVideoType(peerConnection, transceiver) { + getTransceiverType(peerConnection, transceiver) { const transceivers = peerConnection.getTransceivers(); return ORDERED_TRANSCEIVER_NAMES[transceivers.indexOf(transceiver)]; } @@ -904,6 +920,7 @@ export class Rtc { session.audioError = undefined; session.videoError = undefined; session.isTalking = false; + session.mainVideoStreamType = undefined; this.removeVideoFromSession(session); session.dataChannel?.close(); delete session.dataChannel; @@ -1291,7 +1308,7 @@ export class Rtc { } if (!track && trackKind !== "audio") { this.notify([session], "trackChange", { - type: this.getVideoType(session.peerConnection, transceiver), + type: this.getTransceiverType(session.peerConnection, transceiver), }); } } @@ -1457,38 +1474,34 @@ export class Rtc { /** * @param {RtcSession} session * @param {"screen"|"camera"} [videoType] + * @param {Object} [parm2] + * @param {boolean} [parm2.addVideo] */ updateActiveSession(session, videoType, { addVideo = false } = {}) { const activeRtcSession = this.state.channel.activeRtcSession; if (addVideo) { if (videoType === "screen") { this.state.channel.activeRtcSession = session; - session.mainVideoStream = session.videoStreams.get("screen"); + session.mainVideoStreamType = videoType; return; } - if (activeRtcSession && session.videoStreams.size && !session.mainVideoStream) { - session.mainVideoStream = session.videoStreams.get("camera"); + if (activeRtcSession && session.hasVideo && !session.isMainVideoStreamActive) { + session.mainVideoStreamType = videoType; } return; } if (!activeRtcSession || activeRtcSession.notEq(session)) { return; } - if (activeRtcSession.mainVideoStream) { - const activeVideoType = - activeRtcSession.videoStreams.get("screen") === session.mainVideoStream - ? "screen" - : "camera"; - if (videoType === activeVideoType) { - session.mainVideoStream = undefined; + if (activeRtcSession.isMainVideoStreamActive) { + if (videoType === session.mainVideoStreamType) { if (videoType === "screen") { this.state.channel.activeRtcSession = undefined; + } else { + session.mainVideoStreamType = "screen"; } } } - if (!activeRtcSession.mainVideoStream && videoType === "screen") { - session.mainVideoStream = undefined; - } } updateVideoDownload(rtcSession, { viewCountIncrement }) { @@ -1581,6 +1594,19 @@ export const rtcService = { services["mail.user_settings"].setVolumes(payload); } }); + services["bus_service"].subscribe( + "discuss.channel.rtc.session/update_and_broadcast", + (payload) => { + const { data, channelId } = payload; + /** + * If this event comes from the channel of the current call, information is shared in real time + * through the peer to peer connection. So we do not use this less accurate broadcast. + */ + if (channelId !== rtc.state.channel?.id) { + rtc.store.RtcSession.insert(data); + } + } + ); return rtc; }, }; diff --git a/addons/mail/static/src/discuss/call/common/rtc_session_model.js b/addons/mail/static/src/discuss/call/common/rtc_session_model.js index ffed90d4ad2..cfd6f0754fe 100644 --- a/addons/mail/static/src/discuss/call/common/rtc_session_model.js +++ b/addons/mail/static/src/discuss/call/common/rtc_session_model.js @@ -22,12 +22,18 @@ export class RtcSession extends Record { } // Server data + /** @type {boolean} */ channelMember = Record.one("ChannelMember", { inverse: "rtcSession" }); + /** @type {boolean} */ isCameraOn; - id; - isDeaf; - isSelfMuted; + /** @type {boolean} */ isScreenSharingOn; + /** @type {number} */ + id; + /** @type {boolean} */ + isDeaf; + /** @type {boolean} */ + isSelfMuted; // Client data /** @type {HTMLAudioElement} */ audioElement; @@ -46,8 +52,8 @@ export class RtcSession extends Record { videoComponentCount = 0; /** @type {Map<'screen'|'camera', MediaStream>} */ videoStreams = new Map(); - /** @type {MediaStream} */ - mainVideoStream; + /** @type {string} */ + mainVideoStreamType; // RTC stats connectionState; localCandidateType; @@ -68,6 +74,26 @@ export class RtcSession extends Record { return this.isSelfMuted || this.isDeaf; } + get mainVideoStream() { + return this.isMainVideoStreamActive && this.videoStreams.get(this.mainVideoStreamType); + } + + get isMainVideoStreamActive() { + if (!this.mainVideoStreamType) { + return false; + } + return this.mainVideoStreamType === "camera" ? this.isCameraOn : this.isScreenSharingOn; + } + + get hasVideo() { + return this.isScreenSharingOn || this.isCameraOn; + } + + getStream(type) { + const isActive = type === "camera" ? this.isCameraOn : this.isScreenSharingOn; + return isActive && this.videoStreams.get(type); + } + get partnerId() { const persona = this.channelMember?.persona; return persona.type === "partner" ? persona.id : undefined; @@ -111,6 +137,18 @@ export class RtcSession extends Record { } } + /** + * @param {"audio" | "camera" | "screen"} type + * @param {boolean} state + */ + updateStreamState(type, state) { + if (type === "camera") { + this.isCameraOn = state; + } else if (type === "screen") { + this.isScreenSharingOn = state; + } + } + async updateStats() { delete this.localCandidateType; delete this.remoteCandidateType; diff --git a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js index 5b91aeb723b..78e8e577950 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js +++ b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel.js @@ -718,7 +718,8 @@ patch(MockServer.prototype, { "ADD", (channel.rtc_session_ids || []).map((rtcSessionId) => this._mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormat( - rtcSessionId + rtcSessionId, + { extra: true } ) ), ], diff --git a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel_rtc_session.js b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel_rtc_session.js index af38bfedbe5..2de86e67305 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/discuss_channel_rtc_session.js +++ b/addons/mail/static/tests/helpers/mock_server/models/discuss_channel_rtc_session.js @@ -11,18 +11,23 @@ patch(MockServer.prototype, { * @param {integer} id * @returns {Object} */ - _mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormat(id) { + _mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormat(id, { extra = false } = {}) { const [rtcSession] = this.getRecords("discuss.channel.rtc.session", [["id", "=", id]]); - return { + const vals = { id: rtcSession.id, channelMember: this._mockDiscussChannelMember_DiscussChannelMemberFormat([ rtcSession.channel_member_id, ])[0], - isCameraOn: rtcSession.is_camera_on, - isDeaf: rtcSession.is_deaf, - isSelfMuted: rtcSession.is_self_muted, - isScreenSharingOn: rtcSession.is_screen_sharing_on, }; + if (extra) { + Object.assign(vals, { + isCameraOn: rtcSession.is_camera_on, + isDeaf: rtcSession.is_deaf, + isSelfMuted: rtcSession.is_self_muted, + isScreenSharingOn: rtcSession.is_screen_sharing_on, + }); + } + return vals; }, /** * Simulates `_mail_rtc_session_format` on `discuss.channel.rtc.session`. @@ -31,7 +36,10 @@ patch(MockServer.prototype, { * @param {integer[]} ids * @returns {Object} */ - _mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormatByChannel(ids) { + _mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormatByChannel( + ids, + { extra = false } = {} + ) { const rtcSessions = this.getRecords("discuss.channel.rtc.session", [["id", "in", ids]]); const data = {}; for (const rtcSession of rtcSessions) { @@ -39,7 +47,9 @@ patch(MockServer.prototype, { data[rtcSession.channel_id] = []; } data[rtcSession.channel_id].push( - this._mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormat(rtcSession.id) + this._mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormat(rtcSession.id, { + extra, + }) ); } return data; diff --git a/addons/mail/tests/discuss/test_rtc.py b/addons/mail/tests/discuss/test_rtc.py index a526db9b47d..dbf0d2231f7 100644 --- a/addons/mail/tests/discuss/test_rtc.py +++ b/addons/mail/tests/discuss/test_rtc.py @@ -55,10 +55,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }])], }, }, @@ -80,10 +76,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }]), ('DELETE', [{'id': channel_member.rtc_session_ids.id - 1}]), ], @@ -127,10 +119,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }])], }, }, @@ -209,10 +197,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }])], }, }, @@ -232,10 +216,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }])], }, }, @@ -362,10 +342,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }, ])], }, @@ -429,10 +405,6 @@ class TestChannelRTC(MailCommon): "type": "guest", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }, ])], }, @@ -670,10 +642,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }, } }, @@ -696,10 +664,6 @@ class TestChannelRTC(MailCommon): "type": "partner", }, }, - 'isCameraOn': False, - 'isDeaf': False, - 'isSelfMuted': False, - 'isScreenSharingOn': False, }, } },