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.
+-
Indicates the voice transmission is active.
+-
+ 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 00000000000..058bbe7d5bc
Binary files /dev/null and b/addons/mail/push-to-talk-extension/assets/icons/active_icon.png differ
diff --git a/addons/mail/push-to-talk-extension/assets/icons/inactive_icon.png b/addons/mail/push-to-talk-extension/assets/icons/inactive_icon.png
new file mode 100644
index 00000000000..57675905bc0
Binary files /dev/null and b/addons/mail/push-to-talk-extension/assets/icons/inactive_icon.png differ
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",