[FIX] discuss: rely on streaming state flags instead of streams

Before this commit, we used to rely on duplicates of streams and the
presence of streams to know if a session was streaming.

This commit changes this and make it so that we now use streaming
activity flags instead.

This is a prerequisite for https://github.com/odoo/odoo/pull/132153 as,
similarly to webRTC "unified plan", the SFU keeps the same streams for
the whole lifespan of a connection.

closes odoo/odoo#140053

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
TSO
2023-11-07 04:29:04 +00:00
committed by Alexandre Kühn
parent f09d659a26
commit bc16aae6e2
10 changed files with 160 additions and 133 deletions
@@ -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()
@@ -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
@@ -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() {
@@ -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),
};
}
@@ -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;
@@ -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;
},
};
@@ -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;
@@ -718,7 +718,8 @@ patch(MockServer.prototype, {
"ADD",
(channel.rtc_session_ids || []).map((rtcSessionId) =>
this._mockDiscussChannelRtcSession_DiscussChannelRtcSessionFormat(
rtcSessionId
rtcSessionId,
{ extra: true }
)
),
],
@@ -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;
-36
View File
@@ -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,
},
}
},