commit 0f1f2a309e2a457a00bee7cc5527a18a3b8993cf Author: Justin Oro Date: Sun Aug 16 22:55:33 2026 -0700 Initial commit: Plex Auto Skip Chrome extension diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..08236d1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7c4dee --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.DS_Store +*.zip diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a57161e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Justin Oros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b243028 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Plex Auto Skip + +A small Chrome extension that automatically clicks "Skip Intro" and "Skip Credits" on Plex Web, so you don't have to reach for the mouse every time. + +## Features + +- 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. + +## Install + +1. Download or clone this repo. +2. Open `chrome://extensions` in Chrome. +3. Turn on **Developer mode** (top right toggle). +4. Click **Load unpacked** and select this project's folder. +5. Click the extension's icon in the toolbar to open its settings. + +## Settings + +- **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. + +Changing the Plex URL takes effect on your next page load — refresh your Plex tab after saving a new one. Toggle changes apply immediately. + +## 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 new file mode 100644 index 0000000..fc04466 --- /dev/null +++ b/popup.js @@ -0,0 +1,64 @@ +document.addEventListener("DOMContentLoaded", () => { + const urlPreset = document.getElementById("urlPreset"); + const customUrlRow = document.getElementById("customUrlRow"); + const customUrl = document.getElementById("customUrl"); + const skipIntro = document.getElementById("skipIntro"); + const skipCredits = document.getElementById("skipCredits"); + const saveBtn = document.getElementById("saveBtn"); + const status = document.getElementById("status"); + + function toggleCustomRow() { + customUrlRow.style.display = urlPreset.value === "custom" ? "block" : "none"; + } + + urlPreset.addEventListener("change", toggleCustomRow); + + chrome.storage.sync.get(DEFAULT_SETTINGS, (settings) => { + if (settings.plexUrl === DEFAULT_SETTINGS.plexUrl) { + urlPreset.value = "default"; + } else { + urlPreset.value = "custom"; + customUrl.value = settings.plexUrl.replace(/^https?:\/\//, ""); + } + skipIntro.checked = settings.skipIntro; + skipCredits.checked = settings.skipCredits; + toggleCustomRow(); + }); + + function showStatus(message, isError) { + status.textContent = message; + status.style.color = isError ? "#c0392b" : "#2a8f2a"; + setTimeout(() => { + status.textContent = ""; + }, 1500); + } + + saveBtn.addEventListener("click", () => { + const isCustom = urlPreset.value === "custom"; + let plexUrl = DEFAULT_SETTINGS.plexUrl; + + if (isCustom) { + const trimmed = customUrl.value.trim(); + if (!trimmed) { + showStatus("Enter a URL first", true); + return; + } + plexUrl = normalizePlexUrl(trimmed); + if (!getHostFromUrl(plexUrl)) { + showStatus("That URL doesn't look valid", true); + return; + } + } + + chrome.storage.sync.set( + { + plexUrl: plexUrl, + skipIntro: skipIntro.checked, + skipCredits: skipCredits.checked + }, + () => { + showStatus("Saved", false); + } + ); + }); +}); diff --git a/tests/content.test.js b/tests/content.test.js new file mode 100644 index 0000000..6c9e0ce --- /dev/null +++ b/tests/content.test.js @@ -0,0 +1,123 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { loadContentScript, wait } = require("./helpers"); + +test("clicks a Skip Intro button on app.plex.tv", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("clicks a Skip Credits button on app.plex.tv", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("does nothing on a non-matching host", async () => { + const { window } = loadContentScript({ + url: "https://example.com/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 0); +}); + +test("respects skipIntro:false toggle", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: ``, + settings: { skipIntro: false, skipCredits: true } + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 0); +}); + +test("matches a custom configured URL by hostname", async () => { + const { window } = loadContentScript({ + url: "http://192.168.1.50:32400/web/index.html", + html: ``, + settings: { plexUrl: "http://192.168.1.50:32400/web" } + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("clicks a button added dynamically after page load", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(20); + + const btn = window.document.createElement("button"); + btn.textContent = "Skip Intro"; + window.document.body.appendChild(btn); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("does not click an unrelated button", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 0); +}); + +test("real Plex markup: skip button includes a countdown suffix", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1); +}); + +test("same DOM node reused for a later segment still gets clicked after cooldown", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 1, "first segment's skip button should be clicked"); + + const btn = window.document.getElementById("skip"); + btn.textContent = ""; + await wait(1700); + + btn.textContent = "Skip Intro"; + await wait(50); + assert.strictEqual( + window.__clickedElements.length, + 2, + "the same reused button should be clickable again after the cooldown" + ); +}); + +test("does not click a hidden skip button", async () => { + const { window } = loadContentScript({ + url: "https://app.plex.tv/web/index.html", + html: `` + }); + + await wait(50); + assert.strictEqual(window.__clickedElements.length, 0); +}); diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..2ceda0c --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,89 @@ +const fs = require("fs"); +const path = require("path"); +const { JSDOM } = require("jsdom"); + +function loadContentScript({ url, html, settings }) { + const dom = new JSDOM(html || "", { + url: url, + runScripts: "dangerously" + }); + + const window = dom.window; + const storedSettings = Object.assign( + { plexUrl: "https://app.plex.tv", skipIntro: true, skipCredits: true }, + settings || {} + ); + const changeListeners = []; + + window.__clickedElements = []; + const originalClick = window.HTMLElement.prototype.click; + window.HTMLElement.prototype.click = function () { + window.__clickedElements.push(this); + return originalClick.call(this); + }; + + window.chrome = { + storage: { + sync: { + get: (defaults, cb) => cb(Object.assign({}, defaults, storedSettings)), + set: (obj, cb) => { + Object.assign(storedSettings, obj); + if (cb) cb(); + } + }, + onChanged: { + addListener: (fn) => changeListeners.push(fn) + } + } + }; + + const utilsCode = fs.readFileSync(path.join(__dirname, "..", "utils.js"), "utf8"); + const contentCode = fs.readFileSync(path.join(__dirname, "..", "content.js"), "utf8"); + window.eval(utilsCode + "\n" + contentCode); + + function fireStorageChange(changes) { + const wrapped = {}; + Object.keys(changes).forEach((k) => { + wrapped[k] = { newValue: changes[k] }; + }); + changeListeners.forEach((fn) => fn(wrapped, "sync")); + } + + return { dom, window, fireStorageChange }; +} + +function loadPopupScript({ initialSettings } = {}) { + const html = fs.readFileSync(path.join(__dirname, "..", "popup.html"), "utf8"); + 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 }, + initialSettings || {} + ); + const savedCalls = []; + + window.chrome = { + storage: { + sync: { + get: (defaults, cb) => cb(Object.assign({}, defaults, storedSettings)), + set: (obj, cb) => { + savedCalls.push(obj); + Object.assign(storedSettings, obj); + if (cb) cb(); + } + } + } + }; + + const utilsCode = fs.readFileSync(path.join(__dirname, "..", "utils.js"), "utf8"); + const popupCode = fs.readFileSync(path.join(__dirname, "..", "popup.js"), "utf8"); + window.eval(utilsCode + "\n" + popupCode); + + return { dom, window, savedCalls, storedSettings }; +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +module.exports = { loadContentScript, loadPopupScript, wait }; diff --git a/tests/popup.test.js b/tests/popup.test.js new file mode 100644 index 0000000..9156392 --- /dev/null +++ b/tests/popup.test.js @@ -0,0 +1,94 @@ +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 () => { + const { window } = loadPopupScript(); + await wait(20); + + assert.strictEqual(window.document.getElementById("urlPreset").value, "default"); + 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"); +}); + +test("shows custom URL field pre-filled when a custom URL is already saved", async () => { + const { window } = loadPopupScript({ + initialSettings: { plexUrl: "http://192.168.1.50:32400/web" } + }); + await wait(20); + + assert.strictEqual(window.document.getElementById("urlPreset").value, "custom"); + assert.strictEqual(window.document.getElementById("customUrl").value, "192.168.1.50:32400/web"); +}); + +test("saving the default preset persists the default Plex URL", async () => { + const { window, savedCalls } = loadPopupScript(); + await wait(20); + + window.document.getElementById("saveBtn").click(); + await wait(20); + + assert.strictEqual(savedCalls.length, 1); + assert.strictEqual(savedCalls[0].plexUrl, "https://app.plex.tv"); +}); + +test("saving a custom URL normalizes and persists it", async () => { + const { window, savedCalls } = loadPopupScript(); + await wait(20); + + const urlPreset = window.document.getElementById("urlPreset"); + urlPreset.value = "custom"; + urlPreset.dispatchEvent(new window.Event("change")); + window.document.getElementById("customUrl").value = "192.168.1.50:32400/web"; + + window.document.getElementById("saveBtn").click(); + await wait(20); + + assert.strictEqual(savedCalls.length, 1); + assert.strictEqual(savedCalls[0].plexUrl, "http://192.168.1.50:32400/web"); +}); + +test("saving Custom URL with a blank field does not silently fall back to the default", async () => { + const { window, savedCalls } = loadPopupScript(); + await wait(20); + + const urlPreset = window.document.getElementById("urlPreset"); + urlPreset.value = "custom"; + urlPreset.dispatchEvent(new window.Event("change")); + window.document.getElementById("customUrl").value = " "; + + window.document.getElementById("saveBtn").click(); + await wait(20); + + assert.strictEqual(savedCalls.length, 0, "should not save when the custom URL field is blank"); + assert.match(window.document.getElementById("status").textContent, /enter|url/i); +}); + +test("saving an unparseable custom URL is rejected with an error, not saved", async () => { + const { window, savedCalls } = loadPopupScript(); + await wait(20); + + const urlPreset = window.document.getElementById("urlPreset"); + urlPreset.value = "custom"; + urlPreset.dispatchEvent(new window.Event("change")); + window.document.getElementById("customUrl").value = "http://"; + + window.document.getElementById("saveBtn").click(); + await wait(20); + + assert.strictEqual(savedCalls.length, 0, "should not save an invalid URL"); +}); + +test("unchecking a toggle and saving persists it as false", async () => { + const { window, savedCalls } = loadPopupScript(); + await wait(20); + + window.document.getElementById("skipCredits").checked = false; + window.document.getElementById("saveBtn").click(); + await wait(20); + + assert.strictEqual(savedCalls.length, 1); + assert.strictEqual(savedCalls[0].skipCredits, false); + assert.strictEqual(savedCalls[0].skipIntro, true); +}); diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..f3b4dc5 --- /dev/null +++ b/utils.js @@ -0,0 +1,26 @@ +const DEFAULT_SETTINGS = { + plexUrl: "https://app.plex.tv", + skipIntro: true, + skipCredits: true +}; + +function normalizePlexUrl(input) { + let value = (input || "").trim(); + if (!value) return DEFAULT_SETTINGS.plexUrl; + if (!/^https?:\/\//i.test(value)) value = "http://" + value; + return value; +} + +function getHostFromUrl(urlString) { + try { + return new URL(urlString).hostname; + } catch (e) { + return null; + } +} + +function hostMatchesConfig(currentHostname, configuredUrl) { + const configuredHost = getHostFromUrl(configuredUrl); + if (!configuredHost) return false; + return currentHostname === configuredHost; +}