Browser Extension

ContinuousPlay keeps YouTube playing, automatically.

YouTube interrupts long sessions with the "Video paused. Continue watching?" prompt. ContinuousPlay watches for it and clicks Continue the instant it appears - so your music, lectures, and background video never stall. Works on Opera and Firefox.

Submitted to the Opera and Firefox (AMO) add-on catalogs - pending review. A Chrome / Edge port may follow.
ContinuousPlay - Continue Playing for Opera
ContinuousPlayv1.0 · Opera & Firefox

Why you will want it

One job, done quietly in the background.

Never stalls

Auto-clicks the "Continue watching?" confirmation the moment it appears. Playback just keeps going.

Zero setup

No options, no account, no popups. Install it and forget it - it only runs on youtube.com.

Private by design

No data collected, no network calls, no tracking. It asks for zero permissions beyond YouTube itself.

How it works

A tiny content script, nothing more.

  1. It watches the YouTube page for the idle "Video paused. Continue watching?" dialog.
  2. The instant that dialog appears, it clicks the Continue button for you.
  3. It then re-arms for next time - so a single sitting can run for hours untouched.

Privacy

The short version: it collects nothing.

ContinuousPlay runs entirely in your browser on youtube.com. It makes no network requests, stores no data, sets no cookies, and asks for no special permissions - just the single content-script match for YouTube. There is nothing to opt out of because nothing leaves your machine.

Support

Found a bug, want a feature, or just have feedback? Two ways to reach us.

Community forum

Browse known issues, ask a question, or see what others have reported. Public and free.

Open the forum

Bug report / feature request / feedback

Source code

The complete, unminified source - exactly what runs in the published package.

No build step, bundler, or minifier. Load the unpacked folder as-is, or download the exact reviewed package: ContinuousPlay-1.0.1.zip.

manifest.json

{
  "manifest_version": 3,
  "name": "ContinuousPlay",
  "version": "1.0.1",
  "description": "Auto-clicks YouTube's 'Continue watching?' prompt so playback never stalls - runs only on youtube.com, collects no data.",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "content_scripts": [
    {
      "matches": ["https://www.youtube.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["worker.js"],
      "matches": ["https://www.youtube.com/*"]
    }
  ]
}

content.js

/*
 * ContinuousPlay - keep YouTube playing
 * -------------------------------------
 * YouTube shows a "Video paused. Continue watching?" dialog after a long idle
 * period. This content script auto-clicks its confirm button so playback never
 * stalls, with active verification:
 *
 *   1. Checks once per second (setInterval) AND instantly on DOM mutation
 *      (MutationObserver), so a missed mutation can never wedge it.
 *   2. After clicking, it CONFIRMS the button actually left the DOM and does not
 *      immediately come back; if it is still present it retries the click.
 *
 * Runs only on youtube.com. No network requests, no storage, no tracking - it
 * does exactly one thing, locally, in your browser.
 */

(function () {
  "use strict";

  var POLL_MS = 1000; // check at least once per second
  var COOLDOWN_MS = 1500; // do not re-click the same dialog while it animates out
  var CONFIRM_INTERVAL_MS = 250; // re-check cadence after a click
  var CONFIRM_CHECKS = 6; // ~1.5s of post-click verification
  var CONFIRM_CLEAN_NEEDED = 2; // consecutive "gone" checks to call it confirmed
  var CLICK_RETRIES = 3; // click attempts before giving up

  var lastClickTs = 0;

  function log(msg) {
    try { console.debug("[ContinuousPlay]", msg); } catch (e) {}
  }

  /* ---------------- button detection ---------------- */

  function findConfirmButton() {
    var dialog = document.querySelector("yt-confirm-dialog-renderer");
    if (dialog) {
      var byId = dialog.querySelector("#confirm-button");
      if (byId) {
        var clickable = resolveClickable(byId);
        if (isVisible(clickable)) return clickable;
      }
    }

    var globalConfirm = document.querySelector(
      "yt-button-renderer#confirm-button, #confirm-button"
    );
    if (globalConfirm) {
      var c2 = resolveClickable(globalConfirm);
      if (isVisible(c2)) return c2;
    }

    var candidates = document.querySelectorAll(
      "yt-spec-button-shape-next, .ytSpecButtonShapeNextHost, " +
        "tp-yt-paper-button, button"
    );
    for (var i = 0; i < candidates.length; i++) {
      var el = candidates[i];
      if (!isVisible(el)) continue;
      var label = (
        (el.getAttribute("aria-label") || "") + " " + (el.textContent || "")
      )
        .trim()
        .toLowerCase();
      if (/\b(yes|continue watching|continue)\b/.test(label)) {
        if (el.closest("yt-confirm-dialog-renderer, tp-yt-paper-dialog, ytd-popup-container")) {
          return resolveClickable(el);
        }
      }
    }

    return null;
  }

  function resolveClickable(wrapper) {
    if (!wrapper) return null;
    var inner = wrapper.querySelector(
      "yt-spec-button-shape-next button, .ytSpecButtonShapeNextHost button, " +
        "button, a#button, a"
    );
    return inner || wrapper;
  }

  function isVisible(el) {
    if (!el) return false;
    var rect = el.getBoundingClientRect();
    if (rect.width <= 0 || rect.height <= 0) return false;
    var style = window.getComputedStyle(el);
    if (style.display === "none" || style.visibility === "hidden") return false;
    if (parseFloat(style.opacity || "1") === 0) return false;
    return true;
  }

  /* present + visible -> the clickable, else null */
  function buttonPresent() {
    var b = findConfirmButton();
    return b && isVisible(b) ? b : null;
  }

  /* ---------------- click + post-click verification ---------------- */

  function doClick(attempt) {
    var btn = buttonPresent();
    if (!btn) return;

    lastClickTs = Date.now();
    try { btn.click(); } catch (e) { log("click threw: " + e.message); }
    log("clicked confirm (attempt " + attempt + ")");

    // Verify the button leaves the DOM and does not come straight back.
    var checks = 0;
    var clean = 0;
    var iv = setInterval(function () {
      checks++;
      if (!buttonPresent()) {
        clean++;
        if (clean >= CONFIRM_CLEAN_NEEDED) {
          clearInterval(iv);
          log("confirmed gone");
        }
        return;
      }
      // still present this check
      clean = 0;
      if (checks >= CONFIRM_CHECKS) {
        clearInterval(iv);
        if (attempt < CLICK_RETRIES) {
          log("button still present, retrying click");
          doClick(attempt + 1);
        } else {
          log("button did not disappear after " + CLICK_RETRIES + " clicks");
        }
      }
    }, CONFIRM_INTERVAL_MS);
  }

  /* ---------------- scheduling ---------------- */

  function maybeClick() {
    var btn = buttonPresent();
    if (btn && Date.now() - lastClickTs > COOLDOWN_MS) doClick(1);
    return !!btn;
  }

  // Instant reaction to DOM changes while the tab is visible. NOTE: we call
  // maybeClick() directly here, NOT via requestAnimationFrame -- rAF is PAUSED
  // for hidden/minimized tabs, so an rAF-scheduled scan would never run.
  var observer = new MutationObserver(function () { maybeClick(); });

  // The once-per-second heartbeat runs in a Web Worker. A minimized window /
  // hidden tab THROTTLES main-thread setInterval (to ~1/min after 5 min) and
  // pauses rAF, so the prompt would be missed while minimized. A Worker's timer
  // is NOT throttled, so the click still fires within ~1s. The worker is an
  // extension resource (web_accessible) so the page CSP can't block it; if the
  // Worker can't start we fall back to a (throttled) main-thread interval.
  function startTicker() {
    try {
      var url = (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.getURL)
        ? chrome.runtime.getURL("worker.js") : null;
      if (url) {
        var w = new Worker(url);
        w.onmessage = function () { maybeClick(); };
        log("worker ticker started");
        return;
      }
    } catch (e) {
      log("worker failed, using interval fallback: " + e.message);
    }
    window.setInterval(maybeClick, POLL_MS);
  }

  function start() {
    if (!document.body) {
      window.setTimeout(start, 50);
      return;
    }
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["aria-hidden", "style", "class", "hidden"]
    });
    startTicker();
    log("armed");
    maybeClick();
  }

  start();
})();

worker.js

/*
 * ContinuousPlay timer worker.
 *
 * A Web Worker's timers are NOT subject to the main-thread background-tab
 * throttling that slows setInterval to ~1/min (and pauses requestAnimationFrame)
 * when the Opera window is minimized or the tab is hidden. So we keep the
 * once-per-second heartbeat here and post it to the content script, which does
 * the actual DOM check + click. This is what makes ContinuousPlay keep firing
 * while minimized.
 */
setInterval(function () { postMessage(0); }, 1000);