Add configurable skip delay (0-30s) for Skip Intro and Skip Credits

This commit is contained in:
Justin Oro
2026-09-02 16:50:04 -07:00
parent 0f1f2a309e
commit b2082d0624
9 changed files with 333 additions and 21 deletions
+6 -4
View File
@@ -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 `<button>` or `[role="button"]` element whose visible text contains "skip intro" or "skip credit" (case-insensitive, so it still matches if Plex appends a countdown like "Skip Intro 0:05"). When a match appears and is actually visible, it clicks it — with a short cooldown per element so a single button can't be double-clicked in one burst, but can still be clicked again later if Plex reuses the same button for the next episode.
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 `<button>` or `[role="button"]` element whose visible text contains "skip intro" or "skip credit" (case-insensitive, so it still matches if Plex appends a countdown like "Skip Intro 0:05"). When a match appears and is actually visible, it schedules a click — immediately if its delay is 0, or after the configured delay otherwise. Right before a delayed click actually fires, it re-checks that the button is still present, visible, still says "Skip Intro"/"Skip Credits", and that its toggle is still on — so turning a toggle off, or Plex removing the button, cancels a pending click instead of firing it late. A short cooldown per element stops a single button from being double-clicked in one burst, but still allows a later click if Plex reuses the same button element for the next episode.
Settings (your Plex URL and the two toggles) are stored with `chrome.storage.sync`, so they follow you across machines if you're signed into Chrome.
@@ -39,7 +41,7 @@ npm install
npm test
```
The suite covers: clicking Skip Intro/Skip Credits, ignoring non-Plex pages, respecting the on/off toggles, matching a custom server URL, reacting to buttons added dynamically after page load, ignoring unrelated buttons, handling countdown suffixes in the button text, not clicking hidden buttons, correctly re-clicking a reused button after its cooldown, and the settings popup's save/validation/load behavior.
The suite covers: clicking Skip Intro/Skip Credits, ignoring non-Plex pages, respecting the on/off toggles, matching a custom server URL, reacting to buttons added dynamically after page load, ignoring unrelated buttons, handling countdown suffixes in the button text, not clicking hidden buttons, correctly re-clicking a reused button after its cooldown, delayed clicks (including cancellation when a toggle turns off or the button disappears mid-delay), and the settings popup's save/validation/load behavior for both the URL and the delay sliders.
## Troubleshooting
+48 -11
View File
@@ -1,6 +1,6 @@
(function () {
let settings = DEFAULT_SETTINGS;
const recentClicks = new WeakMap();
const elementState = new WeakMap();
const CLICK_COOLDOWN_MS = 1500;
function isSkipIntroButton(text) {
@@ -16,11 +16,48 @@
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
}
function canClick(el) {
const last = recentClicks.get(el);
if (last && Date.now() - last < CLICK_COOLDOWN_MS) return false;
recentClicks.set(el, Date.now());
return true;
function getDelayMs(type) {
const seconds = type === "intro" ? settings.introDelaySeconds : settings.creditsDelaySeconds;
return clampDelaySeconds(seconds) * 1000;
}
function isStillEligible(el, type) {
if (!document.body.contains(el)) return false;
if (!isVisible(el)) return false;
if (type === "intro" && !settings.skipIntro) return false;
if (type === "credits" && !settings.skipCredits) return false;
if (!hostMatchesConfig(window.location.hostname, settings.plexUrl)) return false;
const text = (el.innerText || el.textContent || "").trim().toLowerCase();
return type === "intro" ? isSkipIntroButton(text) : isSkipCreditsButton(text);
}
function performClick(el) {
const state = elementState.get(el) || {};
state.timer = null;
state.lastClickAt = Date.now();
elementState.set(el, state);
el.click();
}
function scheduleClick(el, type) {
const state = elementState.get(el) || {};
if (state.timer) return;
if (state.lastClickAt && Date.now() - state.lastClickAt < CLICK_COOLDOWN_MS) return;
const delayMs = getDelayMs(type);
if (delayMs <= 0) {
performClick(el);
return;
}
state.timer = setTimeout(() => {
const current = elementState.get(el) || {};
current.timer = null;
elementState.set(el, current);
if (isStillEligible(el, type)) performClick(el);
}, delayMs);
elementState.set(el, state);
}
function scanAndClick() {
@@ -33,11 +70,11 @@
if (!text) return;
if (!isVisible(el)) return;
const wantsIntroSkip = settings.skipIntro && isSkipIntroButton(text);
const wantsCreditsSkip = settings.skipCredits && isSkipCreditsButton(text);
if (!wantsIntroSkip && !wantsCreditsSkip) return;
if (canClick(el)) el.click();
if (settings.skipIntro && isSkipIntroButton(text)) {
scheduleClick(el, "intro");
} else if (settings.skipCredits && isSkipCreditsButton(text)) {
scheduleClick(el, "credits");
}
});
}
+37
View File
@@ -45,6 +45,28 @@
.toggle-row span {
font-size: 13px;
}
.delay-row {
padding: 0 0 10px;
}
.delay-row-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: #777;
margin-bottom: 4px;
}
.delay-row-label span:last-child {
font-weight: 600;
color: #555;
}
input[type="range"] {
width: 100%;
accent-color: #e5a00d;
}
input[type="range"]:disabled {
opacity: 0.4;
}
button.save {
width: 100%;
margin-top: 14px;
@@ -85,10 +107,25 @@
<span>Skip Intro</span>
<input type="checkbox" id="skipIntro">
</div>
<div class="delay-row">
<div class="delay-row-label">
<span>Delay before clicking</span>
<span id="introDelayValue">0s</span>
</div>
<input type="range" id="introDelay" min="0" max="30" step="1" value="0">
</div>
<div class="toggle-row">
<span>Skip Credits</span>
<input type="checkbox" id="skipCredits">
</div>
<div class="delay-row">
<div class="delay-row-label">
<span>Delay before clicking</span>
<span id="creditsDelayValue">0s</span>
</div>
<input type="range" id="creditsDelay" min="0" max="30" step="1" value="0">
</div>
<button class="save" id="saveBtn">Save</button>
<div id="status"></div>
+31 -1
View File
@@ -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);
+104
View File
@@ -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: `<!DOCTYPE html><html><body><button>Skip Intro</button></body></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: `<!DOCTYPE html><html><body><button>Skip Credits</button></body></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: `<!DOCTYPE html><html><body><button id="skip">Skip Intro</button></body></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: `<!DOCTYPE html><html><body><button>Skip Intro</button></body></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: `<!DOCTYPE html><html><body><button id="skip">Skip Intro</button></body></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: `<!DOCTYPE html><html><body><button id="skip">Skip Intro</button></body></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: `<!DOCTYPE html><html><body>
<button id="intro">Skip Intro</button>
<button id="credits">Skip Credits</button>
</body></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");
});
+26 -3
View File
@@ -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("<!DOCTYPE html><html><body></body></html>", {
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 };
+42 -1
View File
@@ -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 () => {
+30
View File
@@ -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);
});
+9 -1
View File
@@ -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;