Add configurable skip delay (0-30s) for Skip Intro and Skip Credits
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user