diff --git a/BrowserExtension/manifest.json b/BrowserExtension/manifest.json
index 56f3086..26679e4 100644
--- a/BrowserExtension/manifest.json
+++ b/BrowserExtension/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "VoxClaw",
- "description": "Pauses YouTube when VoxClaw speaks, resumes when it finishes.",
- "version": "1.0.0",
+ "description": "Pauses YouTube and ducks Spotify when VoxClaw speaks, restores when it finishes.",
+ "version": "1.1.0",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
@@ -19,7 +19,8 @@
"https://*.youtube.com/*",
"https://youtube.com/*",
"https://youtu.be/*",
- "https://*.youtube-nocookie.com/*"
+ "https://*.youtube-nocookie.com/*",
+ "https://open.spotify.com/*"
],
"action": {
"default_title": "VoxClaw",
diff --git a/BrowserExtension/popup.html b/BrowserExtension/popup.html
index 6107fcc..0ac0f2c 100644
--- a/BrowserExtension/popup.html
+++ b/BrowserExtension/popup.html
@@ -31,7 +31,7 @@
VoxClaw
Checking...
diff --git a/BrowserExtension/service_worker.js b/BrowserExtension/service_worker.js
index e0d1997..fc3c1eb 100644
--- a/BrowserExtension/service_worker.js
+++ b/BrowserExtension/service_worker.js
@@ -1,8 +1,19 @@
const POLL_INTERVAL_MS = 1000;
const STATUS_URL = "http://localhost:4140/status";
+// Spotify ducking: lower playing tabs to this volume while speaking, with
+// short fades so it reads as ducking rather than a glitch. Restore is held
+// briefly because /status flips to not-reading during the queue's 1s
+// inter-item gap — without the hold, volume yo-yos between queued utterances.
+const DUCK_VOLUME = 0.2;
+const FADE_DOWN_MS = 300;
+const FADE_UP_MS = 500;
+const RESTORE_HOLD_MS = 1500;
+
let wasReading = false;
let pausedTabs = [];
+let duckedTabs = [];
+let restoreTimer = null;
let pollTimer = null;
function log(...args) {
@@ -27,10 +38,21 @@ async function pollStatus() {
if (isReading && !wasReading) {
pausedTabs = await pauseYouTube();
log(`Speaking started, paused ${pausedTabs.length} tab(s)`);
- } else if (!isReading && wasReading && pausedTabs.length > 0) {
- await resumeYouTube(pausedTabs);
- log(`Speaking finished, resumed ${pausedTabs.length} tab(s)`);
- pausedTabs = [];
+ if (restoreTimer) {
+ // Speech resumed within the hold window — stay ducked.
+ clearTimeout(restoreTimer);
+ restoreTimer = null;
+ } else if (duckedTabs.length === 0) {
+ duckedTabs = await duckSpotify();
+ log(`Ducked ${duckedTabs.length} Spotify tab(s)`);
+ }
+ } else if (!isReading && wasReading) {
+ if (pausedTabs.length > 0) {
+ await resumeYouTube(pausedTabs);
+ log(`Speaking finished, resumed ${pausedTabs.length} tab(s)`);
+ pausedTabs = [];
+ }
+ scheduleSpotifyRestore();
}
wasReading = isReading;
@@ -42,6 +64,88 @@ async function pollStatus() {
pausedTabs = [];
}
}
+ // Listener gone — restore immediately rather than waiting out the hold.
+ if (restoreTimer) {
+ clearTimeout(restoreTimer);
+ restoreTimer = null;
+ }
+ if (duckedTabs.length > 0) {
+ const tabs = duckedTabs;
+ duckedTabs = [];
+ await restoreSpotify(tabs);
+ }
+ }
+}
+
+function scheduleSpotifyRestore() {
+ if (restoreTimer || duckedTabs.length === 0) return;
+ restoreTimer = setTimeout(async () => {
+ restoreTimer = null;
+ const tabs = duckedTabs;
+ duckedTabs = [];
+ await restoreSpotify(tabs);
+ log(`Restored volume on ${tabs.length} Spotify tab(s)`);
+ }, RESTORE_HOLD_MS);
+}
+
+async function duckSpotify() {
+ const tabs = await chrome.tabs.query({ url: ["https://open.spotify.com/*"] });
+
+ const ducked = [];
+ for (const tab of tabs) {
+ if (!tab.id) continue;
+ try {
+ const [{ result } = {}] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: (duckVolume, fadeMs) => {
+ const media = [...document.querySelectorAll("video, audio")].find(
+ el => !el.paused && !el.ended && el.readyState >= 2 && el.volume > duckVolume
+ );
+ if (!media) return null;
+ const from = media.volume;
+ const start = performance.now();
+ const step = now => {
+ const t = Math.min((now - start) / fadeMs, 1);
+ media.volume = from + (duckVolume - from) * t;
+ if (t < 1) requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ return from;
+ },
+ args: [DUCK_VOLUME, FADE_DOWN_MS]
+ });
+ if (result !== null && result !== undefined) {
+ ducked.push({ tabId: tab.id, volume: result });
+ }
+ } catch {}
+ }
+ return ducked;
+}
+
+async function restoreSpotify(tabs) {
+ for (const { tabId, volume } of tabs) {
+ try {
+ const tab = await chrome.tabs.get(tabId);
+ if (!tab?.url?.startsWith("https://open.spotify.com/")) continue;
+ await chrome.scripting.executeScript({
+ target: { tabId },
+ func: (targetVolume, fadeMs) => {
+ // Restore even if playback was paused mid-speech, so the next
+ // play resumes at the original volume.
+ const media = document.querySelector("video, audio");
+ if (!media || media.volume >= targetVolume) return;
+ const from = media.volume;
+ const start = performance.now();
+ const step = now => {
+ const t = Math.min((now - start) / fadeMs, 1);
+ media.volume = from + (targetVolume - from) * t;
+ if (t < 1) requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ },
+ args: [volume, FADE_UP_MS]
+ });
+ } catch {}
}
}
@@ -108,6 +212,15 @@ function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
}
+ if (restoreTimer) {
+ clearTimeout(restoreTimer);
+ restoreTimer = null;
+ }
+ if (duckedTabs.length > 0) {
+ const tabs = duckedTabs;
+ duckedTabs = [];
+ void restoreSpotify(tabs);
+ }
log("Polling stopped");
}