New:Socket for Asana Is Now Available.Learn more
Get Started

YouTube Pagination

Package Overview
Versions
1
Alerts
File Explorer
Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

yt-pagination@example.com - firefox Package Compare versions

Comparing version
1.0.0
to
1.0.2
+179
-28
content.js

@@ -8,5 +8,3 @@ // YouTube Pagination

// names. The selectors below are current as of mid-2026 but may need
// updating if YouTube ships changes that break this extension. Mobile-site
// (m.youtube.com) selectors are best-effort and less battle-tested than
// the desktop-site ones.
// updating if YouTube ships changes that break this extension.

@@ -18,5 +16,39 @@ (function () {

// Known video/item card tags across desktop (ytd-*) and mobile (ytm-*)
// YouTube. These lists differ because mobile YouTube names things
// differently (e.g. "video-with-context" instead of just "video").
const ITEM_SELECTOR = [
'ytd-rich-item-renderer',
'ytd-video-renderer',
'ytd-grid-video-renderer',
'ytd-playlist-video-renderer',
'ytd-compact-video-renderer',
'ytm-rich-item-renderer',
'ytm-video-with-context-renderer',
'ytm-compact-video-renderer',
'ytm-grid-video-renderer',
'ytm-playlist-video-renderer',
].join(', ');
// Containers we should never paginate inside of, because they're
// horizontally-scrolling shelves, not the main vertical feed.
const EXCLUDE_ANCESTOR_SELECTOR = [
'ytd-reel-shelf-renderer',
'ytd-rich-shelf-renderer',
'ytm-reel-shelf-renderer',
].join(', ');
const CONTINUATION_SELECTOR =
'ytd-continuation-item-renderer, ytm-continuation-item-renderer';
// Pages where pagination should never run, even though the item tags
// technically match (e.g. a playlist's own page lists videos with
// ytd-playlist-video-renderer / ytm-playlist-video-renderer).
function isExcludedPage() {
return window.location.pathname === '/playlist';
}
const STATE = {
allItems: [], // every video/item element loaded so far, in order
currentIndex: 0, // index of the first item of the visible page
allItems: [], // every video/item element loaded so far, in order
currentIndex: 0, // index of the first item of the visible page
pageSize: DEFAULT_PAGE_SIZE,

@@ -26,2 +58,4 @@ continuationEl: null,

controlsEl: null,
styleEl: null,
themeObserver: null,
loading: false,

@@ -34,22 +68,24 @@ };

// Elements that represent an actual video/item card in a feed.
// Covers both the desktop (ytd-*) and mobile (ytm-*) custom element sets.
function isItemNode(node) {
if (node.nodeType !== 1) return false;
const tag = node.tagName;
if (!tag) return false;
return /^(YTD|YTM)-(RICH-ITEM|VIDEO|GRID-VIDEO|PLAYLIST-VIDEO|COMPACT-VIDEO)-RENDERER$/.test(
tag
);
function findContinuation() {
return document.querySelector(CONTINUATION_SELECTOR);
}
function findContinuation() {
return document.querySelector(
'ytd-continuation-item-renderer, ytm-continuation-item-renderer'
);
// Walk up from the continuation trigger until we find an ancestor that
// also contains actual video items. This is more robust than assuming a
// fixed nesting depth, since that depth differs between desktop/mobile
// and between page types (home, search, channel).
function findFeedRoot(continuationEl) {
let node = continuationEl.parentElement;
let hops = 0;
while (node && hops < 8) {
if (node.querySelector(ITEM_SELECTOR)) return node;
node = node.parentElement;
hops++;
}
return null;
}
function collectAllItems(container, excludeEl) {
return Array.from(container.children).filter(
(el) => el !== excludeEl && isItemNode(el)
function collectAllItems(container) {
return Array.from(container.querySelectorAll(ITEM_SELECTOR)).filter(
(el) => !el.closest(EXCLUDE_ANCESTOR_SELECTOR)
);

@@ -116,2 +152,102 @@ }

// ---- styling (injected inline so it still applies even if our controls
// end up inside a Shadow DOM tree, which the manifest's external
// style.css cannot reach) ----
const CSS_TEXT = `
#ytpag-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
padding: 16px;
margin: 8px 0 24px 0;
box-sizing: border-box;
background: var(--yt-spec-badge-chip-background, #f8f8f8);
border: 1px solid var(--yt-spec-10-percent-layer, #e5e5e5);
border-radius: 12px;
font-family: Roboto, Arial, sans-serif;
}
.ytpag-btn {
padding: 8px 18px;
border-radius: 18px;
border: 1px solid var(--yt-spec-10-percent-layer, #ccc);
background: var(--yt-spec-general-background-a, #ffffff);
color: var(--yt-spec-text-primary, #0f0f0f);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.ytpag-btn:hover:not(:disabled) {
background: var(--yt-spec-10-percent-layer, #e5e5e5);
}
.ytpag-btn:disabled {
opacity: 0.5;
cursor: default;
}
#ytpag-indicator {
font-size: 14px;
color: var(--yt-spec-text-secondary, #606060);
min-width: 160px;
text-align: center;
}
#ytpag-size-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--yt-spec-text-secondary, #606060);
}
#ytpag-size {
width: 56px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid var(--yt-spec-10-percent-layer, #ccc);
background: var(--yt-spec-general-background-a, #ffffff);
color: var(--yt-spec-text-primary, #0f0f0f);
font-size: 13px;
}
/* Explicit dark-theme overrides, toggled via JS (not a CSS attribute
selector on <html>), because that selector can't reach across a
Shadow DOM boundary if our bar ends up inside one. */
#ytpag-controls.ytpag-dark {
background: #1f1f1f;
border-color: #3f3f3f;
}
#ytpag-controls.ytpag-dark .ytpag-btn {
background: #0f0f0f;
color: #f1f1f1;
border-color: #3f3f3f;
}
#ytpag-controls.ytpag-dark .ytpag-btn:hover:not(:disabled) {
background: #3f3f3f;
}
#ytpag-controls.ytpag-dark #ytpag-indicator,
#ytpag-controls.ytpag-dark #ytpag-size-label {
color: #aaaaaa;
}
#ytpag-controls.ytpag-dark #ytpag-size {
background: #0f0f0f;
color: #f1f1f1;
border-color: #3f3f3f;
}
`;
function applyTheme() {
if (!STATE.controlsEl) return;
const isDark = document.documentElement.hasAttribute('dark');
STATE.controlsEl.classList.toggle('ytpag-dark', isDark);
}
function watchTheme() {
if (STATE.themeObserver) STATE.themeObserver.disconnect();
STATE.themeObserver = new MutationObserver(applyTheme);
STATE.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['dark'],
});
}
// ---- controls UI ----

@@ -132,5 +268,12 @@

const style = document.createElement('style');
style.textContent = CSS_TEXT;
bar.prepend(style);
STATE.styleEl = style;
// Insert right after the feed container, so it only appears once the
// user has scrolled to the bottom of the current batch of videos,
// instead of floating over the page.
// instead of floating over the page. Inserting it here (rather than
// appending to document.body) also guarantees it lands in the same
// DOM tree as the feed itself, even if that tree is a Shadow DOM.
STATE.itemsContainer.insertAdjacentElement('afterend', bar);

@@ -158,2 +301,4 @@

STATE.controlsEl = bar;
applyTheme();
watchTheme();
updateIndicator();

@@ -223,3 +368,3 @@ }

});
mo.observe(STATE.itemsContainer, { childList: true });
mo.observe(STATE.itemsContainer, { childList: true, subtree: true });

@@ -235,5 +380,3 @@ // Safety net in case nothing loads (e.g. end of the list reached).

const fresh = collectAllItems(STATE.itemsContainer, STATE.continuationEl);
// Only keep items we haven't already tracked.
STATE.allItems = fresh;
STATE.allItems = collectAllItems(STATE.itemsContainer);

@@ -248,2 +391,3 @@ STATE.loading = false;

function teardown() {
if (STATE.themeObserver) STATE.themeObserver.disconnect();
if (STATE.controlsEl) STATE.controlsEl.remove();

@@ -256,2 +400,4 @@ showItems(STATE.allItems);

STATE.controlsEl = null;
STATE.styleEl = null;
STATE.themeObserver = null;
STATE.loading = false;

@@ -263,9 +409,14 @@ }

if (isExcludedPage()) {
log('Pagination disabled on this page (playlist view).');
return true; // "true" so retries don't keep firing pointlessly
}
const continuationEl = findContinuation();
if (!continuationEl) return false;
const container = continuationEl.parentElement;
const container = findFeedRoot(continuationEl);
if (!container) return false;
const initialItems = collectAllItems(container, continuationEl);
const initialItems = collectAllItems(container);
if (initialItems.length === 0) return false;

@@ -272,0 +423,0 @@

+5
-3
{
"manifest_version": 2,
"name": "YouTube Pagination",
"version": "1.0.0",
"version": "1.0.2",
"description": "Replaces YouTube's infinite scroll with simple Previous/Next page controls.",

@@ -28,3 +28,3 @@ "permissions": [

"id": "yt-pagination@example.com",
"strict_min_version": "78.0",
"strict_min_version": "140.0",
"data_collection_permissions": {

@@ -36,4 +36,6 @@ "required": [

},
"gecko_android": {}
"gecko_android": {
"strict_min_version": "142.0"
}
}
}

@@ -40,2 +40,44 @@ # YouTube Pagination (Firefox extension)

## Mobile bugs found and fixed (v1.0.1)
Testing on Firefox Android surfaced two real bugs, both specific to how
mobile YouTube's DOM differs from desktop's:
1. **Item detection was too narrow.** Mobile YouTube's main feed items are
`ytm-video-with-context-renderer`, a tag name the original selector
didn't account for (it only matched `ytm-video-renderer`, which
doesn't exist). Real videos were invisible to the pagination logic —
never hidden, never counted — which is why "Next" seemed to just pile
on more videos instead of resetting the page, and why the count never
matched the configured page size. Fixed by broadening the selector
list and by walking up from the continuation trigger to find whichever
ancestor actually contains item elements, instead of assuming a fixed
DOM depth.
2. **Styling wasn't reaching the controls at all.** Mobile YouTube makes
heavier use of real Shadow DOM. CSS injected into the top-level
document (via manifest `content_scripts.css`) cannot cross into a
Shadow DOM tree, so if the control bar ended up inside one, none of
its styling — including dark-mode handling — was ever applied. Fixed
by generating the styles as a `<style>` tag created in JS and inserted
as a child of the control bar itself, so it always lives in the same
DOM tree as what it's styling. Dark mode is now also toggled via a
JS-added class (checked against `<html dark>`) rather than a CSS
selector, since selectors can't reach across a Shadow DOM boundary
either.
## Page scope (v1.0.2)
- **Subscriptions feed** (`/feed/subscriptions`): works the same as home
and search, no special-casing needed — the content script runs on all
of youtube.com/m.youtube.com, and item detection is generic.
- **Playlist page** (`/playlist?list=...`): now explicitly **excluded**.
Playlist videos use the same renderer tags (`ytd-playlist-video-renderer`
/ `ytm-playlist-video-renderer`) the extension already watches for
elsewhere, so without this exclusion it was paginating playlists too,
which isn't what you want when browsing a single ordered list.
- A playlist sidebar shown *while watching a video* (the panel next to
the player) was never affected — it uses a different renderer
(`ytd-playlist-panel-video-renderer`) that was never in the watched
list to begin with.
## Position, dark mode, and page size

@@ -42,0 +84,0 @@

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet