Greasy Fork is available in English.

Newgrounds: Disable Infinite Scroll

Disable infinite scroll in favor of restoring page navigation buttons for Newgrounds

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Newgrounds: Disable Infinite Scroll
// @namespace    861ddd094884eac5bea7a3b12e074f34
// @version      1.6.1
// @description  Disable infinite scroll in favor of restoring page navigation buttons for Newgrounds
// @author       Anonymous, Claude 4.5 Sonnet, GitHub Copilot (Claude 4.5 Haiku, GPT 5-mini Thinking)
// @match        https://www.newgrounds.com/art
// @match        https://www.newgrounds.com/art?*
// @match        https://www.newgrounds.com/art/*
// @exclude      https://www.newgrounds.com/art/view/*
// @match        https://www.newgrounds.com/games
// @match        https://www.newgrounds.com/games?*
// @match        https://www.newgrounds.com/games/*
// @match        https://www.newgrounds.com/movies
// @match        https://www.newgrounds.com/movies?*
// @match        https://www.newgrounds.com/movies/*
// @match        https://www.newgrounds.com/audio
// @match        https://www.newgrounds.com/audio?*
// @match        https://www.newgrounds.com/audio/*
// @exclude      https://www.newgrounds.com/audio/listen/*
// @match        https://www.newgrounds.com/series
// @match        https://www.newgrounds.com/series?*
// @match        https://www.newgrounds.com/series/browse/*
// @match        https://www.newgrounds.com/collections
// @match        https://www.newgrounds.com/collections?*
// @match        https://www.newgrounds.com/collections/browse/*
// @match        https://www.newgrounds.com/playlists
// @match        https://www.newgrounds.com/playlists?*
// @match        https://www.newgrounds.com/playlists/browse/*
// @match        https://www.newgrounds.com/search/*
// @match        https://www.newgrounds.com/collab*
// @match        https://*.newgrounds.com/art
// @match        https://*.newgrounds.com/art?*
// @match        https://*.newgrounds.com/art/tree*
// @match        https://*.newgrounds.com/games
// @match        https://*.newgrounds.com/games?*
// @match        https://*.newgrounds.com/movies
// @match        https://*.newgrounds.com/movies?*
// @match        https://*.newgrounds.com/audio
// @match        https://*.newgrounds.com/audio?*
// @match        https://*.newgrounds.com/audio/tree*
// @match        https://*.newgrounds.com/favorites/*
// @grant        none
// @iconURL      https://greasyfork.s3.us-east-2.amazonaws.com/0m3whdf19wgz78r38ded13q6lucz
// @license      MIT-0
// @homepageURL  https://greasyfork.org/en/scripts/558817-newgrounds-disable-infinite-scroll
// @supportURL   https://greasyfork.org/en/scripts/558817-newgrounds-disable-infinite-scroll/feedback
// ==/UserScript==

//
/// ✔️ FEATURES
//
// Supported pages:
// - category browse/search (movies, games, art, audio)
// - playlists, collections and series browse/search
// - blog and forum search (global and per user)
// - user upload and favorite galleries
// - userbase search
// - collabinator / help wanted
// - artist scout pages
//
// Keyboard shortcuts for page navigation:
//    ┌──────────┬────────────────┐
//    │ Action   │ Key bindings   │
//    ├──────────┼────────────────┤
//    │ Previous │ A, Left Arrow  │
//    │ Next     │ D, Right Arrow │
//    └──────────┴────────────────┘
//

//
/// 📋️ TODO
//
// Enhancements (nice-to-haves):
// - Items that require DOM parsing to get post ID to offset from for next page
//   - Support logged in user feed of favorite artists' uploads
//     https://www.newgrounds.com/social/feeds/show/favorite-artists*
//   - Support Community News pages (user news is natively paginated)
//     https://www.newgrounds.com/news
//     https://www.newgrounds.com/news/*
//
// Bug fixes:
// - If less items than expected are returned in the list, prevent next
//   page buttons from being created and nav events from being executed.
//   If we're on the first page, prevent pagination buttons from being
//   created *at all.*
//
// Outstanding questions:
// - Is there a dedicated 'shared creations' list accessible from a
//   user profile, and if so, can it be supported?
//

//
/// 📜 CHANGELOG
//
// v1.6.1       Improve and refine page support
//              - match additional category pages when they are filtered
//              - exclude item view pages
// v1.6         Enhancements, fixes and cleanup
//              - Update games offset
//              - Support artist scout pages
//              - Consolidate conditionals
//              - Document flow
// v1.5         Enhancements, fixes and cleanup
//              - support collabinator page
//              - fix init loop bug (uncaught reference)
//              - circumvent onclick highjacking by site
//              - improvements to readability, commenting
//              - accidentally several semicolons!! 😱😭
// v1.4.3       Minor improvements; debug logging, readability
// v1.4.2       Various improvements and bug fixes
//              - improvements to readability, logging, commenting
//              - fix browser-native key events being caught
//                  and suppressed (e.g. alt+arrow)
//              - fix pagination elements being duplicated or
//                 missing when the search summary page is involved
//                 in partial page rewrites
//              - fix major bug where browse/search pages weren't detected
// v1.4.1       Readability improvements
// v1.4         Support playlists, collections and series browse categories,
//                as well as those search types.
// v1.3         Support user favorites gallery pages
// v1.2         Support user upload gallery pages
// v1.1         Add navigational keyboard shortcuts
// v1.0         Initial release
//

//
/// 🙇‍♀️ CREDITS
//
// Thanks to 14HourLunchBreak on Newgrounds for the script's icon graphic
// Source:  https://www.newgrounds.com/art/view/14hourlunchbreak/pixel-ng-tank
// License: https://creativecommons.org/licenses/by-nc/3.0/
//

(function() {
    'use strict';

    const DEBUG = true;

    let FIRST_INIT = true;
    let PATH = window.location.pathname;
    let PARAMS = new URLSearchParams(window.location.search);
    const FQDN = window.location.hostname;
    const DOMAIN = FQDN.split('.');

    const pathMap = {
        '/movies': 20,
        '/games': 20,
        '/audio': 30,
        '/art': 28,
    };
    const ITEMS_PER_PAGE = Object.entries(pathMap).find(
        ([str]) => PATH.startsWith(str)
    )?.[1];

    const ENDPOINTS = {
        'media': ['/art', '/games', '/movies', '/audio'],
        'playlists': ['/series', '/collections', '/playlists'],
        'scouts': ['/art/tree', '/audio/tree'],
    }

    let NAVIGATION_TYPE;
    if (isUserPage()
        || PATH.startsWith('/search/conduct')
        || startsWithMany(PATH, ENDPOINTS['playlists'])
        || startsWithMany(PATH, ['/collab', '/art/tree'])
    ) {
        NAVIGATION_TYPE = 'page';
    } else if (startsWithMany(PATH, ENDPOINTS['media'])) {
        NAVIGATION_TYPE = 'offset';
    }

    //////
    // CIRCUMVENTION
    ///////////////////

    // when a user changes search category: the page intercepts the click,
    // an AJAX call fetches the remote source, the DOM is rewriteen, and
    // history is updated. we patch the history update prototype to
    // re-initialize the script, as if we did a fresh page load.
    //   ref: https://stackoverflow.com/a/64927639/2272443
    window.history.pushState = new Proxy(window.history.pushState, {
        apply: (target, thisArg, argArray) => {
            if (DEBUG) console.debug('Intercepted manual history write');

            if (DEBUG) console.debug('Writing manual history event');
            let output = target.apply(thisArg, argArray);
            PATH = window.location.pathname; // refresh state
            PARAMS = new URLSearchParams(window.location.search);

            if (DEBUG) console.debug('Removing pagination elements');
            document.getElementById('ng-pagination')?.remove();

            if (DEBUG) console.debug('Re-initializing userscript');
            init();

            return output;
        },
    });

    function disableInfiniteScroll() {
        let w = window;

        // Aggressively prevent scroll-based loading
        if (FIRST_INIT) {
            w.addEventListener('scroll', function(e) {
                e.stopImmediatePropagation();
            }, true);
        }

        // Circumvent scroll boundary detection
        if (w.ngutils) {
            // Remove event listener
            if (w.ngutils.event) {
                if (DEBUG) console.debug('Remvoing scroll boundry detection\'s event listener')
                w.ngutils.event.removeListener('ngutils.element.whenOnscreen');
            }
            // noop its function just to be sure
            if (DEBUG) console.debug('nooping scroll boundry detection\'s function')
            if (w.ngutils.element && w.ngutils.element.whenOnscreen) {
                w.ngutils.element.whenOnscreen = function() {
                    return;
                };
            }
        }
    }

    //////
    // HELPERS
    /////////////

    // https://stackoverflow.com/a/25937397/2272443
    // https://gist.github.com/zenparsing/5dffde82d9acef19e43c
    function dedent(callSite, ...args) {
        function format(str) {
            let size = -1;
            return str.replace(/\n(\s+)/g, (m, m1) => {
                if (size < 0) size = m1.replace(/\t/g, "    ").length;
                return "\n" + m1.slice(Math.min(m1.length, size));
            });
        }

        if (typeof callSite === "string") return format(callSite);
        if (typeof callSite === "function") {
            return (...args) => format(callSite(...args));
        }

        let output = callSite
            .slice(0, args.length + 1)
            .map((text, i) => (i === 0 ? '' : args[i - 1]) + text)
            .join('');

        return format(output);
    }

    // .startsWith() but accepting an array of strings to check
    function startsWithMany(str, arr) {
        return arr.some(s => str.startsWith(s));
    }

    // Differentiate various user galleries from browse pages
    function isUserPage() {
        return (DOMAIN.length > 2 && !DOMAIN[0].startsWith('www'));
    }

    function styleElement(element, style) {
        for (const [key, value] of Object.entries(style)) {
            element.style[key] = value;
        }; return element;
    }

    //////
    // FUNCTIONS
    ///////////////

    // Parse query string to get current page
    function getCurrentPage() {
        if (DEBUG) console.debug('Trying to discern current page number...');

        let page;
        if (NAVIGATION_TYPE === 'page') {
            page = parseInt(PARAMS.get('page')) || 1;
        } else if (NAVIGATION_TYPE === 'offset') {
            const offset = parseInt(PARAMS.get('offset')) || 0;
            page = Math.floor(offset / ITEMS_PER_PAGE) + 1;
        }

        if (typeof page !== 'number'
            || (isNaN(page) || page < 0)
        ) {
            console.error('Unable to discern current page!');
            return false;
        } else {
            if (DEBUG) console.debug('Current page:', page);
            return page; // is typeof number
        }
    }

    //
    // KEYBOARD SHORTCUT NAVIGATION
    //

    function redirect(source, n) {
        if (NAVIGATION_TYPE === 'page') {
            PARAMS.set('page', n.toString());
        } else if (NAVIGATION_TYPE === 'offset') {
            const offset = (n - 1) * ITEMS_PER_PAGE;
            PARAMS.set('offset', offset.toString());
        }

        const queryString = PARAMS.toString()
        const dest = (queryString)
            ? `${PATH}?${queryString}`
            : PATH;
        if (DEBUG) console.debug(`Navigating via ${source} to`, dest);
        window.location.href = dest;
    }

    // Keybind handlers
    function navigate(direction) {
        if (DEBUG) console.debug('Running keybind navigation logic...');

        const currentPage = getCurrentPage();
        if (!currentPage) return;

        const dest = (direction === 'previous')
            ? (currentPage - 1)
            : (currentPage + 1);
        if (dest < 1) return;

        redirect('keybind', dest);
    }

    document.addEventListener('keydown', function(e) {
        // ignore modifiers
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;

        // ignore when input field is focused
        if (e.target.getAttribute('role') === 'search'
            || e.target.tagName === 'INPUT'
            || e.target.tagName === 'TEXTAREA'
        ) {
            if (DEBUG) console.debug('Input field focused; ignoring nav event.');
            return;
        }

        const key = (e.key || '').toLowerCase();
        if (key === 'arrowleft' || key === 'a') {
            if (DEBUG) console.debug('Caught key:', key);
            e.preventDefault();
            navigate('previous');
        } else if (key === 'arrowright' || key === 'd') {
            if (DEBUG) console.debug('Caught key:', key);
            e.preventDefault();
            navigate('next');
        }
    });

    //
    // PAGINATION
    //

    function getLoadMoreElement() {
        let e;
        let d = document;
        if (PATH.startsWith('/search/conduct')
            || PATH.startsWith('/collab')
        ) {
            e = d.querySelector('*[id$="-load-more"]');
        } else if (isUserPage() && PATH.startsWith('/favorites')) {
            e = d.querySelector('*[id^="usersfavoritescomponents-load-more"]');
        } else if (startsWithMany(PATH, ENDPOINTS['scouts'])) {
            e = d.querySelector('div[id^="scroll-"]');
        } else if (startsWithMany(PATH, ENDPOINTS['media'])) {
            e = isUserPage()
                ? d.querySelector('li[id$="_scroll_more"]')
                : d.getElementById('load-more-items');
        } else if (startsWithMany(PATH, ENDPOINTS['playlists'])) {
            e = d.querySelector('li[id^="playlists"]');
        }

        if (DEBUG) {
            if (typeof e !== 'undefined' && e !== null) {
                console.debug('Found "load more" element: ', e);
            } else {
                console.debug('Unable to find "load more" element.');
            }
        }

        return e;
    }

    // Detect insertion point for pagination elements
    function getListContainer() {
        let d = document;
        let listContainer;
        if (DEBUG) console.debug('Looking for pagination insertion point...');
        if (startsWithMany(PATH, ENDPOINTS['scouts'])) {
            listContainer = d.querySelectorAll('div[class="pod"]')[1];
        } else if (startsWithMany(PATH, ENDPOINTS['media'])) {
            if (isUserPage()) {
                listContainer = d.querySelector('div[id$="_browse"]');
            } else {
                listContainer = d.querySelector('div[id^="content-results-"]');
            }
        } else if (PATH.startsWith('/search/conduct')) {
            listContainer = d.querySelector(
                'div[id^="search_results_container_"]'
            );
        } else if (
            (isUserPage() && PATH.startsWith('/favorites'))
            || startsWithMany(PATH, ENDPOINTS['playlists'])
        ) {
            listContainer = d.querySelectorAll('div[class="pod-body"]')[1];
        } else if (PATH.startsWith('/collab')) {
            listContainer = d.querySelector('div[id="collab-index-inner"]');
        }
        return listContainer;
    }

    // Build page number buttons with ellipses separating distant pages
    // Shows 1 2 3 ... x y z where y is the current page
    function paginate(currentPage, btnLimit) {
        const pageSet = new Set();

        const container = document.createElement('div');
        container.id = 'ng-pagination';
        container.style.cssText = dedent
            `text-align: center;
            padding: 20px;
            clear: both;`;

        if (currentPage > 1) {
            const prevBtn = createButton('← Previous', currentPage - 1, {});
            container.appendChild(prevBtn);
        }

        const range = Math.floor(btnLimit / 2);
        for (let i = 1; i <= btnLimit; i++)
            pageSet.add(i);
        for (let i = currentPage - range; i <= currentPage + range; i++)
            if (i >= 1) pageSet.add(i);

        let j = 0;
        const pageArray = Array.from(pageSet).sort((a, b) => a - b);
        for (const page of pageArray) {
            if (page > j + 1) {
                const span = createSpan('⋯', {});
                container.appendChild(span);
            } else if (page === currentPage) {
                const span = createSpan(page.toString(), {});
                container.appendChild(span);
            } else {
                const btn = createButton(page.toString(), page, {});
                container.appendChild(btn);
            }; j = page;
        }

        if (currentPage < Math.max(...pageSet)) {
            const nextBtn = createButton('Next →', currentPage + 1, {});
            container.appendChild(nextBtn);
        }

        return container;
    }

    function createSpan(text, style) {
        let span = document.createElement('span');
        span.textContent = text;
        span.style.margin = '0 5px';
        span.style.color = '#fda238';
        span.style.fontSize = '0.9em';
        span = styleElement(span, style);
        return span;
    }

    function createButton(text, page, style) {
        let btn = document.createElement('button');
        btn.textContent = text;
        btn.dataset.ngPage = page;
        btn.style.cssText = dedent
            `margin: 0 5px;
            padding: 8px 15px;
            cursor: pointer;
            background:
                linear-gradient(
                    to bottom,
                    #34393D 0%,
                    #34393D 60%,
                    #4E575E 70%,
                    #4E575E 100%
                );
            color: #fda238;
            font-weight: bold;`;
        btn = styleElement(btn, style);
        btn.addEventListener('click', handleButtonClick, true);
        return btn;
    }

    function handleButtonClick(e) {
        PARAMS = new URLSearchParams(window.location.search);
        PATH = window.location.pathname;

        // Prevent all propagation and site-dictated behaviors
        e.stopPropagation();
        e.stopImmediatePropagation();
        e.preventDefault();

        const page = parseInt(e.currentTarget.dataset.ngPage);
        if (DEBUG) console.debug('Button clicked, navigating to page:', page);
        redirect('button', page);
    }

    //
    // MAIN
    //

    function init() {
        if (DEBUG) console.debug('Initializing userscript')

        // Invoke again, in case JS still loading
        disableInfiniteScroll();

        if (DEBUG) console.debug('Searching for "load more" element...');
        getLoadMoreElement()?.remove();

        // Insert pagination elements
        const listContainer = getListContainer();
        if (typeof listContainer !== 'undefined' && listContainer !== null) {
            const currentPage = getCurrentPage();
            if (DEBUG) console.debug('Creating pagination elements...')
            const pagination = paginate(currentPage, 3);
            if (DEBUG) console.debug('Pagination element created: ', pagination);
            // NOTE: implementation detail of Node.insertBefore's referenceNode
            //   says that passing null results in element insertion at the
            //   end of the target node's children.
            // https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore
            if (DEBUG) console.debug('Inserting pagination elements (after last child of): ', listContainer.parentNode);
            listContainer.parentNode.insertBefore(pagination, null);
        } else if (listContainer === null) {
            console.error('listContainer: ', null);
            return;
        } else if (
            typeof listContainer === 'undefined'
            && document.readyState !== 'complete'
        ) {
            if (DEBUG) {
                console.debug('listContainer: ', undefined);
                console.debug('Page may not be finished loading; retrying...');
            }
            FIRST_INIT = false;
            setTimeout(waitForDOM, 500);
        } else {
            console.error('Unable to find pagination insertion point!');
        }

        FIRST_INIT = false;
    }

    function waitForDOM() {
        // Aggressively try to disable infinite scroll
        if (FIRST_INIT) disableInfiniteScroll();

        // Wait for jQuery/ngutils libraries to be available
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => {
                setTimeout(init, 500);
            });
        } else {
            init();
        }
    }; waitForDOM();
})();