// ==UserScript==
// @name YouTube-UI-Customizer
// @namespace https://github.com/p65536
// @version 1.0.0
// @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-idle
// @noframes
// ==/UserScript==
(() => {
'use strict';
// =================================================================================
// 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
// =================================================================================
window.__myproject_guard__ = window.__myproject_guard__ || {};
if (window.__myproject_guard__[`${APPID}_executed`]) return;
window.__myproject_guard__[`${APPID}_executed`] = true;
// =================================================================================
// 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;
}
/**
* 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|Array|string|Node} [propsOrChildren] - Attributes object or children.
* @param {Array|string|Node} [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', 'path'].includes(tagName);
const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);
if (id) el.id = id.slice(1);
if (classList) el.className = classList.replace(/\./g, ' ').trim();
let props = {};
let childrenArray;
if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
props = propsOrChildren;
childrenArray = children;
} else {
childrenArray = propsOrChildren;
}
const directProperties = new Set(['value', 'checked', 'selected', 'textContent']);
for (const [key, value] of Object.entries(props)) {
if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (directProperties.has(key)) {
el[key] = value;
} else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (value !== false && value != null) {
el.setAttribute(key, value === true ? '' : value);
}
}
const fragment = document.createDocumentFragment();
function append(child) {
if (child == null || child === false) return;
if (typeof child === 'string' || typeof child === 'number') {
fragment.appendChild(document.createTextNode(child));
} else if (Array.isArray(child)) {
child.forEach(append);
} else if (child instanceof Node) {
fragment.appendChild(child);
}
}
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 and Constants
// =================================================================================
const CONSTANTS = {
CONFIG_KEY: `${APPID}_config`,
TIMERS: {
DEBOUNCE_MS: 300,
FULL_SCAN_DELAY_MS: 150,
},
SELECTORS: {
pageManager: 'ytd-page-manager',
parentContainers: {
fullScan: 'ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-item-section-renderer, ytd-guide-entry-renderer, ytd-mini-guide-entry-renderer, ytd-rich-section-renderer',
dynamicOnly: 'ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-item-section-renderer',
},
// Selectors for the full scan (on navigation/save)
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"]',
],
// Lightweight selectors for dynamically added content (on scroll)
shortsDynamicOnly: [
'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/"])',
],
},
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: 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);
}, 300);
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 {
constructor(callbacks) {
super(callbacks);
this.debouncedSave = debounce(async () => {
const newConfig = await this._collectDataFromForm();
EventBus.publish('config:save', newConfig);
}, 300);
this._handleDocumentClick = this._handleDocumentClick.bind(this);
}
hide() {
this.element.style.display = 'none';
document.removeEventListener('click', this._handleDocumentClick, true);
this.callbacks.onClose?.(); // Notify that the panel has closed
}
_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. When turned off, a page reload is required to show them again.')]),
h(`div.${APPID}-settings-note`, '* Turning this off requires a page reload to take effect.'),
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;
}
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) {
// If the incoming change is to turn sync OFF, we still need to update
// the local config to reflect that, but we won't apply other settings.
this.app.configManager.config = newConfig;
Logger.log('Sync disabled remotely. Updating local config state only.');
return;
}
if (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.observer = null;
this.syncManager = new SyncManager(this);
this.debouncedProcessNodes = debounce((nodes) => this.processNodes(nodes), 300);
}
/**
* Lightweight processor for dynamically added nodes (e.g., from infinite scroll).
* It only searches for video items, not static elements like sidebar links.
* @param {NodeList} nodes - The nodes to process.
*/
processNodes(nodes) {
const config = this.configManager.get();
if (!config.options.hideShorts) return;
const dynamicSelectors = CONSTANTS.SELECTORS.shortsDynamicOnly;
let removedCount = 0;
for (const node of nodes) {
if (node.nodeType !== 1) continue;
for (const selector of dynamicSelectors) {
const elements = node.matches(selector) ? [node] : node.querySelectorAll(selector);
elements.forEach((el) => {
if (el.dataset.ytteProcessed) return;
const parentToRemove = el.closest(CONSTANTS.SELECTORS.parentContainers.dynamicOnly);
(parentToRemove || el).remove();
removedCount++;
});
}
}
if (removedCount > 0) {
Logger.log(`Removed ${removedCount} new Shorts item(s).`);
}
}
/**
* Heavyweight full-page scan to remove all types of Shorts elements.
* Called only on major page loads/navigations.
*/
runFullShortsScan() {
const config = this.configManager.get();
if (!config.options.hideShorts) return;
const allShortsSelectors = CONSTANTS.SELECTORS.shortsFullScan;
let removedCount = 0;
for (const selector of allShortsSelectors) {
document.querySelectorAll(selector).forEach((el) => {
if (el.dataset.ytteProcessed) return;
const parentToRemove = el.closest(CONSTANTS.SELECTORS.parentContainers.fullScan);
const target = parentToRemove || el;
target.remove();
target.dataset.ytteProcessed = 'true';
removedCount++;
});
}
if (removedCount > 0) {
Logger.log(`Initial scan removed ${removedCount} Shorts element(s).`);
}
}
/**
* Lightweight method to apply styles. Called frequently.
*/
applySettings() {
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();
this.runFullShortsScan();
// Repopulate the form in case the user opens it later.
this.uiManager.components.settingsPanel.populateForm();
}
handleDOMChanges(mutationsList) {
const addedNodes = [];
for (const mutation of mutationsList) {
addedNodes.push(...mutation.addedNodes);
}
if (addedNodes.length > 0) {
// Call the lightweight processor for new nodes.
this.debouncedProcessNodes(addedNodes);
}
}
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();
// If the user just turned on "hideShorts", run a full scan once to clean the page.
if (newConfig.options.hideShorts) {
this.runFullShortsScan();
}
}
handleNavigation() {
Logger.log(`Navigation finished. Running updates for: ${window.location.href}`);
const config = this.configManager.get();
if (config.options.redirectShorts && window.location.pathname.startsWith('/shorts/')) {
const videoId = window.location.pathname.split('/shorts/')[1];
if (videoId) {
const newUrl = `/watch?v=${videoId}`;
Logger.log(`Shorts page detected, redirecting to: ${newUrl}`);
window.location.href = newUrl;
return;
}
}
// On navigation, apply styles immediately and run a full, heavyweight scan.
this.applySettings();
setTimeout(() => {
this.runFullShortsScan();
}, CONSTANTS.TIMERS.FULL_SCAN_DELAY_MS);
}
async init() {
Logger.log('Initializing...');
this.configManager = new ConfigManager();
await this.configManager.load();
StyleManager.init();
this.syncManager.init(); // Initialize the sync listener.
const siteStyles = SITE_STYLES.youtube;
this.uiManager = new UIManager(() => this.configManager.get(), siteStyles, {
onPanelClose: () => this.syncManager.onPanelClose(),
});
this.uiManager.init();
EventBus.subscribe('config:save', this.handleSave.bind(this));
document.addEventListener('yt-navigate-finish', this.handleNavigation.bind(this));
this.observer = new MutationObserver(this.handleDOMChanges.bind(this));
// Use a preliminary observer to find the stable content container, then switch to it.
const parentObserver = new MutationObserver((mutations, obs) => {
const targetNode = document.querySelector(CONSTANTS.SELECTORS.pageManager);
if (targetNode) {
Logger.log('Found ytd-page-manager. Starting primary observer.');
this.observer.observe(targetNode, { childList: true, subtree: true });
obs.disconnect();
}
});
parentObserver.observe(document.body, { childList: true, subtree: true });
this.handleNavigation();
}
}
// =================================================================================
// SECTION: Entry Point
// =================================================================================
Logger.log('Script loaded.');
const app = new MainApp();
app.init();
})();