YouTube Grid Row Controller

Adds simple buttons to control items per row on Youtube's home feed, works for shorts and news sections too. Buttons can be hidden if needed.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube Grid Row Controller
// @namespace    https://github.com/HageFX-78
// @version      1.0
// @description  Adds simple buttons to control items per row on Youtube's home feed, works for shorts and news sections too. Buttons can be hidden if needed.
// @author       HageFX78
// @license      MIT
// @match        *://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    // Configurable options
    const hideControls = GM_getValue('hideControls', false); // set true to hide UI controls, it will use the default values instead

    const transparentButtons = GM_getValue('transparentButtons', false); // set true to make the buttons transparent and less intrusive, only applies if hideControls is false

    const defaultSettingValue = {
        // Default values mainly used when if you want to hide the buttons, change the values to your liking
        content: 4,
        news: 5,
        shorts: 6,
    };

    let currentSettingValues = {
        content: GM_getValue('itemPerRow', defaultSettingValue.content),
        news: GM_getValue('newsPerRow', defaultSettingValue.news),
        shorts: GM_getValue('shortsPerRow', defaultSettingValue.shorts),
    };

    // Styles
    const style = (css) => {
        const el = document.createElement('style');
        el.textContent = css;
        document.head.appendChild(el);
        return el;
    };

    // Some of it maybe irrelevant after so long, will cleanup someday...
    style(`
		${hideControls ? '' : '#chips-content{width: 92% !important;}'}

		.justify-left-custom { justify-content: left !important; }
        .justify-center-custom { justify-content: center !important; }

        ytd-rich-item-renderer[rendered-from-rich-grid][is-in-first-column] { margin-left: calc(var(--ytd-rich-grid-item-margin) / 2) !important; }
		
		ytd-rich-item-renderer[hidden][is-responsive-grid], [is-slim-media]{ display: block !important; }

		ytd-rich-item-renderer{ margin-bottom: var(--ytd-rich-grid-row-margin) !important; }

		.button-container.ytd-rich-shelf-renderer { display: none !important;  }
		
		#dismissible.ytd-rich-shelf-renderer {
			padding-bottom: 0 !important;
			border-bottom: none !important;
		}
            
        #selected-chip-content{
            width: 0% !important;
        }

        #spacer.ytd-shelf-renderer {
            flex: 9 !important; /* Spacing gets weird in subscription feed page */
        }

        ytd-feed-filter-chip-bar-renderer[frosted-glass-mode=with-chipbar] #chips-wrapper.ytd-feed-filter-chip-bar-renderer {
            flex-direction: row;
        }
		.itemPerRowControl {
            display: flex;
            justify-content: right;
            align-items: center;

            z-index: 2025;
            flex: 1;         
            gap: 10px;
            box-sizing: border-box;
            user-select: none;
			width: 8%;
        }

        .itemPerRowControl button {

            border: none;
            color: var(--yt-spec-text-primary);
            background-color:${transparentButtons ? 'transparent' : 'var(--yt-spec-badge-chip-background)'};
            font-size: 24px;
            
            text-align: center;
            display: inline-block;

            height: 30px;
            aspect-ratio: 1/1;
            border-radius: 50%;
        }

        .itemPerRowControl button:hover {
            background-color: var(--yt-spec-button-chip-background-hover);
            cursor: pointer;
        }
	`);

    const dynamicStyle = style('');

    function updatePageLayout() {
        dynamicStyle.textContent = `
			ytd-rich-grid-renderer {
				--ytd-rich-grid-items-per-row: ${hideControls ? defaultSettingValue.content : currentSettingValues.content} !important;
			}
			ytd-rich-shelf-renderer:not([is-shorts]) {
				--ytd-rich-grid-items-per-row: ${hideControls ? defaultSettingValue.news : currentSettingValues.news} !important;
			}
			ytd-rich-shelf-renderer[is-shorts] {
				--ytd-rich-grid-slim-items-per-row: ${hideControls ? defaultSettingValue.shorts : currentSettingValues.shorts} !important;
                --ytd-rich-grid-items-per-row: ${hideControls ? defaultSettingValue.shorts : currentSettingValues.shorts} !important;
			}
		`;
    }

    function saveValues() {
        GM_setValue('itemPerRow', currentSettingValues.content);
        GM_setValue('newsPerRow', currentSettingValues.news);
        GM_setValue('shortsPerRow', currentSettingValues.shorts);
    }

    function updateAndSave() {
        updatePageLayout();
        saveValues();
    }

    function isCreatorPage() {
        return location.pathname.startsWith('/@');
    }

    function initGlobalWatcher() {
        const targets = [
            {
                selector: '#chips-wrapper',
                type: 'content',
                place: (anchor, control) => anchor.appendChild(control),
            },
            {
                selector: 'ytd-rich-section-renderer #menu-container',
                type: (node) => (node.closest('ytd-rich-section-renderer')?.querySelector('[is-shorts]') ? 'shorts' : 'news'),
                place: (anchor, control) => anchor.parentNode.insertBefore(control, anchor),
            },
            {
                selector: 'ytd-shelf-renderer #title-container.style-scope.ytd-shelf-renderer',
                type: 'content',
                place: (anchor, control) => anchor.appendChild(control),
            },
        ];

        scanExistingAnchors(targets); // Some elements load before observer can be hooked, like the #chips

        const observer = new MutationObserver((muts) => {
            for (const m of muts) {
                for (const node of m.addedNodes) {
                    if (node.nodeType !== 1) continue;

                    for (const t of targets) {
                        const anchor = node.matches(t.selector) ? node : node.querySelector?.(t.selector);

                        if (anchor) tryAttachControl(anchor, t);
                    }
                }
            }
        });

        observer.observe(document.documentElement, { subtree: true, childList: true });
    }

    function tryAttachControl(anchor, t) {
        if (!anchor) return;
        if (isCreatorPage()) return;

        // Prevent duplicates
        if (anchor.parentNode?.querySelector?.('.itemPerRowControl')) return;

        const type = typeof t.type === 'function' ? t.type(anchor) : t.type;
        const control = createControlDivRaw(type);

        // CENTER for #chips-wrapper and the shelf title container
        if (t.selector === '#chips-wrapper') {
            control.classList.add('justify-left-custom');
        } else if (t.selector.startsWith('ytd-shelf-renderer')) {
            control.classList.add('justify-center-custom');
        }

        t.place(anchor, control);
    }

    function createControlDivRaw(type) {
        const controlDiv = document.createElement('div');
        controlDiv.classList.add('style-scope', 'ytd-rich-grid-renderer', 'itemPerRowControl');

        ['-', '+'].forEach((symbol) => {
            const btn = document.createElement('button');
            btn.textContent = symbol;

            btn.addEventListener('click', () => {
                if (symbol === '+') currentSettingValues[type]++;
                else if (currentSettingValues[type] > 1) currentSettingValues[type]--;

                updateAndSave();
            });

            controlDiv.appendChild(btn);
        });

        return controlDiv;
    }

    function scanExistingAnchors(targets) {
        for (const t of targets) {
            document.querySelectorAll(t.selector).forEach((anchor) => {
                tryAttachControl(anchor, t);
            });
        }
    }

    function setupGMMenu() {
        function rebuildButtonStyles(newVal) {
            document.querySelectorAll('.itemPerRowControl button').forEach((btn) => {
                btn.style.backgroundColor = newVal ? 'transparent' : 'var(--yt-spec-badge-chip-background)';
            });
        }

        function applyHideControls(newVal) {
            const controls = document.querySelectorAll('.itemPerRowControl');
            controls.forEach((c) => {
                c.style.display = newVal ? 'none' : 'flex';
            });

            // force layout update
            updatePageLayout();
        }

        if (typeof GM_registerMenuCommand === 'function') {
            GM_registerMenuCommand(`Reset Values`, () => {
                GM_setValue('itemPerRow', defaultSettingValue.content);
                GM_setValue('newsPerRow', defaultSettingValue.news);
                GM_setValue('shortsPerRow', defaultSettingValue.shorts);

                currentSettingValues = { ...defaultSettingValue };
                updatePageLayout();
            });

            GM_registerMenuCommand(`Toggle hideControls [ ${hideControls} ]`, () => {
                let newVal = !GM_getValue('hideControls', false);
                GM_setValue('hideControls', newVal);
                applyHideControls(newVal);
            });

            GM_registerMenuCommand(`Toggle transparentButtons [ ${transparentButtons} ]`, () => {
                let newVal = !GM_getValue('transparentButtons', false);
                GM_setValue('transparentButtons', newVal);
                rebuildButtonStyles(newVal);
            });
        }
    }

    // ----------------------------------- Main Execution -----------------------------------
    setupGMMenu();
    updatePageLayout();

    if (!hideControls) initGlobalWatcher();
})();