Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions BrowserExtension/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion BrowserExtension/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<h3>VoxClaw</h3>
<label>
<input type="checkbox" id="enabled" checked>
Pause YouTube while speaking
Pause YouTube &amp; duck Spotify while speaking
</label>
<div class="status" id="status">Checking...</div>
<script src="popup.js"></script>
Expand Down
121 changes: 117 additions & 4 deletions BrowserExtension/service_worker.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
Expand All @@ -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 {}
}
}

Expand Down Expand Up @@ -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");
}

Expand Down
Loading