YouTube Dynamic Video Grid

Dynamically adjusts the YouTube video grid to display an optimal number of videos per row based on window width, overriding the default 3-video grid for a more responsive layout

目前為 2025-06-03 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name               YouTube Dynamic Video Grid
// @name:zh-CN         YouTube 动态视频网格
// @description        Dynamically adjusts the YouTube video grid to display an optimal number of videos per row based on window width, overriding the default 3-video grid for a more responsive layout
// @description:zh-CN  根据窗口宽度动态调整 YouTube 视频网格,以每行显示最佳数量的视频,覆盖默认的 3 个视频网格,以实现响应更快的布局
// @author             Nick Fedor
// @namespace          https://github.com/nicholas-fedor/youtube-dynamic-grid
// @supportURL         https://github.com/nicholas-fedor/youtube-dynamic-grid/issues
// @homepageURL        https://github.com/nicholas-fedor/youtube-dynamic-grid
// @homepage           https://github.com/nicholas-fedor/youtube-dynamic-grid
// @license            MIT
// @match              https://www.youtube.com/*
// @match              https://youtube.com/*
// @icon               https://www.youtube.com/s/desktop/ee47b5e0/img/logos/favicon_144x144.png
// @compatible         chrome
// @compatible         firefox
// @compatible         edge
// @compatible         opera
// @compatible         safari
// @compatible         kiwi
// @compatible         qq
// @compatible         via
// @compatible         brave
// @version            2025.6.3.1
// @run-at             document-end
// ==/UserScript==

/**
 * YouTube Dynamic Video Grid Tampermonkey script.
 * Adjusts the YouTube video grid to display 3-12 videos per row based on window width.
 * Skips execution on playlist pages to prevent layout overlap.
 * Version history:
 * - 1.0.2 (2025-05-30): Fixed playlist overlap, improved navigation handling, refactored for maintainability.
 * - 1.0.1 (2025-05-13): Added SPA navigation, CSP compliance, optimized polling.
 * - 1.0.0 (2024-04-25): Initial release.
 */

(function () {
    'use strict'

    // Configuration constants
    const CONFIG = {
        VIDEO_WIDTH: 340, // Thumbnail width in pixels
        MARGIN: 40, // Total left/right margins in pixels
        GAP: 10, // Spacing between thumbnails in pixels
        MIN_ITEMS: 3, // Minimum items per row
        MAX_ITEMS: 12, // Maximum items per row
        NAVIGATION_POLL_INTERVAL: 500, // Navigation polling interval in ms
        GRID_POLL_INTERVALS: { early: 200, regular: 1000 }, // Polling intervals in ms
        POLLING_DURATIONS: { early: 5000, regular: 25000 }, // Polling durations in ms
        DEBOUNCE_DELAY: 100, // Debounce delay for navigation and resize in ms
        UPDATE_DELAY: 50, // Debounce delay for grid updates in ms
        RETRY_ATTEMPTS: 20, // Grid update retry attempts
        RETRY_INTERVAL: 100, // Retry interval in ms
        STABLE_COUNT: 5 // Number of stable checks to stop polling
    }

    // Utility Functions
    /**
     * Checks if the current page is a YouTube playlist page.
     * @returns {boolean} True if URL starts with /playlist
     */
    const isPlaylistPage = () => window.location.pathname.startsWith('/playlist')

    /**
     * Logs messages to the console based on debug mode and log level.
     * @param {string} message - Message to log
     * @param {'info' | 'debug' | 'error'} [level='info'] - Log level
     */
    const logger = (message, level = 'info') => {
        const isDebug = window.location.search.includes('debug=1')
        if (
            level === 'error' ||
            (isDebug && (level === 'info' || level === 'debug'))
        ) {
            console[level === 'error' ? 'error' : 'log'](
                `[YouTube Dynamic Grid][${level}] ${message}`
            )
        }
    }

    // Grid Management Functions
    const GridManager = {
        /**
         * Calculates the number of items per row based on container width.
         * @returns {number} Number of items per row (3-12)
         */
        calculateItemsPerRow() {
            const container = document.querySelector(
                '#contents, ytd-two-column-browse-results-renderer, body'
            )
            if (!container) {
                logger('No container found for grid calculation', 'error')
                return CONFIG.MIN_ITEMS
            }
            const width =
                container.getBoundingClientRect().width || window.innerWidth
            const items = Math.round(
                (width - CONFIG.MARGIN) / (CONFIG.VIDEO_WIDTH + CONFIG.GAP)
            )
            return Math.max(CONFIG.MIN_ITEMS, Math.min(CONFIG.MAX_ITEMS, items))
        },

        /**
         * Updates the grid's elements-per-row attribute and CSS styles.
         */
        updateGrid() {
            if (this.updateTimeout) clearTimeout(this.updateTimeout)
            this.updateTimeout = setTimeout(() => {
                try {
                    const grid = document.querySelector(
                        'ytd-rich-grid-renderer:not([hidden])'
                    )
                    if (!grid) {
                        logger('No visible grid found')
                        return
                    }
                    const itemsPerRow = this.calculateItemsPerRow()
                    const currentItems = getComputedStyle(grid)
                        .getPropertyValue('--ytd-rich-grid-items-per-row')
                        .trim()
                    const currentAttr =
                        grid.getAttribute('elements-per-row') || 'unknown'
                    if (
                        currentItems !== String(itemsPerRow) ||
                        currentAttr !== String(itemsPerRow)
                    ) {
                        grid.setAttribute('elements-per-row', itemsPerRow)
                        this.updateCSS(itemsPerRow)
                        logger(`Updated grid to ${itemsPerRow} items`)
                    }
                } catch (error) {
                    logger(`Failed to update grid: ${error.message}`, 'error')
                }
            }, CONFIG.UPDATE_DELAY)
        },

        /**
         * Injects or updates the CSS style element for the grid.
         */
        injectCSS() {
            if (isPlaylistPage()) {
                logger('Skipping CSS injection on playlist page')
                this.removeCSS()
                return
            }
            let style = document.getElementById('dynamic-grid-style')
            if (!style) {
                style = document.createElement('style')
                style.id = 'dynamic-grid-style'
                const nonce = document.querySelector('meta[name="csp-nonce"]')?.content
                if (nonce) style.nonce = nonce
                document.head.appendChild(style)
                logger('Injected CSS style element', 'debug')
            }
            this.updateCSS(this.calculateItemsPerRow())
        },

        /**
         * Updates the CSS rule for the specified number of items per row.
         * @param {number} itemsPerRow - Number of items per row
         */
        updateCSS(itemsPerRow) {
            const style = document.getElementById('dynamic-grid-style')
            if (!style) return
            style.textContent = `
          #contents, ytd-two-column-browse-results-renderer {
            max-width: 100% !important;
            width: 100% !important;
            padding: 0 !important;
          }
          ytd-rich-grid-renderer:not([hidden]) {
            --ytd-rich-grid-items-per-row: ${itemsPerRow} !important;
            --ytd-rich-grid-posts-per-row: ${itemsPerRow} !important;
            max-width: 100% !important;
            padding: 0 !important;
            gap: ${CONFIG.GAP}px !important;
          }
          ytd-rich-grid-renderer:not([hidden]) [is-in-first-column] {
            margin-left: 8px !important;
            margin-right: 8px !important;
            margin-bottom: 16px !important;
          }
          ytd-rich-grid-renderer:not([hidden]) ytd-rich-item-renderer {
            margin: 0 8px 16px 8px !important;
          }
        `
        },

        /**
         * Removes the CSS style element and resets grid attributes.
         */
        removeCSS() {
            const style = document.getElementById('dynamic-grid-style')
            if (style) {
                style.remove()
                logger('Removed CSS style element', 'debug')
            }
            const grid = document.querySelector('ytd-rich-grid-renderer')
            if (grid) {
                grid.removeAttribute('elements-per-row')
                grid.style.removeProperty('--ytd-rich-grid-items-per-row')
                grid.style.removeProperty('--ytd-rich-grid-posts-per-row')
                logger('Reset grid attributes', 'debug')
            }
        },

        /**
         * Attempts to update the grid with retries.
         * @param {number} [attempts=CONFIG.RETRY_ATTEMPTS] - Retry attempts
         */
        tryUpdate(attempts = CONFIG.RETRY_ATTEMPTS) {
            if (isPlaylistPage()) {
                logger('Skipping grid update on playlist page')
                this.removeCSS()
                return
            }
            this.updateGrid()
            if (
                attempts > 0 &&
                !document.querySelector('ytd-rich-grid-renderer:not([hidden])')
            ) {
                logger(`Retrying grid update (${attempts} attempts left)`, 'debug')
                setTimeout(() => this.tryUpdate(attempts - 1), CONFIG.RETRY_INTERVAL)
            }
        },

        /**
         * Polls the grid for changes until stable.
         * @param {'early' | 'regular'} mode - Polling mode
         */
        pollGrid(mode = 'early') {
            if (isPlaylistPage()) {
                logger(`Skipping ${mode} polling on playlist page`)
                this.removeCSS()
                return
            }
            if (this.pollInterval) clearInterval(this.pollInterval)
            let stableCount = 0
            const interval = CONFIG.GRID_POLL_INTERVALS[mode]
            const duration = CONFIG.POLLING_DURATIONS[mode]
            this.pollInterval = setInterval(() => {
                const grid = document.querySelector(
                    'ytd-rich-grid-renderer:not([hidden])'
                )
                if (grid) {
                    const currentItems = getComputedStyle(grid)
                        .getPropertyValue('--ytd-rich-grid-items-per-row')
                        .trim()
                    const currentAttr =
                        grid.getAttribute('elements-per-row') || 'unknown'
                    const itemsPerRow = this.calculateItemsPerRow()
                    if (
                        currentItems !== String(itemsPerRow) ||
                        currentAttr !== String(itemsPerRow)
                    ) {
                        this.updateGrid()
                        stableCount = 0
                    } else {
                        stableCount++
                        if (stableCount >= CONFIG.STABLE_COUNT) {
                            clearInterval(this.pollInterval)
                            logger(`Stopped ${mode} polling: grid stable`)
                            if (mode === 'early') this.pollGrid('regular')
                            else this.updateGrid()
                        }
                    }
                }
            }, interval)
            setTimeout(() => {
                clearInterval(this.pollInterval)
                logger(`Stopped ${mode} polling`)
                if (mode === 'early') this.pollGrid('regular')
                else this.updateGrid()
            }, duration)
        },

        // Internal state
        updateTimeout: null,
        pollInterval: null
    }

    // Navigation Management Functions
    const NavigationManager = {
        /**
         * Handles navigation events by reinitializing the grid.
         */
        handleNavigation() {
            if (this.navigationDebounce) clearTimeout(this.navigationDebounce)
            this.navigationDebounce = setTimeout(() => {
                this.navigationTimeouts.forEach(clearTimeout)
                this.navigationTimeouts = []
                cleanup()
                if (isPlaylistPage()) {
                    logger('Skipping grid initialization on playlist page')
                    GridManager.removeCSS()
                    return
                }
                logger('Initializing grid for non-playlist page')
                GridManager.injectCSS()
                GridManager.tryUpdate()
                this.startObserver()
                GridManager.pollGrid()
                this.navigationTimeouts.push(
                    setTimeout(() => GridManager.updateGrid(), 1000)
                )
                this.navigationTimeouts.push(
                    setTimeout(() => GridManager.updateGrid(), 5000)
                )
                this.navigationTimeouts.push(
                    setTimeout(() => GridManager.updateGrid(), 10000)
                )
                if (this.eventListeners) cleanup()
                this.eventListeners = this.setupEventListeners()
            }, CONFIG.DEBOUNCE_DELAY)
        },

        /**
         * Monitors SPA navigation via URL polling.
         */
        monitorNavigation() {
            setInterval(() => {
                const currentUrl = location.href
                if (currentUrl !== this.lastUrl) {
                    this.lastUrl = currentUrl
                    logger(`Navigated to ${currentUrl}`, 'debug')
                    this.handleNavigation()
                }
            }, CONFIG.NAVIGATION_POLL_INTERVAL)
            logger('Started navigation polling', 'debug')
        },

        /**
         * Starts the MutationObserver for grid changes.
         */
        startObserver() {
            if (isPlaylistPage()) {
                logger('Skipping observer start on playlist page')
                GridManager.removeCSS()
                return
            }
            if (this.observer) {
                this.observer.disconnect()
                logger('Disconnected previous observer', 'debug')
            }
            this.observer = new MutationObserver(mutations => {
                for (const mutation of mutations) {
                    if (
                        mutation.target.matches('ytd-rich-grid-renderer') ||
                        mutation.target.closest('ytd-rich-grid-renderer') ||
                        mutation.target.querySelector('ytd-rich-grid-renderer') ||
                        ['style', 'hidden', 'class', 'elements-per-row'].includes(
                            mutation.attributeName
                        ) ||
                        mutation.addedNodes.length > 0
                    ) {
                        GridManager.updateGrid()
                        break
                    }
                }
            })
            const target = document.querySelector(
                '#contents, ytd-two-column-browse-results-renderer, ytd-app'
            )
            if (target) {
                this.observer.observe(target, {
                    childList: true,
                    subtree: true,
                    attributes: true,
                    attributeFilter: ['style', 'class', 'hidden', 'elements-per-row']
                })
                logger('Started observer', 'debug')
            } else {
                logger('Observer target not found, retrying', 'debug')
                setTimeout(() => this.startObserver(), CONFIG.RETRY_INTERVAL)
            }
        },

        /**
         * Sets up event listeners for resize, load, and popstate.
         * @returns {Object} Listener references
         */
        setupEventListeners() {
            if (isPlaylistPage()) {
                logger('Skipping event listener setup on playlist page')
                return null
            }
            const resizeHandler = () => {
                clearTimeout(this.resizeTimeout)
                this.resizeTimeout = setTimeout(() => {
                    logger('Window resized', 'debug')
                    if (!isPlaylistPage()) GridManager.updateGrid()
                }, CONFIG.DEBOUNCE_DELAY)
            }
            const loadHandler = () => {
                logger('Page loaded', 'debug')
                this.handleNavigation()
            }
            const popstateHandler = () => {
                logger('Popstate navigation detected', 'debug')
                this.handleNavigation()
            }
            window.addEventListener('resize', resizeHandler)
            window.addEventListener('load', loadHandler)
            window.addEventListener('popstate', popstateHandler)
            logger('Set up event listeners', 'debug')
            return { resizeHandler, loadHandler, popstateHandler }
        },

        // Internal state
        lastUrl: location.href,
        observer: null,
        navigationTimeouts: [],
        navigationDebounce: null,
        resizeTimeout: null,
        eventListeners: null
    }

    /**
     * Cleans up all resources (observers, intervals, listeners).
     */
    function cleanup() {
        if (NavigationManager.observer) {
            NavigationManager.observer.disconnect()
            NavigationManager.observer = null
        }
        if (GridManager.pollInterval) {
            clearInterval(GridManager.pollInterval)
            GridManager.pollInterval = null
        }
        NavigationManager.navigationTimeouts.forEach(clearTimeout)
        clearTimeout(NavigationManager.resizeTimeout)
        clearTimeout(GridManager.updateTimeout)
        clearTimeout(NavigationManager.navigationDebounce)
        if (NavigationManager.eventListeners) {
            window.removeEventListener(
                'resize',
                NavigationManager.eventListeners.resizeHandler
            )
            window.removeEventListener(
                'load',
                NavigationManager.eventListeners.loadHandler
            )
            window.removeEventListener(
                'popstate',
                NavigationManager.eventListeners.popstateHandler
            )
            NavigationManager.eventListeners = null
        }
        logger('Cleaned up resources', 'debug')
    }

    /**
     * Initializes the script.
     */
    function init() {
        if (isPlaylistPage()) {
            logger('Skipping initialization on playlist page', 'info')
            GridManager.removeCSS()
            NavigationManager.monitorNavigation()
            return
        }
        logger('Initializing script', 'info')
        NavigationManager.monitorNavigation()
        NavigationManager.handleNavigation()
    }

    // Start the script with a delay to ensure URL evaluation
    setTimeout(init, CONFIG.DEBOUNCE_DELAY)
})()