+87
-19
@@ -0,1 +1,3 @@ | ||
| // Key design constraints: see CLAUDE.md before making changes | ||
| // Cross-browser compatibility shim: Firefox exposes `browser`, Chrome exposes `chrome` | ||
@@ -71,9 +73,9 @@ const browser = globalThis.browser ?? globalThis.chrome; | ||
| browser.storage.local | ||
| .get("enabled") | ||
| .then((result) => { | ||
| enabled = result.enabled !== false; // default true | ||
| }) | ||
| .catch((err) => { | ||
| console.warn("[YT Levelr] Failed to load enabled state:", err); | ||
| }); | ||
| .get("enabled") | ||
| .then((result) => { | ||
| enabled = result.enabled !== false; // default true | ||
| }) | ||
| .catch((err) => { | ||
| console.warn("[YT Levelr] Failed to load enabled state:", err); | ||
| }); | ||
@@ -312,4 +314,5 @@ // Listen for messages from popup | ||
| log( | ||
| `Gain update at ${(playingMs / 1000).toFixed(1)}s playing: ${currentGain.toFixed(3)}x (true RMS: ${medianRMS.toFixed(4)}, volume: ${volumeScale.toFixed(2)})`, | ||
| ); | ||
| `Gain update at ${(playingMs / 1000).toFixed(1)}s playing: ` + | ||
| `${currentGain.toFixed(3)}x ` + | ||
| ` (true RMS: ${medianRMS.toFixed(4)}, volume: ${volumeScale.toFixed(2)})`, ); | ||
| } | ||
@@ -355,3 +358,3 @@ | ||
| let currentUrl = location.href; | ||
| let lastInitiatedUrl = null; | ||
| let videoEl = null; | ||
@@ -368,5 +371,24 @@ | ||
| waitForVideo().then((el) => { | ||
| log(`waitForVideo resolved: paused=${el.paused} readyState=${el.readyState} src="${el.currentSrc.slice(0, 60)}"`); | ||
| videoEl = el; | ||
| const tryResumeOnGesture = () => { | ||
| if (!audioCtx || audioCtx.state !== "suspended") { | ||
| return; | ||
| } | ||
| const resume = () => { | ||
| audioCtx.resume().catch(() => {}); | ||
| }; | ||
| document.addEventListener("click", resume, { | ||
| capture: true, | ||
| once: true | ||
| }); | ||
| document.addEventListener("keydown", resume, { | ||
| capture: true, | ||
| once: true | ||
| }); | ||
| }; | ||
| const initGraph = () => { | ||
| log(`initGraph called: audioCtx=${audioCtx ? audioCtx.state : "null"} paused=${videoEl.paused}`); | ||
| if (!audioCtx) { | ||
@@ -379,4 +401,9 @@ try { | ||
| } | ||
| // Attempt immediate resume in case a prior user gesture already exists; | ||
| // if not, register gesture listeners as fallback for autoplay-blocked contexts. | ||
| audioCtx.resume().catch(() => {}); | ||
| tryResumeOnGesture(); | ||
| } else if (audioCtx.state === "suspended") { | ||
| audioCtx.resume().catch(() => {}); | ||
| tryResumeOnGesture(); | ||
| } | ||
@@ -391,4 +418,6 @@ if (intervalId) { | ||
| // Video is already playing (e.g. script injected mid-playback) | ||
| log("Video already playing, calling initGraph immediately"); | ||
| initGraph(); | ||
| } else { | ||
| log("Video paused, waiting for play event"); | ||
| videoEl.addEventListener("play", initGraph, { | ||
@@ -398,2 +427,4 @@ once: true, | ||
| } | ||
| }).catch((err) => { | ||
| console.warn("[YT Levelr] onNewVideo aborted:", err.message); | ||
| }); | ||
@@ -403,3 +434,4 @@ } | ||
| function waitForVideo() { | ||
| return new Promise((resolve) => { | ||
| return new Promise((resolve, reject) => { | ||
| const start = Date.now(); | ||
| const check = () => { | ||
@@ -410,2 +442,5 @@ const el = document.querySelector("video"); | ||
| } | ||
| if (Date.now() - start > 10000) { | ||
| return reject(new Error("video element not found within 10s")); | ||
| } | ||
| setTimeout(check, 200); | ||
@@ -422,13 +457,46 @@ }; | ||
| // YouTube fires this on SPA navigation | ||
| window.addEventListener("yt-navigate-finish", () => { | ||
| if (location.href !== currentUrl) { | ||
| currentUrl = location.href; | ||
| // maybeStartForCurrentPage is called on every YouTube SPA event and on initial | ||
| // load. It fires onNewVideo() only when the current page is a watch or Shorts | ||
| // URL that hasn't already been handled. Using a dedicated lastInitiatedUrl | ||
| // (rather than a shared currentUrl that was also written for other purposes) | ||
| // avoids a race where yt-navigate-finish fires before location.href has updated, | ||
| // causing currentUrl to be set to the new URL before onNewVideo() is called, | ||
| // which then causes the subsequent yt-page-data-updated guard to suppress it. | ||
| function maybeStartForCurrentPage() { | ||
| const url = location.href; | ||
| log(`maybeStartForCurrentPage: pathname="${location.pathname}" readyState="${document.readyState}" lastInitiatedUrl="${lastInitiatedUrl}"`); | ||
| if (url === lastInitiatedUrl) { | ||
| log("maybeStartForCurrentPage: skipping, URL already handled"); | ||
| return; | ||
| } | ||
| if (location.pathname === "/watch" || location.pathname.startsWith("/shorts/")) { | ||
| lastInitiatedUrl = url; | ||
| onNewVideo(); | ||
| } else { | ||
| log(`maybeStartForCurrentPage: pathname not a watch/shorts page, ignoring`); | ||
| } | ||
| }); | ||
| } | ||
| // Also handle initial page load if already on a watch page | ||
| if (location.pathname === "/watch") { | ||
| onNewVideo(); | ||
| // --- Startup sequence — four cases, do not simplify without reading CLAUDE.md --- | ||
| // | ||
| // Case 1: Direct load into /watch (bookmark, address bar) | ||
| // Caught by: DOMContentLoaded -> maybeStartForCurrentPage, or | ||
| // yt-page-data-updated if that fires later | ||
| // Case 2: SPA navigation to /watch from another YouTube page | ||
| // Caught by: yt-navigate-finish -> maybeStartForCurrentPage | ||
| // Case 3: Extension reloaded with /watch tab already open | ||
| // Caught by: direct maybeStartForCurrentPage() call (readyState === "complete") | ||
| // Case 4: SPA navigation away from /watch and back | ||
| // Caught by: yt-navigate-finish, guarded by lastInitiatedUrl | ||
| window.addEventListener("yt-navigate-finish", maybeStartForCurrentPage); | ||
| window.addEventListener("yt-page-data-updated", maybeStartForCurrentPage); | ||
| // Handle initial page load if already on a watch or Shorts page. | ||
| // With document_start injection the DOM may not be ready yet, so wait for | ||
| // DOMContentLoaded if needed. On re-injection into a live page (e.g. after | ||
| // extension reload) readyState will already be "complete" so we call directly. | ||
| if (document.readyState === "loading") { | ||
| document.addEventListener("DOMContentLoaded", maybeStartForCurrentPage); | ||
| } else { | ||
| maybeStartForCurrentPage(); | ||
| } | ||
@@ -435,0 +503,0 @@ |
+2
-2
| { | ||
| "manifest_version": 3, | ||
| "name": "YT Levelr", | ||
| "version": "1.3.4", | ||
| "version": "1.3.5", | ||
| "description": "Automatic loudness levelling for YouTube \u2014 useful for podcasts", | ||
@@ -31,3 +31,3 @@ "homepage_url": "https://github.com/AndyP2/yt-levelr", | ||
| ], | ||
| "run_at": "document_idle" | ||
| "run_at": "document_start" | ||
| } | ||
@@ -34,0 +34,0 @@ ], |
+100
-99
@@ -0,1 +1,3 @@ | ||
| // Key design constraints: see CLAUDE.md before making changes | ||
| // Cross-browser compatibility shim: Firefox exposes `browser`, Chrome exposes `chrome` | ||
@@ -236,78 +238,77 @@ const browser = globalThis.browser ?? globalThis.chrome; | ||
| let lastPollTime = 0; // Last poll timestamp for debouncing | ||
| let pollInterval = null; | ||
| function pollState() { | ||
| browser.tabs | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (!tabs[0]) { | ||
| return; | ||
| } | ||
| const now = Date.now(); | ||
| // Debounce polling when tab is inactive | ||
| if (now - lastPollTime < POLL_DEBOUNCE_MS) { | ||
| return; | ||
| } | ||
| lastPollTime = now; | ||
| browser.tabs | ||
| .sendMessage(tabs[0].id, { | ||
| type: "getState", | ||
| }) | ||
| .then((tabs) => { | ||
| if (!tabs[0]) { | ||
| .then((state) => { | ||
| if (!state) { | ||
| return; | ||
| } | ||
| const now = Date.now(); | ||
| const gainDb = gainToDb(state.gain); | ||
| gainDisplay.textContent = state.gain.toFixed(2); | ||
| // Restore the unit span safely | ||
| const unitSpan = document.createElement("span"); | ||
| unitSpan.className = "unit"; | ||
| unitSpan.textContent = "x"; | ||
| gainDisplay.appendChild(unitSpan); | ||
| gainBar.style.width = gainToBarPercent(state.gain) + "%"; | ||
| // Debounce polling when tab is inactive | ||
| if (now - lastPollTime < POLL_DEBOUNCE_MS) { | ||
| return; | ||
| if (!state.enabled) { | ||
| statusDot.className = "status-dot off"; | ||
| statusText.textContent = "disabled"; | ||
| confidenceBar.className = "confidence-bar-fill"; | ||
| confidenceBar.style.width = "0%"; | ||
| confidencePct.textContent = "—"; | ||
| } else if (state.locked) { | ||
| statusDot.className = "status-dot locked"; | ||
| statusText.textContent = `locked · ${gainDb >= 0 ? "+" : ""}${gainDb.toFixed(1)} dB`; | ||
| confidenceBar.className = "confidence-bar-fill locked"; | ||
| confidencePct.textContent = "locked"; | ||
| } else { | ||
| const pct = elapsedToConfidence(state.elapsed); | ||
| statusDot.className = "status-dot measuring"; | ||
| statusText.textContent = "measuring…"; | ||
| confidenceBar.className = "confidence-bar-fill"; | ||
| confidenceBar.style.width = pct + "%"; | ||
| confidencePct.textContent = Math.round(pct) + "%"; | ||
| } | ||
| lastPollTime = now; | ||
| browser.tabs | ||
| .sendMessage(tabs[0].id, { | ||
| type: "getState", | ||
| }) | ||
| .then((state) => { | ||
| if (!state) { | ||
| return; | ||
| } | ||
| const gainDb = gainToDb(state.gain); | ||
| gainDisplay.textContent = state.gain.toFixed(2); | ||
| // Restore the unit span safely | ||
| const unitSpan = document.createElement("span"); | ||
| unitSpan.className = "unit"; | ||
| unitSpan.textContent = "x"; | ||
| gainDisplay.appendChild(unitSpan); | ||
| gainBar.style.width = gainToBarPercent(state.gain) + "%"; | ||
| if (!state.enabled) { | ||
| statusDot.className = "status-dot off"; | ||
| statusText.textContent = "disabled"; | ||
| confidenceBar.className = "confidence-bar-fill"; | ||
| confidenceBar.style.width = "0%"; | ||
| confidencePct.textContent = "—"; | ||
| } else if (state.locked) { | ||
| statusDot.className = "status-dot locked"; | ||
| statusText.textContent = `locked · ${gainDb >= 0 ? "+" : ""}${gainDb.toFixed(1)} dB`; | ||
| confidenceBar.className = "confidence-bar-fill locked"; | ||
| confidencePct.textContent = "locked"; | ||
| } else { | ||
| const pct = elapsedToConfidence(state.elapsed); | ||
| statusDot.className = "status-dot measuring"; | ||
| statusText.textContent = "measuring…"; | ||
| confidenceBar.className = "confidence-bar-fill"; | ||
| confidenceBar.style.width = pct + "%"; | ||
| confidencePct.textContent = Math.round(pct) + "%"; | ||
| } | ||
| drawWaveform(state); | ||
| }) | ||
| .catch((err) => { | ||
| console.log("[YT Levelr popup] sendMessage failed:", err.message); | ||
| statusDot.className = "status-dot off"; | ||
| statusText.textContent = "not on a YouTube video"; | ||
| confidenceBar.className = "confidence-bar-fill"; | ||
| confidenceBar.style.width = "0%"; | ||
| confidencePct.textContent = "\u2014"; | ||
| drawWaveform({ | ||
| waveform: new Array(100).fill(null), | ||
| }); | ||
| }); | ||
| drawWaveform(state); | ||
| }) | ||
| .catch((err) => { | ||
| console.warn("[YT Levelr popup] tabs.query failed:", err); | ||
| console.log("[YT Levelr popup] sendMessage failed:", err.message); | ||
| statusDot.className = "status-dot off"; | ||
| statusText.textContent = "not on a YouTube video"; | ||
| confidenceBar.className = "confidence-bar-fill"; | ||
| confidenceBar.style.width = "0%"; | ||
| confidencePct.textContent = "\u2014"; | ||
| drawWaveform({ | ||
| waveform: new Array(100).fill(null), | ||
| }); | ||
| }); | ||
| }) | ||
| .catch((err) => { | ||
| console.warn("[YT Levelr popup] tabs.query failed:", err); | ||
| }); | ||
| } | ||
@@ -317,3 +318,3 @@ | ||
| pollState(); | ||
| pollInterval = setInterval(pollState, POLL_INTERVAL_MS); | ||
| setInterval(pollState, POLL_INTERVAL_MS); | ||
@@ -330,14 +331,14 @@ // ---- Controls ---- | ||
| browser.tabs | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (tabs[0]) { | ||
| browser.tabs.sendMessage(tabs[0].id, { | ||
| type: "setEnabled", | ||
| value: enabled, | ||
| }); | ||
| } | ||
| }); | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (tabs[0]) { | ||
| browser.tabs.sendMessage(tabs[0].id, { | ||
| type: "setEnabled", | ||
| value: enabled, | ||
| }); | ||
| } | ||
| }); | ||
| }); | ||
@@ -353,14 +354,14 @@ | ||
| browser.tabs | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (tabs[0]) { | ||
| browser.tabs.sendMessage(tabs[0].id, { | ||
| type: "setTarget", | ||
| value: rms, | ||
| }); | ||
| } | ||
| }); | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (tabs[0]) { | ||
| browser.tabs.sendMessage(tabs[0].id, { | ||
| type: "setTarget", | ||
| value: rms, | ||
| }); | ||
| } | ||
| }); | ||
| }); | ||
@@ -370,13 +371,13 @@ | ||
| browser.tabs | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (tabs[0]) { | ||
| browser.tabs.sendMessage(tabs[0].id, { | ||
| type: "remeasure", | ||
| }); | ||
| } | ||
| }); | ||
| .query({ | ||
| active: true, | ||
| currentWindow: true, | ||
| }) | ||
| .then((tabs) => { | ||
| if (tabs[0]) { | ||
| browser.tabs.sendMessage(tabs[0].id, { | ||
| type: "remeasure", | ||
| }); | ||
| } | ||
| }); | ||
| statusDot.className = "status-dot measuring"; | ||
@@ -383,0 +384,0 @@ statusText.textContent = "measuring…"; |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet