From 2eac72adcae4f5156697a33ee36e9153a4b283e2 Mon Sep 17 00:00:00 2001 From: tsm-odoo Date: Fri, 17 Nov 2023 12:03:45 +0100 Subject: [PATCH] [IMP] mail: add ptt extension for chromium-based browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, discuss push-to-talk only works when the browser has focus because key events are not fired otherwise. This commit adds an extension for chromium-based browsers that allows to register a system-wide push-to-talk shortcut and adapts the rtc service in order to listen to the extension events. closes odoo/odoo#140752 Signed-off-by: Sébastien Theys (seb) --- addons/mail/push-to-talk-extension/README.md | 38 ++++++++ .../assets/icons/active_icon.png | Bin 0 -> 1796 bytes .../assets/icons/inactive_icon.png | Bin 0 -> 2035 bytes .../mail/push-to-talk-extension/background.js | 87 ++++++++++++++++++ addons/mail/push-to-talk-extension/content.js | 11 +++ .../mail/push-to-talk-extension/manifest.json | 39 ++++++++ addons/mail/push-to-talk-extension/utils.js | 12 +++ .../call/common/ptt_extension_service.js | 68 ++++++++++++++ .../src/discuss/call/common/rtc_service.js | 48 ++++++---- 9 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 addons/mail/push-to-talk-extension/README.md create mode 100644 addons/mail/push-to-talk-extension/assets/icons/active_icon.png create mode 100644 addons/mail/push-to-talk-extension/assets/icons/inactive_icon.png create mode 100644 addons/mail/push-to-talk-extension/background.js create mode 100644 addons/mail/push-to-talk-extension/content.js create mode 100644 addons/mail/push-to-talk-extension/manifest.json create mode 100644 addons/mail/push-to-talk-extension/utils.js create mode 100644 addons/mail/static/src/discuss/call/common/ptt_extension_service.js diff --git a/addons/mail/push-to-talk-extension/README.md b/addons/mail/push-to-talk-extension/README.md new file mode 100644 index 00000000000..0aaa8986b82 --- /dev/null +++ b/addons/mail/push-to-talk-extension/README.md @@ -0,0 +1,38 @@ +# Discuss Push-to-Talk Extension + +This extension enhances your communication experience by providing system-wide +Push-to-Talk functionality. Say goodbye to tab-specific limitations, as this +extension allows you to effortlessly utilize Push-to-Talk across your entire +system. Whether Chrome is in focus or not, on the same tab or not, enjoy +seamless communication in Odoo Discuss without any constraints. + +> Note that this extension only works for Odoo 17 and higher + +## Installing the extension + +- Go to the chrome extension panel: `chrome://extensions/`. +- Click on `Load unpacked`. +- Select the `push-to-talk-extension` folder. +- Your extension is active! + +## Extension State + +You can check whether your voice transmission is activated at anytime by looking +at the extension icon. +- active_icon Indicates the voice transmission is active. +- inactive_icon + Indicates the voice transmission is inactive. + +## Prerequisites + +- Before installing the Discuss Push-to-Talk Extension, ensure that the `Repeat + key` feature is enabled on your system. +- Command scope must be set to `global`. You can click the extension icon to get + to `Keyboard shortcuts` settings or access `chrome://extensions/shortcuts` + directly. +- Push-to-Talk should be enabled on Odoo Discuss voice settings. + +## Commands + +- `Push-to-Talk`: Voice is activated as long as the key is held. +- `Voice Toggle`: Switch your voice transmission on or off. diff --git a/addons/mail/push-to-talk-extension/assets/icons/active_icon.png b/addons/mail/push-to-talk-extension/assets/icons/active_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..058bbe7d5bc0e5e34f2902033bf8f4e88b12f8a4 GIT binary patch literal 1796 zcmV+f2mAPmP)rFHqXcc3KiTggYp2;PL>FCxFlgDD<+V zotO@tPN#*K1mZs$>vWIefE$u%C9R~DcYiZ+Jd@+t(&z7-vny!@V6j*%7K_DVu~;nm zf=~i_^9^rQb(;v}RRG=%06p=^z44#D$yfsDijC@uCmlrSj);DN03VVw>Kyu}`w%oo zOF?M9(df-M7aYQ703d{b1}K2VasZaa?WKmiQoNR5>9LEX-iEP_!_G3Bp2D9QSf=Zjhy5VR9Fwcdg+OxHpjC zEa*%@!sIGQZ*lH20bVLJHN`{Je_E)2{cHhpA{Rl}6-bpk1auP0$*L&)r}J=&0~yo5 zHunXQ`=G>PFvp?h{=B(3cL_2kQ$hNRb2o_ahZ3cz{Wyw%*9R9*T!swEBxUiKqF;6I zXxBk009THQN4hsd{yA)Sx}X^44&iz+s8#MwqZqHjQ$wuY?X^4)6r)rS76Zt=SMqux zh3FPPLaZqlMVTO+rnr-W9g}@ih5yFxI__ZgHnluuHBy-%Tr*5?#!lEN10f11LWv-Q z#kcgE0?7j3-Qy2!5(tg77#a5D5!RhwxIr^+D=!N~*RuoR<=Vz9|`JM6*5v z`1hgxBG!n8AJ^NR4kRR3gv$zieaRhiJlsVk8~0&?fUL_Tf1iu<3L*+l z*W14>$9Yj~hZyUQeg$HJg6|+Eu^eKoJD9auz<&=yOk%qvmP3qnhX9AM9TL9GaqxVs z7sv)0?G6F{aYPjLM53(C(C|IkV<*xM83k?=OohiE$;4n91DI*a21^Sf8omo)D#OHL zFrqyRK9diY!1#2<^H*XXD+MId)|^b*l5&XA>57pXo$rfm+L98af^Y^*g-7Rpx;a<~ zGZB3v6Dajg;&?|BOl32@J3QS3W0Ho<2@#Qk_<`$zsibRn zccI#hiil2Gj(I08yrMe`3K4Y#y{Mo_L0rTS!BFs}vUC;V0KF3(69JJyhiE6^VxGRc z`X3~5gf;pd(Xhj`V-yziiGPY-R7kW#fXs6UJ7YZqK_rM2#Ke{tpB;dV@S*}TzhI|y zusdET8Vn}D3bF%S7p-nk+-|XgOv3KiD5iF7V+ENYbcdhmE>Sc6ZUijEvpQsq6-1T{ zJFC|ab)rJ7AWUxK4tyRJVFeN3pGP1M#MDXTdR@i~f@_%<@@>dLw1Vsl3-LVjLjDS* z;Bi!p(SqPV2lH|V)2+2vtV8x9OjR#ykQ)T|Wx~u9gk2H;#OXAM`T=)j0?iYIuk*;M ztD0m)!=Gg0j1vT(*7K|zTM#YFUfyPuAox^_#=H4-Rb#l_ZOaCmBgkeYkGZN{kxf^O z%9D=tMWiVF>o61n618VzT#Y;Z4q-94hzEtNh)V(ZSK_>2x*f7DhF{G0F0MP6>2(OV zx16T@!ZtCsJDBNo2#fLLNSq7(m6OCB%=8JeUBC+z0}{G}nJz&Jt{G#92o{nq(KJ0Z zL=Q2T)q49;;-$%_!y#hO7pAFVTH8r@6`)Tc1*I1xoJP-g=5{X#2p7_^D1Rvg(ajYMJA=hYTf}#ww;87+A_YOhhf3}Z!s7@o()zlA2|Fn9 za3$^n$f}S@SwD)OCL}=|B*GW9Ehp&g2EGS7(NX|(t0*D1ky~@|>an{b@>FCXfAfU^ z$V(dWiOjmpHN6RKJ<_r`B(yXGgIigQDUu*n)cgME+-%joJx*O)BJ$4X9lluw9E!uG zy%Gq(7hUo_)B%@u5@C{fOO1c~b_+KSoEJs;B>1kLOKuQ!#!ot8<2nK0V}35aE%);- m&=!luVzF2(7K_DF2L1zr9dax0=c%Xw0000Px+tw}^dRCr$Po!xTNMij@-QR)B6H9#TjOB zxM+Jvc>*##K$E^e6P^J20!@0`+Et)UVmXmc=hzN$4EC;8D`_K1Gnpjg-PP*+^grk9 zYPCkl4x=o9M)fA^3}iqIy9qKN84$xPU|4rDg=9bsvw&gU$rO?S@o#}*-JTh1yYmD% za|rOcMKl8d{KLo5@6JC6utNYl0I697tV3b!MXNr!t)pF?ZI}b32Z1cQ2>|mtFLFd%n*gwC5}eq9 zoPB7|d#<}k0AJ}+f)Y%ERVa?F#I2gxfG~(d*L@VB8ss>|3?_0Q#Rv8blKX@#VAo{s z5fhk5ffOG!A0g7+SY4C9TY!LYrS;SFioa^=r6U2tsvtaEL%{3mU5&FQZ9(CKg(%`n z=s*hJH@~wis-GfN3?pwN;N$%tr+*If)(90yq1JqYKzGBufQZ{B?~CS2d#N0n3kwHG z-7Xa!cg?sAN&`K*+T>O?9L(Q_%4MDr(0XVGa{A-NX#4hn00F+VFn7CExvX`xuN4Tx z0CYD7=a;nJLNPaX1fpf=(#6`~NW1lFk=p8y0sAq9B7RVNXfOO`i(0!SJL7yD6a}LnQ{|zmR zfHq+6Mvx#M3`ni9mE?@-9m`Me15NFXgx3l3)wvsTgx@Wt3?wyiqu*wq>;iq*651LO zR$7&^QcP6>(i?BEI#PAe#Ww)s2f}L(;<}W8Ow<~y9>5)8&k|OEuGj#UCv{asu-0BG z-&U&hi@QhRR@etd50O?_khtCkkqP!%{|!y z`e;}BPVW`;trA~AQmYPt?)c-J;}-}J;j{MATvfKBG>}xQgMnOn4Ny3gwd>n*oN=`< zH!C~&C=KMya%0gYc;>rDNvJSpWd`+oH14+ef?lAM7X#;)suZ-~n2X`qTsdrb3 z@uQCpIhdc4aa&SAQthrde(*!7oZNtLh(x5_mK2aet+9q+_)5bd4rNr`f(vv>!&(M} zb*ECkCF-UWknvj6CLk@Z1c*9h+~;cJk9RJklWkS!rbO2#1d>~}uaA-YQq)jVKUYn6 z-(wF#%+HFRl?Z`MJZ#+bT;MfU={tg@yMy*3M69+dbE~5aDF(9Ktb0WK+Cch_^Ls$0 zeQnn*^PEHoB+UU`Lw0onCRzBby*PRrMF=Fd146agU6GoEv%fMx()HZDXHwP4#F0c0 zwwy|NHc$*nWy-5Jm>=6;2asZ|K_VR{?ekF7(KQk|)v^^xA*lnx>fot%$!F(2Z^jIW zPlEwX?*UBdOLYg%p8@d&gw=83bi6KtK8$gYE*OD%9nr9$ZZ9)QN@9qbD^2?Qlzf*J?ch&2 zn5QF##0vxbsw3Ghi__bS{2H1YwU;_NpRd@FY=y)RLrERUR0zANPv69;47RJ1I+6z@ z=9tlQJJefLt+bVFa92V+dzb=XaI1y9i>iVe?f%;>PJ8shbloj%+DCn=85*PF)P?xG3KD zFy?sicJvqZM#{#-5LN~6s35V$p6D*r_IVszjhg_(G*xs)esOKaA1e?}XW2cOqzYyM zuo>IeRm1|stPHB(!x11pbt-fBw7I>3hRh1;41$9;Kl3&uXdlnQqD| zU=SQGdhz0G(Y6>OW6OLVFA%dqbY8Flq^F_<(lUmYEh7XH%4^YP2J4ft0$?36e?JOU z_#LV1`JxscNARwm2F8azgLE<|MC-BN4UlCOASx$RNO8Hb2&$W3^5$P>+ugL)tkm^{ z8W3>DvtZpKd>$8wGDjo*OaOJPyYjLx=!cso^IRUYkfO=0&2l)(RPf<@~N z6`srNhv?@7s%3==;f70fyVPpCzW{ZOHw{x5-EkKG@R8@vp@z! z&2FL9%7BDsTD68VAZm6CtyTskG}EdzoB>g@TWGa1AfcI7t>Fxan%zRH^*?&}a+0-0 RJ#hd4002ovPDHLkV1ixozcK&- literal 0 HcmV?d00001 diff --git a/addons/mail/push-to-talk-extension/background.js b/addons/mail/push-to-talk-extension/background.js new file mode 100644 index 00000000000..26c78dbf403 --- /dev/null +++ b/addons/mail/push-to-talk-extension/background.js @@ -0,0 +1,87 @@ +/* global chrome */ + +import { throttle } from "./utils.js"; + +const ACTIVE_APP_ICON = "/assets/icons/active_icon.png"; +const INACTIVE_APP_ICON = "/assets/icons/inactive_icon.png"; + +// ServiceWorker can be terminated at any time, storage session is +// used to ensure local state is not lost. +chrome.storage.session.set({ isTalkingByTabId: {} }); + +chrome.tabs.onRemoved.addListener(async (tabId) => { + const { isTalkingByTabId } = await chrome.storage.session.get(); + delete isTalkingByTabId[tabId]; + await chrome.storage.session.set({ isTalkingByTabId }); + await updateAppIcon(); +}); + +chrome.action.onClicked.addListener(function () { + chrome.tabs.create({ url: "chrome://extensions/shortcuts" }); +}); + +async function updateAppIcon() { + const { isTalkingByTabId } = await chrome.storage.session.get(); + const isTalking = Object.values(isTalkingByTabId).some(Boolean); + chrome.action.setIcon({ path: isTalking ? ACTIVE_APP_ICON : INACTIVE_APP_ICON }); +} + +chrome.runtime.onMessage.addListener(async function (request, sender) { + const { from, type, value } = request; + if (from !== "discuss") { + return; + } + switch (type) { + case "subscribe": + { + const { isTalkingByTabId } = await chrome.storage.session.get(); + isTalkingByTabId[sender.tab.id] = false; + await chrome.storage.session.set({ isTalkingByTabId }); + } + break; + case "unsubscribe": + { + const { isTalkingByTabId } = await chrome.storage.session.get(); + delete isTalkingByTabId[sender.tab.id]; + await chrome.storage.session.set({ isTalkingByTabId }); + await updateAppIcon(); + } + break; + case "is-talking": + { + const { isTalkingByTabId } = await chrome.storage.session.get(); + isTalkingByTabId[sender.tab.id] = value; + await chrome.storage.session.set({ isTalkingByTabId }); + await updateAppIcon(); + } + break; + case "ask-is-enabled": + chrome.tabs.sendMessage(sender.tab.id, { + from: "discuss-push-to-talk", + type: "answer-is-enabled", + }); + break; + } +}); + +async function onCommand(command) { + const { isTalkingByTabId } = await chrome.storage.session.get(); + for (const tabId of Object.keys(isTalkingByTabId)) { + switch (command) { + case "toggle-voice": + chrome.tabs.sendMessage(Number(tabId), { + from: "discuss-push-to-talk", + type: "toggle-voice", + }); + break; + case "ptt-pressed": + chrome.tabs.sendMessage(Number(tabId), { + from: "discuss-push-to-talk", + type: "push-to-talk-pressed", + }); + break; + } + } +} + +chrome.commands.onCommand.addListener(throttle(onCommand, 150)); diff --git a/addons/mail/push-to-talk-extension/content.js b/addons/mail/push-to-talk-extension/content.js new file mode 100644 index 00000000000..3b51129209c --- /dev/null +++ b/addons/mail/push-to-talk-extension/content.js @@ -0,0 +1,11 @@ +/* global chrome */ + +window.addEventListener("message", function ({ data }) { + if (data.from === "discuss") { + chrome.runtime.sendMessage(data); + } +}); + +chrome.runtime.onMessage.addListener(function (request) { + window.postMessage(request); +}); diff --git a/addons/mail/push-to-talk-extension/manifest.json b/addons/mail/push-to-talk-extension/manifest.json new file mode 100644 index 00000000000..1c6038c777e --- /dev/null +++ b/addons/mail/push-to-talk-extension/manifest.json @@ -0,0 +1,39 @@ +{ + "manifest_version": 3, + "name": "Discuss Push-to-talk", + "description": "Empowers Odoo Discuss with system-wide Push-to-Talk", + "version": "1.0.0.0", + "permissions": [ + "storage" + ], + "action": {}, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "icons": { + "128": "assets/icons/inactive_icon.png" + }, + "content_scripts": [ + { + "matches": [ + "https://*/*", + "http://*/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_start" + } + ], + "commands": { + "ptt-pressed": { + "suggested_key": "Alt+Shift+Y", + "description": "Push-to-Talk" + }, + "toggle-voice": { + "suggested_key": "Alt+Shift+U", + "description": "Voice Toggle" + } + } +} diff --git a/addons/mail/push-to-talk-extension/utils.js b/addons/mail/push-to-talk-extension/utils.js new file mode 100644 index 00000000000..4810432d031 --- /dev/null +++ b/addons/mail/push-to-talk-extension/utils.js @@ -0,0 +1,12 @@ +export function throttle(fn, delay) { + let timeout = null; + + return function (...args) { + if (timeout === null) { + fn(...args); + timeout = setTimeout(() => { + timeout = null; + }, delay); + } + }; +} diff --git a/addons/mail/static/src/discuss/call/common/ptt_extension_service.js b/addons/mail/static/src/discuss/call/common/ptt_extension_service.js new file mode 100644 index 00000000000..b3c39c5aed2 --- /dev/null +++ b/addons/mail/static/src/discuss/call/common/ptt_extension_service.js @@ -0,0 +1,68 @@ +/* @odoo-module */ + +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; + +export const pttExtensionHookService = { + start(env) { + const INITIAL_RELEASE_TIMEOUT = 500; + const COMMON_RELEASE_TIMEOUT = 200; + let isEnabled = false; + let voiceActivated = false; + + browser.addEventListener("message", ({ data }) => { + const rtc = env.services["discuss.rtc"]; + if (data.from !== "discuss-push-to-talk" || !rtc) { + return; + } + switch (data.type) { + case "push-to-talk-pressed": + { + voiceActivated = false; + const isFirstPress = !rtc.state.selfSession?.isTalking; + rtc.onPushToTalk(); + if (rtc.state.selfSession?.isTalking) { + // Second key press is slow to come thus, the first timeout + // must be greater than the following ones. + rtc.setPttReleaseTimeout( + isFirstPress ? INITIAL_RELEASE_TIMEOUT : COMMON_RELEASE_TIMEOUT + ); + } + } + break; + case "toggle-voice": + { + if (voiceActivated) { + rtc.setPttReleaseTimeout(0); + } else { + rtc.onPushToTalk(); + } + voiceActivated = !voiceActivated; + } + break; + case "answer-is-enabled": + isEnabled = true; + break; + } + }); + window.postMessage({ from: "discuss", type: "ask-is-enabled" }); + + return { + notifyIsTalking(isTalking) { + window.postMessage({ from: "discuss", type: "is-talking", value: isTalking }); + }, + subscribe() { + window.postMessage({ from: "discuss", type: "subscribe" }); + }, + unsubscribe() { + voiceActivated = false; + window.postMessage({ from: "discuss", type: "unsubscribe" }); + }, + get isEnabled() { + return isEnabled; + }, + }; + }, +}; + +registry.category("services").add("discuss.ptt_extension", pttExtensionHookService); 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 5a1ced30d35..3c6c517e301 100644 --- a/addons/mail/static/src/discuss/call/common/rtc_service.js +++ b/addons/mail/static/src/discuss/call/common/rtc_service.js @@ -99,6 +99,7 @@ export class Rtc { this.rpc = services.rpc; this.soundEffectsService = services["mail.sound_effects"]; this.userSettingsService = services["mail.user_settings"]; + this.pttExtService = services["discuss.ptt_extension"]; this.state = reactive({ hasPendingRequest: false, selfSession: undefined, @@ -162,19 +163,10 @@ export class Rtc { browser.addEventListener( "keydown", (ev) => { - if ( - !this.state.channel || - this.userSettingsService.isRegisteringKey || - !this.userSettingsService.usePushToTalk || - !this.userSettingsService.isPushToTalkKey(ev) - ) { + if (!this.userSettingsService.isPushToTalkKey(ev)) { return; } - browser.clearTimeout(this.state.pttReleaseTimeout); - if (!this.state.selfSession.isTalking && !this.state.selfSession.isMute) { - this.soundEffectsService.play("push-to-talk-on", { volume: 0.3 }); - } - this.setTalking(true); + this.onPushToTalk(); }, { capture: true } ); @@ -189,12 +181,7 @@ export class Rtc { ) { return; } - this.state.pttReleaseTimeout = browser.setTimeout(() => { - this.setTalking(false); - if (!this.state.selfSession?.isMute) { - this.soundEffectsService.play("push-to-talk-off", { volume: 0.3 }); - } - }, Math.max(this.userSettingsService.voiceActiveDuration || 0, 200)); + this.setPttReleaseTimeout(); }, { capture: true } ); @@ -231,6 +218,29 @@ export class Rtc { }, 30_000); } + setPttReleaseTimeout(duration = 200) { + this.state.pttReleaseTimeout = browser.setTimeout(() => { + this.setTalking(false); + if (!this.state.selfSession?.isMute) { + this.soundEffectsService.play("push-to-talk-off", { volume: 0.3 }); + } + }, Math.max(this.userSettingsService.voiceActiveDuration || 0, duration)); + } + + onPushToTalk() { + if ( + !this.state.channel || + this.userSettingsService.isRegisteringKey || + !this.userSettingsService.usePushToTalk + ) { + return; + } + browser.clearTimeout(this.state.pttReleaseTimeout); + if (!this.state.selfSession.isTalking && !this.state.selfSession.isMute) { + this.soundEffectsService.play("push-to-talk-on", { volume: 0.3 }); + } + this.setTalking(true); + } /** * @param {Object} param0 * @param {any} param0.id @@ -275,6 +285,7 @@ export class Rtc { channel.rtcInvitingSession = undefined; channel.activeRtcSession = undefined; if (channel.eq(this.state.channel)) { + this.pttExtService.unsubscribe(); this.clear(); this.soundEffectsService.play("channel-leave"); } @@ -718,6 +729,7 @@ export class Rtc { this.notification.add(_t("Your browser does not support webRTC."), { type: "warning" }); return; } + this.pttExtService.subscribe(); const { rtcSessions, iceServers, sessionId } = await this.rpc( "/mail/rtc/channel/join_call", { @@ -1067,6 +1079,7 @@ export class Rtc { } this.state.selfSession.isTalking = isTalking; if (!this.state.selfSession.isMute) { + this.pttExtService.notifyIsTalking(isTalking); await this.refreshAudioStatus(); } } @@ -1552,6 +1565,7 @@ export class Rtc { export const rtcService = { dependencies: [ "bus_service", + "discuss.ptt_extension", "mail.sound_effects", "mail.store", "mail.user_settings",