From b2082d0624fcf37f6c1bcc8235d4878d6ab94db5 Mon Sep 17 00:00:00 2001 From: Justin Oro Date: Wed, 2 Sep 2026 16:50:04 -0700 Subject: [PATCH] Add configurable skip delay (0-30s) for Skip Intro and Skip Credits --- README.md | 10 +++-- content.js | 59 ++++++++++++++++++++----- popup.html | 37 ++++++++++++++++ popup.js | 32 +++++++++++++- tests/delay.test.js | 104 ++++++++++++++++++++++++++++++++++++++++++++ tests/helpers.js | 29 ++++++++++-- tests/popup.test.js | 43 +++++++++++++++++- tests/utils.test.js | 30 +++++++++++++ utils.js | 10 ++++- 9 files changed, 333 insertions(+), 21 deletions(-) create mode 100644 tests/delay.test.js create mode 100644 tests/utils.test.js diff --git a/README.md b/README.md index b243028..a945a05 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ A small Chrome extension that automatically clicks "Skip Intro" and "Skip Credit - Watches the Plex Web player and clicks "Skip Intro" / "Skip Credits" the instant either button appears. - Works on `app.plex.tv` by default, or point it at your own server address (local IP, custom domain, whatever you use). - Toggle Skip Intro and Skip Credits independently — both on by default. -- Settings save instantly and apply live, no reload required for toggle changes. +- Optional delay (0-30s) before each is clicked, in case a show needs a few extra seconds before the credits skip feels right. +- Settings save instantly and apply live, no reload required for toggle or delay changes. ## Install @@ -21,12 +22,13 @@ A small Chrome extension that automatically clicks "Skip Intro" and "Skip Credit - **Plex URL** — defaults to `app.plex.tv`. Choose "Custom URL" and enter your own server address (e.g. `192.168.1.50:32400/web` or a custom domain) if you watch there instead. - **Skip Intro** / **Skip Credits** — both on by default. Uncheck either one if you'd rather click it yourself sometimes. +- **Delay before clicking** — a 0-30 second slider under each toggle. Leave at 0 to click the instant the button appears (the original behavior), or add a few seconds if a particular show's Skip Credits button shows up before you're actually ready for it to fire. Each slider is disabled while its skip toggle is off, since a delay for a skip that isn't happening doesn't do anything. -Changing the Plex URL takes effect on your next page load — refresh your Plex tab after saving a new one. Toggle changes apply immediately. +Changing the Plex URL takes effect on your next page load — refresh your Plex tab after saving a new one. Toggle and delay changes apply immediately, no refresh needed. ## How it works -The extension's content script runs on every page but only activates once the page's hostname matches your configured Plex URL. From there it uses a `MutationObserver` to watch the page for any `
diff --git a/popup.js b/popup.js index fc04466..5476239 100644 --- a/popup.js +++ b/popup.js @@ -4,6 +4,10 @@ document.addEventListener("DOMContentLoaded", () => { const customUrl = document.getElementById("customUrl"); const skipIntro = document.getElementById("skipIntro"); const skipCredits = document.getElementById("skipCredits"); + const introDelay = document.getElementById("introDelay"); + const introDelayValue = document.getElementById("introDelayValue"); + const creditsDelay = document.getElementById("creditsDelay"); + const creditsDelayValue = document.getElementById("creditsDelayValue"); const saveBtn = document.getElementById("saveBtn"); const status = document.getElementById("status"); @@ -11,7 +15,24 @@ document.addEventListener("DOMContentLoaded", () => { customUrlRow.style.display = urlPreset.value === "custom" ? "block" : "none"; } + function formatDelay(seconds) { + return seconds + "s"; + } + + function updateDelayEnabled() { + introDelay.disabled = !skipIntro.checked; + creditsDelay.disabled = !skipCredits.checked; + } + urlPreset.addEventListener("change", toggleCustomRow); + skipIntro.addEventListener("change", updateDelayEnabled); + skipCredits.addEventListener("change", updateDelayEnabled); + introDelay.addEventListener("input", () => { + introDelayValue.textContent = formatDelay(introDelay.value); + }); + creditsDelay.addEventListener("input", () => { + creditsDelayValue.textContent = formatDelay(creditsDelay.value); + }); chrome.storage.sync.get(DEFAULT_SETTINGS, (settings) => { if (settings.plexUrl === DEFAULT_SETTINGS.plexUrl) { @@ -22,7 +43,14 @@ document.addEventListener("DOMContentLoaded", () => { } skipIntro.checked = settings.skipIntro; skipCredits.checked = settings.skipCredits; + + introDelay.value = clampDelaySeconds(settings.introDelaySeconds); + introDelayValue.textContent = formatDelay(introDelay.value); + creditsDelay.value = clampDelaySeconds(settings.creditsDelaySeconds); + creditsDelayValue.textContent = formatDelay(creditsDelay.value); + toggleCustomRow(); + updateDelayEnabled(); }); function showStatus(message, isError) { @@ -54,7 +82,9 @@ document.addEventListener("DOMContentLoaded", () => { { plexUrl: plexUrl, skipIntro: skipIntro.checked, - skipCredits: skipCredits.checked + skipCredits: skipCredits.checked, + introDelaySeconds: clampDelaySeconds(introDelay.value), + creditsDelaySeconds: clampDelaySeconds(creditsDelay.value) }, () => { showStatus("Saved", false); diff --git a/tests/delay.test.js b/tests/delay.test.js new file mode 100644 index 0000000..826ab01 --- /dev/null +++ b/tests/delay.test.js @@ -0,0 +1,104 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { loadContentScript, wait } = require("./helpers"); + +test("zero delay still clicks immediately (default behavior preserved)", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { introDelaySeconds: 0 } + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("does not click immediately when a delay is configured, but clicks once it elapses", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { creditsDelaySeconds: 1 } + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 0, "should not click before the delay elapses"); + + await wait(1100); + assert.strictEqual(window.__clickedElements.length, 1, "should click once the delay elapses"); +}); + +test("does not schedule duplicate clicks while a delay is already pending", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { introDelaySeconds: 1 } + }); + + await wait(50); + window.document.body.appendChild(window.document.createElement("div")); + await wait(50); + window.document.body.appendChild(window.document.createElement("div")); + + await wait(1200); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("cancels a scheduled click if its toggle is turned off mid-delay", async () => { + const { window, fireStorageChange } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { introDelaySeconds: 1 } + }); + + await wait(50); + fireStorageChange({ skipIntro: false }); + + await wait(1200); + assert.strictEqual(window.__clickedElements.length, 0); +}); + +test("does not click if the button is removed from the page before the delay elapses", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { introDelaySeconds: 1 } + }); + + await wait(50); + window.document.getElementById("skip").remove(); + + await wait(1200); + assert.strictEqual(window.__clickedElements.length, 0); +}); + +test("does not click if the delayed button is hidden before the delay elapses", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { introDelaySeconds: 1 } + }); + + await wait(50); + window.document.getElementById("skip").style.display = "none"; + + await wait(1200); + assert.strictEqual(window.__clickedElements.length, 0); +}); + +test("intro and credits delays run independently", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ` + + + `, + settings: { introDelaySeconds: 0, creditsDelaySeconds: 1 } + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1, "Skip Intro should click immediately"); + assert.strictEqual(window.__clickedElements[0], window.document.getElementById("intro")); + + await wait(1100); + assert.strictEqual(window.__clickedElements.length, 2, "Skip Credits should click after its own delay"); +}); diff --git a/tests/helpers.js b/tests/helpers.js index 2ceda0c..3f05a59 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -10,7 +10,13 @@ function loadContentScript({ url, html, settings }) { const window = dom.window; const storedSettings = Object.assign( - { plexUrl: "https://app.plex.tv", skipIntro: true, skipCredits: true }, + { + plexUrl: "https://app.plex.tv", + skipIntro: true, + skipCredits: true, + introDelaySeconds: 0, + creditsDelaySeconds: 0 + }, settings || {} ); const changeListeners = []; @@ -57,7 +63,13 @@ function loadPopupScript({ initialSettings } = {}) { const dom = new JSDOM(html, { url: "chrome-extension://fake/popup.html", runScripts: "dangerously" }); const window = dom.window; const storedSettings = Object.assign( - { plexUrl: "https://app.plex.tv", skipIntro: true, skipCredits: true }, + { + plexUrl: "https://app.plex.tv", + skipIntro: true, + skipCredits: true, + introDelaySeconds: 0, + creditsDelaySeconds: 0 + }, initialSettings || {} ); const savedCalls = []; @@ -82,8 +94,19 @@ function loadPopupScript({ initialSettings } = {}) { return { dom, window, savedCalls, storedSettings }; } +function loadUtils() { + const dom = new JSDOM("", { + url: "https://example.com", + runScripts: "dangerously" + }); + const window = dom.window; + const utilsCode = fs.readFileSync(path.join(__dirname, "..", "utils.js"), "utf8"); + window.eval(utilsCode); + return window; +} + function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -module.exports = { loadContentScript, loadPopupScript, wait }; +module.exports = { loadContentScript, loadPopupScript, loadUtils, wait }; diff --git a/tests/popup.test.js b/tests/popup.test.js index 9156392..5e261da 100644 --- a/tests/popup.test.js +++ b/tests/popup.test.js @@ -2,7 +2,7 @@ const test = require("node:test"); const assert = require("node:assert"); const { loadPopupScript, wait } = require("./helpers"); -test("defaults to app.plex.tv preset with both toggles on", async () => { +test("defaults to app.plex.tv preset with both toggles on and delays at 0s", async () => { const { window } = loadPopupScript(); await wait(20); @@ -10,6 +10,47 @@ test("defaults to app.plex.tv preset with both toggles on", async () => { assert.strictEqual(window.document.getElementById("skipIntro").checked, true); assert.strictEqual(window.document.getElementById("skipCredits").checked, true); assert.strictEqual(window.document.getElementById("customUrlRow").style.display, "none"); + assert.strictEqual(window.document.getElementById("introDelay").value, "0"); + assert.strictEqual(window.document.getElementById("creditsDelay").value, "0"); + assert.strictEqual(window.document.getElementById("introDelayValue").textContent, "0s"); + assert.strictEqual(window.document.getElementById("creditsDelayValue").textContent, "0s"); +}); + +test("loads a previously saved delay value onto the sliders", async () => { + const { window } = loadPopupScript({ + initialSettings: { introDelaySeconds: 3, creditsDelaySeconds: 12 } + }); + await wait(20); + + assert.strictEqual(window.document.getElementById("introDelay").value, "3"); + assert.strictEqual(window.document.getElementById("introDelayValue").textContent, "3s"); + assert.strictEqual(window.document.getElementById("creditsDelay").value, "12"); + assert.strictEqual(window.document.getElementById("creditsDelayValue").textContent, "12s"); +}); + +test("disables a delay slider when its skip toggle is off", async () => { + const { window } = loadPopupScript({ + initialSettings: { skipCredits: false } + }); + await wait(20); + + assert.strictEqual(window.document.getElementById("introDelay").disabled, false); + assert.strictEqual(window.document.getElementById("creditsDelay").disabled, true); +}); + +test("saving persists the configured delay values", async () => { + const { window, savedCalls } = loadPopupScript(); + await wait(20); + + window.document.getElementById("introDelay").value = "5"; + window.document.getElementById("creditsDelay").value = "20"; + + window.document.getElementById("saveBtn").click(); + await wait(20); + + assert.strictEqual(savedCalls.length, 1); + assert.strictEqual(savedCalls[0].introDelaySeconds, 5); + assert.strictEqual(savedCalls[0].creditsDelaySeconds, 20); }); test("shows custom URL field pre-filled when a custom URL is already saved", async () => { diff --git a/tests/utils.test.js b/tests/utils.test.js new file mode 100644 index 0000000..5a0331c --- /dev/null +++ b/tests/utils.test.js @@ -0,0 +1,30 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { loadUtils } = require("./helpers"); + +test("clampDelaySeconds keeps in-range integer values as-is", () => { + const window = loadUtils(); + assert.strictEqual(window.clampDelaySeconds(0), 0); + assert.strictEqual(window.clampDelaySeconds(5), 5); + assert.strictEqual(window.clampDelaySeconds(30), 30); +}); + +test("clampDelaySeconds clamps below 0 and above 30", () => { + const window = loadUtils(); + assert.strictEqual(window.clampDelaySeconds(-5), 0); + assert.strictEqual(window.clampDelaySeconds(45), 30); +}); + +test("clampDelaySeconds rounds fractional values", () => { + const window = loadUtils(); + assert.strictEqual(window.clampDelaySeconds(15.6), 16); + assert.strictEqual(window.clampDelaySeconds(15.4), 15); +}); + +test("clampDelaySeconds falls back to 0 for non-numeric input", () => { + const window = loadUtils(); + assert.strictEqual(window.clampDelaySeconds("abc"), 0); + assert.strictEqual(window.clampDelaySeconds(undefined), 0); + assert.strictEqual(window.clampDelaySeconds(null), 0); + assert.strictEqual(window.clampDelaySeconds(NaN), 0); +}); diff --git a/utils.js b/utils.js index f3b4dc5..4ccf14e 100644 --- a/utils.js +++ b/utils.js @@ -1,9 +1,17 @@ const DEFAULT_SETTINGS = { plexUrl: "https://app.plex.tv", skipIntro: true, - skipCredits: true + skipCredits: true, + introDelaySeconds: 0, + creditsDelaySeconds: 0 }; +function clampDelaySeconds(value) { + const num = Number(value); + if (!Number.isFinite(num)) return 0; + return Math.min(30, Math.max(0, Math.round(num))); +} + function normalizePlexUrl(input) { let value = (input || "").trim(); if (!value) return DEFAULT_SETTINGS.plexUrl;