YouTube-UI-Customizer

Enhances your YouTube experience. Customize the video grid layout by adjusting thumbnails per row, hide Shorts content, and automatically redirect the Shorts player to the standard video player.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         YouTube-UI-Customizer
// @namespace    https://github.com/p65536
// @version      1.1.2
// @license      MIT
// @description  Enhances your YouTube experience. Customize the video grid layout by adjusting thumbnails per row, hide Shorts content, and automatically redirect the Shorts player to the standard video player.
// @icon         https://www.youtube.com/favicon.ico
// @author       p65536
// @match        https://www.youtube.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// @run-at       document-start
// @noframes
// ==/UserScript==

(() => {
    'use strict';

    // --- Fast Redirect for Shorts (Full Page Load) ---
    // This runs at @run-at document-start, *before* the DOM is ready.
    // It intercepts full page loads (e.g., new tab, ctrl+click) of /shorts/ URLs
    // and immediately replaces them with the standard /watch?v= player.
    // This prevents the Shorts player UI from ever loading or "flashing".
    if (location.pathname.startsWith('/shorts/')) {
        location.replace(location.href.replace('/shorts/', '/watch?v='));
        return; // Stop the rest of the script from executing, as we are navigating away.
    }

    // =================================================================================
    // SECTION: Script-Specific Definitions
    // =================================================================================

    const OWNERID = 'p65536';
    const APPID = 'ytuic';
    const APPNAME = 'YouTube UI Customizer';
    const LOG_PREFIX = `[${APPID.toUpperCase()}]`;

    // =================================================================================
    // SECTION: Logging Utility
    // =================================================================================

    const Logger = {
        levels: { error: 0, warn: 1, info: 2, log: 3 },
        level: 'log',
        setLevel(level) {
            if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
                this.level = level;
            } else {
                console.warn(LOG_PREFIX, `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
            }
        },
        error(...args) {
            if (this.levels[this.level] >= this.levels.error) {
                console.error(LOG_PREFIX, ...args);
            }
        },
        warn(...args) {
            if (this.levels[this.level] >= this.levels.warn) {
                console.warn(LOG_PREFIX, ...args);
            }
        },
        info(...args) {
            if (this.levels[this.level] >= this.levels.info) {
                console.info(LOG_PREFIX, ...args);
            }
        },
        log(...args) {
            if (this.levels[this.level] >= this.levels.log) {
                console.log(LOG_PREFIX, ...args);
            }
        },
    };

    // =================================================================================
    // SECTION: Execution Guard
    // =================================================================================

    class ExecutionGuard {
        // A shared key for all scripts from the same author to avoid polluting the window object.
        static #GUARD_KEY = `__${OWNERID}_guard__`;
        // A specific key for this particular script.
        static #APP_KEY = `${APPID}_executed`;

        /**
         * Checks if the script has already been executed on the page.
         * @returns {boolean} True if the script has run, otherwise false.
         */
        static hasExecuted() {
            return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
        }

        /**
         * Sets the flag indicating the script has now been executed.
         */
        static setExecuted() {
            window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
            window[this.#GUARD_KEY][this.#APP_KEY] = true;
        }
    }

    // =================================================================================
    // SECTION: Configuration and Constants
    // =================================================================================

    const CONSTANTS = {
        CONFIG_KEY: `${APPID}_config`,
        TIMERS: {
            DEBOUNCE_MS: 300,
        },
        SELECTORS: {
            shortsFullScan: [
                'ytd-reel-shelf-renderer',
                'ytd-rich-section-renderer:has(ytd-rich-shelf-renderer[is-shorts])',
                'ytd-rich-item-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="SHORTS"])',
                'ytd-grid-video-renderer:has(ytd-thumbnail-overlay-time-status-renderer[overlay-style="SHORTS"])',
                'ytd-video-renderer:has(a[href*="/shorts/"])',
                'ytd-compact-video-renderer:has(a[href*="/shorts/"])',
                'ytd-guide-entry-renderer[guide-entry-title="Shorts"]',
                'ytd-mini-guide-entry-renderer[aria-label="Shorts"]',
            ],
        },
        UI_DEFAULTS: {
            SETTINGS_BUTTON: {
                top: '12px',
                right: '240px',
                width: '36px',
                height: '36px',
                zIndex: 10000,
            },
            SLIDER: {
                min: 2,
                max: 10,
                step: 1,
            },
        },
    };

    const DEFAULT_CONFIG = {
        options: {
            itemsPerRow: 5,
            hideShorts: true,
            redirectShorts: true,
            syncTabs: true,
        },
    };

    const SITE_STYLES = {
        youtube: {
            SETTINGS_BUTTON: {
                background: 'var(--yt-spec-brand-background-solid, transparent)',
                borderColor: 'var(--yt-spec-border-primary, #ddd)',
                backgroundHover: 'var(--yt-spec-badge-chip-background, #f0f0f0)',
                borderRadius: '50%',
                iconDef: {
                    tag: 'svg',
                    props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'var(--yt-spec-icon-active-other, #606060)' },
                    children: [
                        {
                            tag: 'path',
                            props: {
                                d: 'M480-160H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v200h-80v-200H160v480h320v80ZM380-300v-360l280 180-280 180ZM714-40l-12-60q-12-5-22.5-10.5T658-124l-58 18-40-68 46-40q-2-14-2-26t2-26l-46-40 40-68 58 18q11-8 21.5-13.5T702-380l12-60h80l12 60q12 5 22.5 11t21.5 15l58-20 40 70-46 40q2 12 2 25t-2 25l46 40-40 68-58-18q-11 8-21.5 13.5T806-100l-12 60h-80Zm40-120q33 0 56.5-23.5T834-240q0-33-23.5-56.5T754-320q-33 0-56.5 23.5T674-240q0 33 23.5 56.5T754-160Z',
                            },
                        },
                    ],
                },
            },
            SETTINGS_PANEL: {
                bg: 'var(--yt-spec-menu-background, #fff)',
                text_primary: 'var(--yt-spec-text-primary, #030303)',
                text_secondary: 'var(--yt-spec-text-secondary, #606060)',
                border_default: 'var(--yt-spec-border-primary, #ddd)',
                accent_color: 'var(--yt-spec-call-to-action, #065fd4)',
                input_bg: 'var(--yt-spec-brand-background-primary, #f9f9f9)',
            },
        },
    };

    // =================================================================================
    // SECTION: Event-Driven Architecture (Pub/Sub)
    // =================================================================================

    const EventBus = {
        events: {},
        subscribe(event, listener) {
            if (!this.events[event]) {
                this.events[event] = [];
            }
            if (!this.events[event].includes(listener)) {
                this.events[event].push(listener);
            }
        },
        publish(event, ...args) {
            if (!this.events[event]) {
                return;
            }
            this.events[event].forEach((listener) => {
                try {
                    listener(...args);
                } catch (e) {
                    Logger.error(`EventBus error in listener for event "${event}":`, e);
                }
            });
        },
    };

    // =================================================================================
    // SECTION: Utility Functions
    // =================================================================================

    /**
     * @param {Function} func
     * @param {number} delay
     * @returns {Function}
     */
    function debounce(func, delay) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    /**
     * Helper function to check if an item is a non-array object.
     * @param {*} item The item to check.
     * @returns {boolean}
     */
    function isObject(item) {
        return !!(item && typeof item === 'object' && !Array.isArray(item));
    }

    /**
     * Recursively merges the properties of a source object into a target object.
     * @param {object} target The target object (e.g., a deep copy of default config).
     * @param {object} source The source object (e.g., user config).
     * @returns {object} The mutated target object.
     */
    function deepMerge(target, source) {
        for (const key in source) {
            if (Object.prototype.hasOwnProperty.call(source, key)) {
                const sourceVal = source[key];
                if (isObject(sourceVal) && Object.prototype.hasOwnProperty.call(target, key) && isObject(target[key])) {
                    deepMerge(target[key], sourceVal);
                } else if (typeof sourceVal !== 'undefined') {
                    target[key] = sourceVal;
                }
            }
        }
        return target;
    }

    /**
     * @typedef {Node|string|number|boolean|null|undefined} HChild
     */
    /**
     * Creates a DOM element using a hyperscript-style syntax.
     * @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
     * @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
     * @param {HChild | HChild[]} [children] - Children (if props are specified).
     * @returns {HTMLElement|SVGElement} The created DOM element.
     */
    function h(tag, propsOrChildren, children) {
        const SVG_NS = 'http://www.w3.org/2000/svg';
        const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
        if (!match) throw new Error(`Invalid tag syntax: ${tag}`);

        const [, tagName, id, classList] = match;
        const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
        const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);

        if (id) el.id = id.slice(1);
        if (classList) {
            const classes = classList.replace(/\./g, ' ').trim();
            if (classes) {
                el.classList.add(...classes.split(/\s+/));
            }
        }

        let props = {};
        let childrenArray;
        if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
            props = propsOrChildren;
            childrenArray = children;
        } else {
            childrenArray = propsOrChildren;
        }

        // --- Start of Attribute/Property Handling ---
        const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
        const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
        const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);

        for (const [key, value] of Object.entries(props)) {
            // 0. Handle `ref` callback (highest priority after props parsing).
            if (key === 'ref' && typeof value === 'function') {
                value(el);
            }
            // 1. Security check for URL attributes.
            else if (urlAttributes.has(key)) {
                const url = String(value);
                try {
                    const parsedUrl = new URL(url); // Throws if not an absolute URL.
                    if (safeProtocols.has(parsedUrl.protocol)) {
                        el.setAttribute(key, url);
                    } else {
                        el.setAttribute(key, '#');
                        Logger.warn(`Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
                    }
                } catch {
                    el.setAttribute(key, '#');
                    Logger.warn(`Blocked invalid or relative URL in attribute "${key}":`, url);
                }
            }
            // 2. Direct property assignments.
            else if (directProperties.has(key)) {
                el[key] = value;
            }
            // 3. Other specialized handlers.
            else if (key === 'style' && typeof value === 'object') {
                Object.assign(el.style, value);
            } else if (key === 'dataset' && typeof value === 'object') {
                for (const [dataKey, dataVal] of Object.entries(value)) {
                    el.dataset[dataKey] = dataVal;
                }
            } else if (key.startsWith('on') && typeof value === 'function') {
                el.addEventListener(key.slice(2).toLowerCase(), value);
            } else if (key === 'className') {
                const classes = String(value).trim();
                if (classes) {
                    el.classList.add(...classes.split(/\s+/));
                }
            } else if (key.startsWith('aria-')) {
                el.setAttribute(key, String(value));
            }
            // 4. Default attribute handling.
            else if (value !== false && value !== null) {
                el.setAttribute(key, value === true ? '' : String(value));
            }
        }
        // --- End of Attribute/Property Handling ---

        const fragment = document.createDocumentFragment();
        /**
         *
         * @param child
         */
        function append(child) {
            if (child === null || child === false || typeof child === 'undefined') return;
            if (typeof child === 'string' || typeof child === 'number') {
                fragment.appendChild(document.createTextNode(String(child)));
            } else if (Array.isArray(child)) {
                child.forEach(append);
            } else if (child instanceof Node) {
                fragment.appendChild(child);
            } else {
                throw new Error('Unsupported child type');
            }
        }
        append(childrenArray);

        el.appendChild(fragment);

        return el;
    }

    /**
     * Recursively builds a DOM element from a definition object using the h() function.
     * @param {object} def The definition object for the element.
     * @returns {HTMLElement | SVGElement | null} The created DOM element.
     */
    function createIconFromDef(def) {
        if (!def) return null;
        const children = def.children ? def.children.map((child) => createIconFromDef(child)) : [];
        return h(def.tag, def.props, children);
    }

    // =================================================================================
    // SECTION: Configuration Management (GM Storage)
    // =================================================================================

    class ConfigManagerBase {
        constructor({ configKey, defaultConfig }) {
            if (!configKey || !defaultConfig) {
                throw new Error('configKey and defaultConfig must be provided.');
            }
            this.CONFIG_KEY = configKey;
            this.DEFAULT_CONFIG = defaultConfig;
            this.config = null;
        }

        async load() {
            const raw = await GM_getValue(this.CONFIG_KEY);
            let userConfig = null;
            if (raw) {
                try {
                    userConfig = JSON.parse(raw);
                } catch (e) {
                    Logger.error('Failed to parse configuration. Resetting to default settings.', e);
                    userConfig = null;
                }
            }

            const completeConfig = JSON.parse(JSON.stringify(this.DEFAULT_CONFIG));
            this.config = deepMerge(completeConfig, userConfig || {});
        }

        async save(obj) {
            this.config = obj;
            await GM_setValue(this.CONFIG_KEY, JSON.stringify(obj));
        }

        get() {
            return this.config;
        }
    }

    class ConfigManager extends ConfigManagerBase {
        constructor() {
            super({
                configKey: CONSTANTS.CONFIG_KEY,
                defaultConfig: DEFAULT_CONFIG,
            });
        }
    }

    // =================================================================================
    // SECTION: UI Elements - Base Classes
    // =================================================================================

    /**
     * @abstract
     * @description Base class for a UI component.
     */
    class UIComponentBase {
        constructor(callbacks = {}) {
            this.callbacks = callbacks;
            this.element = null;
        }

        /** @abstract */
        render() {
            throw new Error('Component must implement render method.');
        }

        destroy() {
            this.element?.remove();
            this.element = null;
        }
    }

    /**
     * @abstract
     * @description Base class for a settings panel/submenu UI component.
     */
    class SettingsPanelBase extends UIComponentBase {
        constructor(callbacks) {
            super(callbacks);
            this.debouncedSave = debounce(async () => {
                const newConfig = await this._collectDataFromForm();
                EventBus.publish('config:save', newConfig);
            }, CONSTANTS.TIMERS.DEBOUNCE_MS);
            this._handleDocumentClick = this._handleDocumentClick.bind(this);
        }

        render() {
            // Basic rendering logic, subclasses will provide content.
            this._injectStyles();
            this.element = this._createPanelContainer();
            const content = this._createPanelContent();
            this.element.appendChild(content);

            document.body.appendChild(this.element);
            this._setupEventListeners();
            return this.element;
        }

        toggle() {
            const shouldShow = this.element.style.display === 'none';
            if (shouldShow) {
                this.show();
            } else {
                this.hide();
            }
        }

        isOpen() {
            return this.element && this.element.style.display !== 'none';
        }

        async show() {
            await this.populateForm();
            const anchorRect = this.callbacks.getAnchorElement().getBoundingClientRect();
            this.element.style.display = 'block';
            // Position panel near the anchor element
            this.element.style.top = `${anchorRect.bottom + 8}px`;
            this.element.style.right = `${window.innerWidth - anchorRect.right - anchorRect.width / 2}px`;
            document.addEventListener('click', this._handleDocumentClick, true);
        }

        hide() {
            this.element.style.display = 'none';
            document.removeEventListener('click', this._handleDocumentClick, true);
            this.callbacks.onClose?.(); // Notify SyncManager that the panel has closed
        }

        _createPanelContainer() {
            return h(`div#${APPID}-settings-panel`, { style: { display: 'none' }, role: 'menu' });
        }

        _handleDocumentClick(e) {
            const anchor = this.callbacks.getAnchorElement();
            if (this.element && !this.element.contains(e.target) && anchor && !anchor.contains(e.target)) {
                this.hide();
            }
        }

        _createPanelContent() {
            throw new Error('Subclass must implement _createPanelContent()');
        }
        _injectStyles() {
            throw new Error('Subclass must implement _injectStyles()');
        }
        populateForm() {
            throw new Error('Subclass must implement populateForm()');
        }
        _collectDataFromForm() {
            throw new Error('Subclass must implement _collectDataFromForm()');
        }
        _setupEventListeners() {
            throw new Error('Subclass must implement _setupEventListeners()');
        }
    }

    // =================================================================================
    // SECTION: UI Elements - Components and Manager
    // =================================================================================

    class CustomSettingsButton extends UIComponentBase {
        constructor(callbacks, options) {
            super(callbacks);
            this.options = options;
            this.id = this.options.id;
            this.styleId = `${this.id}-style`;
        }

        render() {
            this._injectStyles();
            this.element = h('button', {
                id: this.id,
                title: this.options.title,
                onclick: (e) => {
                    e.stopPropagation();
                    this.callbacks.onClick?.();
                },
            });
            const iconDef = this.options.siteStyles.iconDef;
            if (iconDef) {
                this.element.appendChild(createIconFromDef(iconDef));
            }
            document.body.appendChild(this.element);
            return this.element;
        }

        _injectStyles() {
            if (document.getElementById(this.styleId)) return;
            const { zIndex, siteStyles } = this.options;
            const buttonStyle = CONSTANTS.UI_DEFAULTS.SETTINGS_BUTTON;
            const style = h('style', {
                id: this.styleId,
                textContent: `
                #${this.id} {
                    position: fixed;
                    top: ${buttonStyle.top};
                    right: ${buttonStyle.right};
                    z-index: ${zIndex};
                    width: ${buttonStyle.width};
                    height: ${buttonStyle.height};
                    border-radius: ${siteStyles.borderRadius};
                    background: ${siteStyles.background};
                    border: 1px solid ${siteStyles.borderColor};
                    cursor: pointer;
                    transition: background 0.12s;
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    padding: 0;
                }
                #${this.id}:hover {
                    background: ${siteStyles.backgroundHover};
                }
            `,
            });
            document.head.appendChild(style);
        }
    }

    class SettingsPanelComponent extends SettingsPanelBase {
        _createPanelContent() {
            const sliderSettings = CONSTANTS.UI_DEFAULTS.SLIDER;
            const createToggle = (id, title) => {
                return h(`label.${APPID}-toggle-switch`, { title: title }, [h('input', { type: 'checkbox', id: id }), h(`span.${APPID}-toggle-slider`)]);
            };

            return h('div', [
                h(`div.${APPID}-submenu-row-stacked`, [
                    h('label', { htmlFor: `${APPID}-items-per-row-slider` }, 'Items per row'),
                    h(`div.${APPID}-slider-wrapper`, [
                        h('input', {
                            type: 'range',
                            id: `${APPID}-items-per-row-slider`,
                            min: sliderSettings.min,
                            max: sliderSettings.max,
                            step: sliderSettings.step,
                        }),
                        h(`span#${APPID}-slider-value-display`),
                    ]),
                ]),
                h('div', { style: { borderTop: '1px solid var(--yt-spec-border-primary, #ddd)', margin: '12px 0' } }),
                h(`div.${APPID}-submenu-row`, [
                    h('label', { htmlFor: `${APPID}-hide-shorts-toggle` }, 'Hide YouTube Shorts'),
                    createToggle(`${APPID}-hide-shorts-toggle`, 'Hides Shorts videos from shelves, search results, and navigation menus.'),
                ]),
                h('div', { style: { borderTop: '1px solid var(--yt-spec-border-primary, #ddd)', margin: '12px 0' } }),
                h(`div.${APPID}-submenu-row`, [
                    h('label', { htmlFor: `${APPID}-redirect-shorts-toggle` }, 'Redirect Shorts player'),
                    createToggle(`${APPID}-redirect-shorts-toggle`, 'Redirects the Shorts player to the standard video player.'),
                ]),
                h('div', { style: { borderTop: '1px solid var(--yt-spec-border-primary, #ddd)', margin: '12px 0' } }),
                h(`div.${APPID}-submenu-row`, [h('label', { htmlFor: `${APPID}-sync-tabs-toggle` }, 'Sync settings across tabs'), createToggle(`${APPID}-sync-tabs-toggle`, 'Automatically apply settings changes to all open YouTube tabs.')]),
                h(`div#${APPID}-sync-note.${APPID}-settings-note`, { style: { 'text-align': 'right', color: 'var(--yt-spec-text-brand, #c00)' } }),
            ]);
        }

        async populateForm() {
            const config = await this.callbacks.getCurrentConfig();
            const slider = this.element.querySelector(`#${APPID}-items-per-row-slider`);
            slider.value = config.options.itemsPerRow;
            this._updateSliderAppearance(slider);

            this.element.querySelector(`#${APPID}-hide-shorts-toggle`).checked = config.options.hideShorts;
            this.element.querySelector(`#${APPID}-redirect-shorts-toggle`).checked = config.options.redirectShorts;
            this.element.querySelector(`#${APPID}-sync-tabs-toggle`).checked = config.options.syncTabs;
        }

        async _collectDataFromForm() {
            const currentConfig = await this.callbacks.getCurrentConfig();
            const newConfig = JSON.parse(JSON.stringify(currentConfig));
            const slider = this.element.querySelector(`#${APPID}-items-per-row-slider`);
            newConfig.options.itemsPerRow = parseInt(slider.value, 10);

            newConfig.options.hideShorts = this.element.querySelector(`#${APPID}-hide-shorts-toggle`).checked;
            newConfig.options.redirectShorts = this.element.querySelector(`#${APPID}-redirect-shorts-toggle`).checked;
            newConfig.options.syncTabs = this.element.querySelector(`#${APPID}-sync-tabs-toggle`).checked;

            return newConfig;
        }

        _setupEventListeners() {
            // Use event delegation for all toggles and the slider for efficiency.
            this.element.addEventListener('change', (e) => {
                if (e.target.matches('input[type="checkbox"]')) {
                    this.debouncedSave();
                }
            });
            this.element.addEventListener('input', (e) => {
                if (e.target.matches('input[type="range"]')) {
                    this._updateSliderAppearance(e.target);
                    this.debouncedSave();
                }
            });
        }

        _updateSliderAppearance(slider) {
            const display = this.element.querySelector(`#${APPID}-slider-value-display`);
            display.textContent = slider.value;
        }

        _injectStyles() {
            const styleId = `${APPID}-ui-styles`;
            if (document.getElementById(styleId)) return;
            const styles = this.callbacks.siteStyles;
            const style = h('style', {
                id: styleId,
                textContent: `
                #${APPID}-settings-panel {
                    position: fixed;
                    width: 250px;
                    background: ${styles.bg};
                    color: ${styles.text_primary};
                    border: 1px solid ${styles.border_default};
                    border-radius: 12px;
                    box-shadow: 0 4px 4px rgba(0,0,0,0.3);
                    padding: 16px;
                    z-index: 11000;
                    font-size: 14px;
                    transform: translateX(50%);
                }
                .${APPID}-submenu-row, .${APPID}-submenu-row-stacked {
                    display: flex;
                    align-items: center;
                    gap: 8px;
                }
                .${APPID}-submenu-row {
                    justify-content: space-between;
                }
                .${APPID}-submenu-row-stacked {
                    flex-direction: column;
                    align-items: stretch; /* Stretch children to fill width */
                }
                .${APPID}-slider-wrapper {
                    display: flex; align-items: center; gap: 16px;
                }
                #${APPID}-items-per-row-slider {
                    flex-grow: 1;
                }
                #${APPID}-slider-value-display {
                    font-weight: 500; min-width: 20px; text-align: right; color: ${styles.text_secondary};
                }
                .${APPID}-toggle-switch {
                    position: relative;
                    display: inline-block;
                    width: 40px;
                    height: 22px;
                    flex-shrink: 0;
                }
                .${APPID}-toggle-switch input {
                    opacity: 0; width: 0; height: 0;
                }
                .${APPID}-toggle-slider {
                    position: absolute; cursor: pointer;
                    top: 0; left: 0; right: 0; bottom: 0;
                    background-color: var(--yt-spec-icon-disabled, #ccc);
                    transition: .3s;
                    border-radius: 22px;
                }
                .${APPID}-toggle-slider:before {
                    position: absolute; content: "";
                    height: 16px; width: 16px;
                    left: 3px; bottom: 3px;
                    background-color: white;
                    transition: .3s;
                    border-radius: 50%;
                }
                .${APPID}-toggle-switch input:checked + .${APPID}-toggle-slider {
                    background-color: ${styles.accent_color};
                }
                .${APPID}-toggle-switch input:checked + .${APPID}-toggle-slider:before {
                    transform: translateX(18px);
                }
                .${APPID}-settings-note {
                    font-size: 12px;
                    color: ${styles.text_secondary};
                    margin-top: 8px;
                    text-align: left;
                }
                #${APPID}-sync-note {
                    min-height: 1.5em; /* Reserve space for the message */
                }
            `,
            });
            document.head.appendChild(style);
        }
    }

    class UIManager {
        constructor(getCurrentConfig, siteStyles, callbacks = {}) {
            this.getCurrentConfig = getCurrentConfig;
            this.siteStyles = siteStyles;
            this.callbacks = callbacks;
            this.components = {};
        }

        init() {
            this.components.settingsBtn = new CustomSettingsButton(
                { onClick: () => this.components.settingsPanel.toggle() },
                {
                    id: `${APPID}-settings-btn`,
                    title: `${APPNAME} Settings`,
                    zIndex: CONSTANTS.UI_DEFAULTS.SETTINGS_BUTTON.zIndex,
                    siteStyles: this.siteStyles.SETTINGS_BUTTON,
                }
            );
            this.components.settingsPanel = new SettingsPanelComponent({
                getCurrentConfig: this.getCurrentConfig,
                getAnchorElement: () => this.components.settingsBtn.element,
                siteStyles: this.siteStyles.SETTINGS_PANEL,
                onClose: this.callbacks.onPanelClose, // Pass the callback down
            });

            this.components.settingsBtn.render();
            this.components.settingsPanel.render();
        }
    }

    // =================================================================================
    // SECTION: Sync Manager
    // =================================================================================

    class SyncManager {
        constructor(app) {
            this.app = app;
            this.pendingRemoteConfig = null;
        }

        init() {
            GM_addValueChangeListener(CONSTANTS.CONFIG_KEY, this._handleRemoteChange.bind(this));
        }

        /**
         * Called by MainApp when a local save occurs.
         */
        onSave() {
            this.pendingRemoteConfig = null;
            this._clearConflictNotification();
        }

        /**
         * Called by MainApp (via UIManager) when the settings panel is closed.
         */
        onPanelClose() {
            if (this.pendingRemoteConfig) {
                Logger.log('Applying pending remote config after panel closed.');
                this.app.applyRemoteUpdate(this.pendingRemoteConfig);
                this.pendingRemoteConfig = null;
                this._clearConflictNotification();
            }
        }

        /**
         * Handles the GM_addValueChangeListener event.
         * @private
         */
        async _handleRemoteChange(name, oldValue, newValue, remote) {
            if (!remote) {
                return;
            }

            // Guard: Wait for the local config to be loaded before processing a remote change.
            await this.app.configPromise;
            if (!this.app.configManager.config) {
                Logger.error('Config is still not available after promise resolved. Aborting remote change.');
                return;
            }

            Logger.log('Remote config change detected.');
            let newConfig;
            try {
                newConfig = JSON.parse(newValue);
            } catch (e) {
                Logger.error('Failed to parse remote config.', e);
                return;
            }

            // Check the INCOMING config to see if sync is enabled.
            // This allows a tab to RECEIVE a "sync on" command from another tab.
            if (!newConfig.options.syncTabs) {
                this.app.configManager.config.options.syncTabs = false; // Only update the sync option
                Logger.log('Sync disabled remotely. Updating local config state only.');
                return;
            }

            // Guard: UIManager might not be initialized yet
            if (this.app.uiManager && this.app.uiManager.components.settingsPanel.isOpen()) {
                Logger.log('Settings panel is open. Deferring update and showing notification.');
                this.pendingRemoteConfig = newConfig;
                this._showConflictNotification();
            } else {
                Logger.log('Applying silent remote update.');
                this.app.applyRemoteUpdate(newConfig);
            }
        }

        /**
         * Displays a notification in the settings panel about a remote change.
         * @private
         */
        _showConflictNotification() {
            const noteElement = document.querySelector(`#${APPID}-sync-note`);
            if (noteElement) {
                noteElement.textContent = 'Updated in another tab. Reopen to see changes.';
            }
        }

        /**
         * Clears the notification in the settings panel.
         * @private
         */
        _clearConflictNotification() {
            const noteElement = document.querySelector(`#${APPID}-sync-note`);
            if (noteElement) {
                noteElement.textContent = '';
            }
        }
    }

    // =================================================================================
    // SECTION: Style Manager
    // =================================================================================

    class StyleManager {
        static styleElement = null;

        static init() {
            if (this.styleElement) return;
            this.styleElement = h('style', { id: `${APPID}-dynamic-styles` });
            document.head.appendChild(this.styleElement);
        }

        static update(options) {
            const { itemsPerRow, hideShorts } = options;
            const GAP = 12; // A reasonable default gap in pixels

            let cssText = `
                /* Widen the main content container and remove padding */
                #primary.ytd-two-column-browse-results-renderer,
                #contents.ytd-page-manager {
                    width: 100% !important;
                    max-width: 100% !important;
                    padding: 0 !important;
                }

                /* Apply user settings and layout fixes to the video grid */
                ytd-rich-grid-renderer {
                    --ytd-rich-grid-items-per-row: ${itemsPerRow} !important;
                    max-width: 100% !important;
                    margin: 0 !important;
                    gap: ${GAP}px !important;
                }
            `;

            if (hideShorts) {
                const selectorsToHide = CONSTANTS.SELECTORS.shortsFullScan.join(',\n');
                cssText += `
                    /* CSS to hide Shorts elements */
                    ${selectorsToHide} {
                        display: none !important;
                    }
                `;
            }

            if (this.styleElement.textContent !== cssText) {
                this.styleElement.textContent = cssText;
                Logger.log(`Styles updated: ItemsPerRow=${itemsPerRow}, HideShorts=${hideShorts}`);
            }
        }
    }

    // =================================================================================
    // SECTION: Main Application Controller
    // =================================================================================

    class MainApp {
        constructor() {
            this.configManager = null;
            this.uiManager = null;
            this.syncManager = new SyncManager(this);
            this.configPromise = null; // Promise for config load

            // Promise to resolve when DOM-dependent components are initialized
            this.domReadyPromise = new Promise((resolve) => {
                this.resolveDomReadyPromise = resolve;
            });
        }

        /**
         * Stage 1: Initialize non-DOM components and listeners.
         * This runs immediately at document-start.
         */
        init() {
            Logger.log('Initializing (Stage 1)...');

            this.configManager = new ConfigManager();
            this.configPromise = this.configManager.load(); // Start loading config

            this.syncManager.init(); // Initialize the sync listener.

            // Register all event listeners immediately to prevent race conditions
            EventBus.subscribe('config:save', this.handleSave.bind(this));
            document.addEventListener('yt-navigate-start', this.handleRedirect.bind(this));
            document.addEventListener('yt-navigate-finish', this.handleNavigation.bind(this));

            // Register Stage 2: Initialize DOM components when ready
            document.addEventListener('DOMContentLoaded', this.initDOMComponents.bind(this));
        }

        /**
         * Stage 2: Initialize DOM-dependent components.
         * This runs after the DOM is ready.
         */
        async initDOMComponents() {
            Logger.log('Initializing (Stage 2 - DOM Ready)...');

            // Ensure config is loaded before creating UI
            await this.configPromise;
            Logger.log('Config loaded, initializing UI.');

            StyleManager.init();

            const siteStyles = SITE_STYLES.youtube;
            this.uiManager = new UIManager(() => this.configManager.get(), siteStyles, {
                onPanelClose: () => this.syncManager.onPanelClose(),
            });
            this.uiManager.init();

            // Apply initial settings now that UI and config are ready
            this.applySettings();

            // Signal that DOM initialization is complete
            this.resolveDomReadyPromise();
        }

        /**
         * Lightweight method to apply styles.
         * Ensures config is loaded and DOM components (StyleManager) are initialized before acting.
         */
        async applySettings() {
            // Wait for both config and DOM initialization to be complete
            await this.configPromise;
            await this.domReadyPromise;

            const config = this.configManager.get();
            StyleManager.update(config.options);
        }

        /**
         * Applies an update received from another tab.
         * @param {object} newConfig - The new configuration object from the remote tab.
         */
        applyRemoteUpdate(newConfig) {
            this.configManager.config = newConfig;
            this.applySettings();

            // Guard: UIManager might not be initialized yet
            if (this.uiManager) {
                // Repopulate the form in case the user opens it later.
                this.uiManager.components.settingsPanel.populateForm();
            }
        }

        async handleSave(newConfig) {
            this.syncManager.onSave(); // Notify SyncManager that a local save is happening.
            await this.configManager.save(newConfig);
            Logger.log('Configuration saved.');

            // On save, only apply the (fast) stylesheet update.
            this.applySettings();
        }

        async handleRedirect(e) {
            await this.configPromise; // Wait for config
            const config = this.configManager.get();
            if (!config.options.redirectShorts) return;

            const url = e.detail.url;
            if (url && url.startsWith('/shorts/')) {
                const videoId = url.split('/shorts/')[1];
                if (videoId) {
                    e.preventDefault(); // Stop the navigation to the Shorts player
                    const newUrl = `/watch?v=${videoId}`;
                    Logger.log(`Shorts navigation detected, redirecting to: ${newUrl}`);
                    window.history.pushState({}, '', newUrl);
                    window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
                }
            }
        }

        handleNavigation() {
            Logger.log(`Navigation finished. Running updates for: ${window.location.href}`);

            // On navigation, apply styles.
            // applySettings() will wait for config if it's not ready yet.
            this.applySettings();
        }
    }

    // =================================================================================
    // SECTION: Entry Point
    // =================================================================================

    if (ExecutionGuard.hasExecuted()) return;
    ExecutionGuard.setExecuted();

    Logger.log('Script loaded. Initializing Stage 1...');
    const app = new MainApp();
    app.init(); // Run Stage 1 initialization immediately
})();