您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
根据窗口宽度动态调整 YouTube 视频网格,以每行显示最佳数量的视频,覆盖默认的 3 个视频网格,以实现响应更快的布局
当前为
// ==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) })()
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址