diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml index b075b50..7634016 100644 --- a/.github/workflows/extension-release.yml +++ b/.github/workflows/extension-release.yml @@ -45,21 +45,29 @@ jobs: - run: node tools/check-locales.mjs + - run: node tools/build-extension.mjs + + - name: Lint the Firefox build + run: npx --yes web-ext@8 lint --source-dir dist/firefox --self-hosted + - id: version run: echo "value=$(node -p "require('./extension/manifest.json').version")" >> "$GITHUB_OUTPUT" - - name: Package the extension + - name: Package the extensions run: | - (cd extension && zip -qr ../offmic.zip .) - sha256sum offmic.zip > offmic.zip.sha256 - unzip -l offmic.zip + (cd dist/chrome && zip -qr ../../offmic-chrome.zip .) + (cd dist/firefox && zip -qr ../../offmic-firefox.zip .) + sha256sum offmic-chrome.zip offmic-firefox.zip > SHA256SUMS + unzip -l offmic-chrome.zip + unzip -l offmic-firefox.zip - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: extension path: | - offmic.zip - offmic.zip.sha256 + offmic-chrome.zip + offmic-firefox.zip + SHA256SUMS if-no-files-found: error release: @@ -82,7 +90,7 @@ jobs: PRERELEASE: ${{ inputs.prerelease }} working-directory: dist run: | - files=(offmic.zip offmic.zip.sha256) + files=(offmic-chrome.zip offmic-firefox.zip SHA256SUMS) if [ "$OFFICIAL" = "true" ]; then flags="" [ "$PRERELEASE" = "true" ] && flags="--prerelease" diff --git a/README.md b/README.md index ea0221a..74c39f2 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ OffMic **never controls** the Teams microphone. It watches it. 2. **Capture.** An offscreen document opens the microphone alongside Teams. 3. **Decide.** The outgoing track is enabled only when the connection is up **and** the Teams mic is muted. +Where the capture and the peer connections live depends on the browser. Chrome hosts them in an offscreen document; Firefox refuses microphone access from an extension background page, so it hosts them in the Teams tab itself. The media code is the same file in both cases, only the host changes. + Voice travels over **peer-to-peer WebRTC**, in a mesh. It never goes through a server. The signaling server only introduces peers to each other, just long enough to exchange SDP descriptions and ICE candidates. Once the peers are connected, nothing flows through it anymore. ``` @@ -54,16 +56,30 @@ Voice travels over **peer-to-peer WebRTC**, in a mesh. It never goes through a s - **Headphones are mandatory.** If your team's voice comes out of your speakers while your Teams mic is live, the client will hear it. No software can fix that. - **Teams web only.** A browser extension has no reach into the Teams desktop client. - **Detection depends on the Teams DOM.** Microsoft can change its UI and break it. English and French interfaces are recognized, with a polling fallback. -- **Chrome for now.** The offscreen document is Chrome specific. A Firefox port is planned. +- **Chrome and Firefox.** Chrome runs the voice channel in an offscreen document, Firefox inside your Teams tab. Same code, one manifest each. - **Up to 5 or 6 people.** Beyond that, a peer-to-peer mesh costs too much upstream bandwidth. ## Install +### Chrome + OffMic is published on the Chrome Web Store: **[chromewebstore.google.com/detail/onacjipehnjfchieiakjlofndidcnkod](https://chromewebstore.google.com/detail/onacjipehnjfchieiakjlofndidcnkod)** -Add it to Chrome, open the popup, and click the microphone permission link once. That step matters: an offscreen document has no UI, so it cannot show a permission prompt. Without it, capture fails with `NotAllowedError`. +### Firefox + +Firefox 115 or newer. The build is not on addons.mozilla.org yet, so grab `offmic-firefox.zip` from the [latest release](https://github.com/Bysimeit/OffMic/releases), unzip it, then open `about:debugging` → **This Firefox** → **Load Temporary Add-on** and pick the `manifest.json` inside. Being a temporary add-on, it goes away when you restart Firefox. + +### On Chrome, one permission step + +Open the popup and click the microphone permission link once. That step matters: the offscreen document has no UI, so it cannot show a permission prompt. Without it, capture fails with `NotAllowedError`. + +### On Firefox, nothing to grant + +Firefox refuses `getUserMedia` from an extension background page outright, whatever permission is stored, because there is no browsing context to attach the capture to. So on Firefox the voice channel runs inside your Teams tab instead, as a content script. It captures under the Teams origin, which already holds the microphone permission for your meeting, so there is no second prompt and nothing extra to click. + +The consequence is that the Teams tab **is** the channel. Connect with your meeting open, and if that tab is closed or reloaded the channel stops and the popup says so. Nothing else to install. The extension already points at a hosted relay, so joining a room is all it takes. @@ -111,14 +127,30 @@ That value is only the **default** shown on a fresh Chrome profile. The address ### 2. Load the extension from source -Skip this if you already installed the store build, it works the same against your own relay. +Skip this if you already installed a packaged build, it works the same against your own relay. + +`extension/` holds one source tree and two manifests: `manifest.json` for Chrome, `manifest.firefox.json` for Firefox. The build script copies the tree into `dist/`, one folder per browser, with the right manifest in place: + +```bash +node tools/build-extension.mjs +``` + +On Chrome you can also load `extension/` directly, since its `manifest.json` is already the Chrome one. + +**Chrome** 1. Open `chrome://extensions` 2. Turn on **Developer mode** -3. **Load unpacked**, then pick the `extension/` folder +3. **Load unpacked**, then pick `dist/chrome/` (or `extension/`) 4. Open the popup and click the microphone permission link -Step 4 matters: an offscreen document has no UI, so it cannot show a permission prompt. You have to grant access once from the dedicated page, otherwise capture fails with `NotAllowedError`. +**Firefox** + +1. Open `about:debugging` → **This Firefox** +2. **Load Temporary Add-on**, then pick `dist/firefox/manifest.json` +3. Open the popup and click the microphone permission link + +The last step matters on both: the audio runs in a context with no UI, so it cannot show a permission prompt. You have to grant access once from the dedicated page, otherwise capture fails with `NotAllowedError`. ### 3. Run the signaling server diff --git a/extension/_locales/de/messages.json b/extension/_locales/de/messages.json index e9b2084..7662d3a 100644 --- a/extension/_locales/de/messages.json +++ b/extension/_locales/de/messages.json @@ -176,6 +176,9 @@ "errOffscreenFailed": { "message": "Interner Fehler: Das Mikrofon-Dokument konnte nicht starten. Lade die Erweiterung neu." }, + "errNoTeamsTab": { + "message": "OffMic braucht den Tab deiner Teams-Besprechung. Öffne ihn und verbinde dich erneut." + }, "errNoResponse": { "message": "Keine Antwort der Erweiterung. Lade sie unter chrome://extensions neu und versuche es erneut." } diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json index 7b4c1c5..cc11354 100644 --- a/extension/_locales/en/messages.json +++ b/extension/_locales/en/messages.json @@ -176,6 +176,9 @@ "errOffscreenFailed": { "message": "Internal error: the microphone document could not start. Reload the extension." }, + "errNoTeamsTab": { + "message": "OffMic needs your Teams meeting tab to be open. Open it, then connect again." + }, "errNoResponse": { "message": "No response from the extension. Reload it from chrome://extensions, then try again." } diff --git a/extension/_locales/es/messages.json b/extension/_locales/es/messages.json index a09aa16..1616226 100644 --- a/extension/_locales/es/messages.json +++ b/extension/_locales/es/messages.json @@ -176,6 +176,9 @@ "errOffscreenFailed": { "message": "Error interno: el documento de micrófono no pudo iniciarse. Vuelve a cargar la extensión." }, + "errNoTeamsTab": { + "message": "OffMic necesita que la pestaña de tu reunión de Teams esté abierta. Ábrela y vuelve a conectar." + }, "errNoResponse": { "message": "La extensión no responde. Vuelve a cargarla desde chrome://extensions e inténtalo de nuevo." } diff --git a/extension/_locales/fr/messages.json b/extension/_locales/fr/messages.json index 0294a7f..f4f711e 100644 --- a/extension/_locales/fr/messages.json +++ b/extension/_locales/fr/messages.json @@ -176,6 +176,9 @@ "errOffscreenFailed": { "message": "Erreur interne : le document micro n'a pas pu démarrer. Recharge l'extension." }, + "errNoTeamsTab": { + "message": "OffMic a besoin que l'onglet de ta réunion Teams soit ouvert. Ouvre-le, puis reconnecte-toi." + }, "errNoResponse": { "message": "Pas de réponse de l'extension. Recharge-la depuis chrome://extensions, puis réessaie." } diff --git a/extension/_locales/nl/messages.json b/extension/_locales/nl/messages.json index e7fe614..f004ab0 100644 --- a/extension/_locales/nl/messages.json +++ b/extension/_locales/nl/messages.json @@ -176,6 +176,9 @@ "errOffscreenFailed": { "message": "Interne fout: het microfoondocument kon niet starten. Herlaad de extensie." }, + "errNoTeamsTab": { + "message": "OffMic heeft het tabblad van je Teams-vergadering nodig. Open het en verbind opnieuw." + }, "errNoResponse": { "message": "Geen antwoord van de extensie. Herlaad ze via chrome://extensions en probeer opnieuw." } diff --git a/extension/background.js b/extension/background.js index d77c7db..50d9553 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,6 +1,14 @@ -importScripts("i18n.js"); +if (typeof importScripts === "function") { + importScripts("compat.js", "i18n.js"); +} + +const HAS_OFFSCREEN = !!(api.offscreen && api.offscreen.createDocument); +const KEEP_ALIVE_INTERVAL = 20000; let creating = null; +let keepAliveTimer = null; +let mediaTabId = null; +let pendingPayload = null; let lastStatus = { connected: false, teamsMuted: false, transmitting: false, peers: [] }; const MARK_COLOR = "#4f6bed"; @@ -54,122 +62,207 @@ async function updateIcon(status) { for (const size of ICON_SIZES) { imageData[size] = drawMark(size, connected); } - chrome.action.setIcon({ imageData }); - chrome.action.setBadgeText({ text: "" }); + api.action.setIcon({ imageData }); + api.action.setBadgeText({ text: "" }); const { dict } = await i18nLoadPreferred(); - chrome.action.setTitle({ + api.action.setTitle({ title: i18nText(dict, connected ? "actionTitleConnected" : "actionTitle") }); } -chrome.runtime.onStartup.addListener(() => updateIcon(null)); -chrome.runtime.onInstalled.addListener(() => updateIcon(null)); +function setKeepAlive(on) { + if (HAS_OFFSCREEN) return; + if (on && !keepAliveTimer) { + keepAliveTimer = setInterval(() => { + const pending = api.runtime.getPlatformInfo(); + if (pending && typeof pending.catch === "function") { + pending.catch(() => {}); + } + }, KEEP_ALIVE_INTERVAL); + } else if (!on && keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } +} + +function applyStatus(status) { + lastStatus = status; + updateIcon(status); + setKeepAlive(!!(status && status.connected)); +} + +api.runtime.onStartup.addListener(() => updateIcon(null)); +api.runtime.onInstalled.addListener(() => updateIcon(null)); -chrome.storage.onChanged.addListener((changes, area) => { +api.storage.onChanged.addListener((changes, area) => { if (area === "local" && changes.settings) { updateIcon(lastStatus); } }); -async function ensureOffscreen() { - const has = await chrome.offscreen.hasDocument(); - if (has) return; - if (!creating) { - creating = chrome.offscreen.createDocument({ - url: "offscreen.html", - reasons: ["USER_MEDIA", "AUDIO_PLAYBACK"], - justification: "Microphone capture and WebRTC connections for the team voice channel." - }); - } - try { - await creating; - } finally { - creating = null; +const TEAMS_URLS = [ + "https://teams.microsoft.com/*", + "https://teams.live.com/*", + "https://teams.cloud.microsoft/*" +]; + +async function findTeamsTab() { + const active = await api.tabs.query({ url: TEAMS_URLS, active: true, currentWindow: true }); + if (active && active.length) return active[0].id; + const any = await api.tabs.query({ url: TEAMS_URLS }); + if (any && any.length) return any[0].id; + return null; +} + +async function ensureMediaHost() { + if (HAS_OFFSCREEN) { + const has = await api.offscreen.hasDocument(); + if (has) return; + if (!creating) { + creating = api.offscreen.createDocument({ + url: "offscreen.html", + reasons: ["USER_MEDIA", "AUDIO_PLAYBACK"], + justification: "Microphone capture and WebRTC connections for the team voice channel." + }); + } + try { + await creating; + } finally { + creating = null; + } + return; } + + const tabId = await findTeamsTab(); + if (tabId === null) throw new Error("noTeamsTab"); + await api.tabs.sendMessage(tabId, { target: "media", cmd: "ping" }, { frameId: 0 }); + mediaTabId = tabId; } function pushError(code) { lastStatus = Object.assign({}, lastStatus, { connected: false, error: code }); - chrome.runtime.sendMessage({ cmd: "status", status: lastStatus }, () => chrome.runtime.lastError); + apiSend({ cmd: "status", status: lastStatus }); updateIcon(lastStatus); } -function toOffscreen(cmd, extra) { - chrome.runtime.sendMessage( - Object.assign({ target: "offscreen", cmd }, extra || {}), - () => chrome.runtime.lastError - ); +async function toMedia(cmd, extra) { + const msg = Object.assign({ target: "media", cmd }, extra || {}); + if (HAS_OFFSCREEN) { + apiSend(msg); + return; + } + if (mediaTabId === null) { + try { + mediaTabId = await findTeamsTab(); + } catch (e) { + return; + } + } + if (mediaTabId !== null) { + apiSendTab(mediaTabId, msg, { frameId: 0 }); + } } -const TEAMS_URLS = [ - "https://teams.microsoft.com/*", - "https://teams.live.com/*", - "https://teams.cloud.microsoft/*" -]; +function dropMediaHost() { + mediaTabId = null; + applyStatus({ connected: false, teamsMuted: false, transmitting: false, peers: [], error: "noTeamsTab" }); + apiSend({ cmd: "status", status: lastStatus }); +} function syncMuteState() { - chrome.tabs.query({ url: TEAMS_URLS }, (tabs) => { - if (chrome.runtime.lastError || !tabs) return; - for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, { cmd: "requestMuteState" }, () => chrome.runtime.lastError); - } - }); + api.tabs + .query({ url: TEAMS_URLS }) + .then((tabs) => { + for (const tab of tabs || []) { + apiSendTab(tab.id, { cmd: "requestMuteState" }); + } + }) + .catch(() => {}); } -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { +api.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (!msg || !msg.cmd) return; if (msg.cmd === "connect") { - ensureOffscreen() + pendingPayload = msg.payload; + ensureMediaHost() .then(() => { - toOffscreen("connect", { payload: msg.payload }); + toMedia("prepare", { payload: msg.payload }); syncMuteState(); }) .catch((e) => { - console.error("OffMic: offscreen document unavailable", e); - pushError("offscreenFailed"); + console.error("OffMic: media context unavailable", e); + pushError(e && e.message === "noTeamsTab" ? "noTeamsTab" : "offscreenFailed"); }); sendResponse({ ok: true }); return true; } if (msg.cmd === "disconnect") { - toOffscreen("disconnect"); + pendingPayload = null; + if (!HAS_OFFSCREEN) signalClose(); + toMedia("disconnect"); sendResponse({ ok: true }); return true; } + if (msg.cmd === "mediaReady") { + if (!HAS_OFFSCREEN && pendingPayload) signalOpen(pendingPayload, msg.peerId); + return; + } + + if (msg.cmd === "signalOut") { + if (!HAS_OFFSCREEN) signalSend(msg.msg); + return; + } + if (msg.cmd === "muteState") { - toOffscreen("muteState", { muted: msg.muted }); + toMedia("muteState", { muted: msg.muted }); return; } if (msg.cmd === "setListen") { - toOffscreen("setListen", { listen: msg.listen }); + toMedia("setListen", { listen: msg.listen }); return; } if (msg.cmd === "setAudio") { - toOffscreen("setAudio", { audio: msg.audio }); + toMedia("setAudio", { audio: msg.audio }); return; } if (msg.cmd === "setPeerAudio") { - toOffscreen("setPeerAudio", { peerId: msg.peerId, patch: msg.patch }); + toMedia("setPeerAudio", { peerId: msg.peerId, patch: msg.patch }); return; } if (msg.cmd === "getStatus") { - toOffscreen("getStatus"); + toMedia("getStatus"); syncMuteState(); sendResponse(lastStatus); return true; } if (msg.cmd === "status") { - lastStatus = msg.status; - updateIcon(msg.status); + applyStatus(msg.status); return; } }); + +if (!HAS_OFFSCREEN) { + offmicSignalHooks.message = (msg) => toMedia("signalIn", { msg }); + offmicSignalHooks.link = (connected) => toMedia("link", { connected }); + offmicSignalHooks.error = (code) => toMedia("fail", { code }); + + api.tabs.onRemoved.addListener((tabId) => { + if (tabId === mediaTabId && lastStatus && lastStatus.connected) dropMediaHost(); + else if (tabId === mediaTabId) mediaTabId = null; + }); + + api.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (tabId !== mediaTabId || changeInfo.status !== "loading") return; + if (lastStatus && lastStatus.connected) dropMediaHost(); + else mediaTabId = null; + }); +} diff --git a/extension/compat.js b/extension/compat.js new file mode 100644 index 0000000..f01abca --- /dev/null +++ b/extension/compat.js @@ -0,0 +1,21 @@ +const api = globalThis.browser || globalThis.chrome; + +function apiSend(msg) { + try { + const result = api.runtime.sendMessage(msg); + if (result && typeof result.catch === "function") { + result.catch(() => {}); + } + } catch (e) {} +} + +function apiSendTab(tabId, msg, options) { + try { + const result = options + ? api.tabs.sendMessage(tabId, msg, options) + : api.tabs.sendMessage(tabId, msg); + if (result && typeof result.catch === "function") { + result.catch(() => {}); + } + } catch (e) {} +} diff --git a/extension/content.js b/extension/content.js index 420588c..02c7000 100644 --- a/extension/content.js +++ b/extension/content.js @@ -67,9 +67,7 @@ function poll() { if (muted === null) return; if (muted !== lastState) { lastState = muted; - try { - chrome.runtime.sendMessage({ cmd: "muteState", muted }); - } catch (e) {} + apiSend({ cmd: "muteState", muted }); } } @@ -84,7 +82,7 @@ observer.observe(document.documentElement, { setInterval(poll, 1500); poll(); -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { +api.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg && msg.cmd === "requestMuteState") { lastState = null; poll(); diff --git a/extension/i18n.js b/extension/i18n.js index 270db83..4046de6 100644 --- a/extension/i18n.js +++ b/extension/i18n.js @@ -5,13 +5,13 @@ function i18nResolve(preference) { if (preference && preference !== "auto") { if (I18N_SUPPORTED.includes(preference)) return preference; } - const ui = (chrome.i18n.getUILanguage() || I18N_FALLBACK).toLowerCase(); + const ui = (api.i18n.getUILanguage() || I18N_FALLBACK).toLowerCase(); const base = ui.split("-")[0]; return I18N_SUPPORTED.includes(base) ? base : I18N_FALLBACK; } async function i18nLoad(locale) { - const url = chrome.runtime.getURL("_locales/" + locale + "/messages.json"); + const url = api.runtime.getURL("_locales/" + locale + "/messages.json"); const response = await fetch(url); const raw = await response.json(); const dict = {}; @@ -22,7 +22,7 @@ async function i18nLoad(locale) { } async function i18nLoadPreferred() { - const data = await chrome.storage.local.get("settings"); + const data = await api.storage.local.get("settings"); const preference = data.settings && data.settings.language; const locale = i18nResolve(preference); const dict = await i18nLoad(locale); diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json new file mode 100644 index 0000000..acaca6f --- /dev/null +++ b/extension/manifest.firefox.json @@ -0,0 +1,53 @@ +{ + "manifest_version": 3, + "name": "__MSG_extName__", + "version": "0.1.0", + "description": "__MSG_extDescription__", + "default_locale": "en", + "browser_specific_settings": { + "gecko": { + "id": "contact@xeron.be", + "strict_min_version": "140.0", + "data_collection_permissions": { + "required": ["none"] + } + } + }, + "permissions": ["storage"], + "host_permissions": [ + "https://teams.microsoft.com/*", + "https://teams.live.com/*", + "https://teams.cloud.microsoft/*" + ], + "background": { + "scripts": ["compat.js", "i18n.js", "signal.js", "background.js"] + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_popup": "popup/popup.html", + "default_title": "__MSG_actionTitle__", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "content_scripts": [ + { + "matches": [ + "https://teams.microsoft.com/*", + "https://teams.live.com/*", + "https://teams.cloud.microsoft/*" + ], + "js": ["compat.js", "media.js", "media-content.js", "content.js"], + "run_at": "document_idle", + "all_frames": true + } + ] +} diff --git a/extension/manifest.json b/extension/manifest.json index af2cf8e..07470a6 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -34,7 +34,7 @@ "https://teams.live.com/*", "https://teams.cloud.microsoft/*" ], - "js": ["content.js"], + "js": ["compat.js", "content.js"], "run_at": "document_idle", "all_frames": true } diff --git a/extension/media-content.js b/extension/media-content.js new file mode 100644 index 0000000..ea90a37 --- /dev/null +++ b/extension/media-content.js @@ -0,0 +1,14 @@ +if (window.top === window) { + offmicSignalOut = (msg) => apiSend({ cmd: "signalOut", msg }); + offmicMediaReady = (peerId) => apiSend({ cmd: "mediaReady", peerId }); + + api.runtime.onMessage.addListener((msg) => { + if (!msg || msg.target !== "media") return; + if (msg.cmd === "ping") return Promise.resolve({ ok: true }); + offmicMediaHandle(msg); + }); + + window.addEventListener("pagehide", () => { + offmicMediaHandle({ cmd: "disconnect" }); + }); +} diff --git a/extension/media.js b/extension/media.js new file mode 100644 index 0000000..654801c --- /dev/null +++ b/extension/media.js @@ -0,0 +1,362 @@ +var offmicSignalOut = null; +var offmicMediaReady = null; + +const state = { + serverUrl: null, + room: null, + name: null, + peerId: null, + localStream: null, + outgoingTrack: null, + connected: false, + teamsMuted: false, + listenWhileUnmuted: true, + peers: new Map(), + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + lastError: null, + audio: { micDevice: "", speakerDevice: "", micVolume: 100, teamVolume: 100 }, + audioCtx: null, + micSource: null, + micGain: null, + micDestination: null +}; + +function toLevel(percent) { + const value = Number(percent); + if (!Number.isFinite(value)) return 1; + return Math.min(1, Math.max(0, value / 100)); +} + +function send(msg) { + if (typeof offmicSignalOut === "function") { + offmicSignalOut(JSON.parse(JSON.stringify(msg))); + } +} + +function report() { + const status = { + connected: state.connected, + error: state.lastError, + room: state.room, + teamsMuted: state.teamsMuted, + listenWhileUnmuted: state.listenWhileUnmuted, + transmitting: !!(state.outgoingTrack && state.outgoingTrack.enabled), + peers: Array.from(state.peers.entries()).map(([id, entry]) => ({ + id, + name: entry.name, + state: entry.pc.connectionState, + muted: entry.muted, + volume: entry.volume + })) + }; + apiSend({ cmd: "status", status }); +} + +function applyOutgoing() { + if (state.outgoingTrack) { + state.outgoingTrack.enabled = state.connected && state.teamsMuted; + } +} + +function applyPlayback() { + const allow = state.connected && (state.teamsMuted || state.listenWhileUnmuted); + const master = toLevel(state.audio.teamVolume); + for (const entry of state.peers.values()) { + if (!entry.audio) continue; + const audible = allow && !entry.muted; + entry.audio.muted = !audible; + entry.audio.volume = master * toLevel(entry.volume); + if (audible) { + entry.audio.play().catch(() => {}); + } + } +} + +function applySink(audio) { + if (typeof audio.setSinkId !== "function") return; + audio.setSinkId(state.audio.speakerDevice || "").catch(() => {}); +} + +function applyMicVolume() { + if (state.micGain) { + state.micGain.gain.value = toLevel(state.audio.micVolume); + } +} + +function buildMicChain(stream) { + if (!state.audioCtx) { + state.audioCtx = new AudioContext(); + state.micGain = state.audioCtx.createGain(); + state.micDestination = state.audioCtx.createMediaStreamDestination(); + state.micGain.connect(state.micDestination); + } + if (state.micSource) { + state.micSource.disconnect(); + } + state.micSource = state.audioCtx.createMediaStreamSource(stream); + state.micSource.connect(state.micGain); + applyMicVolume(); + if (state.audioCtx.state === "suspended") { + state.audioCtx.resume().catch(() => {}); + } +} + +function micErrorCode(error) { + const name = error && error.name; + if (name === "NotAllowedError" || name === "SecurityError") return "micPermission"; + if (name === "NotFoundError" || name === "DevicesNotFoundError") return "micMissing"; + return "micFailed"; +} + +function captureMic(deviceId) { + const constraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + if (deviceId) { + constraints.deviceId = { exact: deviceId }; + } + return navigator.mediaDevices.getUserMedia({ audio: constraints }); +} + +async function openMic() { + const requested = state.audio.micDevice; + let stream; + + try { + stream = await captureMic(requested); + } catch (e) { + const code = micErrorCode(e); + if (!requested || code === "micPermission") { + state.lastError = code; + report(); + return false; + } + try { + stream = await captureMic(""); + } catch (fallbackError) { + state.lastError = micErrorCode(fallbackError); + report(); + return false; + } + } + + state.lastError = null; + state.localStream = stream; + buildMicChain(stream); + state.outgoingTrack = state.micDestination.stream.getAudioTracks()[0]; + applyOutgoing(); + return true; +} + +async function switchMic() { + const previous = state.localStream; + const ok = await openMic(); + if (ok && previous && previous !== state.localStream) { + previous.getTracks().forEach((t) => t.stop()); + } +} + +async function prepare(payload) { + if (state.localStream) return; + state.room = payload.room; + state.name = payload.name; + state.serverUrl = payload.serverUrl; + if (Array.isArray(payload.iceServers) && payload.iceServers.length) { + state.iceServers = payload.iceServers; + } + if (payload.audio) { + Object.assign(state.audio, payload.audio); + } + state.peerId = crypto.randomUUID(); + state.lastError = null; + + const ready = await openMic(); + if (!ready) return; + + report(); + if (typeof offmicMediaReady === "function") { + offmicMediaReady(state.peerId, payload); + } +} + +function setLink(connected) { + state.connected = connected; + if (connected) { + state.lastError = null; + applyOutgoing(); + report(); + return; + } + teardown(); +} + +function fail(code) { + state.lastError = code; + teardown(); +} + +async function signalIn(msg) { + if (!msg) return; + + if (msg.type === "peers") { + for (const peer of msg.peers) { + await createPeer(peer.peerId, peer.name, true); + } + report(); + } else if (msg.type === "peer-joined") { + report(); + } else if (msg.type === "peer-left") { + removePeer(msg.peerId); + report(); + } else if (msg.type === "signal") { + await onPeerSignal(msg); + } +} + +async function createPeer(peerId, name, initiator) { + if (state.peers.has(peerId)) return state.peers.get(peerId); + + const pc = new RTCPeerConnection({ iceServers: state.iceServers }); + const audio = new Audio(); + audio.autoplay = true; + audio.volume = toLevel(state.audio.teamVolume); + applySink(audio); + const entry = { pc, name: name || "", audio, muted: false, volume: 100 }; + state.peers.set(peerId, entry); + + if (state.outgoingTrack) { + pc.addTrack(state.outgoingTrack, state.micDestination.stream); + } + + pc.addEventListener("icecandidate", (e) => { + if (e.candidate) { + send({ type: "signal", to: peerId, from: state.peerId, data: { ice: e.candidate } }); + } + }); + + pc.addEventListener("track", (e) => { + entry.stream = e.streams[0]; + audio.srcObject = e.streams[0]; + applyPlayback(); + }); + + pc.addEventListener("connectionstatechange", report); + + if (initiator) { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + send({ type: "signal", to: peerId, from: state.peerId, data: { sdp: pc.localDescription } }); + } + + return entry; +} + +async function onPeerSignal(msg) { + const from = msg.from; + const data = msg.data || {}; + let entry = state.peers.get(from); + + if (data.sdp) { + if (!entry) { + entry = await createPeer(from, msg.name || "", false); + } + await entry.pc.setRemoteDescription(data.sdp); + if (data.sdp.type === "offer") { + const answer = await entry.pc.createAnswer(); + await entry.pc.setLocalDescription(answer); + send({ type: "signal", to: from, from: state.peerId, data: { sdp: entry.pc.localDescription } }); + } + } else if (data.ice) { + if (entry) { + try { + await entry.pc.addIceCandidate(data.ice); + } catch (e) {} + } + } +} + +function removePeer(peerId) { + const entry = state.peers.get(peerId); + if (!entry) return; + try { + entry.pc.close(); + } catch (e) {} + if (entry.audio) { + entry.audio.srcObject = null; + } + state.peers.delete(peerId); +} + +function setMute(muted) { + state.teamsMuted = muted; + applyOutgoing(); + applyPlayback(); + report(); +} + +function setListen(listen) { + state.listenWhileUnmuted = listen; + applyPlayback(); + report(); +} + +function setAudio(audio) { + if (!audio) return; + const previous = state.audio; + state.audio = Object.assign({}, previous, audio); + applyMicVolume(); + if (state.audio.speakerDevice !== previous.speakerDevice) { + for (const entry of state.peers.values()) { + if (entry.audio) applySink(entry.audio); + } + } + if (state.audio.micDevice !== previous.micDevice && state.localStream) { + switchMic(); + } + applyPlayback(); +} + +function setPeerAudio(peerId, patch) { + const entry = state.peers.get(peerId); + if (!entry || !patch) return; + if (typeof patch.muted === "boolean") { + entry.muted = patch.muted; + } + if (patch.volume !== undefined) { + entry.volume = patch.volume; + } + applyPlayback(); + report(); +} + +function teardown() { + for (const id of Array.from(state.peers.keys())) { + removePeer(id); + } + if (state.micSource) { + state.micSource.disconnect(); + state.micSource = null; + } + if (state.localStream) { + state.localStream.getTracks().forEach((t) => t.stop()); + } + state.localStream = null; + state.outgoingTrack = null; + state.connected = false; + report(); +} + +function offmicMediaHandle(msg) { + if (!msg) return; + if (msg.cmd === "prepare") prepare(msg.payload); + else if (msg.cmd === "link") setLink(msg.connected); + else if (msg.cmd === "signalIn") signalIn(msg.msg); + else if (msg.cmd === "fail") fail(msg.code); + else if (msg.cmd === "disconnect") { + state.lastError = null; + teardown(); + } + else if (msg.cmd === "muteState") setMute(msg.muted); + else if (msg.cmd === "setListen") setListen(msg.listen); + else if (msg.cmd === "setAudio") setAudio(msg.audio); + else if (msg.cmd === "setPeerAudio") setPeerAudio(msg.peerId, msg.patch); + else if (msg.cmd === "getStatus") report(); +} diff --git a/extension/offscreen.html b/extension/offscreen.html index 38e08b4..7eaa2c8 100644 --- a/extension/offscreen.html +++ b/extension/offscreen.html @@ -4,6 +4,9 @@
+ + +