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 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",