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