YouTube Pagination
+179
-28
@@ -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" | ||
| } | ||
| } | ||
| } |
+42
-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