[IMP] mail: add ptt extension for chromium-based browsers

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) <seb@odoo.com>
This commit is contained in:
tsm-odoo
2023-11-24 06:53:28 +00:00
parent 8adb65993e
commit 2eac72adca
9 changed files with 286 additions and 17 deletions
@@ -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.
- <img src="assets/icons/active_icon.png" alt="active_icon" width="24"/> Indicates the voice transmission is active.
- <img src="assets/icons/inactive_icon.png" alt="inactive_icon" width="24"/>
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -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));
@@ -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);
});
@@ -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"
}
}
}
@@ -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);
}
};
}
@@ -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);
@@ -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",