diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index ebde3fd3b0f..7570b2c8d7b 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -117,6 +117,7 @@ For more specific needs, you may also assign custom-defined actions 'mail.assets_messaging': [ 'mail/static/src/model/*.js', 'mail/static/src/models/*.js', + 'mail/static/lib/selfie_segmentation/selfie_segmentation.js', ], # Custom bundle in case we want to remove things that are later added to web.assets_common 'mail.assets_common_discuss_public': [ diff --git a/addons/mail/static/lib/selfie_segmentation/selfie_segmentation.js b/addons/mail/static/lib/selfie_segmentation/selfie_segmentation.js new file mode 100644 index 00000000000..d0126cc9d6a --- /dev/null +++ b/addons/mail/static/lib/selfie_segmentation/selfie_segmentation.js @@ -0,0 +1,75 @@ +(function(){/* + + Copyright The Closure Library Authors. + SPDX-License-Identifier: Apache-2.0 +*/ +'use strict';var D;function aa(a){var b=0;return function(){return b>>0)+"_",f=0;return b}); +J("Symbol.iterator",function(a){if(a)return a;a=Symbol("Symbol.iterator");for(var b="Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array".split(" "),c=0;cc&&(c=Math.max(c+f,0));ch)d[c++]=h;else{if(2048>h)d[c++]=h>>6|192;else{if(55296<=h&&57343>=h){if(56319>=h&&f=e){h=1024*(h-55296)+e-56320+65536;d[c++]=h>>18|240;d[c++]=h>>12&63|128;d[c++]=h>>6&63|128;d[c++]=h&63|128;continue}else f--}if(b)throw Error("Found an unpaired surrogate");h=65533}d[c++]= +h>>12|224;d[c++]=h>>6&63|128}d[c++]=h&63|128}}a=d.subarray(0,c)}return a};var Fa={},Ga=null;function Ha(a,b){void 0===b&&(b=0);Ja();b=Fa[b];for(var c=Array(Math.floor(a.length/3)),d=b[64]||"",f=0,h=0;f>2];e=b[(e&3)<<4|g>>4];g=b[(g&15)<<2|k>>6];k=b[k&63];c[h++]=l+e+g+k}l=0;k=d;switch(a.length-f){case 2:l=a[f+1],k=b[(l&15)<<2]||d;case 1:a=a[f],c[h]=b[a>>2]+b[(a&3)<<4|l>>4]+k+d}return c.join("")} +function Ka(a){var b=a.length,c=3*b/4;c%3?c=Math.floor(c):-1!="=.".indexOf(a[b-1])&&(c=-1!="=.".indexOf(a[b-2])?c-2:c-1);var d=new Uint8Array(c),f=0;La(a,function(h){d[f++]=h});return d.subarray(0,f)} +function La(a,b){function c(k){for(;d>4);64!=e&&(b(h<<4&240|e>>2),64!=g&&b(e<<6&192|g))}} +function Ja(){if(!Ga){Ga={};for(var a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(""),b=["+/=","+/","-_=","-_.","-_"],c=0;5>c;c++){var d=a.concat(b[c].split(""));Fa[c]=d;for(var f=0;fc)return a.g+=1,d;c=b[a.g+1];d|=(c&127)<<7;if(128>c)return a.g+=2,d;c=b[a.g+2];d|=(c&127)<<14;if(128>c)return a.g+=3,d;c=b[a.g+3];d|=(c&127)<<21;if(128>c)return a.g+=4,d;c=b[a.g+4];d|=(c&15)<<28;if(128>c)return a.g+=5,d>>>0;a.g+=5;128<=b[a.g++]&&128<=b[a.g++]&&128<=b[a.g++]&&128<=b[a.g++]&&a.g++;return d} +function R(a){var b=a.h[a.g];var c=a.h[a.g+1];var d=a.h[a.g+2],f=a.h[a.g+3];a.g+=4;c=(b<<0|c<<8|d<<16|f<<24)>>>0;a=2*(c>>31)+1;b=c>>>23&255;c&=8388607;return 255==b?c?NaN:Infinity*a:0==b?a*Math.pow(2,-149)*c:a*Math.pow(2,b-150)*(c+Math.pow(2,23))}var Sa=[];function Ta(){this.g=new Uint8Array(64);this.h=0}Ta.prototype.push=function(a){if(!(this.h+1>>=7;a.push(b)};function Ua(a){var b={},c=void 0===b.N?!1:b.N;this.o={v:void 0===b.v?!1:b.v};this.N=c;b=this.o;Sa.length?(c=Sa.pop(),b&&(c.v=b.v),a&&Qa(c,a),a=c):a=new Pa(a,b);this.g=a;this.m=this.g.g;this.h=this.i=this.l=-1;this.j=!1}Ua.prototype.reset=function(){this.g.reset();this.h=this.l=-1};function Va(a){var b=a.g;(b=b.g==b.i)||(b=a.j)||(b=a.g,b=b.l||0>b.g||b.g>b.i);if(b)return!1;a.m=a.g.g;b=Ra(a.g);var c=b&7;if(0!=c&&5!=c&&1!=c&&2!=c&&3!=c&&4!=c)return a.j=!0,!1;a.i=b;a.l=b>>>3;a.h=c;return!0} +function Wa(a){switch(a.h){case 0:if(0!=a.h)Wa(a);else{for(a=a.g;a.h[a.g]&128;)a.g++;a.g++}break;case 1:1!=a.h?Wa(a):(a=a.g,a.g+=8);break;case 2:if(2!=a.h)Wa(a);else{var b=Ra(a.g);a=a.g;a.g+=b}break;case 5:5!=a.h?Wa(a):(a=a.g,a.g+=4);break;case 3:b=a.l;do{if(!Va(a)){a.j=!0;break}if(4==a.h){a.l!=b&&(a.j=!0);break}Wa(a)}while(1);break;default:a.j=!0}}function Xa(a,b,c){var d=a.g.i,f=Ra(a.g);f=a.g.g+f;a.g.i=f;c(b,a);a.g.g=f;a.g.i=d;return b} +function Ya(a){var b=Ra(a.g);a=a.g;var c=a.g;a.g+=b;a=a.h;var d;if(Ba)(d=Aa)||(d=Aa=new TextDecoder("utf-8",{fatal:!1})),d=d.decode(a.subarray(c,c+b));else{b=c+b;for(var f=[],h=null,e,g,k;ce?f.push(e):224>e?c>=b?f.push(65533):(g=a[c++],194>e||128!==(g&192)?(c--,f.push(65533)):f.push((e&31)<<6|g&63)):240>e?c>=b-1?f.push(65533):(g=a[c++],128!==(g&192)||224===e&&160>g||237===e&&160<=g||128!==((d=a[c++])&192)?(c--,f.push(65533)):f.push((e&15)<<12|(g&63)<<6|d&63)):244>=e?c>=b-2?f.push(65533): +(g=a[c++],128!==(g&192)||0!==(e<<28)+(g-144)>>30||128!==((d=a[c++])&192)||128!==((k=a[c++])&192)?(c--,f.push(65533)):(e=(e&7)<<18|(g&63)<<12|(d&63)<<6|k&63,e-=65536,f.push((e>>10&1023)+55296,(e&1023)+56320))):f.push(65533),8192<=f.length&&(h=za(h,f),f.length=0);d=za(h,f)}return d};function Za(){this.h=[];this.i=0;this.g=new Ta}function $a(a,b){0!==b.length&&(a.h.push(b),a.i+=b.length)}function ab(a){var b=a.i+a.g.length();if(0===b)return new Uint8Array(0);b=new Uint8Array(b);for(var c=a.h,d=c.length,f=0,h=0;hd?1:0)?-d:d;0===d?0<1/d?P=Q=0:(Q=0,P=2147483648):isNaN(d)?(Q=0,P=2147483647):3.4028234663852886E38>>0):1.1754943508222875E-38>d?(d=Math.round(d/Math.pow(2,-149)),Q=0,P=(c<<31|d)>>>0):(b=Math.floor(Math.log(d)/Math.LN2),d*=Math.pow(2,-b),d=Math.round(8388608*d)&8388607,Q=0,P=(c<<31|b+127<<23|d)>>>0);c=P;a.push(c>>>0&255);a.push(c>>>8&255);a.push(c>>>16&255);a.push(c>>>24&255)}};var bb="function"===typeof Uint8Array;function cb(a,b,c){if(null!=a)return"object"===typeof a?bb&&a instanceof Uint8Array?c(a):db(a,b,c):b(a)}function db(a,b,c){if(Array.isArray(a)){for(var d=Array(a.length),f=0;f=a.l?a.h?a.h[b]:void 0:a.g[b+a.j]}function kb(a){var b=void 0===b?!1:b;var c=V(a,1,b);null==c&&(c=ib);c===ib&&(c=eb([]),W(a,1,c,b));return c}function X(a,b,c){a=V(a,b);a=null==a?a:+a;return null==a?void 0===c?0:c:a}function W(a,b,c,d){(void 0===d?0:d)||b>=a.l?(jb(a),a.h[b]=c):a.g[b+a.j]=c} +function lb(a,b){a.i||(a.i={});var c=a.i[1];if(!c){var d=kb(a);c=[];for(var f=0;fB;B=Math.abs(B);var n=B>>>0;B=Math.floor((B-n)/4294967296);B>>>=0;C&&(B=~B>>> +0,n=(~n>>>0)+1,4294967295>>7|n<<25)>>>0,n>>>=7;p.push(C)}qb(r,A);I=ab(I);F.call(E,K,I);if(z.O)for(E=0;EB;B++)C.push(n&127|128),n>>=7;C.push(1)}T(p,2,V(A,2));C=V(A,3);null!=C&&(C=Ea(C),S(p.g,26),S(p.g,C.length),$a(p,p.g.end()),$a(p,C));C=V(A,4);null!=C&&(C=Ea(C),S(p.g,34),S(p.g,C.length),$a(p,p.g.end()),$a(p, +C));qb(A,p);r=ab(r);K.call(F,I,r)}}m=m.data;break a;default:m={}}}v=m;w=q.stream;switch(q.type){case "video":e.pushTexture2d(Object.assign(Object.assign({},v),{stream:w,timestamp:h}));break;case "detections":t=v;t.stream=w;t.timestamp=h;e.pushDetectionList(t);break;default:throw Error("Unknown input config type: '"+q.type+"'");}}f.i.send(e);return N(y,f.C,4);case 4:e.delete(),y.g=0}})})}; +function Ub(a,b,c){return Y(a,function f(){var h,e,g,k,l,q,v=this,w,t,y,m,u,x,z,E;return O(f,function(F){switch(F.g){case 1:if(!c)return F.return(b);h={};e=0;g=M(Object.keys(c));for(k=g.next();!k.done;k=g.next())l=k.value,q=c[l],"string"!==typeof q&&"texture"===q.type&&void 0!==b[q.stream]&&++e;1Z&&128<=L;Z++)L=G.h[G.g++],Ia|=(L&127)<<7*Z;128<=L&&(L=G.h[G.g++],Ia|=(L&127)<<28,n|=(L&127)>>4);if(128<=L)for(Z=0;5>Z&&128<=L;Z++)L= +G.h[G.g++],n|=(L&127)<<7*Z+3;if(128>L){G=Ia>>>0;L=n>>>0;if(n=L&2147483648)G=~G+1>>>0,L=~L>>>0,0==G&&(L=L+1>>>0);G=4294967296*L+(G>>>0);n=n?-G:G}else G.l=!0,n=void 0;W(B,6,n);break;default:if(!rb(B,p))break a}}B={Z:X(B,1),$:X(B,2),height:X(B,3),width:X(B,4),rotation:X(B,5,0),X:ob(B,6)};n=I.get(C);a:for(p=new yb,n=new Ua(n);Va(n);)switch(n.i){case 10:G=Xa(n,new wb,xb);mb(p,G,wb,void 0);break;default:if(!rb(p,n))break a}p=lb(p,wb).map(Db);G=r.get(C);a:for(n=new ub,G=new Ua(G);Va(G);)switch(G.i){case 10:n.addClassification(Xa(G, +new sb,tb));break;default:if(!rb(n,G))break a}B={T:B,O:p,M:Cb(n)};A.push(B)}K=A}else K=[];h[t]=K;F.g=7;break}if("proto_list"===y.type){if(m){K=Array(m.size());for(I=0;IShow video participants only + +
+ +
+
+ +
+ +
+
+ +
+
diff --git a/addons/mail/static/src/models/blur_manager.js b/addons/mail/static/src/models/blur_manager.js new file mode 100644 index 00000000000..15ca4974875 --- /dev/null +++ b/addons/mail/static/src/models/blur_manager.js @@ -0,0 +1,254 @@ +/** @odoo-module **/ + +import { registerModel } from '@mail/model/model_core'; +import { clear } from '@mail/model/model_field_command'; +import { attr, one } from '@mail/model/model_field'; + +function drawAndBlurImageOnCanvas(image, blurAmount, canvas) { + canvas.width = image.width; + canvas.height = image.height; + if (blurAmount === 0) { + canvas.getContext('2d').drawImage(image, 0, 0, image.width, image.height); + return; + } + canvas.getContext('2d').clearRect(0, 0, image.width, image.height); + canvas.getContext('2d').save(); + // FIXME : Does not work on safari https://bugs.webkit.org/show_bug.cgi?id=198416 + canvas.getContext('2d').filter = `blur(${blurAmount}px)`; + canvas.getContext('2d').drawImage(image, 0, 0, image.width, image.height); + canvas.getContext('2d').restore(); +} + +registerModel({ + name: 'BlurManager', + lifecycleHooks: { + _willDelete() { + this.video.removeEventListener('loadeddata', this._onVideoPlay); + this.selfieSegmentation.reset(); + this.video.srcObject = null; + if (this.rejectStreamPromise) { + this.rejectStreamPromise(new Error(this.env._t('The blur manager was removed before the beginning of the blur process'))); + } + }, + }, + recordMethods: { + onRequestFrameTimerTimeout() { + this._requestFrame(); + }, + /** + * @private + */ + _drawWithCompositing(image, compositeOperation) { + this.canvas.getContext('2d').globalCompositeOperation = compositeOperation; + this.canvas.getContext('2d').drawImage(image, 0, 0); + }, + /** + * @private + */ + _onChangeBackgroundBlurAmountSetting() { + this.selfieSegmentation.setOptions({ + backgroundBlur: this.userSetting.backgroundBlurAmount, + }); + }, + /** + * @private + */ + _onChangeEdgeBlurAmountSetting() { + this.selfieSegmentation.setOptions({ + edgeBlurAmount: this.userSetting.edgeBlurAmount, + }); + }, + /** + * @private + */ + async _onChangeSrcStream() { + this.video.srcObject = null; + this.selfieSegmentation.reset(); + if (this.rejectStreamPromise) { + this.rejectStreamPromise(new Error(this.env._t('The source stream was removed before the beginning of the blur process'))); + } + if (!this.srcStream) { + return; + } + let rejectStreamPromise; + let resolveStreamPromise; + this.update({ + isVideoDataLoaded: false, + stream: new Promise((resolve, reject) => { + rejectStreamPromise = reject; + resolveStreamPromise = resolve; + }), + rejectStreamPromise, + resolveStreamPromise, + }); + this.video.srcObject = this.srcStream.webMediaStream; + this.video.load(); + this.selfieSegmentation.setOptions({ + selfieMode: false, + backgroundBlur: this.userSetting.backgroundBlurAmount, + edgeBlur: this.userSetting.edgeBlurAmount, + modelSelection: 1, + }); + this.selfieSegmentation.onResults(this._onSelfieSegmentationResults); + this.video.addEventListener('loadeddata', this._onVideoPlay); + this.video.autoplay = true; + Promise.resolve(this.video.play()).catch(()=>{}); + }, + /** + * @private + */ + async _onFrame() { + if (!this.selfieSegmentation) { + return; + } + if (!this.video) { + return; + } + if (!this.srcStream) { + return; + } + if (!this.isVideoDataLoaded) { + return; + } + await this.selfieSegmentation.send({ image: this.video }); + this.update({ frameRequestTimer: { doReset: true } }); + }, + /** + * @private + */ + _onSelfieSegmentationResults(results) { + if (!this.exists()) { + return; + } + drawAndBlurImageOnCanvas( + results.image, + this.userSetting.backgroundBlurAmount, + this.canvasBlur, + ); + this.canvas.width = this.canvasBlur.width; + this.canvas.height = this.canvasBlur.height; + drawAndBlurImageOnCanvas( + results.segmentationMask, + this.userSetting.edgeBlurAmount, + this.canvasMask, + ); + this.canvas.getContext('2d').save(); + this.canvas.getContext('2d').drawImage( + results.image, + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + this._drawWithCompositing( + this.canvasMask, + 'destination-in', + ); + this._drawWithCompositing( + this.canvasBlur, + 'destination-over', + ); + this.canvas.getContext('2d').restore(); + }, + /** + * @private + */ + _onVideoPlay() { + this.update({ + isVideoDataLoaded: true, + }); + this._requestFrame(); + }, + /** + * @private + */ + _requestFrame() { + window.requestAnimationFrame(async () => { + if (!this.exists()) { + return; + } + await this._onFrame(); + this.resolveStreamPromise(this.canvasStream); + }); + }, + }, + fields: { + canvas: attr({ + default: document.createElement('canvas'), + }), + canvasBlur: attr({ + default: document.createElement('canvas'), + }), + canvasMask: attr({ + default: document.createElement('canvas'), + }), + canvasStream: one('MediaStream', { + compute() { + if (this.srcStream) { + this.canvas.getContext('2d'); // canvas.captureStream() doesn't work on firefox before getContext() is called. + const webMediaStream = this.canvas.captureStream(); + return { webMediaStream, id: webMediaStream.id }; + } + return clear(); + }, + isCausal: true, + }), + frameRequestTimer: one('Timer', { + inverse: 'blurManagerOwnerAsFrameRequest', + isCausal: true, + }), + isVideoDataLoaded: attr({ + default: false, + }), + /** + * promise reject function of this.stream promise + */ + rejectStreamPromise: attr(), + /** + * promise resolve function of this.stream promise + */ + resolveStreamPromise: attr(), + rtc: one('Rtc', { + identifying: true, + inverse: 'blurManager', + }), + selfieSegmentation: attr({ + default: new window.SelfieSegmentation({ + locateFile: (file) => { + return `https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation@0.1/${file}`; + }, + }), + }), + /** + * mail.MediaStream, source stream for which the blur effect is computed. + */ + srcStream: one('MediaStream', { + isCausal: true, + }), + /** + * Promise or undefined, based on this.srcStream, resolved when selfieSegmentation has started painting on the canvas, + * resolves into a web.MediaStream that is the blurred version of this.srcStream. + */ + stream: attr(), + userSetting: one('UserSetting', { + related: 'messaging.userSetting', + }), + video: attr({ + default: document.createElement('video'), + }), + }, + onChanges: [ + { + dependencies: ['userSetting.edgeBlurAmount'], + methodName: '_onChangeEdgeBlurAmountSetting', + }, + { + dependencies: ['userSetting.backgroundBlurAmount'], + methodName: '_onChangeBackgroundBlurAmountSetting', + }, + { + dependencies: ['srcStream'], + methodName: '_onChangeSrcStream', + }, + ], +}); diff --git a/addons/mail/static/src/models/call_settings_menu.js b/addons/mail/static/src/models/call_settings_menu.js index 0adca6d1761..713115818da 100644 --- a/addons/mail/static/src/models/call_settings_menu.js +++ b/addons/mail/static/src/models/call_settings_menu.js @@ -20,12 +20,27 @@ registerModel({ }, }, recordMethods: { + onChangeBackgroundBlurAmount(ev) { + this.userSetting.update({ + backgroundBlurAmount: Number(ev.target.value), + }); + }, + onChangeBlur(ev) { + this.userSetting.update({ + useBlur: !this.userSetting.useBlur, + }); + }, /** * @param {Event} ev */ onChangeDelay(ev) { this.userSetting.setDelayValue(ev.target.value); }, + onChangeEdgeBlurAmount(ev) { + this.userSetting.update({ + edgeBlurAmount: Number(ev.target.value), + }); + }, onChangePushToTalk() { if (this.userSetting.usePushToTalk) { this.userSetting.update({ diff --git a/addons/mail/static/src/models/device.js b/addons/mail/static/src/models/device.js index 4338e65efb3..a73c8853807 100644 --- a/addons/mail/static/src/models/device.js +++ b/addons/mail/static/src/models/device.js @@ -47,6 +47,9 @@ registerModel({ fields: { globalWindowInnerHeight: attr(), globalWindowInnerWidth: attr(), + hasCanvasFilterSupport: attr({ + default: typeof document.createElement('canvas').getContext('2d').filter !== 'undefined', + }), hasRtcSupport: attr({ compute: '_computeHasRtcSupport', }), diff --git a/addons/mail/static/src/models/media_stream.js b/addons/mail/static/src/models/media_stream.js new file mode 100644 index 00000000000..e87f4e10eea --- /dev/null +++ b/addons/mail/static/src/models/media_stream.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import { registerModel } from '@mail/model/model_core'; +import { attr } from '@mail/model/model_field'; + +registerModel({ + name: 'MediaStream', + lifecycleHooks: { + _willDelete() { + for (const track of this.webMediaStream.getTracks()) { + track.stop(); + } + }, + }, + fields: { + id: attr({ + identifying: true, + }), + webMediaStream: attr({ + required: true, + readonly: true, + }), + }, +}); diff --git a/addons/mail/static/src/models/rtc.js b/addons/mail/static/src/models/rtc.js index a79d04b0fdf..5f4ee870218 100644 --- a/addons/mail/static/src/models/rtc.js +++ b/addons/mail/static/src/models/rtc.js @@ -194,11 +194,13 @@ registerModel({ }); } this.update({ + blurManager: clear(), currentRtcSession: clear(), disconnectAudioMonitor: clear(), logs: clear(), sendUserVideo: clear(), sendDisplay: clear(), + sourceVideoStream: clear(), videoTrack: clear(), audioTrack: clear(), }); @@ -223,8 +225,8 @@ registerModel({ /** * Toggles user video (eg: webcam) broadcasting to peers. */ - async toggleUserVideo() { - this._toggleVideoBroadcast({ type: 'user-video' }); + async toggleUserVideo({ force } = {}) { + this._toggleVideoBroadcast({ type: 'user-video', force }); }, async undeafen() { await this._setDeafState(false); @@ -914,27 +916,42 @@ registerModel({ * @param {boolean} activateVideo true if we want to activate the video */ async _updateLocalVideoTrack(type, activateVideo) { - if (this.videoTrack) { - this.videoTrack.stop(); - } this.update({ sendDisplay: false, sendUserVideo: false, - videoTrack: clear(), }); - let videoStream; + const stopVideo = () => { + if (this.videoTrack) { + this.videoTrack.stop(); + } + this.update({ + sourceVideoStream: clear(), + videoTrack: clear(), + }); + }; if (!activateVideo) { + if (this.blurManager) { + this.blurManager.update({ + srcStream: clear(), + }); + } if (type === 'display') { this.messaging.soundEffects.screenSharing.play(); } + stopVideo(); return; } + let sourceWebMediaStream; try { if (type === 'user-video') { - videoStream = await browser.navigator.mediaDevices.getUserMedia({ video: this.videoConfig }); + if (this.blurManager && this.blurManager.srcStream) { + sourceWebMediaStream = this.blurManager.srcStream.webMediaStream; + } else { + sourceWebMediaStream = await browser.navigator.mediaDevices.getUserMedia({ video: this.videoConfig }); + } } if (type === 'display') { - videoStream = await browser.navigator.mediaDevices.getDisplayMedia({ video: this.videoConfig }); + sourceWebMediaStream = await browser.navigator.mediaDevices.getDisplayMedia({ video: this.videoConfig }); this.messaging.soundEffects.screenSharing.play(); } } catch (_e) { @@ -946,8 +963,28 @@ registerModel({ ), type: 'warning', }); + stopVideo(); return; } + let videoStream = sourceWebMediaStream; + if (this.messaging.userSetting.useBlur && type === 'user-video') { + try { + this.update({ blurManager: { srcStream: { webMediaStream: sourceWebMediaStream, id: sourceWebMediaStream.id } }, }); + const mediaStream = await this.blurManager.stream; + videoStream = mediaStream.webMediaStream; + } catch (_e) { + this.messaging.notify({ + message: sprintf( + this.env._t('To %(name)s: %(message)s)'), { + name: _e.name, + message: _e.message, + }, + ), + type: 'warning', + }); + this.messaging.userSetting.update({ useBlur: false }); + } + } const videoTrack = videoStream ? videoStream.getVideoTracks()[0] : undefined; if (videoTrack) { videoTrack.addEventListener('ended', async () => { @@ -955,6 +992,7 @@ registerModel({ }); } this.update({ + sourceVideoStream: { webMediaStream: sourceWebMediaStream, id: sourceWebMediaStream.id }, videoTrack, sendUserVideo: type === 'user-video' && !!videoTrack, sendDisplay: type === 'display' && !!videoTrack, @@ -1092,6 +1130,10 @@ registerModel({ * audio MediaStreamTrack of the current user */ audioTrack: attr(), + blurManager: one('BlurManager', { + inverse: 'rtc', + isCausal: true, + }), /** * The channel that is hosting the current RTC call. */ @@ -1230,6 +1272,12 @@ registerModel({ sendDisplay: attr({ default: false, }), + /** + * Ensures that we always have a single source stream and that replacing it will properly terminate its tracks. + */ + sourceVideoStream: one('MediaStream', { + isCausal: true, + }), /** * MediaTrackConstraints for the user video track. * Some browsers do not support all constraints, for example firefox diff --git a/addons/mail/static/src/models/timer.js b/addons/mail/static/src/models/timer.js index 5c60a1e6ce5..29e2e057e91 100644 --- a/addons/mail/static/src/models/timer.js +++ b/addons/mail/static/src/models/timer.js @@ -21,6 +21,9 @@ registerModel({ * @returns {integer|FieldCommand} */ _computeDuration() { + if (this.blurManagerOwnerAsFrameRequest) { + return Math.floor(1000 / 30); // 30 fps + } if (this.callMainViewAsShowOverlay) { return 3 * 1000; } @@ -55,6 +58,10 @@ registerModel({ */ _onTimeout() { this.update({ timeoutId: clear() }); + if (this.blurManagerOwnerAsFrameRequest) { + this.blurManagerOwnerAsFrameRequest.onRequestFrameTimerTimeout(); + return; + } if (this.callMainViewAsShowOverlay) { this.callMainViewAsShowOverlay.onShowOverlayTimeout(); return; @@ -104,6 +111,10 @@ registerModel({ }, }, fields: { + blurManagerOwnerAsFrameRequest: one('BlurManager', { + identifying: true, + inverse: 'frameRequestTimer', + }), callMainViewAsShowOverlay: one('CallMainView', { identifying: true, inverse: 'showOverlayTimer', diff --git a/addons/mail/static/src/models/user_setting.js b/addons/mail/static/src/models/user_setting.js index 704069f59a4..72202f286a8 100644 --- a/addons/mail/static/src/models/user_setting.js +++ b/addons/mail/static/src/models/user_setting.js @@ -211,6 +211,12 @@ registerModel({ audioInputDeviceId: audioInputDeviceId || undefined, }); }, + _onChangeUseBlur() { + if (!this.messaging.rtc.sendUserVideo) { + return; + } + this.messaging.rtc.toggleUserVideo({ force: true }); + }, /** * @private * @param {Event} ev @@ -291,6 +297,12 @@ registerModel({ audioInputDeviceId: attr({ default: '', }), + backgroundBlurAmount: attr({ + default: 10, + }), + edgeBlurAmount: attr({ + default: 10, + }), globalSettingsTimeout: attr(), /** * true if listening to keyboard input to register the push to talk key. @@ -308,6 +320,9 @@ registerModel({ compute: '_computePushToTalkKey', default: '', }), + useBlur: attr({ + default: false, + }), /** * If true, push-to-talk will be used over voice activation. */ @@ -333,4 +348,10 @@ registerModel({ default: {}, }), }, + onChanges: [ + { + dependencies: ['useBlur'], + methodName: '_onChangeUseBlur', + }, + ], });