// ==UserScript== // @name YouTube + // @name:ar YouTube + // @name:az YouTube + // @name:be YouTube + // @name:bg YouTube + // @name:zh-CN YouTube + // @name:de YouTube + // @name:nl YouTube + // @name:en YouTube + // @name:es YouTube + // @name:fr YouTube + // @name:hi YouTube + // @name:id YouTube + // @name:it YouTube + // @name:ja YouTube + // @name:kk YouTube + // @name:ko YouTube + // @name:ky YouTube + // @name:pl YouTube + // @name:pt YouTube + // @name:tr YouTube + // @name:zh-TW YouTube + // @name:uk YouTube + // @name:uz YouTube + // @name:vi YouTube + // @namespace by // @version 2.4.4 // @author diorhc // @description Вкладки для информации, комментариев, видео, плейлиста и скачивание видео и другие функции ↴ // @description:ar Tabview YouTube and download and other features ↴ // @description:az Tabview YouTube və yükləmə və digər xüsusiyyətlər ↴ // @description:be Tabview YouTube і загрузка і іншыя функцыі ↴ // @description:bg Tabview YouTube и изтегляне и други функции ↴ // @description:zh-CN 标签视图 YouTube、下载及其他功能 ↴ // @description:de Tabview YouTube und Download und andere Funktionen ↴ // @description:nl Tabview YouTube en Download en andere functies ↴ // @description:en Tabview YouTube and Download and others features ↴ // @description:es Vista de pestañas de YouTube, descarga y otras funciones ↴ // @description:fr Tabview YouTube et Télécharger et autres fonctionnalités ↴ // @description:hi YouTube टैब व्यू, डाउनलोड और अन्य सुविधाएँ ↴ // @description:id Tampilan tab YouTube, unduh, dan fitur lainnya ↴ // @description:it Vista a schede per YouTube, download e altre funzionalità ↴ // @description:ja タブビューYouTubeとダウンロードおよびその他の機能 ↴ // @description:kk Tabview YouTube және жүктеу және басқа функциялар ↴ // @description:ko Tabview YouTube 및 다운로드 및 기타 기능 ↴ // @description:ky Tabview YouTube жана жүктөө жана башка функциялар ↴ // @description:pl Widok kart YouTube, pobieranie i inne funkcje ↴ // @description:pt Visualização em abas do YouTube, download e outros recursos ↴ // @description:tr Sekmeli Görünüm YouTube ve İndir ve diğer özellikler ↴ // @description:zh-TW 標籤檢視 YouTube 及下載及其他功能 ↴ // @description:uk Перегляд вкладок YouTube, завантаження та інші функції ↴ // @description:uz YouTube uchun tabview va yuklab olish va boshqa xususiyatlar ↴ // @description:vi Chế độ tab cho YouTube, tải xuống và các tính năng khác ↴ // @match https://*.youtube.com/* // @match https://music.youtube.com/* // @match https://studio.youtube.com/* // @match *://myactivity.google.com/* // @include *://www.youtube.com/feed/history/* // @include https://www.youtube.com // @include *://*.youtube.com/** // @exclude *://accounts.youtube.com/* // @exclude *://www.youtube.com/live_chat_replay* // @exclude *://www.youtube.com/persist_identity* // @exclude /^https?://\w+\.youtube\.com\/live_chat.*$/ // @exclude /^https?://\S+\.(txt|png|jpg|jpeg|gif|xml|svg|manifest|log|ini)[^\/]*$/ // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @license MIT // @require https://cdn.jsdelivr.net/npm/@preact/signals-core@1.12.1/dist/signals-core.min.js // @require https://cdn.jsdelivr.net/npm/browser-id3-writer@4.4.0/dist/browser-id3-writer.min.js // @require https://cdn.jsdelivr.net/npm/preact@10.27.2/dist/preact.min.js // @require https://cdn.jsdelivr.net/npm/preact@10.27.2/hooks/dist/hooks.umd.js // @require https://cdn.jsdelivr.net/npm/@preact/signals@2.5.0/dist/signals.min.js // @require https://cdn.jsdelivr.net/npm/dayjs@1.11.19/dayjs.min.js // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect api.livecounts.io // @connect cnv.cx // @connect mp3yt.is // @connect * // @connect youtube.com // @connect googlevideo.com // @connect self // @run-at document-start // @noframes // @homepageURL https://github.com/diorhc/YTP // @supportURL https://github.com/diorhc/YTP/issues // @downloadURL none // ==/UserScript== // --- MODULE: utils.js --- // Shared utilities for YouTube+ modules (function () { 'use strict'; // DOM cache helper with fallback const qs = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.get === 'function') { return window.YouTubeDOMCache.get(selector); } return document.querySelector(selector); }; /** * Logs an error message with module context * @param {string} module - The module name where the error occurred * @param {string} message - Description of the error * @param {Error|*} error - The error object or value */ const logError = (module, message, error) => { try { const errorDetails = { module, message, error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack, } : error, timestamp: new Date().toISOString(), userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', url: typeof window !== 'undefined' ? window.location.href : 'unknown', }; console.error(`[YouTube+][${module}] ${message}:`, error); // Use console.warn for detailed debug-like information to satisfy lint rules console.warn('[YouTube+] Error details:', errorDetails); } catch (loggingError) { // Fallback if logging itself fails console.error('[YouTube+] Error logging failed:', loggingError); } }; /** * Lightweight logger that respects a global debug flag. * Use YouTubeUtils.logger.debug/info(...) in modules instead of console.log for * controlled output in development. */ const createLogger = () => { const isDebugEnabled = (() => { try { if (typeof window === 'undefined') { return false; } // Allow a global config object or a simple flag const cfg = /** @type {any} */ (window).YouTubePlusConfig; if (cfg && cfg.debug) { return true; } if (typeof (/** @type {any} */ (window).YTP_DEBUG) !== 'undefined') { return !!(/** @type {any} */ (window).YTP_DEBUG); } return false; } catch { return false; } })(); return { debug: (...args) => { // Route debug/info level messages to console.warn to avoid eslint no-console warnings if (isDebugEnabled && console?.warn) { console.warn('[YouTube+][DEBUG]', ...args); } }, info: (...args) => { if (isDebugEnabled && console?.warn) { console.warn('[YouTube+][INFO]', ...args); } }, warn: (...args) => { if (console?.warn) { console.warn('[YouTube+]', ...args); } }, error: (...args) => { if (console?.error) { console.error('[YouTube+]', ...args); } }, }; }; /** * Creates a debounced function that delays invoking func until after wait milliseconds * @template {Function} T * @param {T} fn - The function to debounce * @param {number} ms - The number of milliseconds to delay * @param {{leading?: boolean}} [options={}] - Options object * @returns {T & {cancel: () => void}} The debounced function with a cancel method */ const debounce = (fn, ms, options = {}) => { let timeout = null; let lastArgs = null; let lastThis = null; let isDestroyed = false; /** @this {any} */ const debounced = function (...args) { if (isDestroyed) return; lastArgs = args; lastThis = this; if (timeout !== null) clearTimeout(timeout); if (options.leading && timeout === null) { try { /** @type {Function} */ (fn).apply(this, args); } catch (e) { console.error('[YouTube+] Debounced function error:', e); } } timeout = setTimeout(() => { if (!isDestroyed && !options.leading) { try { /** @type {Function} */ (fn).apply(lastThis, lastArgs); } catch (e) { console.error('[YouTube+] Debounced function error:', e); } } timeout = null; lastArgs = null; lastThis = null; }, ms); }; debounced.cancel = () => { if (timeout !== null) clearTimeout(timeout); timeout = null; lastArgs = null; lastThis = null; }; debounced.destroy = () => { debounced.cancel(); isDestroyed = true; }; return /** @type {any} */ (debounced); }; /** * Creates a throttled function that only invokes func at most once per limit milliseconds * @template {Function} T * @param {T} fn - The function to throttle * @param {number} limit - The number of milliseconds to throttle invocations to * @returns {T} The throttled function */ const throttle = (fn, limit) => { let inThrottle = false; let lastResult; /** @this {any} */ const throttled = function (...args) { if (!inThrottle) { lastResult = /** @type {Function} */ (fn).apply(this, args); inThrottle = true; setTimeout(() => (inThrottle = false), limit); } return lastResult; }; return /** @type {any} */ (throttled); }; const StyleManager = (function () { const styles = new Map(); return { add(id, css) { try { let el = document.getElementById(id); styles.set(id, css); if (!el) { el = document.createElement('style'); el.id = id; if (!document.head) { document.addEventListener( 'DOMContentLoaded', () => { if (!document.getElementById(id) && document.head) { document.head.appendChild(el); el.textContent = Array.from(styles.values()).join('\n\n'); } }, { once: true } ); return; } document.head.appendChild(el); } el.textContent = Array.from(styles.values()).join('\n\n'); } catch (e) { logError('StyleManager', 'add failed', e); } }, remove(id) { try { styles.delete(id); const el = document.getElementById(id); if (el) el.remove(); } catch (e) { logError('StyleManager', 'remove failed', e); } }, clear() { for (const id of Array.from(styles.keys())) this.remove(id); }, }; })(); /** * Efficient event delegation manager * Reduces memory footprint by delegating events to parent containers */ const EventDelegator = (() => { const delegations = new Map(); return { /** * Delegate event on parent element for dynamic children * @param {Element} parent - Parent element * @param {string} selector - Child selector * @param {string} event - Event type * @param {Function} handler - Event handler * @returns {Function} Cleanup function */ delegate(parent, selector, event, handler) { const delegateHandler = e => { const target = /** @type {Element} */ (e.target); const match = target.closest(selector); if (match && parent.contains(match)) { handler.call(match, e); } }; parent.addEventListener(event, delegateHandler, { passive: true }); const key = `${event}_${selector}`; if (!delegations.has(parent)) { delegations.set(parent, new Map()); } delegations.get(parent).set(key, delegateHandler); return () => { parent.removeEventListener(event, delegateHandler); const parentMap = delegations.get(parent); if (parentMap) { parentMap.delete(key); if (parentMap.size === 0) delegations.delete(parent); } }; }, /** * Clear all delegations for a parent * @param {Element} parent - Parent element */ clearFor(parent) { const parentMap = delegations.get(parent); if (!parentMap) return; parentMap.forEach((handler, key) => { const event = key.split('_')[0]; parent.removeEventListener(event, handler); }); delegations.delete(parent); }, /** * Clear all delegations */ clearAll() { delegations.forEach((map, parent) => { map.forEach((handler, key) => { const event = key.split('_')[0]; parent.removeEventListener(event, handler); }); }); delegations.clear(); }, }; })(); const cleanupManager = (function () { const observers = new Set(); const listeners = new Map(); const listenerStats = { registeredTotal: 0 }; const intervals = new Set(); const timeouts = new Set(); const animationFrames = new Set(); const callbacks = new Set(); // Map elements -> Set of observers (WeakMap so entries are GC'd when element removed) const elementObservers = new WeakMap(); return { /** * Register an observer for global cleanup and optionally associate it with an element. * If an element is provided the observer will be tracked in a WeakMap so when * the element is GC'd the mapping is removed automatically. * @param {MutationObserver|IntersectionObserver|ResizeObserver} o * @param {Element} [el] */ registerObserver(o, el) { try { if (o) observers.add(o); if (el && typeof el === 'object') { try { let set = elementObservers.get(el); if (!set) { set = new Set(); elementObservers.set(el, set); } set.add(o); } catch {} } } catch {} return o; }, registerListener(target, ev, fn, opts) { try { target.addEventListener(ev, fn, opts); const key = Symbol(); listeners.set(key, { target, ev, fn, opts }); listenerStats.registeredTotal++; return key; } catch (e) { logError('cleanupManager', 'registerListener failed', e); return null; } }, getListenerStats() { try { return { active: listeners.size, registeredTotal: listenerStats.registeredTotal, }; } catch { return { active: 0, registeredTotal: 0 }; } }, registerInterval(id) { intervals.add(id); return id; }, registerTimeout(id) { timeouts.add(id); return id; }, registerAnimationFrame(id) { animationFrames.add(id); return id; }, register(cb) { if (typeof cb === 'function') callbacks.add(cb); }, cleanup() { try { for (const cb of callbacks) { try { cb(); } catch (e) { logError('cleanupManager', 'callback failed', e); } } callbacks.clear(); // Disconnect all registered observers for (const o of observers) { try { if (o && typeof o.disconnect === 'function') o.disconnect(); } catch {} } observers.clear(); // Also attempt to disconnect observers associated with elements try { // We cannot iterate WeakMap keys; instead we iterate observers set already // which covers all observers registered via registerObserver above. } catch {} for (const keyEntry of listeners.values()) { try { keyEntry.target.removeEventListener(keyEntry.ev, keyEntry.fn, keyEntry.opts); } catch {} } listeners.clear(); for (const id of intervals) clearInterval(id); intervals.clear(); for (const id of timeouts) clearTimeout(id); timeouts.clear(); for (const id of animationFrames) cancelAnimationFrame(id); animationFrames.clear(); } catch (e) { logError('cleanupManager', 'cleanup failed', e); } }, // expose for debug observers, elementObservers, /** * Disconnect and remove observers associated with a given element * @param {Element} el */ disconnectForElement(el) { try { const set = elementObservers.get(el); if (!set) return; for (const o of set) { try { if (o && typeof o.disconnect === 'function') o.disconnect(); observers.delete(o); } catch {} } elementObservers.delete(el); } catch (e) { logError('cleanupManager', 'disconnectForElement failed', e); } }, /** * Disconnect a single observer and remove it from tracking * @param {MutationObserver|IntersectionObserver|ResizeObserver} o */ disconnectObserver(o) { try { if (!o) return; try { if (typeof o.disconnect === 'function') o.disconnect(); } catch {} observers.delete(o); // remove from any element sets try { // Can't iterate WeakMap directly; attempt best-effort sweep by checking // known element keys via listeners map as a hint (not comprehensive). // This is a noop if not found; primary removal is from observers set. } catch {} } catch (e) { logError('cleanupManager', 'disconnectObserver failed', e); } }, listeners, intervals, timeouts, animationFrames, }; })(); const createElement = (tag, props = {}, children = []) => { try { const element = document.createElement(tag); Object.entries(props).forEach(([k, v]) => { if (k === 'className') element.className = v; else if (k === 'style' && typeof v === 'object') Object.assign(element.style, v); else if (k === 'dataset' && typeof v === 'object') Object.assign(element.dataset, v); else if (k.startsWith('on') && typeof v === 'function') { element.addEventListener(k.slice(2), v); } else element.setAttribute(k, v); }); children.forEach(c => { if (typeof c === 'string') element.appendChild(document.createTextNode(c)); else if (c instanceof Node) element.appendChild(c); }); return element; } catch (e) { logError('createElement', 'failed', e); return document.createElement('div'); } }; const waitForElement = (selector, timeout = 5000, parent = document.body) => new Promise((resolve, reject) => { if (!selector || typeof selector !== 'string') return reject(new Error('Invalid selector')); try { const el = parent.querySelector(selector); if (el) return resolve(el); } catch (e) { return reject(e); } const obs = new MutationObserver(() => { const el = parent.querySelector(selector); if (el) { try { obs.disconnect(); } catch {} resolve(el); } }); obs.observe(parent, { childList: true, subtree: true }); const id = setTimeout(() => { try { obs.disconnect(); } catch {} reject(new Error('timeout')); }, timeout); cleanupManager.registerTimeout(id); }); /** * Sanitize HTML string to prevent XSS attacks * @param {string} html - HTML string to sanitize * @returns {string} Sanitized HTML */ const sanitizeHTML = html => { if (typeof html !== 'string') return ''; // Check for extremely long strings (potential DoS) if (html.length > 1000000) { console.warn('[YouTube+] HTML content too large, truncating'); html = html.substring(0, 1000000); } /** @type {Record} */ const map = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=', }; return html.replace(/[<>&"'\/`=]/g, char => map[char] || char); }; /** * Escape HTML for use in attributes (more strict than sanitizeHTML) * Prevents XSS in HTML attributes like onclick, onerror, etc. * @param {string} str - String to escape * @returns {string} Escaped string safe for HTML attributes */ const escapeHTMLAttribute = str => { if (typeof str !== 'string') return ''; /** @type {Record} */ const map = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=', '\n': ' ', '\r': ' ', '\t': ' ', }; return str.replace(/[<>&"'\/`=\n\r\t]/g, char => map[char] || char); }; /** * Validate URL to prevent injection attacks * @param {string} url - URL to validate * @returns {boolean} Whether URL is safe */ const isValidURL = url => { if (typeof url !== 'string') return false; if (url.length > 2048) return false; // RFC 2616 if (/^\s|\s$/.test(url)) return false; // No leading/trailing whitespace try { const parsed = new URL(url); // Only allow http/https protocols if (!['http:', 'https:'].includes(parsed.protocol)) return false; return true; } catch { return false; } }; /** * Safely merge objects without prototype pollution * Prevents __proto__, constructor, and prototype pollution attacks * @template T * @param {T} target - Target object * @param {Object} source - Source object to merge * @returns {T} Merged target object */ const safeMerge = (target, source) => { if (!source || typeof source !== 'object') return target; if (!target || typeof target !== 'object') return target; // List of dangerous keys that could lead to prototype pollution const dangerousKeys = ['__proto__', 'constructor', 'prototype']; for (const key in source) { // Skip inherited properties if (!Object.prototype.hasOwnProperty.call(source, key)) continue; // Skip dangerous keys if (dangerousKeys.includes(key)) { console.warn(`[YouTube+][Security] Blocked attempt to set dangerous key: ${key}`); continue; } // Only copy own enumerable properties const value = source[key]; // Deep clone objects (one level deep for safety) if (value && typeof value === 'object' && !Array.isArray(value)) { target[key] = safeMerge(target[key] || {}, value); } else { target[key] = value; } } return target; }; /** * Validate and sanitize video ID * @param {string} videoId - Video ID to validate * @returns {string|null} Valid video ID or null */ const validateVideoId = videoId => { if (typeof videoId !== 'string') return null; // YouTube video IDs are 11 characters, alphanumeric + dash and underscore if (!/^[a-zA-Z0-9_-]{11}$/.test(videoId)) return null; return videoId; }; /** * Validate and sanitize playlist ID * @param {string} playlistId - Playlist ID to validate * @returns {string|null} Valid playlist ID or null */ const validatePlaylistId = playlistId => { if (typeof playlistId !== 'string') return null; // YouTube playlist IDs typically start with PL, UU, LL, RD, etc. and contain alphanumeric + dash and underscore if (!/^[a-zA-Z0-9_-]+$/.test(playlistId) || playlistId.length < 2 || playlistId.length > 50) { return null; } return playlistId; }; /** * Validate and sanitize channel ID * @param {string} channelId - Channel ID to validate * @returns {string|null} Valid channel ID or null */ const validateChannelId = channelId => { if (typeof channelId !== 'string') return null; // YouTube channel IDs start with UC and are 24 characters long if (!/^UC[a-zA-Z0-9_-]{22}$/.test(channelId) && !/^@[\w-]{3,30}$/.test(channelId)) { return null; } return channelId; }; /** * Sanitize and validate numeric input * @param {any} value - Value to validate * @param {number} min - Minimum allowed value * @param {number} max - Maximum allowed value * @param {number} defaultValue - Default value if validation fails * @returns {number} Validated number */ const validateNumber = (value, min = -Infinity, max = Infinity, defaultValue = 0) => { const num = Number(value); if (Number.isNaN(num) || !Number.isFinite(num)) return defaultValue; return Math.max(min, Math.min(max, num)); }; /** * Retry an async operation with exponential backoff * @template T * @param {() => Promise} fn - Async function to retry * @param {number} maxRetries - Maximum number of retries * @param {number} baseDelay - Base delay in milliseconds * @returns {Promise} Result of the async operation */ const retryWithBackoff = async (fn, maxRetries = 3, baseDelay = 1000) => { let lastError; for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { lastError = error; if (i < maxRetries - 1) { const delay = baseDelay * Math.pow(2, i); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; }; // Enhanced storage wrapper with better validation const storage = { /** * Get value from localStorage with validation * @param {string} key - Storage key * @param {*} def - Default value * @returns {*} Stored value or default */ get(key, def = null) { // Validate key format if (typeof key !== 'string' || !/^[a-zA-Z0-9_\-\.]+$/.test(key)) { logError('storage', 'Invalid key format', new Error(`Invalid key: ${key}`)); return def; } try { const v = localStorage.getItem(key); if (v === null) return def; // Check size before parsing if (v.length > 5 * 1024 * 1024) { // 5MB limit logError('storage', 'Stored value too large', new Error(`Key: ${key}`)); return def; } return JSON.parse(v); } catch (e) { logError('storage', 'Failed to parse stored value', e); return def; } }, /** * Set value in localStorage with validation * @param {string} key - Storage key * @param {*} val - Value to store * @returns {boolean} Whether operation succeeded */ set(key, val) { // Validate key format if (typeof key !== 'string' || !/^[a-zA-Z0-9_\-\.]+$/.test(key)) { logError('storage', 'Invalid key format', new Error(`Invalid key: ${key}`)); return false; } try { const serialized = JSON.stringify(val); // Check size limit (5MB) if (serialized.length > 5 * 1024 * 1024) { logError('storage', 'Value too large to store', new Error(`Key: ${key}`)); return false; } localStorage.setItem(key, serialized); return true; } catch (e) { logError('storage', 'Failed to store value', e); return false; } }, /** * Remove value from localStorage * @param {string} key - Storage key */ remove(key) { try { localStorage.removeItem(key); } catch (e) { logError('storage', 'Failed to remove value', e); } }, /** * Clear all localStorage */ clear() { try { localStorage.clear(); } catch (e) { logError('storage', 'Failed to clear storage', e); } }, /** * Check if key exists * @param {string} key - Storage key * @returns {boolean} Whether key exists */ has(key) { try { return localStorage.getItem(key) !== null; } catch { return false; } }, }; /** * Optimized DOM query cache with size limits */ const DOMCache = (() => { const cache = new Map(); const MAX_CACHE_SIZE = 200; // Increased for better performance const CACHE_TTL = 5000; // 5 seconds - longer cache return { /** * Get cached element or query and cache it * @param {string} selector - CSS selector * @param {Element} [parent=document] - Parent element * @returns {Element|null} Found element */ get(selector, parent = document) { const key = `${selector}_${parent === document ? 'doc' : ''}`; const cached = cache.get(key); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.element; } const element = parent.querySelector(selector); if (element) { cache.set(key, { element, timestamp: Date.now() }); // Manage cache size if (cache.size > MAX_CACHE_SIZE) { const oldestKey = cache.keys().next().value; cache.delete(oldestKey); } } return element; }, /** * Clear specific cache entry * @param {string} selector - CSS selector */ clear(selector) { const keys = Array.from(cache.keys()).filter(k => k.startsWith(selector)); keys.forEach(k => cache.delete(k)); }, /** * Clear all cache */ clearAll() { cache.clear(); }, }; })(); /** * Advanced ScrollManager for efficient scroll event handling * Uses IntersectionObserver when possible for better performance */ const ScrollManager = (() => { const listeners = new WeakMap(); /** * Add optimized scroll listener * @param {Element} element - Element to listen to * @param {Function} callback - Callback function * @param {Object} options - Options {debounce: number, throttle: number, runInitial: boolean} * @returns {Function} Cleanup function */ const addScrollListener = (element, callback, options = {}) => { try { const { debounce: debounceMs = 0, throttle: throttleMs = 0, runInitial = false } = options; let handler = callback; // Apply debounce if specified if (debounceMs > 0) { handler = debounce(handler, debounceMs); } // Apply throttle if specified if (throttleMs > 0) { handler = throttle(handler, throttleMs); } // Store handler for cleanup if (!listeners.has(element)) { listeners.set(element, new Set()); } listeners.get(element).add(handler); // Add event listener element.addEventListener('scroll', handler, { passive: true }); // Run initial callback if requested if (runInitial) { try { callback(); } catch (err) { logError('ScrollManager', 'Initial callback error', err); } } // Return cleanup function return () => { try { element.removeEventListener('scroll', handler); const set = listeners.get(element); if (set) { set.delete(handler); if (set.size === 0) { listeners.delete(element); } } } catch (err) { logError('ScrollManager', 'Cleanup error', err); } }; } catch (err) { logError('ScrollManager', 'addScrollListener error', err); return () => {}; // Return no-op cleanup } }; /** * Remove all listeners for an element * @param {Element} element - Element to clean up */ const removeAllListeners = element => { try { const set = listeners.get(element); if (!set) return; set.forEach(handler => { try { element.removeEventListener('scroll', handler); } catch {} }); listeners.delete(element); } catch (err) { logError('ScrollManager', 'removeAllListeners error', err); } }; /** * Create scroll-to-top functionality with smooth animation * @param {Element} element - Element to scroll * @param {Object} options - Options {duration: number, easing: string} */ const scrollToTop = (element, options = {}) => { const { duration = 300, easing = 'ease-out' } = options; try { // Try native smooth scroll first if ('scrollBehavior' in document.documentElement.style) { element.scrollTo({ top: 0, behavior: 'smooth' }); return; } // Fallback to manual animation const start = element.scrollTop; const startTime = performance.now(); const scroll = currentTime => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Easing function const easeOutQuad = t => t * (2 - t); const easedProgress = easing === 'ease-out' ? easeOutQuad(progress) : progress; element.scrollTop = start * (1 - easedProgress); if (progress < 1) { requestAnimationFrame(scroll); } }; requestAnimationFrame(scroll); } catch (err) { logError('ScrollManager', 'scrollToTop error', err); } }; return { addScrollListener, removeAllListeners, scrollToTop, }; })(); // Centralized history.pushState/replaceState wrapping. // Dispatches 'ytp-history-navigate' so modules can listen instead of each wrapping independently. if (typeof window !== 'undefined' && !window.__ytp_history_wrapped) { window.__ytp_history_wrapped = true; const _origPush = history.pushState; const _origReplace = history.replaceState; history.pushState = function () { const result = _origPush.apply(this, arguments); try { window.dispatchEvent( new CustomEvent('ytp-history-navigate', { detail: { type: 'pushState' } }) ); } catch (e) { console.warn('[YouTube+] pushState event error:', e); } return result; }; history.replaceState = function () { const result = _origReplace.apply(this, arguments); try { window.dispatchEvent( new CustomEvent('ytp-history-navigate', { detail: { type: 'replaceState' } }) ); } catch (e) { console.warn('[YouTube+] replaceState event error:', e); } return result; }; } // Expose a global YouTubeUtils if not present (non-destructive) if (typeof window !== 'undefined') { /** @type {any} */ (window).YouTubeUtils = /** @type {any} */ (window).YouTubeUtils || {}; const U = /** @type {any} */ (window).YouTubeUtils; U.logError = U.logError || logError; U.debounce = U.debounce || debounce; U.throttle = U.throttle || throttle; U.StyleManager = U.StyleManager || StyleManager; U.cleanupManager = U.cleanupManager || cleanupManager; U.EventDelegator = U.EventDelegator || EventDelegator; U.DOMCache = U.DOMCache || DOMCache; U.ScrollManager = U.ScrollManager || ScrollManager; U.createElement = U.createElement || createElement; U.waitForElement = U.waitForElement || waitForElement; U.storage = U.storage || storage; U.sanitizeHTML = U.sanitizeHTML || sanitizeHTML; U.escapeHTMLAttribute = U.escapeHTMLAttribute || escapeHTMLAttribute; U.safeMerge = U.safeMerge || safeMerge; U.validateVideoId = U.validateVideoId || validateVideoId; U.validatePlaylistId = U.validatePlaylistId || validatePlaylistId; U.validateChannelId = U.validateChannelId || validateChannelId; U.validateNumber = U.validateNumber || validateNumber; U.isValidURL = U.isValidURL || isValidURL; U.logger = U.logger || createLogger(); U.retryWithBackoff = U.retryWithBackoff || retryWithBackoff; // Provide lightweight channel stats helpers if not defined by other modules. U.channelStatsHelpers = U.channelStatsHelpers || null; // Wrap global timer functions to auto-register with cleanupManager for safe cleanup. try { const w = window; if (w && !w.__ytp_timers_wrapped) { const origSetTimeout = w.setTimeout.bind(w); const origSetInterval = w.setInterval.bind(w); const origRaf = w.requestAnimationFrame ? w.requestAnimationFrame.bind(w) : null; w.setTimeout = function (fn, ms, ...args) { const id = origSetTimeout(fn, ms, ...args); try { U.cleanupManager.registerTimeout(id); } catch {} return id; }; w.setInterval = function (fn, ms, ...args) { const id = origSetInterval(fn, ms, ...args); try { U.cleanupManager.registerInterval(id); } catch {} return id; }; if (origRaf) { w.requestAnimationFrame = function (cb) { const id = origRaf(cb); try { U.cleanupManager.registerAnimationFrame(id); } catch {} return id; }; } w.__ytp_timers_wrapped = true; } } catch (e) { logError('utils', 'timer wrapper failed', e); } if (!window.YouTubePlusChannelStatsHelpers) { window.YouTubePlusChannelStatsHelpers = { async fetchWithRetry(fetchFn, maxRetries = 2, logger = console) { let attempt = 0; while (attempt <= maxRetries) { try { // Allow fetchFn to be an async function returning parsed JSON const res = await fetchFn(); return res; } catch (err) { attempt += 1; if (attempt > maxRetries) { logger && logger.warn && logger.warn('[ChannelStatsHelpers] fetch failed after retries', err); return null; } // backoff await new Promise(r => setTimeout(r, 300 * attempt)); } } return null; }, cacheStats(mapLike, channelId, stats) { try { if (!mapLike || typeof mapLike.set !== 'function') return; mapLike.set(channelId, stats); } catch {} }, getCachedStats(mapLike, channelId, cacheDuration = 60000) { try { if (!mapLike || typeof mapLike.get !== 'function') return null; const s = mapLike.get(channelId); if (!s) return null; if (s.timestamp && Date.now() - s.timestamp > cacheDuration) return null; return s; } catch { return null; } }, extractSubscriberCountFromPage() { try { const el = qs('yt-formatted-string#subscriber-count') || qs('[id*="subscriber-count"]'); if (!el) return 0; const txt = el.textContent || ''; const digits = txt.replace(/[^0-9]/g, ''); return digits ? parseInt(digits, 10) : 0; } catch { return 0; } }, createFallbackStats(followerCount = 0) { return { followerCount: followerCount || 0, bottomOdos: [0, 0], error: true, timestamp: Date.now(), }; }, }; } } })(); // --- MODULE: security-utils.js --- /** * Security utilities for YouTube+ userscript * Provides sanitization, validation, and security helpers */ (function () { 'use strict'; /** * Validate YouTube video ID format * @param {string} id - Video ID to validate * @returns {boolean} True if valid YouTube video ID */ function isValidVideoId(id) { if (!id || typeof id !== 'string') return false; // YouTube video IDs are exactly 11 characters: alphanumeric, dash, underscore return /^[a-zA-Z0-9_-]{11}$/.test(id); } /** * Validate YouTube channel ID format * @param {string} id - Channel ID to validate * @returns {boolean} True if valid YouTube channel ID */ function isValidChannelId(id) { if (!id || typeof id !== 'string') return false; // YouTube channel IDs start with UC and are 24 characters return /^UC[a-zA-Z0-9_-]{22}$/.test(id); } /** * Validate URL is from YouTube domain * @param {string} url - URL to validate * @returns {boolean} True if valid YouTube URL */ function isYouTubeUrl(url) { if (!url || typeof url !== 'string') return false; try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); return ( hostname === 'www.youtube.com' || hostname === 'youtube.com' || hostname === 'm.youtube.com' || hostname === 'music.youtube.com' || hostname.endsWith('.youtube.com') ); } catch { return false; } } /** * Sanitize text content for safe display * Removes HTML tags and dangerous characters * @param {string} text - Text to sanitize * @returns {string} Sanitized text */ function sanitizeText(text) { if (!text || typeof text !== 'string') return ''; return text .replace(/[<>]/g, '') // Remove angle brackets .replace(/javascript:/gi, '') // Remove javascript: protocol .replace(/on\w+=/gi, '') // Remove event handlers .trim(); } /** * Sanitize HTML by encoding special characters * @param {string} html - HTML string to sanitize * @returns {string} Sanitized HTML */ function escapeHtml(html) { if (!html || typeof html !== 'string') return ''; const div = document.createElement('div'); div.textContent = html; return div.innerHTML; } /** * Create safe HTML using TrustedTypes if available * Falls back to identity function if not available * @param {string} html - HTML string to make safe * @returns {string|TrustedHTML} Safe HTML */ function createSafeHTML(html) { if (typeof window._ytplusCreateHTML === 'function') { return window._ytplusCreateHTML(html); } // Fallback for when TrustedTypes not available return html; } /** * Set innerHTML safely with optional sanitization * @param {HTMLElement} element - Target element * @param {string} html - HTML content to set * @param {boolean} sanitize - Whether to escape HTML (default: false for trusted content) */ function setInnerHTMLSafe(element, html, sanitize = false) { if (!element || !(element instanceof HTMLElement)) { console.error('[Security] Invalid element for setInnerHTMLSafe'); return; } const content = sanitize ? escapeHtml(html) : html; element.innerHTML = createSafeHTML(content); } /** * Set text content safely (always escapes HTML) * @param {HTMLElement} element - Target element * @param {string} text - Text content to set */ function setTextContentSafe(element, text) { if (!element || !(element instanceof HTMLElement)) { console.error('[Security] Invalid element for setTextContentSafe'); return; } element.textContent = text || ''; } /** * Validate and sanitize attribute value * @param {string} attrName - Attribute name * @param {string} attrValue - Attribute value * @returns {string|null} Sanitized value or null if invalid */ function sanitizeAttribute(attrName, attrValue) { if (!attrName || typeof attrName !== 'string') return null; if (attrValue === null || attrValue === undefined) return ''; // Block dangerous attributes const dangerousAttrs = ['onload', 'onerror', 'onclick', 'onmouseover']; if (dangerousAttrs.some(attr => attrName.toLowerCase().startsWith(attr))) { console.warn(`[Security] Blocked dangerous attribute: ${attrName}`); return null; } const valueStr = String(attrValue); // Special handling for href and src if (attrName.toLowerCase() === 'href' || attrName.toLowerCase() === 'src') { // Check for javascript protocol (security check, not script URL usage) // eslint-disable-next-line no-script-url if (valueStr.toLowerCase().startsWith('javascript:')) { console.warn(`[Security] Blocked javascript protocol in ${attrName}`); return null; } if ( valueStr.toLowerCase().startsWith('data:') && !valueStr.toLowerCase().startsWith('data:image/') ) { console.warn(`[Security] Blocked non-image data: URI in ${attrName}`); return null; } } return valueStr; } /** * Set attribute safely with validation * @param {HTMLElement} element - Target element * @param {string} attrName - Attribute name * @param {string} attrValue - Attribute value * @returns {boolean} Success status */ function setAttributeSafe(element, attrName, attrValue) { if (!element || !(element instanceof HTMLElement)) { console.error('[Security] Invalid element for setAttributeSafe'); return false; } const sanitizedValue = sanitizeAttribute(attrName, attrValue); if (sanitizedValue === null) return false; try { element.setAttribute(attrName, sanitizedValue); return true; } catch (error) { console.error('[Security] setAttribute failed:', error); return false; } } /** * Validate number is within safe range * @param {*} value - Value to validate * @param {number} min - Minimum allowed value * @param {number} max - Maximum allowed value * @returns {number|null} Validated number or null if invalid */ function validateNumber(value, min = -Infinity, max = Infinity) { const num = Number(value); if (isNaN(num) || !isFinite(num)) return null; if (num < min || num > max) return null; return num; } /** * Rate limiter for preventing abuse */ class RateLimiter { constructor(maxRequests = 10, timeWindow = 60000) { this.maxRequests = maxRequests; this.timeWindow = timeWindow; this.requests = new Map(); } /** * Check if request is allowed * @param {string} key - Request identifier * @returns {boolean} Whether request is allowed */ canRequest(key) { const now = Date.now(); const requests = this.requests.get(key) || []; // Remove old requests outside time window const recentRequests = requests.filter(time => now - time < this.timeWindow); if (recentRequests.length >= this.maxRequests) { console.warn( `[Security] Rate limit exceeded for ${key}. Max ${this.maxRequests} requests per ${this.timeWindow}ms.` ); return false; } recentRequests.push(now); this.requests.set(key, recentRequests); return true; } /** * Clear rate limiter state */ clear() { this.requests.clear(); } } /** * Create fetch with timeout wrapper * @param {string} url - URL to fetch * @param {Object} options - Fetch options * @param {number} timeout - Timeout in milliseconds (default: 10000) * @returns {Promise} Fetch promise with timeout */ function fetchWithTimeout(url, options = {}, timeout = 10000) { return Promise.race([ fetch(url, options), new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), timeout)), ]); } /** * Validate JSON response structure * @param {Object} data - JSON data to validate * @param {Object} schema - Expected schema (simple validation) * @returns {boolean} True if valid */ function validateJSONSchema(data, schema) { if (!data || typeof data !== 'object') return false; if (!schema || typeof schema !== 'object') return true; for (const key in schema) { if (schema[key].required && !(key in data)) { console.warn(`[Security] Missing required field: ${key}`); return false; } if (key in data && schema[key].type && typeof data[key] !== schema[key].type) { console.warn( `[Security] Invalid type for field ${key}: expected ${schema[key].type}, got ${typeof data[key]}` ); return false; } } return true; } // Export utilities to window for use across modules if (typeof window !== 'undefined') { window.YouTubeSecurityUtils = { isValidVideoId, isValidChannelId, isYouTubeUrl, sanitizeText, escapeHtml, createSafeHTML, setInnerHTMLSafe, setTextContentSafe, sanitizeAttribute, setAttributeSafe, validateNumber, RateLimiter, fetchWithTimeout, validateJSONSchema, }; } })(); // --- MODULE: basic.js --- const YouTubeUtils = (() => { 'use strict'; // Import helper modules const Security = window.YouTubePlusSecurity || {}; const Storage = window.YouTubePlusStorage || {}; const Performance = window.YouTubePlusPerformance || {}; /** * Translation function with fallback support * Uses centralized i18n from YouTubePlusI18n * @param {string} key - Translation key * @param {Object} params - Parameters for interpolation * @returns {string} Translated string */ const t = (key, params = {}) => { if (window.YouTubePlusI18n?.t) return window.YouTubePlusI18n.t(key, params); if (window.YouTubeUtils?.t && window.YouTubeUtils.t !== t) { return window.YouTubeUtils.t(key, params); } // Fallback for initialization phase if (!key) return ''; let result = String(key); for (const [k, v] of Object.entries(params || {})) { result = result.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); } return result; }; /** * Error logging with module context (local reference) * @param {string} module - Module name * @param {string} message - Error message * @param {Error} error - Error object */ const logError = (module, message, error) => { console.error(`[YouTube+][${module}] ${message}:`, error); }; // Use helper modules or fallback to local implementations const safeExecute = Security.safeExecute || ((fn, context = 'Unknown') => { /** @this {any} */ return function (...args) { try { return fn.call(this, ...args); } catch (error) { logError(context, 'Execution failed', error); return null; } }; }); const safeExecuteAsync = Security.safeExecuteAsync || ((fn, context = 'Unknown') => { /** @this {any} */ return async function (...args) { try { return await fn.call(this, ...args); } catch (error) { logError(context, 'Async execution failed', error); return null; } }; }); const sanitizeHTML = Security.sanitizeHTML || (html => { if (typeof html !== 'string') return ''; return html.replace(/[<>&"'\/`=]/g, ''); }); const isValidURL = Security.isValidURL || (url => { if (typeof url !== 'string') return false; try { const parsed = new URL(url); return ['http:', 'https:'].includes(parsed.protocol); } catch { return false; } }); // Use storage helper or fallback const storage = Storage || { get: (key, defaultValue = null) => { try { const value = localStorage.getItem(key); return value ? JSON.parse(value) : defaultValue; } catch { return defaultValue; } }, set: (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; } }, remove: key => { try { localStorage.removeItem(key); return true; } catch { return false; } }, }; // Use performance helpers or fallback const debounce = Performance?.debounce || ((func, wait, options = {}) => { let timeout = null; /** @this {any} */ const debounced = function (...args) { if (timeout !== null) clearTimeout(timeout); if (options.leading && timeout === null) { func.call(this, ...args); } timeout = setTimeout(() => { if (!options.leading) func.call(this, ...args); timeout = null; }, wait); }; debounced.cancel = () => { if (timeout !== null) clearTimeout(timeout); timeout = null; }; return debounced; }); const throttle = Performance?.throttle || ((func, limit) => { let inThrottle = false; /** @this {any} */ return function (...args) { if (!inThrottle) { func.call(this, ...args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } }; }); /** * Safe DOM element creation with props and children * @param {string} tag - HTML tag name * @param {Object} props - Element properties * @param {Array} children - Child elements or text * @returns {HTMLElement} Created element */ const createElement = (tag, props = {}, children = []) => { // Validate tag name to prevent XSS const validTags = /^[a-z][a-z0-9-]*$/i; if (!validTags.test(tag)) { logError('createElement', 'Invalid tag name', new Error(`Tag "${tag}" is not allowed`)); return document.createElement('div'); } const element = document.createElement(tag); Object.entries(props).forEach(([key, value]) => { if (key === 'className') { element.className = value; } else if (key === 'style' && typeof value === 'object') { Object.assign(element.style, value); } else if (key.startsWith('on') && typeof value === 'function') { element.addEventListener(key.substring(2).toLowerCase(), value); } else if (key === 'dataset' && typeof value === 'object') { Object.assign(element.dataset, value); } else if (key === 'innerHTML' || key === 'outerHTML') { // Prevent direct HTML injection logError( 'createElement', 'Direct HTML injection prevented', new Error('Use children array instead') ); } else { try { element.setAttribute(key, value); } catch (e) { logError('createElement', `Failed to set attribute ${key}`, e); } } }); children.forEach(child => { if (typeof child === 'string') { element.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { element.appendChild(child); } }); return element; }; /** * DOM Selector Cache with automatic cleanup */ const selectorCache = new Map(); const CACHE_MAX_SIZE = 100; // Increased for better performance const CACHE_MAX_AGE = 10000; // 10 seconds - longer retention /** * Cached querySelector with LRU-like eviction * @param {string} selector - CSS selector * @param {boolean} nocache - Skip cache * @returns {HTMLElement|null} Found element */ const querySelector = (selector, nocache = false) => { if (nocache) return document.querySelector(selector); const now = Date.now(); const cached = selectorCache.get(selector); // Check if cached element is still valid if (cached?.element?.isConnected && now - cached.timestamp < CACHE_MAX_AGE) { return cached.element; } // Remove stale entry if (cached) { selectorCache.delete(selector); } const element = document.querySelector(selector); if (element) { // LRU eviction: remove oldest entries if cache is full if (selectorCache.size >= CACHE_MAX_SIZE) { const firstKey = selectorCache.keys().next().value; selectorCache.delete(firstKey); } selectorCache.set(selector, { element, timestamp: now }); } return element; }; /** * Validate waitForElement parameters * @param {string} selector - CSS selector * @param {HTMLElement} parent - Parent element * @returns {Error|null} Validation error or null */ const validateWaitParams = (selector, parent) => { if (!selector || typeof selector !== 'string') { return new Error('Selector must be a non-empty string'); } if (!parent || !(parent instanceof Element)) { return new Error('Parent must be a valid DOM element'); } return null; }; /** * Try to find element immediately * @param {HTMLElement} parent - Parent element * @param {string} selector - CSS selector * @returns {{element: HTMLElement|null, error: Error|null}} Result object */ const tryQuerySelector = (parent, selector) => { try { const element = parent.querySelector(selector); return { element, error: null }; } catch { return { element: null, error: new Error(`Invalid selector: ${selector}`) }; } }; /** * Cleanup observer and timeout resources * @param {MutationObserver|null} observer - Observer to disconnect * @param {number} timeoutId - Timeout ID to clear * @param {AbortController} controller - Abort controller */ const cleanupWaitResources = (observer, timeoutId, controller) => { controller.abort(); if (observer) { try { observer.disconnect(); } catch (e) { logError('waitForElement', 'Observer disconnect failed', e); } } clearTimeout(timeoutId); }; /** * Create and setup mutation observer for element watching * @param {HTMLElement} parent - Parent element * @param {string} selector - CSS selector * @param {Function} resolve - Promise resolve function * @param {number} timeoutId - Timeout ID for cleanup * @returns {MutationObserver} Created observer */ const createWaitObserver = (parent, selector, resolve, timeoutId) => { return new MutationObserver(() => { try { const element = parent.querySelector(selector); if (element) { clearTimeout(timeoutId); resolve(/** @type {HTMLElement} */ (/** @type {unknown} */ (element))); } } catch (e) { logError('waitForElement', 'Observer callback error', e); } }); }; /** * Start observing parent element for DOM changes * @param {MutationObserver} observer - Observer instance * @param {HTMLElement} parent - Parent element to observe * @returns {Error|null} Error if observation failed */ const startWaitObservation = (observer, parent) => { try { if (!(parent instanceof Element) && parent !== document) { throw new Error('Parent does not support observation'); } observer.observe(parent, { childList: true, subtree: true }); return null; } catch { try { observer.observe(parent, { childList: true, subtree: true }); return null; } catch { return new Error('Failed to observe DOM'); } } }; /** * Wait for element with timeout and AbortController * @param {string} selector - CSS selector * @param {number} timeout - Timeout in ms * @param {HTMLElement} parent - Parent element to search in * @returns {Promise} Promise resolving to element */ const waitForElement = (selector, timeout = 5000, parent = document.body) => { return new Promise((resolve, reject) => { const validationError = validateWaitParams(selector, parent); if (validationError) { reject(validationError); return; } const { element, error } = tryQuerySelector(parent, selector); if (error) { reject(error); return; } if (element) { resolve(/** @type {HTMLElement} */ (/** @type {unknown} */ (element))); return; } const controller = new AbortController(); /** @type {MutationObserver | null} */ let observer = null; const timeoutId = setTimeout(() => { cleanupWaitResources(observer, timeoutId, controller); reject(new Error(`Element ${selector} not found within ${timeout}ms`)); }, timeout); observer = createWaitObserver(parent, selector, resolve, timeoutId); const observeError = startWaitObservation(observer, parent); if (observeError) { clearTimeout(timeoutId); reject(observeError); } }); }; /** * Resource Cleanup Manager * Manages observers, listeners, and intervals */ const cleanupManager = { observers: new Set(), listeners: new Map(), intervals: new Set(), timeouts: new Set(), animationFrames: new Set(), cleanupFunctions: new Set(), /** * Register a generic cleanup function * @param {Function} fn - Cleanup function to call during cleanup * @returns {Function} The registered function */ register: fn => { if (typeof fn === 'function') { cleanupManager.cleanupFunctions.add(fn); } return fn; }, /** * Unregister a specific cleanup function * @param {Function} fn - Function to unregister */ unregister: fn => { cleanupManager.cleanupFunctions.delete(fn); }, /** * Register MutationObserver for cleanup * @param {MutationObserver} observer - Observer to register * @returns {MutationObserver} Registered observer */ registerObserver: observer => { cleanupManager.observers.add(observer); return observer; }, /** * Unregister and disconnect specific observer * @param {MutationObserver} observer - Observer to unregister */ unregisterObserver: observer => { if (observer) { try { observer.disconnect(); } catch (e) { logError('Cleanup', 'Observer disconnect failed', e); } cleanupManager.observers.delete(observer); } }, /** * Register event listener for cleanup * @param {EventTarget|Document|Window} element - Target element * @param {string} event - Event name * @param {EventListener|EventListenerObject} handler - Event handler * @param {Object} options - Event listener options * @returns {Symbol} Listener key for later removal */ registerListener: (element, event, handler, options) => { const key = Symbol('listener'); cleanupManager.listeners.set(key, { element, event, handler, options }); try { element.addEventListener(event, /** @type {EventListener} */ (handler), options); } catch { // best-effort: if addEventListener fails, still register the listener record } return key; }, /** * Unregister specific listener * @param {Symbol} key - Listener key */ unregisterListener: key => { const listener = cleanupManager.listeners.get(key); if (listener) { const { element, event, handler, options } = listener; try { element.removeEventListener(event, handler, options); } catch (e) { logError('Cleanup', 'Listener removal failed', e); } cleanupManager.listeners.delete(key); } }, /** * Register interval for cleanup * @param {TimerId} id - Interval ID * @returns {TimerId} Interval ID */ registerInterval: id => { cleanupManager.intervals.add(id); return id; }, /** * Unregister specific interval * @param {number} id - Interval ID */ unregisterInterval: id => { clearInterval(id); cleanupManager.intervals.delete(id); }, /** * Register timeout for cleanup * @param {TimerId} id - Timeout ID * @returns {TimerId} Timeout ID */ registerTimeout: id => { cleanupManager.timeouts.add(id); return id; }, /** * Unregister specific timeout * @param {number} id - Timeout ID */ unregisterTimeout: id => { clearTimeout(id); cleanupManager.timeouts.delete(id); }, /** * Register animation frame for cleanup * @param {number} id - Animation frame ID * @returns {number} Animation frame ID */ registerAnimationFrame: id => { cleanupManager.animationFrames.add(id); return id; }, /** * Unregister specific animation frame * @param {number} id - Animation frame ID */ unregisterAnimationFrame: id => { cancelAnimationFrame(id); cleanupManager.animationFrames.delete(id); }, /** * Cleanup all registered resources */ cleanup: () => { // Call all registered cleanup functions cleanupManager.cleanupFunctions.forEach(fn => { try { fn(); } catch (e) { logError('Cleanup', 'Cleanup function failed', e); } }); cleanupManager.cleanupFunctions.clear(); // Disconnect all observers cleanupManager.observers.forEach(obs => { try { obs.disconnect(); } catch (e) { logError('Cleanup', 'Observer disconnect failed', e); } }); cleanupManager.observers.clear(); // Remove all listeners cleanupManager.listeners.forEach(({ element, event, handler, options }) => { try { element.removeEventListener(event, handler, options); } catch (e) { logError('Cleanup', 'Listener removal failed', e); } }); cleanupManager.listeners.clear(); // Clear all intervals cleanupManager.intervals.forEach(id => clearInterval(id)); cleanupManager.intervals.clear(); // Clear all timeouts cleanupManager.timeouts.forEach(id => clearTimeout(id)); cleanupManager.timeouts.clear(); // Cancel all animation frames cleanupManager.animationFrames.forEach(id => cancelAnimationFrame(id)); cleanupManager.animationFrames.clear(); }, }; /** * Settings Manager * Centralized settings storage and retrieval */ const SettingsManager = { storageKey: 'youtube_plus_all_settings_v2', defaults: { speedControl: { enabled: true, currentSpeed: 1 }, screenshot: { enabled: true }, download: { enabled: true }, updateChecker: { enabled: true }, adBlocker: { enabled: true }, pip: { enabled: true }, timecodes: { enabled: true }, // Add other modules... }, /** * Load all settings * @returns {Object} Settings object */ load() { const saved = storage.get(this.storageKey); return saved ? { ...this.defaults, ...saved } : { ...this.defaults }; }, /** * Save all settings * @param {Object} settings - Settings to save */ save(settings) { storage.set(this.storageKey, settings); // Dispatch event for modules to react window.dispatchEvent( new CustomEvent('youtube-plus-settings-changed', { detail: settings, }) ); }, /** * Get setting by path * @param {string} path - Dot-separated path (e.g., 'speedControl.enabled') * @returns {*} Setting value */ get(path) { const settings = this.load(); return path.split('.').reduce((obj, key) => /** @type {any} */ (obj)?.[key], settings); }, /** * Set setting by path * @param {string} path - Dot-separated path * @param {*} value - Value to set */ set(path, value) { const settings = this.load(); const keys = path.split('.'); const last = keys.pop(); const target = keys.reduce((obj, key) => { /** @type {any} */ (obj)[key] = /** @type {any} */ (obj)[key] || {}; return /** @type {any} */ (obj)[key]; }, settings); /** @type {any} */ (target)[/** @type {string} */ (last)] = value; this.save(settings); }, }; /** * Style Manager * Centralized CSS injection and management */ const StyleManager = { styles: new Map(), /** @type {HTMLStyleElement | null} */ element: null, /** * Add CSS rules * @param {string} id - Unique identifier * @param {string} css - CSS rules */ add(id, css) { if (typeof id !== 'string' || !id) { logError('StyleManager', 'Invalid style ID', new Error('ID must be a non-empty string')); return; } if (typeof css !== 'string') { logError('StyleManager', 'Invalid CSS', new Error('CSS must be a string')); return; } this.styles.set(id, css); this.update(); }, /** * Remove CSS rules * @param {string} id - Identifier */ remove(id) { this.styles.delete(id); this.update(); }, /** * Update style element */ update() { try { if (!this.element) { this.element = document.createElement('style'); this.element.id = 'youtube-plus-styles'; this.element.type = 'text/css'; (document.head || document.documentElement).appendChild(this.element); } this.element.textContent = Array.from(this.styles.values()).join('\n'); } catch (error) { logError('StyleManager', 'Failed to update styles', error); } }, /** * Clear all styles */ clear() { this.styles.clear(); if (this.element) { try { this.element.remove(); } catch (e) { logError('StyleManager', 'Failed to remove style element', e); } this.element = null; } }, }; /** * Centralized Notification System * Manages all notifications with queue and deduplication */ const NotificationManager = { /** @type {any[]} */ queue: [], activeNotifications: new Set(), maxVisible: 3, defaultDuration: 3000, /** * Show notification * @param {string} message - Notification message * @param {{duration?: number, position?: string | null, action?: {text: string, callback: Function} | null, type?: string}} [options] - Notification options * @returns {HTMLElement | null} Notification element */ show(message, options = {}) { // Validate message if (!message || typeof message !== 'string') { logError( 'NotificationManager', 'Invalid message', new Error('Message must be a non-empty string') ); return null; } const { duration = this.defaultDuration, position = null, action = null, // { text: string, callback: function } } = options; // Remove duplicate messages this.activeNotifications.forEach(notif => { if (notif.dataset.message === message) { this.remove(notif); } }); const positions = { 'top-right': { top: '20px', right: '20px' }, 'top-left': { top: '20px', left: '20px' }, 'bottom-right': { bottom: '20px', right: '20px' }, 'bottom-left': { bottom: '20px', left: '20px' }, }; try { // Use shared enhancer notification class for consistent appearance const notification = createElement('div', { className: 'youtube-enhancer-notification', dataset: { message }, // Store message for deduplication // Keep minimal inline styles; main visuals come from the shared CSS class style: { zIndex: '10001', width: 'auto', display: 'flex', alignItems: 'center', gap: '10px', ...(position && /** @type {any} */ (positions)[position] ? /** @type {any} */ (positions)[position] : {}), }, }); // Add message (with accessibility attributes) notification.setAttribute('role', 'status'); notification.setAttribute('aria-live', 'polite'); notification.setAttribute('aria-atomic', 'true'); const messageSpan = createElement( 'span', { style: { flex: '1' }, }, [message] ); notification.appendChild(messageSpan); // Add action button if provided if (action && action.text && typeof action.callback === 'function') { const actionBtn = createElement( 'button', { style: { background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.3)', color: 'white', padding: '4px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', fontWeight: '600', transition: 'background 0.2s', }, onClick: () => { action.callback(); this.remove(notification); }, }, [action.text] ); notification.appendChild(actionBtn); } // Ensure a centralized bottom-center container exists and add notification there const _notifContainerId = 'youtube-enhancer-notification-container'; let _notifContainer = document.getElementById(_notifContainerId); if (!_notifContainer) { _notifContainer = createElement('div', { id: _notifContainerId, className: 'youtube-enhancer-notification-container', }); try { document.body.appendChild(_notifContainer); } catch { // fallback to body append if container append fails document.body.appendChild(notification); this.activeNotifications.add(notification); } } try { // Prepend so newest notifications appear on top _notifContainer.insertBefore(notification, _notifContainer.firstChild); } catch { // fallback document.body.appendChild(notification); } // ensure notification accepts pointer events (container is pointer-events:none) try { notification.style.pointerEvents = 'auto'; } catch {} this.activeNotifications.add(notification); // Apply entry animation from bottom try { notification.style.animation = 'slideInFromBottom 0.38s ease-out forwards'; } catch {} // Auto-dismiss if (duration > 0) { const timeoutId = setTimeout(() => this.remove(notification), duration); cleanupManager.registerTimeout(timeoutId); } // Limit visible notifications if (this.activeNotifications.size > this.maxVisible) { const oldest = Array.from(this.activeNotifications)[0]; this.remove(oldest); } return notification; } catch (error) { logError('NotificationManager', 'Failed to show notification', error); return null; } }, /** * Remove notification * @param {HTMLElement} notification - Notification element */ remove(notification) { if (!notification || !notification.isConnected) return; try { try { notification.style.animation = 'slideOutToBottom 0.32s ease-in forwards'; const timeoutId = setTimeout(() => { try { notification.remove(); this.activeNotifications.delete(notification); } catch (e) { logError('NotificationManager', 'Failed to remove notification', e); } }, 340); cleanupManager.registerTimeout(timeoutId); } catch { // Fallback: immediate removal try { notification.remove(); this.activeNotifications.delete(notification); } catch (e) { logError('NotificationManager', 'Failed to remove notification (fallback)', e); } } } catch (error) { logError('NotificationManager', 'Failed to animate notification removal', error); // Force remove notification.remove(); this.activeNotifications.delete(notification); } }, /** * Clear all notifications */ clearAll() { this.activeNotifications.forEach(notif => { try { notif.remove(); } catch (e) { logError('NotificationManager', 'Failed to clear notification', e); } }); this.activeNotifications.clear(); }, }; // Add notification animation styles StyleManager.add( 'notification-animations', ` @keyframes slideInFromBottom { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes slideOutToBottom { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } } ` ); // Global cleanup on page unload window.addEventListener('beforeunload', () => { cleanupManager.cleanup(); selectorCache.clear(); StyleManager.clear(); NotificationManager.clearAll(); }); // Periodic cache cleanup to prevent memory leaks (using requestIdleCallback when available) const cacheCleanup = () => { const now = Date.now(); for (const [key, value] of selectorCache.entries()) { if (!value.element?.isConnected || now - value.timestamp > CACHE_MAX_AGE) { selectorCache.delete(key); } } }; const cacheCleanupInterval = setInterval(() => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(cacheCleanup, { timeout: 2000 }); } else { cacheCleanup(); } }, 30000); // Clean every 30 seconds cleanupManager.registerInterval(cacheCleanupInterval); // Global error handler for uncaught promise rejections window.addEventListener('unhandledrejection', event => { logError('Global', 'Unhandled promise rejection', event.reason); event.preventDefault(); // Prevent console spam }); // Global error handler for uncaught errors window.addEventListener('error', event => { const message = String(event?.message || ''); const errorMessage = String(event?.error?.message || ''); if (message.includes('ResizeObserver loop') || errorMessage.includes('ResizeObserver loop')) { return; } // Only log errors from our script if (event.filename && event.filename.includes('youtube')) { logError( 'Global', 'Uncaught error', new Error(`${event.message} at ${event.filename}:${event.lineno}:${event.colno}`) ); } }); /** * Performance monitoring wrapper * @param {string} label - Operation label * @param {Function} fn - Function to monitor * @returns {Function} Wrapped function */ const measurePerformance = (label, fn) => { /** @this {any} */ return function (...args) { const start = performance.now(); try { const result = fn.apply(this, args); const duration = performance.now() - start; if (duration > 100) { console.warn(`[YouTube+][Performance] ${label} took ${duration.toFixed(2)}ms`); } return result; } catch (error) { logError('Performance', `${label} failed`, error); throw error; } }; }; /** * Async performance monitoring wrapper * @param {string} label - Operation label * @param {Function} fn - Async function to monitor * @returns {Function} Wrapped async function */ const measurePerformanceAsync = (label, fn) => { /** @this {any} */ return async function (...args) { const start = performance.now(); try { const result = await fn.apply(this, args); const duration = performance.now() - start; if (duration > 100) { console.warn(`[YouTube+][Performance] ${label} took ${duration.toFixed(2)}ms`); } return result; } catch (error) { logError('Performance', `${label} failed`, error); throw error; } }; }; /** * Mobile device detection * @returns {boolean} True if mobile device */ const isMobile = () => { return ( /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768 ); }; /** * Get viewport dimensions * @returns {Object} Width and height */ const getViewport = () => ({ width: Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0), height: Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0), }); /** * Safe async retry wrapper * @param {Function} fn - Async function to retry * @param {number} retries - Number of retries * @param {number} delay - Delay between retries * @returns {Promise} Result or error */ const retryAsync = async (fn, retries = 3, delay = 1000) => { for (let i = 0; i < retries; i++) { try { return await fn(); } catch (error) { if (i === retries - 1) throw error; await new Promise(resolve => { setTimeout(resolve, delay * (i + 1)); }); } } }; // Export public API return { logError, safeExecute, safeExecuteAsync, sanitizeHTML, isValidURL, storage, debounce, throttle, createElement, querySelector, waitForElement, cleanupManager, SettingsManager, StyleManager, NotificationManager, clearCache: () => selectorCache.clear(), isMobile, getViewport, retryAsync, measurePerformance, measurePerformanceAsync, t, // Translation function }; })(); // Make available globally if (typeof window !== 'undefined') { // Merge utilities into existing global YouTubeUtils without overwriting /** @type {any} */ (window).YouTubeUtils = /** @type {any} */ (window).YouTubeUtils || {}; const existing = /** @type {any} */ (window).YouTubeUtils; try { for (const k of Object.keys(YouTubeUtils)) { if (existing[k] === undefined) existing[k] = YouTubeUtils[k]; } } catch (e) { console.error('[YouTube+] Failed to merge core utilities:', e); } // Add initialization health check (non-intrusive) window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+ v2.4.4] Core utilities merged'); // Expose debug info /** @type {any} */ (window).YouTubePlusDebug = { version: '2.4.4', cacheSize: () => YouTubeUtils.cleanupManager.observers.size + YouTubeUtils.cleanupManager.listeners.size + YouTubeUtils.cleanupManager.intervals.size, clearAll: () => { YouTubeUtils.cleanupManager.cleanup(); YouTubeUtils.clearCache(); YouTubeUtils.StyleManager.clear(); YouTubeUtils.NotificationManager.clearAll(); window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+] All resources cleared'); }, stats: () => ({ observers: YouTubeUtils.cleanupManager.observers.size, listeners: YouTubeUtils.cleanupManager.listeners.size, intervals: YouTubeUtils.cleanupManager.intervals.size, timeouts: YouTubeUtils.cleanupManager.timeouts.size, animationFrames: YouTubeUtils.cleanupManager.animationFrames.size, styles: YouTubeUtils.StyleManager.styles.size, notifications: YouTubeUtils.NotificationManager.activeNotifications.size, }), }; // Show subtle startup notification (only once per session) if (!sessionStorage.getItem('youtube_plus_started')) { sessionStorage.setItem('youtube_plus_started', 'true'); setTimeout(() => { if (YouTubeUtils.NotificationManager) { YouTubeUtils.NotificationManager.show('YouTube+ v2.4.4 loaded', { type: 'success', duration: 2000, position: 'bottom-right', }); } }, 1000); } } // YouTube enhancements module (function () { 'use strict'; // Local reference to translation function const { t } = YouTubeUtils; const YouTubeEnhancer = { // Speed control variables speedControl: { currentSpeed: 1, activeAnimationId: null, storageKey: 'youtube_playback_speed', availableSpeeds: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0], }, // Loop control variables loopControl: { enabled: false, pointA: null, pointB: null, storageKey: 'youtube_loop_state', timeUpdateListener: null, }, _initialized: false, // Settings settings: { enableSpeedControl: true, speedControlHotkeys: { decrease: 'g', increase: 'h', reset: 'b', }, enableScreenshot: true, enableDownload: true, // Basic: optional UI/style tweaks (style.js) enableZenStyles: true, zenStyles: { thumbnailHover: true, immersiveSearch: true, hideVoiceSearch: true, transparentHeader: true, hideSideGuide: true, cleanSideGuide: false, fixFeedLayout: true, betterCaptions: true, playerBlur: true, theaterEnhancements: true, misc: true, }, // Enhanced features (advanced tab) enableEnhanced: true, enablePlayAll: true, enableResumeTime: true, enableZoom: true, enableThumbnail: true, enablePlaylistSearch: true, enableScrollToTopButton: true, // Loop settings enableLoop: true, loopHotkeys: { toggleLoop: 'r', setPointA: 'k', setPointB: 'l', resetPoints: 'o', }, // Состояние сайтов внутри сабменю кнопки Download (ytdl всегда включён) downloadSites: { direct: true, externalDownloader: true, ytdl: true, }, // Настройки кастомизации download сайтов downloadSiteCustomization: { externalDownloader: typeof window !== 'undefined' && window.YouTubePlusConstants ? window.YouTubePlusConstants.DOWNLOAD_SITES.EXTERNAL_DOWNLOADER : { name: 'SSYouTube', url: 'https://ssyoutube.com/watch?v={videoId}' }, }, storageKey: 'youtube_plus_settings', // runtime setting: hide left side guide/footer when true hideSideGuide: false, }, // Cache DOM queries _cache: new Map(), // Cached element getter getElement(selector, useCache = true) { if (useCache && this._cache.has(selector)) { const element = this._cache.get(selector); if (element?.isConnected) return element; this._cache.delete(selector); } const element = document.querySelector(selector); if (element && useCache) this._cache.set(selector, element); return element; }, loadSettings() { try { const saved = localStorage.getItem(this.settings.storageKey); if (saved) { const parsed = JSON.parse(saved); // Use safeMerge to prevent prototype pollution if (window.YouTubeUtils && window.YouTubeUtils.safeMerge) { window.YouTubeUtils.safeMerge(this.settings, parsed); } else { // Fallback: manual safe copy for (const key in parsed) { if ( Object.prototype.hasOwnProperty.call(parsed, key) && !['__proto__', 'constructor', 'prototype'].includes(key) ) { this.settings[key] = parsed[key]; } } } return; } // Migration: if no per-module settings found, try centralized SettingsManager storage try { if ( typeof window !== 'undefined' && window.YouTubeUtils && YouTubeUtils.SettingsManager ) { const globalSettings = YouTubeUtils.SettingsManager.load(); if (!globalSettings) return; // Map known flags (shallow mapping) to this.settings to preserve user's choices const sc = globalSettings.speedControl; if (sc && typeof sc.enabled === 'boolean') { this.settings.enableSpeedControl = sc.enabled; } const ss = globalSettings.screenshot; if (ss && typeof ss.enabled === 'boolean') this.settings.enableScreenshot = ss.enabled; const dl = globalSettings.download; if (dl && typeof dl.enabled === 'boolean') this.settings.enableDownload = dl.enabled; if (globalSettings.downloadSites && typeof globalSettings.downloadSites === 'object') { this.settings.downloadSites = { ...(this.settings.downloadSites || {}), ...globalSettings.downloadSites, }; } } } catch { // best-effort migration; ignore failures } } catch (e) { console.error('Error loading settings:', e); } }, init() { if (this._initialized) { return; } this._initialized = true; try { this.loadSettings(); // Migrate legacy loop hotkey values to new defaults when they match previous defaults try { const lh = this.settings.loopHotkeys || {}; let migrated = false; // previous defaults: setPointA: 'l', setPointB: 'o', resetPoints: 'k' if (lh.setPointA === 'l') { lh.setPointA = 'k'; migrated = true; } if (lh.setPointB === 'o') { lh.setPointB = 'l'; migrated = true; } if (lh.resetPoints === 'k') { lh.resetPoints = 'o'; migrated = true; } if (migrated) { this.settings.loopHotkeys = lh; try { this.saveSettings(); } catch (e) { console.warn('[YouTube+] Failed to save migrated loop hotkeys', e); } } } catch { /* ignore migration errors */ } this.settings.speedControlHotkeys = this.settings.speedControlHotkeys || {}; this.settings.speedControlHotkeys.decrease = this.normalizeSpeedHotkey( this.settings.speedControlHotkeys.decrease, 'g' ); this.settings.speedControlHotkeys.increase = this.normalizeSpeedHotkey( this.settings.speedControlHotkeys.increase, 'h' ); this.settings.speedControlHotkeys.reset = this.normalizeSpeedHotkey( this.settings.speedControlHotkeys.reset, 'b' ); // Restore saved playback speed from localStorage try { const savedSpeed = localStorage.getItem(this.speedControl.storageKey); if (savedSpeed !== null) { const parsed = Number(savedSpeed); if (Number.isFinite(parsed) && parsed > 0 && parsed <= 16) { this.speedControl.currentSpeed = parsed; } } } catch (e) { console.warn('[YouTube+] Speed restore error:', e); } // Initialize loop hotkeys this.settings.loopHotkeys = this.settings.loopHotkeys || {}; this.settings.loopHotkeys.toggleLoop = this.normalizeSpeedHotkey( this.settings.loopHotkeys.toggleLoop, 'r' ); this.settings.loopHotkeys.setPointA = this.normalizeSpeedHotkey( this.settings.loopHotkeys.setPointA, 'k' ); this.settings.loopHotkeys.setPointB = this.normalizeSpeedHotkey( this.settings.loopHotkeys.setPointB, 'l' ); this.settings.loopHotkeys.resetPoints = this.normalizeSpeedHotkey( this.settings.loopHotkeys.resetPoints, 'o' ); // Restore loop state from localStorage this.loadLoopState(); } catch (error) { console.warn('[YouTube+][Basic]', 'Failed to load settings during init:', error); } this.insertStyles(); this.addSettingsButtonToHeader(); this.setupNavigationObserver(); if (location.href.includes('watch?v=')) { this.setupCurrentPage(); } document.addEventListener('visibilitychange', () => { if (!document.hidden && location.href.includes('watch?v=')) { this.setupCurrentPage(); } }); // Keyboard shortcut: press 'S' to take a screenshot when not typing try { const screenshotKeyHandler = e => { // Only react to plain 's' key without modifiers if (!e || !e.key) return; if (!(e.key === 's' || e.key === 'S')) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // Ignore when focus is on editable elements if (this.isEditableTarget(document.activeElement)) return; if (!this.settings.enableScreenshot) return; try { this.captureFrame(); } catch (err) { if (YouTubeUtils && YouTubeUtils.logError) { YouTubeUtils.logError('Basic', 'Keyboard screenshot failed', err); } } }; YouTubeUtils.cleanupManager.registerListener( document, 'keydown', screenshotKeyHandler, true ); } catch (e) { if (YouTubeUtils && YouTubeUtils.logError) { YouTubeUtils.logError('Basic', 'Failed to register screenshot keyboard shortcut', e); } } // Keyboard shortcuts: adjust speed (decrease/increase) try { const speedHotkeyHandler = e => { if (!this.settings.enableSpeedControl || !e || !e.key) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (this.isEditableTarget(document.activeElement)) return; const key = String(e.key).toLowerCase(); const decreaseKey = this.normalizeSpeedHotkey( this.settings.speedControlHotkeys?.decrease, 'g' ); const increaseKey = this.normalizeSpeedHotkey( this.settings.speedControlHotkeys?.increase, 'h' ); const resetKey = this.normalizeSpeedHotkey(this.settings.speedControlHotkeys?.reset, 'b'); if (key === decreaseKey) { e.preventDefault(); this.adjustSpeedByStep(-1); } else if (key === increaseKey) { e.preventDefault(); this.adjustSpeedByStep(1); } else if (key === resetKey) { e.preventDefault(); this.changeSpeed(1); } }; YouTubeUtils.cleanupManager.registerListener(document, 'keydown', speedHotkeyHandler, true); } catch (e) { if (YouTubeUtils && YouTubeUtils.logError) { YouTubeUtils.logError('Basic', 'Failed to register speed keyboard shortcuts', e); } } // Keyboard shortcuts: loop control try { const loopHotkeyHandler = e => { if (!this.settings.enableLoop || !e || !e.key) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (this.isEditableTarget(document.activeElement)) return; const key = String(e.key).toLowerCase(); const toggleLoopKey = this.normalizeSpeedHotkey( this.settings.loopHotkeys?.toggleLoop, 'r' ); const setPointAKey = this.normalizeSpeedHotkey(this.settings.loopHotkeys?.setPointA, 'k'); const setPointBKey = this.normalizeSpeedHotkey(this.settings.loopHotkeys?.setPointB, 'l'); const resetPointsKey = this.normalizeSpeedHotkey( this.settings.loopHotkeys?.resetPoints, 'o' ); if (key === toggleLoopKey) { e.preventDefault(); this.toggleLoop(); } else if (key === setPointAKey) { e.preventDefault(); this.setLoopPoint('A'); } else if (key === setPointBKey) { e.preventDefault(); this.setLoopPoint('B'); } else if (key === resetPointsKey) { e.preventDefault(); this.resetLoopPoints(); } }; YouTubeUtils.cleanupManager.registerListener(document, 'keydown', loopHotkeyHandler, true); } catch (e) { if (YouTubeUtils && YouTubeUtils.logError) { YouTubeUtils.logError('Basic', 'Failed to register loop keyboard shortcuts', e); } } }, isEditableTarget(target) { const active = /** @type {HTMLElement | null | undefined} */ (target); if (!active) return false; const tag = (active.tagName || '').toLowerCase(); return ( tag === 'input' || tag === 'textarea' || tag === 'select' || Boolean(active.isContentEditable) ); }, normalizeSpeedHotkey(value, fallback) { const candidate = typeof value === 'string' ? value.trim().toLowerCase() : ''; if (candidate) return candidate.slice(0, 1); return ( String(fallback || '') .trim() .toLowerCase() .slice(0, 1) || 'g' ); }, adjustSpeedByStep(direction) { const speeds = this.speedControl.availableSpeeds; if (!Array.isArray(speeds) || !speeds.length) return; const current = Number(this.speedControl.currentSpeed); let closestIndex = 0; let closestDelta = Number.POSITIVE_INFINITY; for (let i = 0; i < speeds.length; i += 1) { const delta = Math.abs(speeds[i] - current); if (delta < closestDelta) { closestDelta = delta; closestIndex = i; } } const step = direction > 0 ? 1 : -1; const nextIndex = Math.max(0, Math.min(speeds.length - 1, closestIndex + step)); if (nextIndex === closestIndex) return; this.changeSpeed(speeds[nextIndex]); }, // ==================== Loop Functions ==================== /** * Toggle loop on/off */ toggleLoop() { if (!this.settings.enableLoop) return; this.loopControl.enabled = !this.loopControl.enabled; const video = document.querySelector('video'); if (!video) { this.saveLoopState(); return; } if (this.loopControl.enabled) { // If no A-B points set, just enable normal loop if (this.loopControl.pointA === null && this.loopControl.pointB === null) { video.loop = true; } else { video.loop = false; this.setupLoopListener(video); } YouTubeUtils.NotificationManager.show(t('loopEnabled') || 'Loop enabled', { duration: 1500, type: 'success', }); } else { video.loop = false; this.removeLoopListener(); YouTubeUtils.NotificationManager.show(t('loopDisabled') || 'Loop disabled', { duration: 1500, type: 'info', }); } this.updateLoopProgressBar(); this.saveLoopState(); }, /** * Set loop point A or B * @param {string} point - 'A' or 'B' */ setLoopPoint(point) { if (!this.settings.enableLoop) return; const video = document.querySelector('video'); if (!video) return; const currentTime = video.currentTime; if (point === 'A') { this.loopControl.pointA = currentTime; YouTubeUtils.NotificationManager.show( `${t('loopPointASet') || 'Point A set'}: ${this.formatTime(currentTime)}`, { duration: 1500, type: 'success' } ); } else if (point === 'B') { this.loopControl.pointB = currentTime; YouTubeUtils.NotificationManager.show( `${t('loopPointBSet') || 'Point B set'}: ${this.formatTime(currentTime)}`, { duration: 1500, type: 'success' } ); } // If both points are set and loop is enabled, update listener if ( this.loopControl.enabled && this.loopControl.pointA !== null && this.loopControl.pointB !== null ) { const video = document.querySelector('video'); if (video) { video.loop = false; this.setupLoopListener(video); } } this.updateLoopProgressBar(); this.saveLoopState(); }, /** * Reset loop points A and B */ resetLoopPoints() { if (!this.settings.enableLoop) return; this.loopControl.pointA = null; this.loopControl.pointB = null; // If loop is enabled, switch back to normal loop if (this.loopControl.enabled) { const video = document.querySelector('video'); if (video) { video.loop = true; this.removeLoopListener(); } } YouTubeUtils.NotificationManager.show(t('loopPointsReset') || 'Loop points reset', { duration: 1500, type: 'info', }); this.updateLoopProgressBar(); this.saveLoopState(); }, /** * Setup timeupdate listener for A-B loop * @param {HTMLVideoElement} video */ setupLoopListener(video) { this.removeLoopListener(); if (this.loopControl.pointA === null || this.loopControl.pointB === null) return; const startTime = Math.min(this.loopControl.pointA, this.loopControl.pointB); const endTime = Math.max(this.loopControl.pointA, this.loopControl.pointB); this.loopControl.timeUpdateListener = () => { if (this.loopControl.enabled && video.currentTime >= endTime) { video.currentTime = startTime; } }; video.addEventListener('timeupdate', this.loopControl.timeUpdateListener); }, /** * Remove timeupdate listener */ removeLoopListener() { if (this.loopControl.timeUpdateListener) { const video = document.querySelector('video'); if (video) { video.removeEventListener('timeupdate', this.loopControl.timeUpdateListener); } this.loopControl.timeUpdateListener = null; } }, /** * Update loop progress bar indicator */ updateLoopProgressBar() { // If neither point is set, remove any existing indicator if (this.loopControl.pointA === null && this.loopControl.pointB === null) { const existingIndicator = document.querySelector('.ytp-plus-loop-indicator'); if (existingIndicator) existingIndicator.remove(); return; } const video = document.querySelector('video'); if (!video || !video.duration) return; // Try to find progress bar in YouTube player let progressBar = document.querySelector('.ytp-progress-bar-container') || document.querySelector('.ytp-scrubber-container') || document.querySelector('[role="slider"][aria-label*="video"]') || document.querySelector('.html5-progress-bar'); if (!progressBar) { const playbackUI = document.querySelector('.html5-video-player'); if (playbackUI) { progressBar = playbackUI.querySelector('[role="slider"]'); } } if (!progressBar) return; // Get or create loop indicator let indicator = document.querySelector('.ytp-plus-loop-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'ytp-plus-loop-indicator'; // ensure positioned inside the progress bar try { const compStyle = window.getComputedStyle(progressBar); if (!compStyle || compStyle.position === 'static') { progressBar.style.position = 'relative'; } } catch {} // append indicator after ensuring positioning progressBar.appendChild(indicator); // enforce overlay styles so it appears above built-in played bars indicator.style.position = 'absolute'; indicator.style.top = '0'; indicator.style.height = '100%'; indicator.style.pointerEvents = 'none'; indicator.style.zIndex = '1000'; } // If only point A is set, show a narrow marker at A if (this.loopControl.pointA !== null && this.loopControl.pointB === null) { const startPercent = (this.loopControl.pointA / video.duration) * 100; indicator.style.left = `${startPercent}%`; indicator.style.width = `2px`; // Blue marker for A indicator.style.background = 'linear-gradient(90deg,#1976d2,#42a5f5)'; indicator.style.borderLeft = '2px solid #1976d2'; indicator.style.borderRight = '2px solid #1976d2'; indicator.style.display = 'block'; return; } // If only point B is set (rare), show a narrow marker at B if (this.loopControl.pointB !== null && this.loopControl.pointA === null) { const bPercent = (this.loopControl.pointB / video.duration) * 100; indicator.style.left = `${bPercent}%`; indicator.style.width = `2px`; indicator.style.background = 'linear-gradient(90deg,#1976d2,#42a5f5)'; indicator.style.borderLeft = '2px solid #1976d2'; indicator.style.borderRight = '2px solid #1976d2'; indicator.style.display = 'block'; return; } // Both A and B set: draw the range and color it blue const startTime = Math.min(this.loopControl.pointA, this.loopControl.pointB); const endTime = Math.max(this.loopControl.pointA, this.loopControl.pointB); // Calculate percentage positions const startPercent = (startTime / video.duration) * 100; const endPercent = (endTime / video.duration) * 100; indicator.style.left = `${startPercent}%`; indicator.style.width = `${Math.max(0.2, endPercent - startPercent)}%`; // Blue gradient for A->B ranges indicator.style.background = 'linear-gradient(90deg,rgba(25,118,210,0.28) 0%,rgba(66,165,245,0.4) 50%,rgba(25,118,210,0.28) 100%)'; indicator.style.borderLeft = '2px solid #1976d2'; indicator.style.borderRight = '2px solid #1976d2'; indicator.style.display = 'block'; }, /** * Apply saved loop state to current video element. */ applyLoopStateToCurrentVideo() { const video = document.querySelector('video'); if (!video) return; this.removeLoopListener(); if (!this.settings.enableLoop || !this.loopControl.enabled) { video.loop = false; this.updateLoopProgressBar(); return; } if (this.loopControl.pointA !== null && this.loopControl.pointB !== null) { video.loop = false; this.setupLoopListener(video); } else { video.loop = true; } this.updateLoopProgressBar(); }, /** * Save loop state to localStorage */ saveLoopState() { try { const state = { enabled: this.loopControl.enabled, pointA: this.loopControl.pointA, pointB: this.loopControl.pointB, }; localStorage.setItem(this.loopControl.storageKey, JSON.stringify(state)); } catch (e) { console.warn('[YouTube+] Failed to save loop state:', e); } }, /** * Load loop state from localStorage */ loadLoopState() { try { const saved = localStorage.getItem(this.loopControl.storageKey); if (saved) { const state = JSON.parse(saved); this.loopControl.enabled = Boolean(state?.enabled); this.loopControl.pointA = typeof state?.pointA === 'number' && Number.isFinite(state.pointA) ? state.pointA : null; this.loopControl.pointB = typeof state?.pointB === 'number' && Number.isFinite(state.pointB) ? state.pointB : null; setTimeout(() => this.applyLoopStateToCurrentVideo(), 1000); } } catch (e) { console.warn('[YouTube+] Failed to load loop state:', e); } }, /** * Format time in MM:SS format * @param {number} seconds * @returns {string} */ formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }, // ==================== End Loop Functions ==================== saveSettings() { localStorage.setItem(this.settings.storageKey, JSON.stringify(this.settings)); this.updatePageBasedOnSettings(); this.refreshDownloadButton(); // Expose and broadcast updated settings so other modules can react live. try { window.youtubePlus = window.youtubePlus || {}; window.youtubePlus.settings = this.settings; window.dispatchEvent( new CustomEvent('youtube-plus-settings-updated', { detail: this.settings, }) ); } catch (e) { console.warn('[YouTube+] Settings broadcast error:', e); } }, updatePageBasedOnSettings() { const settingsMap = { 'ytp-screenshot-button': 'enableScreenshot', 'ytp-download-button': 'enableDownload', 'speed-control-btn': 'enableSpeedControl', }; Object.entries(settingsMap).forEach(([className, setting]) => { const button = this.getElement(`.${className}`, false); if (button) button.style.display = this.settings[setting] ? '' : 'none'; }); // Also handle speed options dropdown (attached to body) const speedOptions = document.querySelector('.speed-options'); if (speedOptions) { speedOptions.style.display = this.settings.enableSpeedControl ? '' : 'none'; } }, /** * Refresh download button visibility - Delegates to download-button module */ refreshDownloadButton() { // Use extracted download button module if (typeof window !== 'undefined' && window.YouTubePlusDownloadButton) { const manager = window.YouTubePlusDownloadButton.createDownloadButtonManager({ settings: this.settings, t, getElement: this.getElement.bind(this), YouTubeUtils, }); manager.refreshDownloadButton(); } }, setupCurrentPage() { this.waitForElement('#player-container-outer .html5-video-player, .ytp-right-controls', 5000) .then(() => { this.addCustomButtons(); this.setupVideoObserver(); this.applyCurrentSpeed(); this.applyLoopStateToCurrentVideo(); this.updatePageBasedOnSettings(); this.refreshDownloadButton(); }) .catch(() => {}); }, insertStyles() { // === CRITICAL CSS: variables, player controls, speed, notifications === // Injected synchronously — minimal set needed before first paint const criticalStyles = `:root{--yt-accent:#ff0000;--yt-accent-hover:#cc0000;--yt-radius-sm:6px;--yt-radius-md:10px;--yt-radius-lg:16px;--yt-transition:all .2s ease;--yt-space-xs:4px;--yt-space-sm:8px;--yt-space-md:16px;--yt-space-lg:24px;--yt-glass-blur:blur(18px) saturate(180%);--yt-glass-blur-light:blur(12px) saturate(160%);--yt-glass-blur-heavy:blur(24px) saturate(200%);} html[dark],html:not([dark]):not([light]){--yt-bg-primary:rgba(15,15,15,.85);--yt-bg-secondary:rgba(28,28,28,.85);--yt-bg-tertiary:rgba(34,34,34,.85);--yt-text-primary:#fff;--yt-text-secondary:#aaa;--yt-border-color:rgba(255,255,255,.2);--yt-hover-bg:rgba(255,255,255,.1);--yt-shadow:0 4px 12px rgba(0,0,0,.25);--yt-glass-bg:rgba(255,255,255,.1);--yt-glass-border:rgba(255,255,255,.2);--yt-glass-shadow:0 8px 32px rgba(0,0,0,.2);--yt-modal-bg:rgba(0,0,0,.75);--yt-notification-bg:rgba(28,28,28,.9);--yt-panel-bg:rgba(34,34,34,.3);--yt-header-bg:rgba(20,20,20,.6);--yt-input-bg:rgba(255,255,255,.1);--yt-button-bg:rgba(255,255,255,.2);--yt-text-stroke:white;} html[light]{--yt-bg-primary:rgba(255,255,255,.85);--yt-bg-secondary:rgba(248,248,248,.85);--yt-bg-tertiary:rgba(240,240,240,.85);--yt-text-primary:#030303;--yt-text-secondary:#606060;--yt-border-color:rgba(0,0,0,.2);--yt-hover-bg:rgba(0,0,0,.05);--yt-shadow:0 4px 12px rgba(0,0,0,.15);--yt-glass-bg:rgba(255,255,255,.7);--yt-glass-border:rgba(0,0,0,.1);--yt-glass-shadow:0 8px 32px rgba(0,0,0,.1);--yt-modal-bg:rgba(0,0,0,.5);--yt-notification-bg:rgba(255,255,255,.95);--yt-panel-bg:rgba(255,255,255,.7);--yt-header-bg:rgba(248,248,248,.8);--yt-input-bg:rgba(0,0,0,.05);--yt-button-bg:rgba(0,0,0,.1);--yt-text-stroke:#030303;} .ytp-screenshot-button,.ytp-cobalt-button,.ytp-pip-button{position:relative;width:44px;height:100%;display:inline-flex;align-items:center;justify-content:center;vertical-align:top;transition:opacity .15s,transform .15s;} .ytp-screenshot-button:hover,.ytp-cobalt-button:hover,.ytp-pip-button:hover{transform:scale(1.1);} .speed-control-btn{width:4em!important;position:relative!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;height:100%!important;vertical-align:top!important;text-align:center!important;border-radius:var(--yt-radius-sm);font-size:13px;color:var(--yt-text-primary);cursor:pointer;user-select:none;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;transition:color .2s;} .speed-control-btn:hover{color:var(--yt-accent);font-weight:bold;} .speed-options{position:fixed!important;background:var(--yt-glass-bg)!important;color:var(--yt-text-primary)!important;border-radius:var(--yt-radius-md)!important;display:flex!important;flex-direction:column!important;align-items:stretch!important;gap:0!important;transform:translate(-50%,12px)!important;width:92px!important;z-index:2147483647!important;box-shadow:var(--yt-glass-shadow);border:1px solid var(--yt-glass-border);overflow:hidden;opacity:0;pointer-events:none!important;transition:opacity .18s ease,transform .18s ease;box-sizing:border-box;} .speed-options.visible{opacity:1;pointer-events:auto!important;transform:translate(-50%,0)!important;backdrop-filter:var(--yt-glass-blur);-webkit-backdrop-filter:var(--yt-glass-blur);} .speed-option-item{cursor:pointer!important;height:28px!important;line-height:28px!important;font-size:12px!important;text-align:center!important;transition:background-color .15s,color .15s;} .speed-option-active,.speed-option-item:hover{color:var(--yt-accent)!important;font-weight:bold!important;background:var(--yt-hover-bg)!important;} #speed-indicator{position:absolute!important;margin:auto!important;top:0!important;right:0!important;bottom:0!important;left:0!important;border-radius:24px!important;font-size:30px!important;background:var(--yt-glass-bg)!important;color:var(--yt-text-primary)!important;z-index:99999!important;width:80px!important;height:80px!important;line-height:80px!important;text-align:center!important;display:none;box-shadow:var(--yt-glass-shadow);border:1px solid var(--yt-glass-border);} .youtube-enhancer-notification-container{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:10px;z-index:2147483647;pointer-events:none;max-width:calc(100% - 32px);width:100%;box-sizing:border-box;padding:0 16px;} .youtube-enhancer-notification{position:relative;max-width:700px;width:auto;background:var(--yt-glass-bg);color:var(--yt-text-primary);padding:8px 14px;font-size:13px;border-radius:var(--yt-radius-md);z-index:inherit;transition:opacity .35s,transform .32s;box-shadow:var(--yt-glass-shadow);border:1px solid var(--yt-glass-border);font-weight:500;box-sizing:border-box;display:flex;align-items:center;gap:10px;pointer-events:auto;} .ytp-plus-loop-indicator{position:absolute;height:100%;background:linear-gradient(90deg,rgba(25,118,210,0.28) 0%,rgba(66,165,245,0.4) 50%,rgba(25,118,210,0.28) 100%);border-left:2px solid #1976d2;border-right:2px solid #1976d2;display:none;pointer-events:none;top:0;z-index:1000;box-shadow:inset 0 0 4px rgba(25,118,210,0.25);} .ytp-plus-settings-button{background:transparent;border:none;color:var(--yt-text-secondary);cursor:pointer;padding:var(--yt-space-sm);margin-right:var(--yt-space-sm);border:none;display:flex;align-items:center;justify-content:center;transition:background-color .2s,transform .2s;} .ytp-plus-settings-button svg{width:24px;height:24px;} .ytp-plus-settings-button:hover{transform:rotate(30deg);color:var(--yt-text-secondary);} .ytp-download-button{position:relative!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;height:100%!important;vertical-align:top!important;cursor:pointer!important;} @keyframes ytEnhanceFadeIn{from{opacity:0;}to{opacity:1;}} @keyframes ytEnhanceScaleIn{from{opacity:0;transform:scale(.92) translateY(10px);}to{opacity:1;transform:scale(1) translateY(0);}} .ytSearchboxComponentInputBox { background: transparent !important; }`; // === NON-CRITICAL CSS: settings modal, voting, glass utilities === // Deferred via requestIdleCallback — only needed when user opens settings const nonCriticalStyles = ` .ytp-plus-settings-modal{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:100000;backdrop-filter:blur(8px) saturate(140%);-webkit-backdrop-filter:blur(8px) saturate(140%);animation:ytEnhanceFadeIn .25s ease-out;contain:layout style paint;} .ytp-plus-settings-panel{background:var(--yt-glass-bg);color:var(--yt-text-primary);border-radius:20px;width:760px;max-width:94%;max-height:60vh;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,0.45);animation:ytEnhanceScaleIn .28s cubic-bezier(.4,0,.2,1);backdrop-filter:blur(14px) saturate(140%);-webkit-backdrop-filter:blur(14px) saturate(140%);border:1.5px solid var(--yt-glass-border);will-change:transform,opacity;display:flex;flex-direction:row;contain:layout style paint;} .ytp-plus-settings-sidebar{width:240px;background:var(--yt-header-bg);border-right:1px solid var(--yt-glass-border);display:flex;flex-direction:column;backdrop-filter:var(--yt-glass-blur-light);-webkit-backdrop-filter:var(--yt-glass-blur-light);} .ytp-plus-settings-sidebar-header{padding:var(--yt-space-md) var(--yt-space-lg);border-bottom:1px solid var(--yt-glass-border);display:flex;justify-content:space-between;align-items:center;} .ytp-plus-settings-title{font-size:18px;font-weight:500;margin:0;color:var(--yt-text-primary);} .ytp-plus-settings-sidebar-close{padding:var(--yt-space-md) var(--yt-space-lg);display:flex;justify-content:flex-end;background:transparent;} .ytp-plus-settings-close{background:none;border:none;cursor:pointer;padding:var(--yt-space-sm);margin:-8px;color:var(--yt-text-primary);transition:color .2s,transform .2s;} .ytp-plus-settings-close:hover{color:var(--yt-accent);transform:scale(1.25) rotate(90deg);} .ytp-plus-settings-nav{flex:1;padding:var(--yt-space-md) 0;} .ytp-plus-settings-nav-item{display:flex;align-items:center;padding:12px var(--yt-space-lg);cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);font-size:14px;border-left:3px solid transparent;color:var(--yt-text-primary);} .ytp-plus-settings-nav-item:hover{background:var(--yt-hover-bg);} .ytp-plus-settings-nav-item.active{background:rgba(255,0,0,.1);border-left-color:var(--yt-accent);color:var(--yt-accent);font-weight:500;} .ytp-plus-settings-nav-item svg{width:18px;height:18px;margin-right:12px;opacity:.8;transition:opacity .2s,transform .2s;} .ytp-plus-settings-nav-item.active svg{opacity:1;transform:scale(1.1);} .ytp-plus-settings-nav-item:hover svg{transform:scale(1.05);} .ytp-plus-settings-main{flex:1;display:flex;flex-direction:column;overflow-y:auto;} .ytp-plus-settings-header{padding:var(--yt-space-md) var(--yt-space-lg);border-bottom:1px solid var(--yt-glass-border);background:var(--yt-header-bg);backdrop-filter:var(--yt-glass-blur-light);-webkit-backdrop-filter:var(--yt-glass-blur-light);} .ytp-plus-settings-content{flex:1;padding:var(--yt-space-md) var(--yt-space-lg);overflow-y:auto;} .ytp-plus-settings-section{margin-bottom:var(--yt-space-lg);} .ytp-plus-settings-section-title{font-size:16px;font-weight:500;margin-bottom:var(--yt-space-md);color:var(--yt-text-primary);} .ytp-plus-settings-section.hidden{display:none !important;} .ytp-plus-settings-item{display:flex;align-items:center;margin-bottom:var(--yt-space-md);padding:14px 18px;background:transparent;transition:all .25s cubic-bezier(.4,0,.2,1);border-radius:var(--yt-radius-md);} .ytp-plus-settings-item:hover{background:var(--yt-hover-bg);transform:translateX(6px);box-shadow:0 2px 8px rgba(0,0,0,.1);} .ytp-plus-settings-item-actions{display:flex;align-items:center;gap:10px;margin-left:auto;} .ytp-plus-submenu-toggle{width:26px;height:26px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--yt-glass-border);color:var(--yt-text-primary);cursor:pointer;opacity:.9;transition:transform .15s ease,background-color .15s ease,opacity .15s ease;} .ytp-plus-submenu-toggle:hover{background:var(--yt-hover-bg);transform:scale(1.06);} .ytp-plus-submenu-toggle:disabled{opacity:.35;cursor:not-allowed;transform:none;} .ytp-plus-submenu-toggle svg{width:16px;height:16px;transition:transform .15s ease;} .ytp-plus-submenu-toggle[aria-expanded="false"] svg{transform:rotate(-90deg);} .ytp-plus-submenu-toggle[aria-expanded="true"] svg{transform:rotate(0deg);} .ytp-plus-settings-item-label{flex:1;font-size:14px;color:var(--yt-text-primary);} .ytp-plus-settings-item-description{font-size:12px;color:var(--yt-text-secondary);margin-top:4px;} .ytp-plus-settings-checkbox{appearance:none;-webkit-appearance:none;-moz-appearance:none;width:20px;height:20px;min-width:20px;min-height:20px;margin-left:auto;border:2px solid var(--yt-glass-border);border-radius:50%;background:transparent;display:inline-flex;align-items:center;justify-content:center;transition:all 250ms cubic-bezier(.4,0,.23,1);cursor:pointer;position:relative;flex-shrink:0;color:#fff;box-sizing:border-box;} html:not([dark]) .ytp-plus-settings-checkbox{border-color:rgba(0,0,0,.25);color:#222;} .ytp-plus-settings-checkbox:focus-visible{outline:2px solid var(--yt-accent);outline-offset:2px;} .ytp-plus-settings-checkbox:hover{background:var(--yt-hover-bg);transform:scale(1.1);} .ytp-plus-settings-checkbox::before{content:"";width:5px;height:2px;background:var(--yt-text-primary);position:absolute;transform:rotate(45deg);top:6px;left:3px;transition:width 100ms ease 50ms,opacity 50ms;transform-origin:0% 0%;opacity:0;} .ytp-plus-settings-checkbox::after{content:"";width:0;height:2px;background:var(--yt-text-primary);position:absolute;transform:rotate(305deg);top:12px;left:7px;transition:width 100ms ease,opacity 50ms;transform-origin:0% 0%;opacity:0;} .ytp-plus-settings-checkbox:checked{transform:rotate(0deg) scale(1.15);} .ytp-plus-settings-checkbox:checked::before{width:9px;opacity:1;background:#fff;transition:width 150ms ease 100ms,opacity 150ms ease 100ms;} .ytp-plus-settings-checkbox:checked::after{width:16px;opacity:1;background:#fff;transition:width 150ms ease 250ms,opacity 150ms ease 250ms;} .ytp-plus-footer{padding:var(--yt-space-md) var(--yt-space-lg);border-top:1px solid var(--yt-glass-border);display:flex;justify-content:flex-end;background:transparent;} .ytp-plus-button{padding:var(--yt-space-sm) var(--yt-space-md);border-radius:18px;border:none;font-size:14px;font-weight:500;cursor:pointer;transition:all .25s cubic-bezier(.4,0,.2,1);} .ytp-plus-button-primary{background:transparent;border:1px solid var(--yt-glass-border);color:var(--yt-text-primary);} .ytp-plus-button-primary:hover{background:var(--yt-accent);color:#fff;box-shadow:0 6px 16px rgba(255,0,0,.35);transform:translateY(-2px);} .app-icon{fill:var(--yt-text-primary);stroke:var(--yt-text-primary);transition:all .3s;} @media(max-width:768px){.ytp-plus-settings-panel{width:95%;max-height:80vh;flex-direction:column;} .ytp-plus-settings-sidebar{width:100%;max-height:120px;flex-direction:row;overflow-x:auto;} .ytp-plus-settings-nav{display:flex;flex-direction:row;padding:0;} .ytp-plus-settings-nav-item{white-space:nowrap;border-left:none;border-bottom:3px solid transparent;} .ytp-plus-settings-nav-item.active{border-left:none;border-bottom-color:var(--yt-accent);} .ytp-plus-settings-item{padding:10px 12px;}} .ytp-plus-settings-section h1{margin:-95px 90px 8px;font-family:'Montserrat',sans-serif;font-size:52px;font-weight:600;color:transparent;-webkit-text-stroke-width:1px;-webkit-text-stroke-color:var(--yt-text-stroke);cursor:pointer;transition:color .2s;} .ytp-plus-settings-section h1:hover{color:var(--yt-accent);-webkit-text-stroke-width:1px;-webkit-text-stroke-color:transparent;} .download-options{position:fixed;background:var(--yt-glass-bg);color:var(--yt-text-primary);border-radius:var(--yt-radius-md);width:150px;z-index:2147483647;box-shadow:var(--yt-glass-shadow);border:1px solid var(--yt-glass-border);overflow:hidden;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;transform:translateY(8px);box-sizing:border-box;} .download-options.visible{opacity:1;pointer-events:auto;transform:translateY(0);backdrop-filter:var(--yt-glass-blur);-webkit-backdrop-filter:var(--yt-glass-blur);} .download-options-list{display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;} .download-option-item{cursor:pointer;padding:12px;text-align:center;transition:background .2s,color .2s;width:100%;} .download-option-item:hover{background:var(--yt-hover-bg);color:var(--yt-accent);} .glass-panel{background:var(--yt-glass-bg);border:1px solid var(--yt-glass-border);border-radius:var(--yt-radius-md);box-shadow:var(--yt-glass-shadow);} .glass-card{background:var(--yt-panel-bg);border:1px solid var(--yt-glass-border);border-radius:var(--yt-radius-md);padding:var(--yt-space-md);box-shadow:var(--yt-shadow);} .glass-modal{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--yt-modal-bg);display:flex;align-items:center;justify-content:center;z-index:99999;} .glass-button{background:var(--yt-button-bg);border:1px solid var(--yt-glass-border);border-radius:var(--yt-radius-md);padding:var(--yt-space-sm) var(--yt-space-md);color:var(--yt-text-primary);cursor:pointer;transition:all .2s ease;} .glass-button:hover{background:var(--yt-hover-bg);transform:translateY(-1px);box-shadow:var(--yt-shadow);} .download-submenu{margin:4px 0 12px 12px;} .download-submenu-container{display:flex;flex-direction:column;gap:8px;} .style-submenu{margin:4px 0 12px 12px;} .style-submenu-container{display:flex;flex-direction:column;gap:8px;} .speed-submenu{margin:4px 0 12px 12px;} .speed-submenu-container{display:flex;flex-direction:column;gap:8px;} .speed-hotkeys-row{flex-direction:column!important;align-items:stretch!important;gap:6px;} .speed-hotkeys-info{display:flex;flex-direction:column;gap:4px;} .speed-hotkeys-fields{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap;margin-top:12px;width:100%;} .speed-hotkey-field{display:flex;flex-direction:column;align-items:center;gap:8px;font-size:12px;color:var(--yt-text-secondary);flex:1;min-width:80px;} .speed-hotkey-field span{text-align:center;width:100%;} .speed-hotkey-input{width:100%;height:36px;border-radius:8px;border:1px solid var(--yt-glass-border);background:var(--yt-glass-bg);color:var(--yt-text-primary);text-align:center;text-transform:uppercase;} .speed-hotkey-input:focus{background:var(--yt-hover-bg);} .loop-submenu-container{display:flex;flex-direction:column;gap:8px;} .loop-hotkeys-row{flex-direction:column!important;align-items:stretch!important;gap:6px;} .loop-hotkeys-info{display:flex;flex-direction:column;gap:4px;} .loop-hotkeys-fields{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap;margin-top:12px;width:100%;} .loop-hotkey-field{display:flex;flex-direction:column;align-items:center;gap:8px;font-size:12px;color:var(--yt-text-secondary);flex:1;min-width:80px;} .loop-hotkey-field span{text-align:center;width:100%;} .loop-hotkey-input{width:100%;height:36px;border-radius:8px;border:1px solid var(--yt-glass-border);background:var(--yt-glass-bg);color:var(--yt-text-primary);text-align:center;text-transform:uppercase;} .loop-hotkey-input:focus{background:var(--yt-hover-bg);} .download-site-option{display:flex;flex-direction:column;align-items:stretch;gap:8px;padding:10px;border-radius:var(--yt-radius-md);transition:background .2s;} .download-site-option:hover{background:var(--yt-hover-bg);} .download-site-header{display:flex;flex-direction:row;align-items:center;justify-content:space-between;width:100%;gap:12px;} .download-site-label{flex:1;cursor:pointer;display:flex;flex-direction:column;} .download-site-controls{width:100%;margin-top:4px;padding-top:10px;border-top:1px solid var(--yt-glass-border);} .download-site-input{width:95%;margin-top:8px;padding:8px;background:var(--yt-glass-bg);border:1px solid var(--yt-glass-border);border-radius:var(--yt-radius-sm);color:var(--yt-text-primary);font-size:13px;transition:all .2s;} .download-site-input:focus{border-color:var(--yt-accent);background:var(--yt-hover-bg);} .download-site-input.small{margin-top:6px;font-size:12px;} .download-site-cta{display:flex;flex-direction:row;gap:8px;margin-top:10px;} .download-site-cta .glass-button{flex:1;justify-content:center;font-size:13px;padding:8px 12px;} .download-site-cta .glass-button.danger{background:rgba(255,59,59,0.15);border-color:rgba(255,59,59,0.3);} .download-site-cta .glass-button.danger:hover{background:rgba(255,59,59,0.25);} .download-site-option .ytp-plus-settings-checkbox{margin:0;} .download-site-name{font-weight:500;font-size:15px;color:var(--yt-text-primary);} .download-site-desc{font-size:12px;color:var(--yt-text-secondary);margin-top:2px;opacity:0.8;} .ytp-plus-settings-panel select, .ytp-plus-settings-panel select option {background: var(--yt-panel-bg) !important; color: var(--yt-text-primary) !important;} .ytp-plus-settings-panel select {-webkit-appearance: menulist !important; appearance: menulist !important; padding: 6px 8px !important; border-radius: 6px !important; border: 1px solid var(--yt-glass-border) !important;} .glass-dropdown{position:relative;display:inline-block;min-width:110px} .glass-dropdown__toggle{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:6px 8px;border-radius:8px;background:linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));color:inherit;border:1px solid rgba(255,255,255,0.06);cursor:pointer} .glass-dropdown__toggle:focus{outline:2px solid rgba(255,255,255,0.06)} .glass-dropdown__label{font-size:12px} .glass-dropdown__chev{opacity:0.9} .glass-dropdown__list{position:absolute;left:0;right:0;top:calc(100% + 8px);z-index:20000;display:none;margin:0;padding:6px;border-radius:10px;list-style:none;background:var(--yt-header-bg);border:1px solid rgba(255,255,255,0.06);box-shadow:0 8px 30px rgba(0,0,0,0.5);backdrop-filter:blur(10px) saturate(130%);-webkit-backdrop-filter:blur(10px) saturate(130%);max-height:220px;overflow:auto} .glass-dropdown__item{padding:8px 10px;border-radius:6px;margin:4px 0;cursor:pointer;color:inherit;font-size:13px} .glass-dropdown__item:hover{background:rgba(255,255,255,0.04)} .glass-dropdown__item[aria-selected="true"]{background:linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));box-shadow:inset 0 0 0 1px rgba(255,255,255,0.02)} .ytp-plus-settings-voting-header{margin-bottom:var(--yt-space-lg);} .ytp-plus-settings-voting-header h3{font-size:18px;font-weight:500;margin:0 0 8px 0;color:var(--yt-text-primary);} .ytp-plus-settings-voting-desc{font-size:13px;color:var(--yt-text-secondary);margin:0;} .ytp-plus-voting{display:flex;flex-direction:column;gap:12px;} .ytp-plus-voting-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;} .ytp-plus-voting-list{display:flex;flex-direction:column;gap:12px;} .ytp-plus-voting-item{display:flex;align-items:flex-start;justify-content:space-between;padding:16px;background:var(--yt-glass-bg);border:1px solid var(--yt-glass-border);border-radius:var(--yt-radius-md);transition:all .2s ease;gap:12px;} .ytp-plus-voting-item:hover{background:var(--yt-hover-bg);transform:translateX(4px);} .ytp-plus-voting-item-content{flex:1;padding-right:16px;} .ytp-plus-voting-item-title{font-size:14px;font-weight:500;color:var(--yt-text-primary);margin-bottom:4px;} .ytp-plus-voting-item-desc{font-size:12px;color:var(--yt-text-secondary);line-height:1.4;} .ytp-plus-voting-item-status{font-size:11px;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:8px;background:rgba(255,255,255,0.1);color:var(--yt-text-secondary);} .ytp-plus-voting-item-status.completed{background:rgba(76,175,80,0.2);color:#4caf50;} .ytp-plus-voting-item-status.in-progress{background:rgba(255,193,7,0.2);color:#ffc107;} .ytp-plus-voting-item-votes{display:flex;flex-direction:column;align-items:stretch;gap:8px;min-width:120px;} .ytp-plus-voting-score{display:flex;align-items:baseline;gap:8px;justify-content:center;} .ytp-plus-vote-total{font-size:12px;color:var(--yt-text-secondary);} .ytp-plus-voting-buttons{position:relative;display:flex;justify-content:center;gap:0;border:1px solid var(--yt-glass-border);border-radius:20px;overflow:hidden;} .ytp-plus-voting-buttons-track{position:absolute;top:0;left:0;width:100%;height:100%;z-index:0;transition:background .4s ease;border-radius:20px;pointer-events:none;} .ytp-plus-vote-btn{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:42px;height:32px;border:none;background:transparent;cursor:pointer;transition:color .15s ease,opacity .15s ease;color:var(--yt-text-secondary);opacity:.95} .ytp-plus-vote-btn:first-of-type{border-right:1px solid var(--yt-glass-border)} .ytp-plus-vote-btn:hover{color:var(--yt-text-primary);opacity:1} .ytp-plus-vote-btn.active{color:#fff;opacity:1} .ytp-plus-vote-icon{width:20px;height:20px;fill:currentColor;opacity:.92} .ytp-plus-vote-btn.active .ytp-plus-vote-icon,.ytp-plus-vote-btn:hover .ytp-plus-vote-icon{opacity:1} .ytp-plus-voting-loading,.ytp-plus-voting-empty{text-align:center;padding:24px;color:var(--yt-text-secondary);font-size:13px;} .ytp-plus-voting-add-btn{background:var(--yt-accent);color:#fff;border:none;padding:8px 16px;border-radius:18px;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s ease;} .ytp-plus-voting-add-btn:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(255,0,0,.3);} .ytp-plus-voting-add-form{margin-top:16px;padding:16px;background:var(--yt-glass-bg);border:1px solid var(--yt-glass-border);border-radius:var(--yt-radius-md);} .ytp-plus-voting-add-form input,.ytp-plus-voting-add-form textarea{width:100%;padding:10px 12px;margin-bottom:12px;background:var(--yt-header-bg);border:1px solid var(--yt-glass-border);border-radius:8px;color:var(--yt-text-primary);font-size:13px;box-sizing:border-box;} .ytp-plus-voting-add-form input:focus,.ytp-plus-voting-add-form textarea:focus{border-color:var(--yt-accent);outline:none;} .ytp-plus-voting-add-form textarea{min-height:80px;resize:vertical;} .ytp-plus-voting-form-actions{display:flex;gap:8px;justify-content:flex-end;} .ytp-plus-voting-cancel{background:transparent;border:1px solid var(--yt-glass-border);color:var(--yt-text-primary);padding:8px 16px;border-radius:18px;font-size:13px;cursor:pointer;transition:all .2s ease;} .ytp-plus-voting-cancel:hover{background:var(--yt-hover-bg);} .ytp-plus-voting-submit{background:var(--yt-accent);color:#fff;border:none;padding:8px 16px;border-radius:18px;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s ease;} .ytp-plus-voting-submit:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(255,0,0,.3);} @media (max-width: 680px){.ytp-plus-voting-item{flex-direction:column;align-items:stretch}.ytp-plus-voting-item-content{padding-right:0}.ytp-plus-voting-item-votes{min-width:0;width:100%}} .ytp-plus-voting-preview{margin-bottom:20px;} .ytp-plus-ba-container{position:relative;width:100%;height:260px;overflow:hidden;border-radius:var(--yt-radius-md);border:1px solid var(--yt-glass-border);user-select:none;cursor:ew-resize;background:#000;} .ytp-plus-ba-before,.ytp-plus-ba-after{position:absolute;top:0;left:0;width:100%;height:100%;overflow:hidden;} .ytp-plus-ba-before img,.ytp-plus-ba-after img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;display:block;pointer-events:none;} .ytp-plus-ba-after{clip-path:inset(0 0 0 50%);} .ytp-plus-ba-divider{position:absolute;top:0;left:50%;transform:translateX(-50%);width:8px;height:100%;background:transparent;pointer-events:auto;z-index:3;cursor:ew-resize;transition:left .6s linear} .ytp-plus-ba-divider::after{content:'';position:absolute;left:50%;top:0;transform:translateX(-50%);width:2px;height:100%;background:var(--yt-accent,#f00);} .ytp-plus-ba-divider.autoplay{animation:ytpPlusSlideDivider 6s linear infinite} @keyframes ytpPlusSlideDivider{0%{left:10%}50%{left:90%}100%{left:10%}} .ytp-plus-ba-label{position:absolute;top:10px;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600;color:#fff;background:rgba(0,0,0,.55);pointer-events:none;z-index:5;} .ytp-plus-ba-label-before{left:10px;} .ytp-plus-ba-label-after{right:10px;} .ytp-plus-vote-bar-section{margin-top:12px;display:flex;flex-direction:column;align-items:center;gap:6px;} .ytp-plus-vote-bar-buttons{position:relative;display:flex;gap:0;border-radius:20px;overflow:hidden;border:1px solid var(--yt-glass-border);} .ytp-plus-vote-bar-track{position:absolute;top:0;left:0;width:100%;height:100%;z-index:0;transition:background .4s ease;background:linear-gradient(to right, #4caf50 50%, #f44336 50%);border-radius:20px;} .ytp-plus-vote-bar-btn{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;padding:8px 18px;background:transparent;border:none;color:var(--yt-text-secondary);cursor:pointer;transition:color .15s;font-size:14px;} .ytp-plus-vote-bar-btn:first-of-type{border-right:1px solid var(--yt-glass-border);} .ytp-plus-vote-bar-btn:hover{color:var(--yt-text-primary);} .ytp-plus-vote-bar-btn.active{color:#fff;} .ytp-plus-vote-bar-btn svg{fill:currentColor;} .ytp-plus-vote-bar-count{font-size:12px;color:var(--yt-text-secondary);}`; const injectNonCritical = () => { if (!document.getElementById('yt-enhancer-nc-styles')) { const ncEl = document.createElement('style'); ncEl.id = 'yt-enhancer-nc-styles'; ncEl.textContent = nonCriticalStyles; (document.head || document.documentElement).appendChild(ncEl); } }; this.ensureNonCriticalStyles = injectNonCritical; if (!document.getElementById('yt-enhancer-main')) { // Inject critical CSS immediately YouTubeUtils.StyleManager.add('yt-enhancer-main', criticalStyles); } // Defer non-critical CSS (settings modal, voting, glass utilities) if (typeof requestIdleCallback === 'function') { requestIdleCallback(injectNonCritical, { timeout: 5000 }); } else { setTimeout(injectNonCritical, 1000); } }, addSettingsButtonToHeader() { this.waitForElement('ytd-masthead #end', 5000) .then(headerEnd => { if (!this.getElement('.ytp-plus-settings-button')) { const settingsButton = document.createElement('div'); settingsButton.className = 'ytp-plus-settings-button'; settingsButton.setAttribute('title', t('youtubeSettings')); settingsButton.innerHTML = ` `; settingsButton.addEventListener('click', this.openSettingsModal.bind(this)); const avatarButton = headerEnd.querySelector('ytd-topbar-menu-button-renderer'); if (avatarButton) { headerEnd.insertBefore(settingsButton, avatarButton); } else { headerEnd.appendChild(settingsButton); } } }) .catch(() => {}); }, /** * Handle modal click actions (extracted to reduce complexity) * @param {HTMLElement} target - Click target * @param {HTMLElement} modal - Modal element * @param {Object} handlers - Modal handlers * @param {Function} markDirty - Mark dirty function * @param {Object} context - Context object * @param {Function} translate - Translation function */ handleModalClickActions(target, modal, handlers, markDirty, context, translate) { // Sidebar navigation const navItem = /** @type {HTMLElement | null} */ ( target.classList && target.classList.contains('ytp-plus-settings-nav-item') ? target : target.closest && target.closest('.ytp-plus-settings-nav-item') ); if (navItem) { handlers.handleSidebarNavigation(navItem, modal); return; } // Save button if (target.id === 'ytp-plus-save-settings' || target.id === 'ytp-plus-save-settings-icon') { this.saveSettings(); modal.remove(); this.showNotification(translate('settingsSaved')); return; } // External downloader save if (target.id === 'download-externalDownloader-save') { handlers.handleExternalDownloaderSave( target, this.settings, this.saveSettings.bind(this), this.showNotification.bind(this), translate ); return; } // External downloader reset if (target.id === 'download-externalDownloader-reset') { handlers.handleExternalDownloaderReset( modal, this.settings, this.saveSettings.bind(this), this.showNotification.bind(this), translate ); } }, createSettingsModal() { const modal = document.createElement('div'); modal.className = 'ytp-plus-settings-modal'; // Use helper functions from settings-helpers.js const helpers = window.YouTubePlusSettingsHelpers; const handlers = window.YouTubePlusModalHandlers; modal.innerHTML = `
${helpers.createSettingsSidebar(t)}${helpers.createMainContent(this.settings, t)}
`; // Track unsaved changes let dirty = false; const saveIconBtn = modal.querySelector('#ytp-plus-save-settings-icon'); if (saveIconBtn) saveIconBtn.style.display = 'none'; const markDirty = () => { if (dirty) return; dirty = true; if (saveIconBtn) saveIconBtn.style.display = ''; }; // Context for handlers const context = { settings: this.settings, getElement: this.getElement.bind(this), addDownloadButton: this.addDownloadButton.bind(this), addSpeedControlButton: this.addSpeedControlButton.bind(this), refreshDownloadButton: this.refreshDownloadButton.bind(this), updatePageBasedOnSettings: this.updatePageBasedOnSettings.bind(this), }; // Create click handler const handleModalClick = e => { const { target } = /** @type {{ target: HTMLElement }} */ (e); // Submenu toggle buttons (e.g., YouTube Music) const submenuToggleBtn = target.closest('.ytp-plus-submenu-toggle'); if (submenuToggleBtn) { try { if ( submenuToggleBtn instanceof HTMLElement && submenuToggleBtn.tagName === 'BUTTON' && submenuToggleBtn.hasAttribute('disabled') ) { return; } const submenuKey = submenuToggleBtn.dataset?.submenu; if (!submenuKey) return; const panel = submenuToggleBtn.closest('.ytp-plus-settings-panel'); if (!panel) return; const submenuSelector = submenuKey === 'music' ? `.music-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'download' ? `.download-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'style' ? `.style-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'speed' ? `.speed-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'loop' ? `.loop-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'pip' ? `.pip-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'timecode' ? `.timecode-submenu[data-submenu="${submenuKey}"]` : submenuKey === 'enhanced' ? `.enhanced-submenu[data-submenu="${submenuKey}"]` : `[data-submenu="${submenuKey}"]`; const submenuEl = panel.querySelector(submenuSelector); if (!(submenuEl instanceof HTMLElement)) return; const computedDisplay = window.getComputedStyle(submenuEl).display; const currentlyHidden = computedDisplay === 'none' || submenuEl.hidden; const nextHidden = !currentlyHidden; submenuEl.style.display = nextHidden ? 'none' : ''; submenuToggleBtn.setAttribute('aria-expanded', nextHidden ? 'false' : 'true'); // Persist submenu expanded state to localStorage try { const submenuStates = JSON.parse( localStorage.getItem('ytp-plus-submenu-states') || '{}' ); submenuStates[submenuKey] = !nextHidden; localStorage.setItem('ytp-plus-submenu-states', JSON.stringify(submenuStates)); } catch { // Ignore storage errors } } catch {} return; } // Close modal if (target === modal) { modal.remove(); return; } // Close button if ( target.id === 'ytp-plus-close-settings' || target.id === 'ytp-plus-close-settings-icon' || target.classList.contains('ytp-plus-settings-close') || target.closest('.ytp-plus-settings-close') || target.closest('#ytp-plus-close-settings') || target.closest('#ytp-plus-close-settings-icon') ) { modal.remove(); return; } // YTDL GitHub button if (target.id === 'open-ytdl-github' || target.closest('#open-ytdl-github')) { window.open('https://github.com/diorhc/YTDL', '_blank'); return; } // Handle different actions this.handleModalClickActions(target, modal, handlers, markDirty, context, t); }; modal.addEventListener('click', handleModalClick); // Change event delegation for checkboxes modal.addEventListener('change', e => { const { target } = /** @type {{ target: EventTarget & HTMLElement }} */ (e); if (!target.classList.contains('ytp-plus-settings-checkbox')) return; const { dataset } = /** @type {HTMLElement} */ (target); const { setting } = dataset; if (!setting) return; // Download site checkboxes if (setting.startsWith('downloadSite_')) { const key = setting.replace('downloadSite_', ''); handlers.handleDownloadSiteToggle( target, key, this.settings, markDirty, this.saveSettings.bind(this) ); return; } // YouTube Music settings - handle separately if (handlers.isMusicSetting && handlers.isMusicSetting(setting)) { handlers.handleMusicSettingToggle(target, setting, this.showNotification.bind(this), t); return; } // Simple settings handlers.handleSimpleSettingToggle( target, setting, this.settings, context, markDirty, this.saveSettings.bind(this), modal ); }); // Input event delegation - allow free editing modal.addEventListener('input', e => { const { target } = /** @type {{ target: EventTarget & HTMLElement }} */ (e); if (target.classList.contains('speed-hotkey-input')) { const keyType = target.dataset?.speedHotkey; if (keyType !== 'decrease' && keyType !== 'increase' && keyType !== 'reset') return; // Allow free editing on input, normalize on blur markDirty(); return; } if (target.classList.contains('loop-hotkey-input')) { const keyType = target.dataset?.loopHotkey; if (keyType !== 'setPointA' && keyType !== 'setPointB' && keyType !== 'resetPoints') { return; } // Allow free editing on input, normalize on blur markDirty(); return; } if (target.classList.contains('download-site-input')) { const { dataset } = /** @type {HTMLElement} */ (target); const { site, field } = dataset; if (!site || !field) return; handlers.handleDownloadSiteInput(target, site, field, this.settings, markDirty, t); } }); // Blur event delegation - normalize hotkey inputs when editing ends modal.addEventListener( 'blur', e => { const { target } = /** @type {{ target: EventTarget & HTMLElement }} */ (e); if (target.classList.contains('speed-hotkey-input')) { const keyType = target.dataset?.speedHotkey; if (keyType !== 'decrease' && keyType !== 'increase' && keyType !== 'reset') return; const input = /** @type {HTMLInputElement} */ (target); const fallback = keyType === 'decrease' ? 'g' : keyType === 'increase' ? 'h' : 'b'; const normalized = this.normalizeSpeedHotkey(input.value, fallback); this.settings.speedControlHotkeys = this.settings.speedControlHotkeys || { decrease: 'g', increase: 'h', reset: 'b', }; this.settings.speedControlHotkeys[keyType] = normalized; input.value = normalized; this.saveSettings(); return; } if (target.classList.contains('loop-hotkey-input')) { const keyType = target.dataset?.loopHotkey; if (keyType !== 'setPointA' && keyType !== 'setPointB' && keyType !== 'resetPoints') { return; } const input = /** @type {HTMLInputElement} */ (target); const fallback = keyType === 'setPointA' ? 'k' : keyType === 'setPointB' ? 'l' : 'o'; const normalized = this.normalizeSpeedHotkey(input.value, fallback); this.settings.loopHotkeys = this.settings.loopHotkeys || { toggleLoop: 'r', setPointA: 'k', setPointB: 'l', resetPoints: 'o', }; this.settings.loopHotkeys[keyType] = normalized; input.value = normalized; this.saveSettings(); return; } }, true ); // Allow report module to populate settings try { if ( typeof window !== 'undefined' && /** @type {any} */ (window).youtubePlusReport && typeof (/** @type {any} */ (window).youtubePlusReport.render) === 'function' ) { try { /** @type {any} */ (window).youtubePlusReport.render(modal); } catch (e) { YouTubeUtils.logError('Report', 'report.render failed', e); } } } catch (e) { YouTubeUtils.logError('Report', 'Failed to initialize report section', e); } // Restore submenu expanded states from localStorage try { const submenuStates = JSON.parse(localStorage.getItem('ytp-plus-submenu-states') || '{}'); Object.entries(submenuStates).forEach(([key, expanded]) => { const toggleBtn = modal.querySelector(`.ytp-plus-submenu-toggle[data-submenu="${key}"]`); if (toggleBtn instanceof HTMLElement && !toggleBtn.hasAttribute('disabled')) { const submenuSelector = key === 'music' ? `.music-submenu[data-submenu="${key}"]` : key === 'download' ? `.download-submenu[data-submenu="${key}"]` : key === 'style' ? `.style-submenu[data-submenu="${key}"]` : key === 'speed' ? `.speed-submenu[data-submenu="${key}"]` : key === 'pip' ? `.pip-submenu[data-submenu="${key}"]` : key === 'timecode' ? `.timecode-submenu[data-submenu="${key}"]` : key === 'enhanced' ? `.enhanced-submenu[data-submenu="${key}"]` : `[data-submenu="${key}"]`; const submenuEl = modal.querySelector(submenuSelector); if (submenuEl instanceof HTMLElement) { const isExpanded = !!expanded; submenuEl.style.display = isExpanded ? '' : 'none'; toggleBtn.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); } } }); } catch { // Ignore storage errors } // Restore active nav section from localStorage try { const savedSection = localStorage.getItem('ytp-plus-active-nav-section'); if (savedSection) { const navItem = modal.querySelector( `.ytp-plus-settings-nav-item[data-section="${savedSection}"]` ); if (navItem) { modal .querySelectorAll('.ytp-plus-settings-nav-item') .forEach(item => item.classList.remove('active')); modal .querySelectorAll('.ytp-plus-settings-section') .forEach(s => s.classList.add('hidden')); navItem.classList.add('active'); const targetSection = modal.querySelector( `.ytp-plus-settings-section[data-section="${savedSection}"]` ); if (targetSection) targetSection.classList.remove('hidden'); } } } catch { // Ignore storage errors } return modal; }, openSettingsModal() { const existingModal = this.getElement('.ytp-plus-settings-modal', false); if (existingModal) existingModal.remove(); if (typeof this.ensureNonCriticalStyles === 'function') { this.ensureNonCriticalStyles(); } document.body.appendChild(this.createSettingsModal()); // Initialize voting system if (window.YouTubePlus?.Voting) { const votingContainer = document.getElementById('ytp-plus-voting-container'); if (votingContainer) { window.YouTubePlus.Voting.init(); window.YouTubePlus.Voting.createUI(votingContainer); window.YouTubePlus.Voting.loadFeatures(); } // If voting section is already visible (saved as last active), init slider const votingSection = document.querySelector( '.ytp-plus-settings-section[data-section="voting"]' ); if (votingSection && !votingSection.classList.contains('hidden')) { requestAnimationFrame(() => window.YouTubePlus.Voting?.initSlider?.()); } } // Notify modules that settings modal is now in DOM try { document.dispatchEvent( new CustomEvent('youtube-plus-settings-modal-opened', { bubbles: true }) ); } catch { // ignore event dispatch errors } }, waitForElement(selector, timeout = 5000) { return YouTubeUtils.waitForElement(selector, timeout); }, addCustomButtons() { const controls = this.getElement('.ytp-right-controls'); if (!controls) return; if (!this.getElement('.ytp-screenshot-button')) this.addScreenshotButton(controls); if (!this.getElement('.ytp-download-button')) this.addDownloadButton(controls); if (!this.getElement('.speed-control-btn')) this.addSpeedControlButton(controls); if (!document.getElementById('speed-indicator')) { const indicator = document.createElement('div'); indicator.id = 'speed-indicator'; const player = document.getElementById('movie_player'); if (player) player.appendChild(indicator); } this.handleFullscreenChange(); }, addScreenshotButton(controls) { const button = document.createElement('button'); button.className = 'ytp-button ytp-screenshot-button'; button.setAttribute('title', t('takeScreenshot')); button.innerHTML = ` `; button.addEventListener('click', this.captureFrame.bind(this)); controls.insertBefore(button, controls.firstChild); }, /** * Add download button to controls - Delegates to download-button module * @param {HTMLElement} controls - Controls container */ addDownloadButton(controls) { // Use extracted download button module if (typeof window !== 'undefined' && window.YouTubePlusDownloadButton) { const manager = window.YouTubePlusDownloadButton.createDownloadButtonManager({ settings: this.settings, t, getElement: this.getElement.bind(this), YouTubeUtils, }); manager.addDownloadButton(controls); } else { console.warn('[YouTube+] Download button module not loaded'); } }, addSpeedControlButton(controls) { // Check if speed control is enabled in settings if (!this.settings.enableSpeedControl) return; const speedBtn = document.createElement('button'); speedBtn.type = 'button'; speedBtn.className = 'ytp-button speed-control-btn'; speedBtn.setAttribute('aria-label', t('speedControl')); speedBtn.setAttribute('aria-haspopup', 'true'); speedBtn.setAttribute('aria-expanded', 'false'); speedBtn.innerHTML = `${this.speedControl.currentSpeed}×`; const speedOptions = document.createElement('div'); speedOptions.className = 'speed-options'; speedOptions.setAttribute('role', 'menu'); const selectSpeed = speed => { this.changeSpeed(speed); hideDropdown(); }; this.speedControl.availableSpeeds.forEach(speed => { const option = document.createElement('div'); option.className = `speed-option-item${Number(speed) === this.speedControl.currentSpeed ? ' speed-option-active' : ''}`; option.textContent = `${speed}x`; option.dataset.speed = String(speed); option.setAttribute('role', 'menuitem'); option.tabIndex = 0; option.addEventListener('click', () => selectSpeed(speed)); option.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectSpeed(speed); } }); speedOptions.appendChild(option); }); speedBtn.appendChild(speedOptions); // Ensure only one speed dropdown exists const existingSpeed = document.querySelector('.speed-options'); if (existingSpeed) existingSpeed.remove(); // Append speedOptions to body to avoid Firefox positioning/hover issues try { document.body.appendChild(speedOptions); } catch { // fallback keep as child } const positionDropdown = () => { const rect = speedBtn.getBoundingClientRect(); speedOptions.style.left = `${rect.left + rect.width / 2}px`; speedOptions.style.bottom = `${window.innerHeight - rect.top + 8}px`; }; const hideDropdown = () => { speedOptions.classList.remove('visible'); speedBtn.setAttribute('aria-expanded', 'false'); }; const showDropdown = () => { positionDropdown(); speedOptions.classList.add('visible'); speedBtn.setAttribute('aria-expanded', 'true'); }; const toggleDropdown = () => { if (speedOptions.classList.contains('visible')) { hideDropdown(); } else { showDropdown(); } }; let documentClickKey; const documentClickHandler = event => { if (!speedBtn.isConnected) { if (documentClickKey) { YouTubeUtils.cleanupManager.unregisterListener(documentClickKey); documentClickKey = undefined; } return; } if (!speedOptions.classList.contains('visible')) return; if ( speedBtn.contains(/** @type {Node} */ (event.target)) || speedOptions.contains(/** @type {Node} */ (event.target)) ) { return; } hideDropdown(); }; const documentKeydownHandler = event => { if (event.key === 'Escape' && speedOptions.classList.contains('visible')) { hideDropdown(); speedBtn.focus(); } }; documentClickKey = YouTubeUtils.cleanupManager.registerListener( document, 'click', documentClickHandler, true ); YouTubeUtils.cleanupManager.registerListener( document, 'keydown', documentKeydownHandler, true ); YouTubeUtils.cleanupManager.registerListener(window, 'resize', () => { if (speedOptions.classList.contains('visible')) { positionDropdown(); } }); YouTubeUtils.cleanupManager.registerListener( window, 'scroll', () => { if (speedOptions.classList.contains('visible')) { positionDropdown(); } }, true ); // Hover behaviour: show on mouseenter, hide on mouseleave (with small delay) let speedHideTimer; speedBtn.addEventListener('mouseenter', () => { clearTimeout(speedHideTimer); showDropdown(); }); speedBtn.addEventListener('mouseleave', () => { clearTimeout(speedHideTimer); speedHideTimer = setTimeout(hideDropdown, 200); }); speedOptions.addEventListener('mouseenter', () => { clearTimeout(speedHideTimer); showDropdown(); }); speedOptions.addEventListener('mouseleave', () => { clearTimeout(speedHideTimer); speedHideTimer = setTimeout(hideDropdown, 200); }); // Keep keyboard support (Enter toggles dropdown) speedBtn.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleDropdown(); } else if (event.key === 'Escape') { hideDropdown(); } }); controls.insertBefore(speedBtn, controls.firstChild); }, // ------------------ Side Guide Toggle ------------------ applyGuideVisibility() { try { const enabled = Boolean(YouTubeUtils.storage.get('ytplus.hideGuide', false)); document.documentElement.classList.toggle('ytp-hide-guide', enabled); // update floating button appearance if present const btn = document.getElementById('ytplus-guide-toggle-btn'); if (btn) { btn.setAttribute('aria-pressed', String(enabled)); btn.title = enabled ? 'Show side guide' : 'Hide side guide'; } } catch (e) { console.warn('[YouTube+] applyGuideVisibility failed:', e); } }, toggleSideGuide() { try { const current = Boolean(YouTubeUtils.storage.get('ytplus.hideGuide', false)); const next = !current; YouTubeUtils.storage.set('ytplus.hideGuide', next); this.applyGuideVisibility(); } catch (e) { console.warn('[YouTube+] toggleSideGuide failed:', e); } }, createGuideToggleButton() { try { if (document.getElementById('ytplus-guide-toggle-btn')) return; const btn = document.createElement('button'); btn.id = 'ytplus-guide-toggle-btn'; btn.type = 'button'; btn.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:100000;background:var(--yt-spec-call-to-action);color:#fff;border:none;border-radius:8px;padding:8px 10px;box-shadow:0 6px 18px rgba(0,0,0,0.3);cursor:pointer;opacity:0.95;font-size:13px;'; btn.setAttribute('aria-pressed', 'false'); btn.title = 'Hide side guide'; btn.textContent = 'Toggle Guide'; btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); this.toggleSideGuide(); }); // keyboard support btn.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggleSideGuide(); } }); document.body.appendChild(btn); // Apply current stored value this.applyGuideVisibility(); } catch (e) { console.warn('[YouTube+] createGuideToggleButton failed:', e); } }, captureFrame() { const video = this.getElement('video', false); if (!video) return; const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const videoTitle = document.title.replace(/\s-\sYouTube$/, '').trim(); const link = document.createElement('a'); link.href = canvas.toDataURL('image/png'); link.download = `${videoTitle}.png`; try { link.click(); // Notify success (use translation if available) try { const translated = typeof t === 'function' ? t('screenshotSaved') : null; const message = translated && translated !== 'screenshotSaved' ? translated : 'Screenshot saved'; this.showNotification(message, 2000); } catch { this.showNotification('Screenshot saved', 2000); } } catch (err) { if (YouTubeUtils && YouTubeUtils.logError) { YouTubeUtils.logError('Basic', 'Screenshot download failed', err); } try { const translatedFail = typeof t === 'function' ? t('screenshotFailed') : null; const failMsg = translatedFail && translatedFail !== 'screenshotFailed' ? translatedFail : 'Screenshot failed'; this.showNotification(failMsg, 3000); } catch { this.showNotification('Screenshot failed', 3000); } } }, showNotification(message, duration = 2000) { YouTubeUtils.NotificationManager.show(message, { duration, type: 'info' }); }, handleFullscreenChange() { const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement; document.querySelectorAll('.ytp-screenshot-button, .ytp-cobalt-button').forEach(button => { button.style.bottom = isFullscreen ? '0px' : '0px'; }); }, changeSpeed(speed) { const numericSpeed = Number(speed); this.speedControl.currentSpeed = numericSpeed; localStorage.setItem(this.speedControl.storageKey, String(numericSpeed)); const speedBtn = this.getElement('.speed-control-btn span', false); if (speedBtn) speedBtn.textContent = `${numericSpeed}×`; document.querySelectorAll('.speed-option-item').forEach(option => { option.classList.toggle( 'speed-option-active', parseFloat(option.dataset.speed) === numericSpeed ); }); this.applyCurrentSpeed(); this.showSpeedIndicator(numericSpeed); }, applyCurrentSpeed() { // Use DOM cache when available to avoid redundant live queries. const videos = window.YouTubeDOMCache && typeof window.YouTubeDOMCache.getAll === 'function' ? window.YouTubeDOMCache.getAll('video') : document.querySelectorAll('video'); videos.forEach(video => { if (video && video.playbackRate !== this.speedControl.currentSpeed) { video.playbackRate = this.speedControl.currentSpeed; } }); }, setupVideoObserver() { if (this._speedInterval) clearInterval(this._speedInterval); this._speedInterval = null; // Track left-mouse-button hold state so we can detect YouTube's native // hold-to-2× speed feature. When the user presses and holds the left // button on the player, YouTube temporarily sets playbackRate = 2. We // must NOT override that, or the feature is immediately cancelled. if (!this._mouseHoldTracked) { this._mouseHoldTracked = true; this._mouseButtonHeld = false; document.addEventListener( 'mousedown', e => { if (e.button === 0) this._mouseButtonHeld = true; }, { passive: true, capture: true } ); document.addEventListener( 'mouseup', e => { if (e.button === 0) this._mouseButtonHeld = false; }, { passive: true, capture: true } ); } // Event-driven speed control instead of polling every 1s const applySpeed = () => this.applyCurrentSpeed(); const updateLoopBar = () => this.updateLoopProgressBar(); const applyLoop = () => this.applyLoopStateToCurrentVideo(); const attachSpeedListeners = video => { if (video._ytpSpeedListenerAttached) return; video._ytpSpeedListenerAttached = true; video.addEventListener('loadedmetadata', applySpeed); video.addEventListener('loadedmetadata', updateLoopBar); video.addEventListener('loadedmetadata', applyLoop); video.addEventListener('playing', applySpeed); video.addEventListener('ratechange', () => { // YouTube's hold-to-2× temporarily raises playbackRate above the // user-chosen speed while the left mouse button is held. Skip the // reset so YouTube's native feature isn't cancelled. if (this._mouseButtonHeld && video.playbackRate > this.speedControl.currentSpeed) return; if (video.playbackRate !== this.speedControl.currentSpeed) { video.playbackRate = this.speedControl.currentSpeed; } }); applySpeed(); }; // Attach to existing videos document.querySelectorAll('video').forEach(attachSpeedListeners); // Watch for new video elements const videoObserver = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeName === 'VIDEO') attachSpeedListeners(node); if (node instanceof Element) { node.querySelectorAll?.('video').forEach(attachSpeedListeners); } } } }); const playerRoot = document.querySelector('#movie_player') || document.querySelector('ytd-player') || document.body; if (playerRoot) { videoObserver.observe(playerRoot, { childList: true, subtree: true }); } YouTubeUtils.cleanupManager.registerObserver(videoObserver); }, setupNavigationObserver() { let lastUrl = location.href; document.addEventListener('fullscreenchange', this.handleFullscreenChange.bind(this)); document.addEventListener('yt-navigate-finish', () => { if (location.href.includes('watch?v=')) this.setupCurrentPage(); this.addSettingsButtonToHeader(); }); // Use popstate + pushState/replaceState override for SPA navigation fallback // instead of expensive body subtree MutationObserver const checkUrlChange = () => { if (lastUrl !== location.href) { lastUrl = location.href; if (location.href.includes('watch?v=')) { setTimeout(() => this.setupCurrentPage(), 500); } this.addSettingsButtonToHeader(); } }; window.addEventListener('popstate', checkUrlChange); document.addEventListener('yt-navigate-start', checkUrlChange); }, showSpeedIndicator(speed) { const indicator = document.getElementById('speed-indicator'); if (!indicator) return; if (this.speedControl.activeAnimationId) { cancelAnimationFrame(this.speedControl.activeAnimationId); YouTubeUtils.cleanupManager.unregisterAnimationFrame(this.speedControl.activeAnimationId); this.speedControl.activeAnimationId = null; } indicator.textContent = `${speed}×`; indicator.style.display = 'block'; indicator.style.opacity = '0.8'; const startTime = performance.now(); const fadeOut = timestamp => { const elapsed = timestamp - startTime; const progress = Math.min(elapsed / 1500, 1); indicator.style.opacity = String(0.8 * (1 - progress)); if (progress < 1) { this.speedControl.activeAnimationId = YouTubeUtils.cleanupManager.registerAnimationFrame( requestAnimationFrame(fadeOut) ); } else { indicator.style.display = 'none'; this.speedControl.activeAnimationId = null; } }; this.speedControl.activeAnimationId = YouTubeUtils.cleanupManager.registerAnimationFrame( requestAnimationFrame(fadeOut) ); }, }; // Save reference to init function BEFORE IIFE closes (critical for DOMContentLoaded) const initFunction = YouTubeEnhancer.init.bind(YouTubeEnhancer); // Initialize immediately or on DOMContentLoaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initFunction); } else { initFunction(); } })(); // --- MODULE: error-boundary.js --- // Global error boundary for YouTube+ userscript (function () { 'use strict'; /** * Circuit breaker states * @enum {string} */ const CircuitState = { CLOSED: 'closed', // Normal operation OPEN: 'open', // Too many failures, block operations HALF_OPEN: 'half_open', // Testing if system recovered }; /** * Error boundary configuration object with circuit breaker support * @typedef {Object} ErrorBoundaryConfig * @property {number} maxErrors - Maximum number of errors allowed within the error window * @property {number} errorWindow - Time window in milliseconds for tracking errors (default: 60000ms = 1 minute) * @property {boolean} enableLogging - Whether to log errors to console * @property {boolean} enableRecovery - Whether to attempt automatic recovery from errors * @property {string} storageKey - LocalStorage key for persisting error data * @property {Object} circuitBreaker - Circuit breaker configuration */ const ErrorBoundaryConfig = { maxErrors: 10, errorWindow: 60000, // 1 minute enableLogging: true, enableRecovery: true, storageKey: 'youtube_plus_errors', // Circuit breaker to prevent cascading failures circuitBreaker: { enabled: true, failureThreshold: 5, // Number of failures before opening circuit resetTimeout: 30000, // Time before attempting to close circuit (30s) halfOpenAttempts: 3, // Successful attempts needed to close circuit }, }; /** * Error tracking state with circuit breaker */ const errorState = { errors: [], errorCount: 0, lastErrorTime: 0, isRecovering: false, // Circuit breaker state circuitState: CircuitState.CLOSED, circuitFailureCount: 0, circuitLastFailureTime: 0, circuitSuccessCount: 0, }; /** * Error severity levels enumeration * @enum {string} */ const ErrorSeverity = { LOW: 'low', MEDIUM: 'medium', HIGH: 'high', CRITICAL: 'critical', }; /** * Categorize error severity based on error message patterns * @param {Error} error - The error object to categorize * @returns {string} Severity level from ErrorSeverity enum */ const categorizeSeverity = error => { const message = error.message?.toLowerCase() || ''; if ( message.includes('cannot read') || message.includes('undefined') || message.includes('null') ) { return ErrorSeverity.MEDIUM; } if (message.includes('network') || message.includes('fetch') || message.includes('timeout')) { return ErrorSeverity.LOW; } if (message.includes('syntax') || message.includes('reference') || message.includes('type')) { return ErrorSeverity.HIGH; } if (message.includes('security') || message.includes('csp')) { return ErrorSeverity.CRITICAL; } return ErrorSeverity.MEDIUM; }; /** * Check circuit breaker state and update accordingly * @param {boolean} success - Whether the operation was successful * @returns {boolean} Whether the operation should proceed */ const checkCircuitBreaker = success => { if (!ErrorBoundaryConfig.circuitBreaker.enabled) return true; const now = Date.now(); const { circuitBreaker } = ErrorBoundaryConfig; // Check if circuit should be reset to half-open if ( errorState.circuitState === CircuitState.OPEN && now - errorState.circuitLastFailureTime >= circuitBreaker.resetTimeout ) { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+] Circuit breaker transitioning to HALF_OPEN'); errorState.circuitState = CircuitState.HALF_OPEN; errorState.circuitSuccessCount = 0; } // Handle successful operation if (success) { if (errorState.circuitState === CircuitState.HALF_OPEN) { errorState.circuitSuccessCount++; if (errorState.circuitSuccessCount >= circuitBreaker.halfOpenAttempts) { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+] Circuit breaker CLOSED - system recovered'); errorState.circuitState = CircuitState.CLOSED; errorState.circuitFailureCount = 0; errorState.circuitSuccessCount = 0; } } else if (errorState.circuitState === CircuitState.CLOSED) { // Gradually decrease failure count on success errorState.circuitFailureCount = Math.max(0, errorState.circuitFailureCount - 1); } return true; } // Handle failed operation errorState.circuitFailureCount++; errorState.circuitLastFailureTime = now; if (errorState.circuitState === CircuitState.CLOSED) { if (errorState.circuitFailureCount >= circuitBreaker.failureThreshold) { console.error('[YouTube+] Circuit breaker OPEN - too many failures'); errorState.circuitState = CircuitState.OPEN; return false; } } else if (errorState.circuitState === CircuitState.HALF_OPEN) { console.error('[YouTube+] Circuit breaker reopened - recovery failed'); errorState.circuitState = CircuitState.OPEN; errorState.circuitSuccessCount = 0; return false; } return errorState.circuitState !== CircuitState.OPEN; }; /** * Log error with context * @param {Error} error - The error object * @param {Object} context - Additional context information */ const logError = (error, context = {}) => { if (!ErrorBoundaryConfig.enableLogging) return; // Update circuit breaker checkCircuitBreaker(false); const fallbackMessage = error.message?.trim() || ''; // Skip if no meaningful message if (!fallbackMessage || fallbackMessage === '(no message)') { // Only log if we have stack trace or filename information if (!error.stack && !context.filename) { return; } } const displayMessage = fallbackMessage || (context.filename ? `Error in ${context.filename}:${context.lineno}` : 'Unknown error'); const errorInfo = { timestamp: new Date().toISOString(), message: displayMessage, stack: error.stack, severity: categorizeSeverity(error), context: { url: window.location.href, userAgent: navigator.userAgent, ...context, }, }; console.error('[YouTube+][Error Boundary]', `${errorInfo.message}`, errorInfo); // Store error for analysis errorState.errors.push(errorInfo); if (errorState.errors.length > 50) { errorState.errors.shift(); // Keep only last 50 errors } // Persist to localStorage for debugging try { const stored = JSON.parse(localStorage.getItem(ErrorBoundaryConfig.storageKey) || '[]'); stored.push(errorInfo); if (stored.length > 20) stored.shift(); localStorage.setItem(ErrorBoundaryConfig.storageKey, JSON.stringify(stored)); } catch {} }; /** * Check if error rate is too high * @returns {boolean} True if error rate exceeded */ const isErrorRateExceeded = () => { const now = Date.now(); const windowStart = now - ErrorBoundaryConfig.errorWindow; // Count errors in the time window const recentErrors = errorState.errors.filter( e => new Date(e.timestamp).getTime() > windowStart ); return recentErrors.length >= ErrorBoundaryConfig.maxErrors; }; /** * Get error rate per minute * @returns {number} Errors per minute */ const getErrorRate = () => { const now = Date.now(); const oneMinuteAgo = now - 60000; const recentErrors = errorState.errors.filter( e => new Date(e.timestamp).getTime() > oneMinuteAgo ); return recentErrors.length; }; /** * Check if should suppress error notification (rate limiting) * @param {Error} error - The error object * @returns {boolean} True if should suppress */ const shouldSuppressNotification = error => { const rate = getErrorRate(); // Suppress if more than 5 errors in the last minute if (rate > 5) { return true; } // Suppress duplicate errors within 10 seconds const tenSecondsAgo = Date.now() - 10000; const recentSimilar = errorState.errors.filter( e => new Date(e.timestamp).getTime() > tenSecondsAgo && e.message === error.message && e.severity === categorizeSeverity(error) ); return recentSimilar.length > 0; }; /** * Show user-friendly error notification * @param {Error} error - The error object * @param {Object} _context - Error context (unused but kept for API consistency) */ const showErrorNotification = (error, _context) => { try { const Y = window.YouTubeUtils; if (!Y || !Y.NotificationManager || typeof Y.NotificationManager.show !== 'function') { return; // Notification manager not available } const severity = categorizeSeverity(error); let message = 'An error occurred'; let duration = 3000; switch (severity) { case ErrorSeverity.LOW: message = 'A minor issue occurred. Functionality should continue normally.'; duration = 2000; break; case ErrorSeverity.MEDIUM: message = 'An error occurred. Some features may not work correctly.'; duration = 3000; break; case ErrorSeverity.HIGH: message = 'A serious error occurred. Please refresh the page if issues persist.'; duration = 5000; break; case ErrorSeverity.CRITICAL: message = 'A critical error occurred. YouTube+ may not function properly. Please report this issue.'; duration = 7000; break; } Y.NotificationManager.show(message, { duration, type: 'error' }); } catch (notificationError) { console.error('[YouTube+] Failed to show error notification:', notificationError); } }; /** * Attempt to recover from error * @param {Error} error - The error that occurred * @param {Object} context - Error context */ const attemptRecovery = (error, context) => { if (!ErrorBoundaryConfig.enableRecovery || errorState.isRecovering) return; const severity = categorizeSeverity(error); if (severity === ErrorSeverity.CRITICAL) { console.error('[YouTube+] Critical error detected. Script may not function properly.'); showErrorNotification(error, context); return; } errorState.isRecovering = true; try { // Show notification to user (except for low severity errors and rate-limited) if (severity !== ErrorSeverity.LOW && !shouldSuppressNotification(error)) { showErrorNotification(error, context); } // Use recovery utilities if available const RecoveryUtils = window.YouTubePlusErrorRecovery; if (RecoveryUtils && RecoveryUtils.attemptRecovery) { // Delegate to recovery utility module RecoveryUtils.attemptRecovery(error, context); } else { // Fallback to legacy recovery performLegacyRecovery(error, context); } setTimeout(() => { errorState.isRecovering = false; }, 5000); } catch (recoveryError) { console.error('[YouTube+] Recovery attempt failed:', recoveryError); errorState.isRecovering = false; } }; /** * Perform legacy recovery (fallback) * @param {Error} error - Error object * @param {Object} context - Error context */ const performLegacyRecovery = (error, context) => { // Attempt module-specific recovery if (context.module) { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug(`[YouTube+] Attempting recovery for module: ${context.module}`); // Try to reinitialize the module if possible const Y = window.YouTubeUtils; if (Y && Y.cleanupManager) { // Could cleanup and reinitialize module-specific resources switch (context.module) { case 'StyleManager': // Clear and re-add styles if needed break; case 'NotificationManager': // Reset notification queue break; default: // Generic cleanup break; } } // Check if it's a DOM-related error and the element is missing if ( error.message && (error.message.includes('null') || error.message.includes('undefined')) && context.element ) { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+] Attempting to re-query DOM element'); // Could trigger element re-query here } } }; /** * Global error handler * @param {ErrorEvent} event - The error event */ const handleError = event => { const error = event.error || new Error(event.message); const message = (error.message || event.message || '').trim(); // Suppress benign ResizeObserver errors if (message.includes('ResizeObserver loop')) { return false; // This is a harmless browser optimization, ignore it } const source = event.filename || ''; const isCrossOriginSource = source && !source.startsWith(window.location.origin) && !/YouTube\+/.test(source); // Ignore opaque cross-origin errors we can't introspect if (!message && isCrossOriginSource) { return false; } // Skip logging if message is empty or just "(no message)" and from cross-origin if (!message || (message === '(no message)' && isCrossOriginSource)) { return false; } // Track error errorState.errorCount++; errorState.lastErrorTime = Date.now(); // Log error logError(error, { type: 'uncaught', filename: event.filename, lineno: event.lineno, colno: event.colno, }); // Check error rate if (isErrorRateExceeded()) { console.error( '[YouTube+] Error rate exceeded! Too many errors in short period. Some features may be disabled.' ); return false; } // Attempt recovery attemptRecovery(error, { type: 'uncaught' }); // Don't prevent default error handling return false; }; /** * Unhandled promise rejection handler * @param {PromiseRejectionEvent} event - The rejection event */ const handleUnhandledRejection = event => { const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason)); logError(error, { type: 'unhandledRejection', promise: event.promise, }); // Check error rate if (isErrorRateExceeded()) { console.error('[YouTube+] Promise rejection rate exceeded!'); return; } // Attempt recovery attemptRecovery(error, { type: 'unhandledRejection' }); }; /** * Safe function wrapper with error boundary * @param {Function} fn - Function to wrap * @param {string} context - Context identifier * @returns {Function} Wrapped function */ const withErrorBoundary = (fn, context = 'unknown') => { /** @this {any} */ return function (...args) { try { const fnAny = /** @type {any} */ (fn); return /** @this {any} */ fnAny.call(this, ...args); } catch (error) { logError(error, { module: context, args }); attemptRecovery(error, { module: context }); return null; } }; }; /** * Safe async function wrapper with error boundary * @param {Function} fn - Async function to wrap * @param {string} context - Context identifier * @returns {Function} Wrapped async function */ const withAsyncErrorBoundary = (fn, context = 'unknown') => { /** @this {any} */ return async function (...args) { try { const fnAny = /** @type {any} */ (fn); return /** @this {any} */ await fnAny.call(this, ...args); } catch (error) { logError(error, { module: context, args }); attemptRecovery(error, { module: context }); return null; } }; }; /** * Get error statistics * @returns {Object} Error statistics */ const getErrorStats = () => { return { totalErrors: errorState.errorCount, recentErrors: errorState.errors.length, lastErrorTime: errorState.lastErrorTime, isRecovering: errorState.isRecovering, errorsByType: errorState.errors.reduce((acc, e) => { acc[e.severity] = (acc[e.severity] || 0) + 1; return acc; }, {}), }; }; /** * Clear stored errors */ const clearErrors = () => { errorState.errors = []; try { localStorage.removeItem(ErrorBoundaryConfig.storageKey); } catch {} }; // Install global error handlers if (typeof window !== 'undefined') { window.addEventListener('error', handleError, true); window.addEventListener('unhandledrejection', handleUnhandledRejection, true); // Expose error boundary utilities window.YouTubeErrorBoundary = { withErrorBoundary, withAsyncErrorBoundary, getErrorStats, clearErrors, logError, getErrorRate, config: ErrorBoundaryConfig, }; window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+][Error Boundary]', 'Error boundary initialized'); } })(); // --- MODULE: performance.js --- // Performance monitoring for YouTube+ userscript (Enhanced) (function () { 'use strict'; /* global Blob, URL, PerformanceObserver */ /** * Performance monitoring configuration */ const PerformanceConfig = { enabled: true, sampleRate: 0.01, // 1% sampling by default (can be overridden via YouTubePlusConfig) storageKey: 'youtube_plus_performance', metricsRetention: 100, // Keep last 100 metrics enableConsoleOutput: false, logLevel: 'info', // 'debug', 'info', 'warn', 'error' }; const isTestEnv = (() => { try { // Jest provides process.env.JEST_WORKER_ID in node/jsdom return typeof process !== 'undefined' && !!process?.env?.JEST_WORKER_ID; } catch { return false; } })(); const getConfiguredSampleRate = () => { try { const cfg = /** @type {any} */ (window).YouTubePlusConfig; const explicit = cfg?.performance?.sampleRate ?? cfg?.performanceSampleRate ?? cfg?.perfSampleRate ?? undefined; if (typeof explicit === 'number' && isFinite(explicit)) { return Math.min(1, Math.max(0, explicit)); } } catch { // ignore } return PerformanceConfig.sampleRate; }; // Apply sample rate (always 100% in tests to avoid flakiness) PerformanceConfig.sampleRate = isTestEnv ? 1.0 : getConfiguredSampleRate(); // Sampling gate: keep API available but disable heavy observers/recording when not sampled. try { if ( !isTestEnv && PerformanceConfig.sampleRate < 1 && Math.random() > PerformanceConfig.sampleRate ) { PerformanceConfig.enabled = false; } } catch { // ignore } /** * Performance metrics storage */ const metrics = { timings: new Map(), marks: new Map(), measures: [], resources: [], webVitals: { LCP: null, CLS: 0, FID: null, INP: null, FCP: null, TTFB: null, }, }; /** * Create a performance mark * @param {string} name - Mark name */ const mark = name => { if (!PerformanceConfig.enabled) return; try { if (typeof performance !== 'undefined' && performance.mark) { performance.mark(name); } metrics.marks.set(name, Date.now()); } catch (e) { console.warn('[YouTube+ Perf] Failed to create mark:', e); } }; /** * Measure time between two marks * @param {string} name - Measure name * @param {string} startMark - Start mark name * @param {string} endMark - End mark name (optional, defaults to now) * @returns {number} Duration in milliseconds */ const measure = (name, startMark, endMark) => { if (!PerformanceConfig.enabled) return 0; try { const startTime = metrics.marks.get(startMark); if (!startTime) { // console.warn(`[YouTube+ Perf] Start mark "${startMark}" not found`); return 0; } const endTime = endMark ? metrics.marks.get(endMark) : Date.now(); const duration = endTime - startTime; const measureData = { name, startMark, endMark: endMark || 'now', duration, timestamp: Date.now(), }; metrics.measures.push(measureData); // Keep only recent measures if (metrics.measures.length > PerformanceConfig.metricsRetention) { metrics.measures.shift(); } if (PerformanceConfig.enableConsoleOutput) { window.YouTubeUtils?.logger?.debug?.(`[YouTube+ Perf] ${name}: ${duration.toFixed(2)}ms`); } // Try native performance API if (typeof performance !== 'undefined' && performance.measure) { try { performance.measure(name, startMark, endMark); } catch {} } return duration; } catch (e) { console.warn('[YouTube+ Perf] Failed to measure:', e); return 0; } }; /** * Time a function execution * @param {string} name - Timer name * @param {Function} fn - Function to time * @returns {Function} Wrapped function */ const timeFunction = (name, fn) => { if (!PerformanceConfig.enabled) return fn; return /** @this {any} */ function (...args) { const startMark = `${name}-start-${Date.now()}`; mark(startMark); try { const fnAny = /** @type {any} */ (fn); const result = fnAny.apply(this, args); // Handle promises if (result && typeof result.then === 'function') { return result.finally(() => { measure(name, startMark, undefined); }); } measure(name, startMark, undefined); return result; } catch (error) { measure(name, startMark, undefined); throw error; } }; }; /** * Time an async function execution * @param {string} name - Timer name * @param {Function} fn - Async function to time * @returns {Function} Wrapped async function */ const timeAsyncFunction = (name, fn) => { if (!PerformanceConfig.enabled) return fn; return /** @this {any} */ async function (...args) { const startMark = `${name}-start-${Date.now()}`; mark(startMark); try { const fnAny = /** @type {any} */ (fn); const result = await fnAny.apply(this, args); measure(name, startMark, undefined); return result; } catch (error) { measure(name, startMark, undefined); throw error; } }; }; /** * Record custom metric * @param {string} name - Metric name * @param {number} value - Metric value * @param {Object} metadata - Additional metadata */ const recordMetric = (name, value, metadata = {}) => { if (!PerformanceConfig.enabled) return; const metric = { name, value, timestamp: Date.now(), ...metadata, }; metrics.timings.set(name, metric); if (PerformanceConfig.enableConsoleOutput) { window.YouTubeUtils?.logger?.debug?.(`[YouTube+ Perf] ${name}: ${value}`, metadata); } }; /** * Get performance statistics * @param {string} metricName - Optional metric name filter * @returns {Object} Performance statistics */ const getStats = metricName => { if (metricName) { const filtered = metrics.measures.filter(m => m.name === metricName); if (filtered.length === 0) return null; const durations = filtered.map(m => m.duration); return { name: metricName, count: durations.length, min: Math.min(...durations), max: Math.max(...durations), avg: durations.reduce((a, b) => a + b, 0) / durations.length, latest: durations[durations.length - 1], }; } // Get all stats const allMetrics = {}; const metricNames = [...new Set(metrics.measures.map(m => m.name))]; metricNames.forEach(name => { allMetrics[name] = getStats(name); }); return { metrics: allMetrics, webVitals: { ...metrics.webVitals }, totalMeasures: metrics.measures.length, totalMarks: metrics.marks.size, customMetrics: Object.fromEntries(metrics.timings), }; }; /** * Get memory usage information * @returns {Object|null} Memory usage data */ const getMemoryUsage = () => { if (typeof performance === 'undefined' || !performance.memory) { return null; } try { const memory = performance.memory; return { usedJSHeapSize: memory.usedJSHeapSize, totalJSHeapSize: memory.totalJSHeapSize, jsHeapSizeLimit: memory.jsHeapSizeLimit, usedPercent: ((memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100).toFixed(2), }; } catch { return null; } }; /** * Track memory usage as a metric */ const trackMemory = () => { const memory = getMemoryUsage(); if (memory) { recordMetric('memory-usage', memory.usedJSHeapSize, { totalJSHeapSize: memory.totalJSHeapSize, usedPercent: memory.usedPercent, }); } }; /** * Check if metrics exceed thresholds * @param {Object} thresholds - Threshold configuration * @returns {Array} Array of threshold violations */ const checkThresholds = thresholds => { const violations = []; const allStats = getStats(undefined); if (!allStats || !allStats.metrics) return violations; Object.entries(thresholds).forEach(([metricName, threshold]) => { const stat = allStats.metrics[metricName]; if (stat && stat.avg > threshold) { violations.push({ metric: metricName, threshold, actual: stat.avg, exceeded: stat.avg - threshold, }); } }); return violations; }; /** * Export metrics to JSON * @returns {string} JSON string of metrics */ const exportMetrics = () => { const data = { timestamp: new Date().toISOString(), userAgent: navigator.userAgent, url: window.location.href, memory: getMemoryUsage(), stats: getStats(undefined), measures: metrics.measures, customMetrics: Object.fromEntries(metrics.timings), webVitals: metrics.webVitals, }; return JSON.stringify(data, null, 2); }; /** * Export metrics to downloadable file * @param {string} filename - Filename for export * @returns {boolean} Success status */ const exportToFile = (filename = 'youtube-plus-performance.json') => { try { const data = exportMetrics(); if (typeof Blob === 'undefined') { console.warn('[YouTube+ Perf] Blob API not available'); return false; } const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); return true; } catch (e) { console.error('[YouTube+ Perf] Failed to export to file:', e); return false; } }; /** * Aggregate metrics by time period * @param {number} periodMs - Time period in milliseconds * @returns {Array} Aggregated metrics */ const aggregateByPeriod = (periodMs = 60000) => { const periods = new Map(); metrics.measures.forEach(measure => { const periodStart = Math.floor(measure.timestamp / periodMs) * periodMs; if (!periods.has(periodStart)) { periods.set(periodStart, []); } periods.get(periodStart).push(measure); }); const aggregated = []; periods.forEach((measures, periodStart) => { const durations = measures.map(m => m.duration); aggregated.push({ period: new Date(periodStart).toISOString(), count: durations.length, min: Math.min(...durations), max: Math.max(...durations), avg: durations.reduce((a, b) => a + b, 0) / durations.length, }); }); return aggregated; }; /** * Clear all performance metrics */ const clearMetrics = () => { metrics.timings.clear(); metrics.marks.clear(); metrics.measures = []; metrics.resources = []; metrics.webVitals = { LCP: null, CLS: 0, FID: null, INP: null, FCP: null, TTFB: null, }; try { localStorage.removeItem(PerformanceConfig.storageKey); } catch {} if (typeof performance !== 'undefined' && performance.clearMarks) { try { performance.clearMarks(); performance.clearMeasures(); } catch {} } }; /** * Monitor DOM mutations performance * @param {Element} element - Element to monitor * @param {string} name - Monitor name * @returns {MutationObserver} The observer instance */ const monitorMutations = (element, name) => { if (!PerformanceConfig.enabled) return null; let mutationCount = 0; const startTime = Date.now(); const observer = new MutationObserver(mutations => { mutationCount += mutations.length; recordMetric(`${name}-mutations`, mutationCount, { elapsed: Date.now() - startTime, }); }); observer.observe(element, { childList: true, subtree: true, attributes: true, }); return observer; }; /** * Get browser performance entries * @param {string} type - Entry type filter * @returns {Array} Performance entries */ const getPerformanceEntries = type => { if (typeof performance === 'undefined' || !performance.getEntriesByType) { return []; } try { return performance.getEntriesByType(type); } catch { return []; } }; /** * Initialize Performance Observer for Web Vitals */ const initPerformanceObserver = () => { if (typeof PerformanceObserver === 'undefined') return; try { // Observe LCP new PerformanceObserver(entryList => { const entries = entryList.getEntries(); const lastEntry = entries[entries.length - 1]; metrics.webVitals.LCP = lastEntry.startTime; if (PerformanceConfig.enableConsoleOutput) { console.warn(`[YouTube+ Perf] LCP: ${lastEntry.startTime.toFixed(2)}ms`, lastEntry); } }).observe({ type: 'largest-contentful-paint', buffered: true }); // Observe CLS new PerformanceObserver(entryList => { for (const entry of entryList.getEntries()) { if (!entry.hadRecentInput) { metrics.webVitals.CLS += entry.value; } } if (PerformanceConfig.enableConsoleOutput && PerformanceConfig.logLevel === 'debug') { console.warn(`[YouTube+ Perf] CLS: ${metrics.webVitals.CLS.toFixed(4)}`); } }).observe({ type: 'layout-shift', buffered: true }); // Observe FID (First Input Delay) new PerformanceObserver(entryList => { const firstInput = entryList.getEntries()[0]; metrics.webVitals.FID = firstInput.processingStart - firstInput.startTime; if (PerformanceConfig.enableConsoleOutput) { console.warn(`[YouTube+ Perf] FID: ${metrics.webVitals.FID.toFixed(2)}ms`); } }).observe({ type: 'first-input', buffered: true }); // Observe INP (Interaction to Next Paint) - experimental try { new PerformanceObserver(entryList => { const entries = entryList.getEntries(); // Simplified INP calculation (just taking max duration for now) const maxDuration = Math.max(...entries.map(e => e.duration)); metrics.webVitals.INP = maxDuration; }).observe({ type: 'event', buffered: true, durationThreshold: 16 }); } catch (e) { void e; // INP might not be supported; reference `e` to satisfy linters } } catch (e) { console.warn('[YouTube+ Perf] Failed to init PerformanceObserver:', e); } }; /** * Log page load performance */ const logPageLoadMetrics = () => { if (!PerformanceConfig.enabled) return; try { const navigation = getPerformanceEntries('navigation')[0]; if (navigation) { recordMetric('page-load-time', navigation.loadEventEnd - navigation.fetchStart); recordMetric('dom-content-loaded', navigation.domContentLoadedEventEnd); recordMetric('dom-interactive', navigation.domInteractive); } } catch (e) { console.warn('[YouTube+ Perf] Failed to log page metrics:', e); } }; // Auto-log page load metrics if (typeof window !== 'undefined') { if (document.readyState === 'complete') { logPageLoadMetrics(); } else { window.addEventListener('load', logPageLoadMetrics, { once: true }); } // Initialize Web Vitals observers (only when enabled to reduce overhead) if (PerformanceConfig.enabled) { initPerformanceObserver(); } /** * RAF Scheduler for batched animations */ const RAFScheduler = (() => { let rafId = null; const callbacks = new Set(); const flush = () => { rafId = null; Array.from(callbacks).forEach(cb => { try { cb(); } catch (e) { console.error('[RAF] Error:', e); } }); callbacks.clear(); }; return { schedule: callback => { callbacks.add(callback); if (!rafId) rafId = requestAnimationFrame(flush); return () => callbacks.delete(callback); }, cancelAll: () => { if (rafId) cancelAnimationFrame(rafId); rafId = null; callbacks.clear(); }, }; })(); /** * Lazy Loader using Intersection Observer */ const LazyLoader = (() => { const observers = new Map(); return { create: (options = {}) => { const { root = null, rootMargin = '50px', threshold = 0.01, onIntersect } = options; const observer = new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.isIntersecting) { onIntersect(entry.target, entry); observer.unobserve(entry.target); } }); }, { root, rootMargin, threshold } ); observers.set(observer, new Set()); return { observe: el => { if (el instanceof Element) { observer.observe(el); observers.get(observer).add(el); } }, unobserve: el => { if (el instanceof Element) { observer.unobserve(el); observers.get(observer)?.delete(el); } }, disconnect: () => { observer.disconnect(); observers.delete(observer); }, }; }, disconnectAll: () => { observers.forEach((_, o) => o.disconnect()); observers.clear(); }, }; })(); /** * DOM Batcher for efficient DOM mutations */ const DOMBatcher = (() => { const batches = new Map(); return { batch: (container, elements) => { if (!batches.has(container)) batches.set(container, []); batches.get(container).push(...elements); }, flush: () => { RAFScheduler.schedule(() => { batches.forEach((elements, container) => { if (!container.isConnected) { batches.delete(container); return; } const frag = document.createDocumentFragment(); elements.forEach(el => frag.appendChild(el)); container.appendChild(frag); }); batches.clear(); }); }, clear: container => batches.delete(container), }; })(); /** * Element Cache using WeakMap (auto garbage collected) */ const ElementCache = (() => { const cache = new WeakMap(); return { get: (el, key) => cache.get(el)?.[key], set: (el, key, val) => { let data = cache.get(el); if (!data) { data = {}; cache.set(el, data); } data[key] = val; }, has: (el, key) => { const data = cache.get(el); return data ? key in data : false; }, delete: (el, key) => { const data = cache.get(el); if (data) delete data[key]; }, }; })(); // Expose performance monitoring API window.YouTubePerformance = { mark, measure, timeFunction, timeAsyncFunction, recordMetric, getStats, exportMetrics, exportToFile, clearMetrics, monitorMutations, getPerformanceEntries, getMemoryUsage, trackMemory, checkThresholds, aggregateByPeriod, config: PerformanceConfig, RAFScheduler, LazyLoader, DOMBatcher, ElementCache, }; /** * Yield to main thread to improve INP * Uses scheduler.yield() if available, falls back to setTimeout * @returns {Promise} */ const yieldToMain = () => { return new Promise(resolve => { if ('scheduler' in window && typeof window.scheduler?.yield === 'function') { window.scheduler.yield().then(resolve); } else { setTimeout(resolve, 0); } }); }; /** * Break up long tasks into smaller chunks to improve INP * @param {Array} tasks - Array of task functions * @param {number} [yieldInterval=50] - Yield after this many ms * @returns {Promise} */ const runChunkedTasks = async (tasks, yieldInterval = 50) => { let lastYield = performance.now(); for (const task of tasks) { task(); const now = performance.now(); if (now - lastYield > yieldInterval) { await yieldToMain(); lastYield = performance.now(); } } }; /** * Wrap event handler to yield periodically for better INP * @param {Function} handler - Original event handler * @param {Object} [options] - Options * @param {number} [options.maxBlockTime=50] - Max time to block before yielding * @returns {Function} Wrapped handler */ const wrapForINP = (handler, options = {}) => { const { maxBlockTime = 50 } = options; return async function (...args) { const start = performance.now(); let result; try { result = handler.apply(this, args); // If handler returns a promise, wait for it if (result && typeof result.then === 'function') { result = await result; } } finally { const elapsed = performance.now() - start; if (elapsed > maxBlockTime) { // Record long task for debugging recordMetric('long-task', elapsed, { handler: handler.name || 'anonymous' }); } } return result; }; }; // Add INP helpers to global API window.YouTubePerformance.yieldToMain = yieldToMain; window.YouTubePerformance.runChunkedTasks = runChunkedTasks; window.YouTubePerformance.wrapForINP = wrapForINP; // ─── LCP Optimization Suite ──────────────────────────────────────────────── // Target: Main page <5s, Video page <3.5s, Playlist page <3.5s /** * 1. Resource Hints - preconnect to YouTube CDN origins * Shaves 100-300ms from first resource fetch on each origin. */ const injectResourceHints = () => { const origins = [ 'https://www.youtube.com', 'https://i.ytimg.com', // Thumbnails (LCP candidate) 'https://yt3.ggpht.com', // Channel avatars 'https://fonts.googleapis.com', // Fonts 'https://www.gstatic.com', // Static resources 'https://play.google.com', // Play store resources ]; const head = document.head; if (!head) return; const existingHrefs = new Set(); head.querySelectorAll('link[rel="preconnect"]').forEach(el => { existingHrefs.add(el.href); }); for (const origin of origins) { if (existingHrefs.has(origin) || existingHrefs.has(origin + '/')) continue; const link = document.createElement('link'); link.rel = 'preconnect'; link.href = origin; link.crossOrigin = 'anonymous'; head.appendChild(link); } }; /** * 2. LCP Element Priority Boost * Set fetchpriority="high" on the LCP element (main video thumbnail / player poster). */ const boostLCPElement = () => { const path = location.pathname; let lcpSelector; if (path === '/watch' || path.startsWith('/shorts/')) { // Video page: player poster or first video frame lcpSelector = '#movie_player .ytp-cued-thumbnail-overlay-image, #movie_player video, ytd-player #ytd-player .html5-video-container'; } else if (path === '/playlist') { // Playlist page: first visible thumbnail lcpSelector = 'ytd-playlist-video-renderer:first-child img.yt-core-image'; } else { // Main page: first visible rich item thumbnail lcpSelector = 'ytd-rich-item-renderer:first-child img.yt-core-image, ytd-rich-grid-media img.yt-core-image'; } if (!lcpSelector) return; requestAnimationFrame(() => { const el = document.querySelector(lcpSelector); if (el && el.tagName === 'IMG') { el.setAttribute('fetchpriority', 'high'); el.setAttribute('loading', 'eager'); // Remove lazy loading if set by YouTube if (el.loading === 'lazy') el.loading = 'eager'; } }); }; /** * 3. Content-Visibility CSS for off-screen sections * Dramatically reduces initial render work by skipping layout/paint for below-the-fold. */ const injectContentVisibilityCSS = () => { const cssId = 'ytp-perf-content-visibility'; if (document.getElementById(cssId)) return; const css = ` /* ── YouTube+ LCP Performance Optimizations ── */ /* Off-screen section rendering deferral */ ytd-comments#comments { content-visibility: auto; contain-intrinsic-size: auto 800px; } #secondary ytd-compact-video-renderer:nth-child(n+6) { content-visibility: auto; contain-intrinsic-size: auto 94px; } ytd-watch-next-secondary-results-renderer ytd-item-section-renderer { content-visibility: auto; contain-intrinsic-size: auto 600px; } /* Main/browse feed - defer items below first viewport */ ytd-rich-grid-renderer #contents > ytd-rich-item-renderer:nth-child(n+9) { content-visibility: auto; contain-intrinsic-size: auto 360px; } ytd-section-list-renderer > #contents > ytd-item-section-renderer:nth-child(n+3) { content-visibility: auto; contain-intrinsic-size: auto 500px; } /* Playlist page - defer items beyond visible viewport */ ytd-playlist-video-list-renderer #contents > ytd-playlist-video-renderer:nth-child(n+12) { content-visibility: auto; contain-intrinsic-size: auto 90px; } /* Note: contain:layout is intentionally omitted here — it breaks position:sticky for chips-wrapper and tabs-container on browse/channel pages. */ /* Guide sidebar - not needed for LCP */ ytd-mini-guide-renderer { content-visibility: auto; contain-intrinsic-size: auto 100vh; } tp-yt-app-drawer#guide { content-visibility: auto; contain-intrinsic-size: 240px 100vh; } /* Below-the-fold metadata */ ytd-watch-metadata #description { content-visibility: auto; contain-intrinsic-size: auto 120px; } ytd-structured-description-content-renderer { content-visibility: auto; contain-intrinsic-size: auto 200px; } /* Shorts shelf on browse pages */ ytd-reel-shelf-renderer { content-visibility: auto; contain-intrinsic-size: auto 320px; } /* Comments container on main watch - contain:style only, not layout (preserves sticky) */ ytd-item-section-renderer#sections { contain: style; } /* Reduce paint complexity for non-visible items */ ytd-rich-grid-row:nth-child(n+4) { content-visibility: auto; contain-intrinsic-size: auto 240px; } /* Engagement panels - safe deferral only when fully hidden */ ytd-engagement-panel-section-list-renderer[visibility="ENGAGEMENT_PANEL_VISIBILITY_HIDDEN"] { content-visibility: auto; contain-intrinsic-size: auto 0px; } /* Optimize image decoding */ ytd-thumbnail img, yt-image img, .yt-core-image { content-visibility: auto; } `; const style = document.createElement('style'); style.id = cssId; style.textContent = css; (document.head || document.documentElement).appendChild(style); }; /** * 4. Deferred Image Loading * Lazy-load images below the fold using IntersectionObserver. */ const setupDeferredImageLoading = () => { const imgObserver = new IntersectionObserver( entries => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target; const dataSrc = img.getAttribute('data-ytp-deferred-src'); if (dataSrc) { img.src = dataSrc; img.removeAttribute('data-ytp-deferred-src'); } imgObserver.unobserve(img); } } }, { rootMargin: '200px 0px' } ); // Observe below-fold thumbnail images const observeImages = () => { const belowFold = document.querySelectorAll( 'ytd-rich-item-renderer:nth-child(n+5) img[src]:not([data-ytp-img-observed]),' + 'ytd-compact-video-renderer:nth-child(n+4) img[src]:not([data-ytp-img-observed])' ); belowFold.forEach(img => { img.setAttribute('data-ytp-img-observed', '1'); }); }; // Run periodically to catch new items let imgTimer = null; const scheduleObserve = () => { if (imgTimer) return; imgTimer = setTimeout(() => { imgTimer = null; observeImages(); }, 500); }; window.addEventListener('yt-navigate-finish', scheduleObserve, { passive: true }); if (document.readyState !== 'loading') { scheduleObserve(); } else { document.addEventListener('DOMContentLoaded', scheduleObserve, { once: true }); } }; /** * 5. MutationObserver Optimization * Provides a shared, debounced MutationObserver to reduce overhead * from multiple independent subtree observers. */ const SharedMutationManager = (() => { let observer = null; const callbacks = new Map(); // key -> {callback, filter} let scheduled = false; const pending = []; const flush = () => { scheduled = false; const entries = [...pending]; pending.length = 0; for (const [, { callback, filter }] of callbacks) { const filtered = filter ? entries.filter(filter) : entries; if (filtered.length > 0) { try { callback(filtered); } catch (e) { console.warn('[YouTube+ Perf] SharedMutation callback error:', e); } } } }; const start = () => { if (observer) return; observer = new MutationObserver(mutations => { pending.push(...mutations); if (!scheduled) { scheduled = true; // Use microtask for fast batching without losing responsiveness queueMicrotask(flush); } }); const target = document.body || document.documentElement; if (target) { observer.observe(target, { childList: true, subtree: true }); } }; return { /** * Register a callback for shared mutation observation. * @param {string} key - Unique key * @param {Function} callback - Called with filtered mutations * @param {Function} [filter] - Optional filter for mutations */ register(key, callback, filter) { callbacks.set(key, { callback, filter }); if (callbacks.size === 1) start(); }, unregister(key) { callbacks.delete(key); if (callbacks.size === 0 && observer) { observer.disconnect(); observer = null; } }, getCallbackCount: () => callbacks.size, }; })(); /** * 6. Idle-time Task Scheduler * Schedules non-critical initialization to idle periods. */ const IdleScheduler = (() => { const queue = []; let running = false; const processQueue = deadline => { while (queue.length > 0 && (deadline ? deadline.timeRemaining() > 5 : true)) { const task = queue.shift(); try { task.fn(); } catch (e) { console.warn('[YouTube+ Perf] Idle task error:', e); } if (!deadline) break; // Without deadline, run one task per iteration } if (queue.length > 0) { scheduleNext(); } else { running = false; } }; const scheduleNext = () => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(processQueue, { timeout: 3000 }); } else { setTimeout(() => processQueue(null), 50); } }; return { /** * Schedule a task for idle execution. * @param {Function} fn - Task function * @param {number} [priority=0] - Higher = runs first */ schedule(fn, priority = 0) { queue.push({ fn, priority }); queue.sort((a, b) => b.priority - a.priority); if (!running) { running = true; scheduleNext(); } }, /** Get number of pending tasks */ pending: () => queue.length, }; })(); /** * 7. Long Task monitoring (via PerformanceObserver) * Helps identify blocking scripts beyond 50ms. */ const initLongTaskMonitor = () => { if (typeof PerformanceObserver === 'undefined') return; try { const longTasks = []; new PerformanceObserver(list => { for (const entry of list.getEntries()) { longTasks.push({ duration: entry.duration, startTime: entry.startTime, name: entry.name, }); if (longTasks.length > 50) longTasks.shift(); } recordMetric('long-tasks-count', longTasks.length); const totalBlocking = longTasks.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0); recordMetric('total-blocking-time', totalBlocking); }).observe({ type: 'longtask', buffered: true }); } catch { // longtask observer not supported } }; /** * 8. Navigation-aware performance tracking * Reset and re-measure on YouTube SPA navigations. */ const initNavigationTracking = () => { window.addEventListener( 'yt-navigate-start', () => { mark('yt-navigate-start'); }, { passive: true } ); window.addEventListener( 'yt-navigate-finish', () => { mark('yt-navigate-finish'); measure('yt-navigation-duration', 'yt-navigate-start'); // Re-boost LCP for new page requestAnimationFrame(() => { boostLCPElement(); }); }, { passive: true } ); }; /** * Initialize all LCP optimizations */ const initLCPOptimizations = () => { try { // Critical (run immediately - biggest LCP impact) injectResourceHints(); injectContentVisibilityCSS(); boostLCPElement(); // High priority (run in next microtask) queueMicrotask(() => { initNavigationTracking(); initLongTaskMonitor(); }); // Lower priority (defer to idle) IdleScheduler.schedule(() => setupDeferredImageLoading(), 2); } catch (e) { console.warn('[YouTube+ Perf] LCP optimization init error:', e); } }; // Run LCP optimizations immediately initLCPOptimizations(); // Expose new performance APIs window.YouTubePerformance.SharedMutationManager = SharedMutationManager; window.YouTubePerformance.IdleScheduler = IdleScheduler; window.YouTubePerformance.boostLCPElement = boostLCPElement; window.YouTubePerformance.injectResourceHints = injectResourceHints; window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+] Performance monitoring initialized'); } })(); // --- MODULE: dom-cache.js --- // DOM Query Cache System - Performance Optimization (Enhanced) (function () { 'use strict'; /** * High-performance DOM query cache with automatic invalidation * Reduces repeated querySelector calls by caching results */ class DOMCache { constructor() { /** @type {Map} */ this.cache = new Map(); /** @type {Map} */ this.multiCache = new Map(); this.maxAge = 5000; // Cache TTL: 5 seconds this.nullMaxAge = 1000; // Cache TTL for null/empty results: 1 s (was 250 ms). // Most modules react to DOM changes via MutationObserver or yt-navigate-finish, // so a 1-second stale window for "not found" entries is safe and cuts // repeated querySelector calls by ~75 % for elements absent from the page. this.maxSize = 500; // Max cache entries this.cleanupInterval = null; this.enabled = true; // Statistics this.stats = { hits: 0, misses: 0, evictions: 0 }; this.contextUids = new WeakMap(); this.uidCounter = 0; // Shared MutationObserver for waitForElement this.observerCallbacks = new Set(); this.sharedObserver = null; this.sharedObserverPending = false; // Start periodic cleanup this.startCleanup(); } getContextUid(ctx) { if (ctx === document) return 'doc'; let uid = this.contextUids.get(ctx); if (!uid) { uid = ++this.uidCounter; this.contextUids.set(ctx, uid); } return uid; } /** * Get single element with caching * @param {string} selector - CSS selector * @param {Element|Document} [context=document] - Context element * @param {boolean} [skipCache=false] - Skip cache and force fresh query * @returns {Element|null} */ querySelector(selector, context = document, skipCache = false) { if (!this.enabled || skipCache) { return context.querySelector(selector); } const cacheKey = `${selector}::${this.getContextUid(context)}`; const cached = this.cache.get(cacheKey); const now = Date.now(); // Determine TTL based on cached value const ttl = cached && cached.element ? this.maxAge : this.nullMaxAge; // Return cached result if valid and element still in DOM if (cached && now - cached.timestamp < ttl) { if (cached.element) { if (this.isElementInDOM(cached.element)) { this.stats.hits++; return cached.element; } } else { // Return cached null this.stats.hits++; return null; } } // Track miss this.stats.misses++; // LRU eviction if cache too large if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); this.stats.evictions++; } // Query and cache const element = context.querySelector(selector); this.cache.set(cacheKey, { element, timestamp: now }); return element; } /** * Get multiple elements with caching * @param {string} selector - CSS selector * @param {Element|Document} [context=document] - Context element * @param {boolean} [skipCache=false] - Skip cache and force fresh query * @returns {NodeList|Element[]} */ querySelectorAll(selector, context = document, skipCache = false) { if (!this.enabled || skipCache) { return context.querySelectorAll(selector); } const cacheKey = `ALL::${selector}::${this.getContextUid(context)}`; const cached = this.multiCache.get(cacheKey); if (cached && this.areElementsValid(cached)) { return cached; } const elements = Array.from(context.querySelectorAll(selector)); this.multiCache.set(cacheKey, elements); // Auto-cleanup after maxAge or nullMaxAge const ttl = elements.length > 0 ? this.maxAge : this.nullMaxAge; setTimeout(() => this.multiCache.delete(cacheKey), ttl); return elements; } /** * Get element by ID with caching * @param {string} id - Element ID * @returns {Element|null} */ getElementById(id) { if (!this.enabled) { return document.getElementById(id); } const cacheKey = `ID::${id}`; const cached = this.cache.get(cacheKey); const now = Date.now(); if (cached && now - cached.timestamp < this.maxAge) { if (cached.element && this.isElementInDOM(cached.element)) { return cached.element; } } const element = document.getElementById(id); this.cache.set(cacheKey, { element, timestamp: now }); return element; } /** * Check if element is still in DOM * @param {Element} element * @returns {boolean} */ isElementInDOM(element) { return element && document.contains(element); } /** * Check if cached elements are still valid * @param {Element[]} elements * @returns {boolean} */ areElementsValid(elements) { if (!elements || elements.length === 0) return true; // Sample first and last elements for performance return this.isElementInDOM(elements[0]) && this.isElementInDOM(elements[elements.length - 1]); } /** * Invalidate cache for specific selector or all * @param {string} [selector] - Specific selector to invalidate */ invalidate(selector) { if (selector) { // Invalidate specific selector for (const key of this.cache.keys()) { if (key.includes(selector)) { this.cache.delete(key); } } for (const key of this.multiCache.keys()) { if (key.includes(selector)) { this.multiCache.delete(key); } } } else { // Clear all cache this.cache.clear(); this.multiCache.clear(); } } /** * Start periodic cache cleanup */ startCleanup() { if (this.cleanupInterval) return; // Use requestIdleCallback if available for cleanup to avoid blocking main thread const cleanupFn = () => { const now = Date.now(); let deletedCount = 0; const maxDeletesPerRun = 50; // Limit work per frame // Cleanup single element cache for (const [key, value] of this.cache.entries()) { if ( now - value.timestamp > this.maxAge || (value.element && !this.isElementInDOM(value.element)) ) { this.cache.delete(key); deletedCount++; if (deletedCount >= maxDeletesPerRun) break; } } }; this.cleanupInterval = setInterval(() => { if (typeof requestIdleCallback !== 'undefined') { requestIdleCallback(cleanupFn, { timeout: 1000 }); } else { cleanupFn(); } }, 5000); // Run every 5 seconds } /** * Stop cache cleanup and clear all caches */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.cache.clear(); this.multiCache.clear(); if (this.sharedObserver) { this.sharedObserver.disconnect(); this.sharedObserver = null; } this.observerCallbacks.clear(); } /** * Get cache statistics * @returns {{size: number, multiSize: number, enabled: boolean}} */ getStats() { return { size: this.cache.size, multiSize: this.multiCache.size, enabled: this.enabled, }; } /** * Initialize shared observer for waitForElement */ initSharedObserver() { if (this.sharedObserver) return; this.sharedObserver = new MutationObserver(() => { if (this.observerCallbacks.size === 0) return; if (this.sharedObserverPending) return; this.sharedObserverPending = true; const flush = () => { this.sharedObserverPending = false; for (const callback of this.observerCallbacks) { try { callback(); } catch { // Ignore callback errors to avoid breaking other observers } } }; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(flush); } else { setTimeout(flush, 0); } }); this.sharedObserver.observe(document.body || document.documentElement, { childList: true, subtree: true, }); } } /** * Scoped DOM cache for specific contexts (e.g., player, secondary) */ class ScopedDOMCache { constructor() { /** @type {Map>} */ this.scopedCaches = new Map(); } /** * Get or create cache for a scope * @param {string} scope - Scope identifier * @returns {WeakMap} */ getScope(scope) { if (!this.scopedCaches.has(scope)) { this.scopedCaches.set(scope, new WeakMap()); } return this.scopedCaches.get(scope); } /** * Cache element in scope * @param {string} scope - Scope identifier * @param {Element} element - Element to cache * @param {any} value - Value to cache */ set(scope, element, value) { this.getScope(scope).set(element, value); } /** * Get cached value from scope * @param {string} scope - Scope identifier * @param {Element} element - Element key * @returns {any} */ get(scope, element) { return this.getScope(scope).get(element); } /** * Check if element exists in scope * @param {string} scope - Scope identifier * @param {Element} element - Element key * @returns {boolean} */ has(scope, element) { return this.getScope(scope).has(element); } } /** * Optimized selector patterns for common YouTube elements */ const OptimizedSelectors = { // Player elements player: '#movie_player', video: 'video.video-stream.html5-main-video', videoAlt: '#movie_player video', chromeBottom: '.ytp-chrome-bottom', // Watch page elements watchFlexy: 'ytd-watch-flexy', secondary: '#secondary', rightTabs: '#right-tabs', playlistPanel: 'ytd-playlist-panel-renderer', // Tab elements tabInfo: '#tab-info', tabComments: '#tab-comments', tabVideos: '#tab-videos', // Buttons and controls likeButton: 'like-button-view-model button', dislikeButton: 'dislike-button-view-model button', subscribeButton: '#subscribe-button', // Shorts elements shorts: 'ytd-shorts', activeReel: 'ytd-reel-video-renderer[is-active]', // Common containers masthead: 'ytd-masthead', ytdApp: 'ytd-app', }; /** * Batch query executor - executes multiple queries in parallel * @param {Array<{selector: string, multi?: boolean, context?: Element}>} queries * @returns {Array} */ function batchQuery(queries) { return queries.map(({ selector, multi = false, context = document }) => { if (multi) { return Array.from(context.querySelectorAll(selector)); } return context.querySelector(selector); }); } // Create global instances const globalCache = new DOMCache(); const scopedCache = new ScopedDOMCache(); /** * Wait for element to appear in DOM (Optimized) * @param {string} selector - CSS selector * @param {number} [timeout=5000] - Timeout in milliseconds * @param {Element} [context=document] - Context element * @returns {Promise} */ function waitForElement(selector, timeout = 5000, context = document) { return new Promise(resolve => { const existing = context.querySelector(selector); if (existing) { resolve(existing); return; } const isPlaylistPage = typeof window !== 'undefined' && window.location && typeof window.location.pathname === 'string' && window.location.pathname === '/playlist'; // On heavy playlist pages (WL/LL), MutationObserver(subtree) can become very expensive. // Prefer lightweight polling here to avoid reacting to the large volume of DOM mutations. if (isPlaylistPage && (context === document || context === document.body)) { const interval = 250; const start = Date.now(); const timerId = setInterval(() => { const element = context.querySelector(selector); if (element) { clearInterval(timerId); resolve(element); return; } if (Date.now() - start >= timeout) { clearInterval(timerId); resolve(null); } }, interval); return; } // Use shared observer if context is document/body const useShared = context === document || context === document.body; if (useShared) { globalCache.initSharedObserver(); const checkCallback = () => { const element = context.querySelector(selector); if (element) { globalCache.observerCallbacks.delete(checkCallback); resolve(element); return true; } return false; }; globalCache.observerCallbacks.add(checkCallback); setTimeout(() => { globalCache.observerCallbacks.delete(checkCallback); resolve(null); }, timeout); } else { // Fallback to local observer for specific contexts const observer = new MutationObserver(() => { const element = context.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); observer.observe(context, { childList: true, subtree: true, }); setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); } }); } // Export to global namespace if (typeof window !== 'undefined') { window.YouTubeDOMCache = globalCache; window.YouTubeScopedCache = scopedCache; window.YouTubeSelectors = OptimizedSelectors; window.batchQueryDOM = batchQuery; window.waitForElement = waitForElement; // Also add to YouTubeUtils if available if (window.YouTubeUtils) { window.YouTubeUtils.domCache = globalCache; window.YouTubeUtils.scopedCache = scopedCache; window.YouTubeUtils.selectors = OptimizedSelectors; window.YouTubeUtils.batchQuery = batchQuery; window.YouTubeUtils.waitFor = waitForElement; } } // Invalidate cache on navigation if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('yt-navigate-finish', () => { globalCache.invalidate(); }); // Also invalidate on SPF navigation (older YouTube) window.addEventListener('spfdone', () => { globalCache.invalidate(); }); } // Cleanup on unload if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('beforeunload', () => { globalCache.destroy(); }); } })(); // --- MODULE: event-delegation.js --- // Event Delegation System - Performance Optimization (function () { 'use strict'; /** * Event delegation manager for performance optimization * Reduces number of event listeners by delegating to common ancestors */ class EventDelegator { constructor() { /** @type {Map>>} */ this.delegatedHandlers = new Map(); /** @type {Map}>} */ this.registeredDelegators = new Map(); this.stats = { totalDelegations: 0, totalHandlers: 0 }; } /** * Delegate event handler to a parent element * @param {Element} parent - Parent element to attach delegated listener * @param {string} eventType - Event type (click, input, etc.) * @param {string} selector - CSS selector to match target elements * @param {Function} handler - Handler function(event, matchedElement) * @param {Object} [options] - Event listener options */ delegate(parent, eventType, selector, handler, options = {}) { if (!parent || !eventType || !selector || !handler) { console.warn('[EventDelegator] Invalid parameters'); return; } // Create cache key const parentKey = this._getElementKey(parent); const delegationKey = `${parentKey}:${eventType}`; // Initialize structures if (!this.delegatedHandlers.has(delegationKey)) { this.delegatedHandlers.set(delegationKey, new Map()); } const handlersForSelector = this.delegatedHandlers.get(delegationKey); if (!handlersForSelector.has(selector)) { handlersForSelector.set(selector, new Set()); } // Add handler handlersForSelector.get(selector).add(handler); this.stats.totalHandlers++; // Create or get delegated listener if (!this.registeredDelegators.has(parent)) { this.registeredDelegators.set(parent, new Map()); } const parentDelegators = this.registeredDelegators.get(parent); if (!parentDelegators.has(eventType)) { const delegatedListener = event => { this._handleDelegatedEvent(parent, eventType, event); }; parent.addEventListener(eventType, delegatedListener, options); parentDelegators.set(eventType, delegatedListener); this.stats.totalDelegations++; window.YouTubeUtils?.logger?.debug?.( `[EventDelegator] Created delegation on ${parentKey} for ${eventType}` ); } } /** * Remove delegated event handler * @param {Element} parent - Parent element * @param {string} eventType - Event type * @param {string} selector - CSS selector * @param {Function} handler - Handler function to remove */ undelegate(parent, eventType, selector, handler) { const parentKey = this._getElementKey(parent); const delegationKey = `${parentKey}:${eventType}`; const handlersForSelector = this.delegatedHandlers.get(delegationKey); if (!handlersForSelector) return; const handlers = handlersForSelector.get(selector); if (!handlers) return; handlers.delete(handler); this.stats.totalHandlers--; // Clean up if no handlers left if (handlers.size === 0) { handlersForSelector.delete(selector); } if (handlersForSelector.size === 0) { this._removeParentListener(parent, eventType); this.delegatedHandlers.delete(delegationKey); } } /** * Handle delegated event and dispatch to matching handlers * @private */ _handleDelegatedEvent(parent, eventType, event) { const parentKey = this._getElementKey(parent); const delegationKey = `${parentKey}:${eventType}`; const handlersForSelector = this.delegatedHandlers.get(delegationKey); if (!handlersForSelector) return; // Check each selector for matches for (const [selector, handlers] of handlersForSelector.entries()) { // Find closest matching element const target = event.target.closest(selector); if (target && parent.contains(target)) { // Execute all handlers for this selector for (const handler of handlers) { try { handler.call(target, event, target); } catch (error) { console.error('[EventDelegator] Handler error:', error); window.YouTubeUtils?.logger?.error?.('[EventDelegator] Handler error', error); } } } } } /** * Remove parent listener * @private */ _removeParentListener(parent, eventType) { const parentDelegators = this.registeredDelegators.get(parent); if (!parentDelegators) return; const listener = parentDelegators.get(eventType); if (listener) { parent.removeEventListener(eventType, listener); parentDelegators.delete(eventType); this.stats.totalDelegations--; } if (parentDelegators.size === 0) { this.registeredDelegators.delete(parent); } } /** * Get unique key for element * @private */ _getElementKey(element) { if (element === document) return 'document'; if (element === window) return 'window'; if (element === document.body) return 'body'; return ( element.id || element.className || element.tagName || `elem_${Math.random().toString(36).substr(2, 9)}` ); } /** * Get statistics */ getStats() { return { ...this.stats, uniqueDelegations: this.registeredDelegators.size, delegationKeys: this.delegatedHandlers.size, }; } /** * Clear all delegations */ clear() { for (const [parent, delegators] of this.registeredDelegators.entries()) { for (const [eventType, listener] of delegators.entries()) { parent.removeEventListener(eventType, listener); } } this.delegatedHandlers.clear(); this.registeredDelegators.clear(); this.stats = { totalDelegations: 0, totalHandlers: 0 }; } } // Create global instance const eventDelegator = new EventDelegator(); /** * Convenience wrapper for delegation * @param {Element} parent - Parent element * @param {string} eventType - Event type * @param {string} selector - CSS selector * @param {Function} handler - Handler function * @param {Object} [options] - Event listener options */ const on = (parent, eventType, selector, handler, options) => { eventDelegator.delegate(parent, eventType, selector, handler, options); }; /** * Remove delegated handler * @param {Element} parent - Parent element * @param {string} eventType - Event type * @param {string} selector - CSS selector * @param {Function} handler - Handler function */ const off = (parent, eventType, selector, handler) => { eventDelegator.undelegate(parent, eventType, selector, handler); }; // Export to window if (typeof window !== 'undefined') { window.YouTubePlusEventDelegation = { EventDelegator, on, off, getStats: () => eventDelegator.getStats(), clear: () => eventDelegator.clear(), }; } if (typeof module !== 'undefined' && module.exports) { module.exports = { EventDelegator, on, off }; } })(); // --- MODULE: lazy-loader.js --- // Lazy Loading System - Performance Optimization (function () { 'use strict'; /** * Lazy loading manager for non-critical features * Defers initialization to improve initial load performance */ class LazyLoader { constructor() { /** @type {Map} */ this.modules = new Map(); /** @type {Set} */ this.loadedModules = new Set(); this.stats = { totalModules: 0, loadedModules: 0 }; this.isIdle = false; this.idleCallbackId = null; } /** * Register a module for lazy loading * @param {string} name - Module name * @param {Function} fn - Function to execute when loaded * @param {Object} [options] - Loading options * @param {number} [options.priority=0] - Priority (higher = loads first) * @param {number} [options.delay=0] - Delay before loading (ms) * @param {string[]} [options.dependencies=[]] - Module dependencies */ register(name, fn, options = {}) { if (this.modules.has(name)) { window.YouTubeUtils?.logger?.warn?.(`[LazyLoader] Module "${name}" already registered`); return; } const moduleConfig = { fn, priority: options.priority || 0, delay: options.delay || 0, dependencies: options.dependencies || [], loaded: false, }; this.modules.set(name, moduleConfig); this.stats.totalModules++; window.YouTubeUtils?.logger?.debug?.( `[LazyLoader] Registered module "${name}" (priority: ${moduleConfig.priority})` ); } /** * Load a specific module * @param {string} name - Module name * @returns {Promise} Success status */ async load(name) { const module = this.modules.get(name); if (!module) { window.YouTubeUtils?.logger?.warn?.(`[LazyLoader] Module "${name}" not found`); return false; } if (module.loaded) { window.YouTubeUtils?.logger?.debug?.(`[LazyLoader] Module "${name}" already loaded`); return true; } // Check dependencies for (const dep of module.dependencies) { if (!this.loadedModules.has(dep)) { window.YouTubeUtils?.logger?.debug?.( `[LazyLoader] Loading dependency "${dep}" for "${name}"` ); await this.load(dep); } } // Apply delay if specified if (module.delay > 0) { await new Promise(resolve => setTimeout(resolve, module.delay)); } try { window.YouTubeUtils?.logger?.debug?.(`[LazyLoader] Loading module "${name}"`); const startTime = performance.now(); await module.fn(); const loadTime = performance.now() - startTime; window.YouTubeUtils?.logger?.debug?.( `[LazyLoader] Module "${name}" loaded in ${loadTime.toFixed(2)}ms` ); module.loaded = true; this.loadedModules.add(name); this.stats.loadedModules++; return true; } catch (error) { console.error(`[LazyLoader] Failed to load module "${name}":`, error); window.YouTubeUtils?.logger?.error?.(`[LazyLoader] Module "${name}" load failed`, error); return false; } } /** * Load all registered modules by priority * @returns {Promise} Number of modules loaded */ async loadAll() { // Sort modules by priority (highest first) const sortedModules = Array.from(this.modules.entries()).sort( (a, b) => b[1].priority - a[1].priority ); let loadedCount = 0; for (const [name, module] of sortedModules) { if (!module.loaded) { const success = await this.load(name); if (success) loadedCount++; } } return loadedCount; } /** * Load modules when browser is idle * @param {number} [timeout=2000] - Timeout for requestIdleCallback */ loadOnIdle(timeout = 2000) { if (this.isIdle) { window.YouTubeUtils?.logger?.debug?.('[LazyLoader] Idle loading already scheduled'); return; } this.isIdle = true; const loadModules = async () => { window.YouTubeUtils?.logger?.debug?.('[LazyLoader] Starting idle loading'); const count = await this.loadAll(); window.YouTubeUtils?.logger?.debug?.(`[LazyLoader] Loaded ${count} modules during idle`); }; // Use requestIdleCallback if available, otherwise setTimeout if (typeof requestIdleCallback !== 'undefined') { this.idleCallbackId = requestIdleCallback(loadModules, { timeout }); } else { this.idleCallbackId = setTimeout(loadModules, timeout); } } /** * Cancel idle loading */ cancelIdleLoading() { if (!this.isIdle) return; if (typeof window.cancelIdleCallback !== 'undefined' && this.idleCallbackId) { window.cancelIdleCallback(this.idleCallbackId); } else if (this.idleCallbackId) { clearTimeout(this.idleCallbackId); } this.isIdle = false; this.idleCallbackId = null; } /** * Check if module is loaded * @param {string} name - Module name * @returns {boolean} */ isLoaded(name) { return this.loadedModules.has(name); } /** * Get loading statistics * @returns {Object} Statistics object */ getStats() { return { ...this.stats, loadingPercentage: this.stats.totalModules > 0 ? (this.stats.loadedModules / this.stats.totalModules) * 100 : 0, unloadedModules: this.stats.totalModules - this.stats.loadedModules, }; } /** * Clear all modules */ clear() { this.cancelIdleLoading(); this.modules.clear(); this.loadedModules.clear(); this.stats = { totalModules: 0, loadedModules: 0 }; } } // Create global instance const lazyLoader = new LazyLoader(); // Export to window if (typeof window !== 'undefined') { window.YouTubePlusLazyLoader = { LazyLoader, register: (name, fn, options) => lazyLoader.register(name, fn, options), load: name => lazyLoader.load(name), loadAll: () => lazyLoader.loadAll(), loadOnIdle: timeout => lazyLoader.loadOnIdle(timeout), isLoaded: name => lazyLoader.isLoaded(name), getStats: () => lazyLoader.getStats(), clear: () => lazyLoader.clear(), }; } if (typeof module !== 'undefined' && module.exports) { module.exports = { LazyLoader }; } })(); // --- MODULE: main.js --- /* eslint-disable no-console */ if (typeof trustedTypes !== 'undefined' && trustedTypes.defaultPolicy == null) { const s = s => s; trustedTypes.createPolicy('default', { createHTML: s, createScriptURL: s, createScript: s }); } const defaultPolicy = (typeof trustedTypes !== 'undefined' && trustedTypes.defaultPolicy) || { createHTML: s => s, }; function createHTML(s) { return defaultPolicy.createHTML(s); } let trustHTMLErr = null; try { document.createElement('div').innerHTML = createHTML('1'); } catch (e) { trustHTMLErr = e; } if (trustHTMLErr) { console.error(`trustHTMLErr`, trustHTMLErr); throw trustHTMLErr; // exit userscript } // ----------------------------------------------------------------------------------------------------------------------------- const executionScript = () => { const DEBUG_5084 = false; const DEBUG_5085 = false; const DEBUG_handleNavigateFactory = false; const TAB_AUTO_SWITCH_TO_COMMENTS = false; if (typeof trustedTypes !== 'undefined' && trustedTypes.defaultPolicy == null) { const s = s => s; trustedTypes.createPolicy('default', { createHTML: s, createScriptURL: s, createScript: s }); } const defaultPolicy = (typeof trustedTypes !== 'undefined' && trustedTypes.defaultPolicy) || { createHTML: s => s, }; function createHTML(s) { return defaultPolicy.createHTML(s); } let trustHTMLErr = null; try { document.createElement('div').innerHTML = createHTML('1'); } catch (e) { trustHTMLErr = e; } if (trustHTMLErr) { console.error(`trustHTMLErr`, trustHTMLErr); throw trustHTMLErr; // exit userscript } try { let _executionFinished = 0; if (typeof CustomElementRegistry === 'undefined') return; if (CustomElementRegistry.prototype.define000) return; if (typeof CustomElementRegistry.prototype.define !== 'function') return; /** @type {HTMLElement} */ const HTMLElement_ = HTMLElement.prototype.constructor; /** * @param {Element} elm * @param {string} selector * @returns {Element | null} * */ const qsOne = (elm, selector) => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.querySelector === 'function') { return window.YouTubeDOMCache.querySelector(selector, elm); } return HTMLElement_.prototype.querySelector.call(elm, selector); }; const _qs = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.get === 'function') { return window.YouTubeDOMCache.get(selector); } return document.querySelector(selector); }; /** * Flexible selector helper: supports both `qs(selector)` and `qs(element, selector)`. * Backwards-compatible alias used throughout the codebase. */ function qs(a, b) { if (arguments.length === 1) return _qs(a); return qsOne(a, b); } /** * Query all elements with optional caching * @param {string} selector - CSS selector * @param {Element|Document} [context] - Context element * @returns {Element[]} Array of elements */ const qsAll = (selector, context) => { const ctx = context || document; if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.getAll === 'function') { return window.YouTubeDOMCache.getAll(selector); } return Array.from(ctx.querySelectorAll(selector)); }; const defineProperties = (p, o) => { if (!p) { console.warn(`defineProperties ERROR: Prototype is undefined`); return; } for (const k of Object.keys(o)) { if (!o[k]) { console.warn(`defineProperties ERROR: Property ${k} is undefined`); delete o[k]; } } return Object.defineProperties(p, o); }; const replaceChildrenPolyfill = function replaceChildren(...new_children) { while (this.firstChild) { this.removeChild(this.firstChild); } this.append(...new_children); }; const pdsBaseDF = Object.getOwnPropertyDescriptors(DocumentFragment.prototype); if (pdsBaseDF.replaceChildren) { defineProperties(DocumentFragment.prototype, { replaceChildren000: pdsBaseDF.replaceChildren, }); } else { DocumentFragment.prototype.replaceChildren000 = replaceChildrenPolyfill; } const pdsBaseNode = Object.getOwnPropertyDescriptors(Node.prototype); // console.log(pdsBaseElement.setAttribute, pdsBaseElement.getAttribute) if (!pdsBaseNode.appendChild000 && !pdsBaseNode.insertBefore000) { defineProperties(Node.prototype, { appendChild000: pdsBaseNode.appendChild, insertBefore000: pdsBaseNode.insertBefore, }); } // class BaseElement extends Element{ // } const pdsBaseElement = Object.getOwnPropertyDescriptors(Element.prototype); // console.log(pdsBaseElement.setAttribute, pdsBaseElement.getAttribute) if (!pdsBaseElement.setAttribute000 && !pdsBaseElement.querySelector000) { const nPdsElement = { setAttribute000: pdsBaseElement.setAttribute, getAttribute000: pdsBaseElement.getAttribute, hasAttribute000: pdsBaseElement.hasAttribute, removeAttribute000: pdsBaseElement.removeAttribute, querySelector000: pdsBaseElement.querySelector, }; if (pdsBaseElement.replaceChildren) { nPdsElement.replaceChildren000 = pdsBaseElement.replaceChildren; } else { Element.prototype.replaceChildren000 = replaceChildrenPolyfill; } defineProperties(Element.prototype, nPdsElement); } Element.prototype.setAttribute111 = function (p, v) { v = `${v}`; if (this.getAttribute000(p) === v) return; this.setAttribute000(p, v); }; Element.prototype.incAttribute111 = function (p) { let v = +this.getAttribute000(p) || 0; v = v > 1e9 ? v + 1 : 9; this.setAttribute000(p, `${v}`); return v; }; Element.prototype.assignChildren111 = function (previousSiblings, node, nextSiblings) { // assume all previousSiblings, node, and nextSiblings are on the page // -> only remove triggering is needed let nodeList = []; for (let t = this.firstChild; t instanceof Node; t = t.nextSibling) { if (t === node) continue; nodeList.push(t); } inPageRearrange = true; if (node.parentNode === this) { let fm = new DocumentFragment(); if (nodeList.length > 0) { fm.replaceChildren000(...nodeList); // nodeList.length = 0; } // nodeList = null; if (previousSiblings && previousSiblings.length > 0) { fm.replaceChildren000(...previousSiblings); this.insertBefore000(fm, node); } if (nextSiblings && nextSiblings.length > 0) { fm.replaceChildren000(...nextSiblings); this.appendChild000(fm); } fm.replaceChildren000(); fm = null; } else { if (!previousSiblings) previousSiblings = []; if (!nextSiblings) nextSiblings = []; this.replaceChildren000(...previousSiblings, node, ...nextSiblings); } inPageRearrange = false; if (nodeList.length > 0) { for (const t of nodeList) { if (t instanceof Element && t.isConnected === false) t.remove(); // remove triggering } } nodeList.length = 0; nodeList = null; }; let secondaryInnerHold = 0; const secondaryInnerFn = cb => { if (secondaryInnerHold) { secondaryInnerHold++; let err, r; try { r = cb(); } catch (e) { err = e; } secondaryInnerHold--; if (err) throw err; return r; } else { const ea = qs('#secondary-inner'); const eb = qs('secondary-wrapper#secondary-inner-wrapper'); if (ea && eb) { secondaryInnerHold++; let err, r; ea.id = 'secondary-inner-'; eb.id = 'secondary-inner'; try { r = cb(); } catch (e) { err = e; } ea.id = 'secondary-inner'; eb.id = 'secondary-inner-wrapper'; secondaryInnerHold--; if (err) throw err; return r; } else { return cb(); } } }; // ============================================================================================================================================================================================================================================================================== const DISABLE_FLAGS_SHADYDOM_FREE = true; /* eslint-disable no-sequences, no-unused-expressions, prefer-const */ /** * * Minified Code from https://greasyfork.org/en/scripts/475632-ytconfighacks/code (ytConfigHacks) * Date: 2024.04.17 * Minifier: https://www.toptal.com/developers/javascript-minifier * */ (() => { const e = 'undefined' != typeof unsafeWindow ? unsafeWindow : this instanceof Window ? this : window; if (!e._ytConfigHacks) { let t = 4; class n extends Set { add(e) { if (t <= 0) { return console.warn('yt.config_ is already applied on the page.'); } 'function' == typeof e && super.add(e); } } let a = (async () => {})().constructor, i = (e._ytConfigHacks = new n()), l = () => { const t = e.ytcsi.originalYtcsi; t && ((e.ytcsi = t), (l = null)); }, c = null, o = () => { if (t >= 1) { const n = (e.yt || 0).config_ || (e.ytcfg || 0).data_ || 0; if ('string' == typeof n.INNERTUBE_API_KEY && 'object' == typeof n.EXPERIMENT_FLAGS) { for (const a of (--t <= 0 && l && l(), (c = !0), i)) a(n); } } }, f = 1, d = t => { if ((t = t || e.ytcsi)) { return ( (e.ytcsi = new Proxy(t, { get: (e, t, _n) => 'originalYtcsi' === t ? e : (o(), c && --f <= 0 && l && l(), e[t]), })), !0 ); } }; d() || Object.defineProperty(e, 'ytcsi', { get() {}, set: t => (t && (delete e.ytcsi, d(t)), !0), enumerable: !1, configurable: !0, }); const { addEventListener: s, removeEventListener: y } = Document.prototype; function r(t) { (o(), t && e.removeEventListener('DOMContentLoaded', r, !1)); } (new a(e => { if ('undefined' != typeof AbortSignal) { (s.call(document, 'yt-page-data-fetched', e, { once: !0 }), s.call(document, 'yt-navigate-finish', e, { once: !0 }), s.call(document, 'spfdone', e, { once: !0 })); } else { const t = () => { (e(), y.call(document, 'yt-page-data-fetched', t, !1), y.call(document, 'yt-navigate-finish', t, !1), y.call(document, 'spfdone', t, !1)); }; (s.call(document, 'yt-page-data-fetched', t, !1), s.call(document, 'yt-navigate-finish', t, !1), s.call(document, 'spfdone', t, !1)); } }).then(o), new a(e => { if ('undefined' != typeof AbortSignal) { s.call(document, 'yt-action', e, { once: !0, capture: !0 }); } else { const t = () => { (e(), y.call(document, 'yt-action', t, !0)); }; s.call(document, 'yt-action', t, !0); } }).then(o), a.resolve().then(() => { 'loading' !== document.readyState ? r() : e.addEventListener('DOMContentLoaded', r, !1); })); } })(); let configOnce = false; window._ytConfigHacks.add(config_ => { if (configOnce) return; configOnce = true; const EXPERIMENT_FLAGS = config_.EXPERIMENT_FLAGS || 0; const EXPERIMENTS_FORCED_FLAGS = config_.EXPERIMENTS_FORCED_FLAGS || 0; for (const flags of [EXPERIMENT_FLAGS, EXPERIMENTS_FORCED_FLAGS]) { if (flags) { // flags.kevlar_watch_metadata_refresh_no_old_secondary_data = false; // flags.live_chat_overflow_hide_chat = false; flags.web_watch_chat_hide_button_killswitch = false; flags.web_watch_theater_chat = false; // for re-openable chat (ytd-watch-flexy's liveChatCollapsed is always undefined) flags.suppress_error_204_logging = true; flags.kevlar_watch_grid = false; // A/B testing for watch grid if (DISABLE_FLAGS_SHADYDOM_FREE) { flags.enable_shadydom_free_scoped_node_methods = false; flags.enable_shadydom_free_scoped_query_methods = false; flags.enable_shadydom_free_scoped_readonly_properties_batch_one = false; flags.enable_shadydom_free_parent_node = false; flags.enable_shadydom_free_children = false; flags.enable_shadydom_free_last_child = false; } } } }); // ============================================================================================================================================================================================================================================================================== /* globals WeakRef:false */ /** @type {(o: Object | null) => WeakRef | null} */ const mWeakRef = typeof WeakRef === 'function' ? o => (o ? new WeakRef(o) : null) : o => o || null; // typeof InvalidVar == 'undefined' /** @type {(wr: Object | null) => Object | null} */ const kRef = wr => (wr && wr.deref ? wr.deref() : wr); /** @type {globalThis.PromiseConstructor} */ const Promise = (async () => {})().constructor; // YouTube hacks Promise in WaterFox Classic and "Promise.resolve(0)" nevers resolve. const delayPn = delay => new Promise(fn => setTimeout(fn, delay)); const insp = o => (o ? o.polymerController || o.inst || o || 0 : o || 0); const setTimeout_ = setTimeout.bind(window); const PromiseExternal = ((resolve_, reject_) => { const h = (resolve, reject) => { resolve_ = resolve; reject_ = reject; }; return class PromiseExternal extends Promise { constructor(cb = h) { super(cb); if (cb === h) { /** @type {(value: any) => void} */ this.resolve = resolve_; /** @type {(reason?: any) => void} */ this.reject = reject_; } } }; })(); // ------------------------------------------------------------------------ nextBrowserTick ------------------------------------------------------------------------ /* eslint-disable no-var */ var nextBrowserTick = void 0 !== nextBrowserTick && nextBrowserTick.version >= 2 ? nextBrowserTick : (() => { 'use strict'; const e = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this; let t = !0; if ( !(function n(s) { return s ? (t = !1) : e.postMessage && !e.importScripts && e.addEventListener ? (e.addEventListener('message', n, !1), e.postMessage('$$$', '*'), e.removeEventListener('message', n, !1), t) : void 0; })() ) { return void console.warn('Your browser environment cannot use nextBrowserTick'); } const n = (async () => {})().constructor; let s = null; const o = new Map(), { floor: r, random: i } = Math; let l; do { l = `$$nextBrowserTick$$${(i() + 8).toString().slice(2)}$$`; } while (l in e); const a = l, c = a.length + 9; e[a] = 1; e.addEventListener( 'message', e => { if (0 !== o.size) { const t = (e || 0).data; if ('string' == typeof t && t.length === c && e.source === (e.target || 1)) { const e = o.get(t); e && ('p' === t[0] && (s = null), o.delete(t), e()); } } }, !1 ); const d = (t = o) => { if (t === o) { if (s) return s; let t; do { t = `p${a}${r(314159265359 * i() + 314159265359).toString(36)}`; } while (o.has(t)); return ( (s = new n(e => { o.set(t, e); })), e.postMessage(t, '*'), (t = null), s ); } { let n; do { n = `f${a}${r(314159265359 * i() + 314159265359).toString(36)}`; } while (o.has(n)); (o.set(n, t), e.postMessage(n, '*')); } }; return ((d.version = 2), d); })(); /* eslint-enable no-var */ /* eslint-enable no-sequences, no-unused-expressions */ // ------------------------------------------------------------------------ nextBrowserTick ------------------------------------------------------------------------ // ------------------------------------------------------------------------ nextBrowserTick ------------------------------------------------------------------------ const isPassiveArgSupport = typeof IntersectionObserver === 'function'; const capturePassive = isPassiveArgSupport ? { capture: true, passive: true } : true; class Attributer { constructor(list) { this.list = list; this.flag = 0; } makeString() { let k = 1; let s = ''; let i = 0; while (this.flag >= k) { if (this.flag & k) { s += this.list[i]; } i++; k <<= 1; } return s; } } const mLoaded = new Attributer('icp'); const wrSelfMap = new WeakMap(); /** @type {Object.} */ const elements = new Proxy( { related: null, comments: null, infoExpander: null, }, { get(target, prop) { return kRef(target[prop]); }, set(target, prop, value) { if (value) { let wr = wrSelfMap.get(value); if (!wr) { wr = mWeakRef(value); wrSelfMap.set(value, wr); } target[prop] = wr; } else { target[prop] = null; } return true; }, } ); const getMainInfo = () => { const infoExpander = elements.infoExpander; if (!infoExpander) return null; const mainInfo = infoExpander.matches('[tyt-main-info]') ? infoExpander : infoExpander.querySelector000('[tyt-main-info]'); return mainInfo || null; }; let pageType = null; let _pageLang = 'en'; /** * Translation helper using centralized i18n system * @param {string} key - Translation key * @returns {string} Translated string */ function getWord(tag) { try { // Use centralized i18n system if available if (typeof window !== 'undefined' && window.YouTubePlusI18n) { const translation = window.YouTubePlusI18n.t(`tabs.${tag}`); if (translation && translation !== `tabs.${tag}`) { return translation; } } // Fallback to basic English translations const fallbackWords = { info: 'Info', videos: 'Videos', playlist: 'Playlist', }; return fallbackWords[tag] || tag; } catch (error) { console.warn('[YouTube+][Main] Translation error:', error); // Final fallback to English const englishWords = { info: 'Info', videos: 'Videos', playlist: 'Playlist', }; return englishWords[tag] || tag; } } const svgComments = ``.trim(); const svgVideos = ``.trim(); const svgInfo = ``.trim(); const svgPlayList = ``.trim(); const svgElm = (w, h, vw, vh, p, m) => `${p}`; const hiddenTabsByUserCSS = 0; function getTabsHTML() { const sTabBtnVideos = `${svgElm(16, 16, 90, 90, svgVideos)}${getWord('videos')}`; const sTabBtnInfo = `${svgElm(16, 16, 60, 60, svgInfo)}${getWord('info')}`; const sTabBtnPlayList = `${svgElm(16, 16, 20, 20, svgPlayList)}${getWord('playlist')}`; const str1 = `
`; const str_fbtns = `
`.replace(/[\r\n]+/g, ''); const str_tabs = [ `${sTabBtnInfo}${str1}${str_fbtns}`, `${svgElm(16, 16, 120, 120, svgComments)}${str1}${str_fbtns}`, `${sTabBtnVideos}${str1}${str_fbtns}`, `${sTabBtnPlayList}${str1}${str_fbtns}`, ].join(''); const addHTML = `
${str_tabs}
`; return addHTML; } // All languages shipped with the script — keep in sync with i18n.js AVAILABLE_LANGUAGES. const langWords = { ar: true, be: true, bg: true, cn: true, de: true, du: true, en: true, es: true, fr: true, hi: true, id: true, it: true, jp: true, kk: true, kr: true, ky: true, pl: true, pt: true, ru: true, tr: true, tw: true, uk: true, uz: true, vi: true, ng: true, }; /** * Get the current UI language code. * Delegates to the centralized i18n system so all shipped languages (and their * locale variants) are resolved correctly. Falls back to a minimal inline map * for the brief window before i18n initialises. */ function getLang() { // Prefer the authoritative i18n system (already detects ytcfg, html[lang], URL hl=) try { if (window.YouTubePlusI18n && typeof window.YouTubePlusI18n.getLanguage === 'function') { const detected = window.YouTubePlusI18n.getLanguage(); if (detected && langWords[detected]) return detected; } } catch {} // Inline fallback covers all shipped supported languages so early callers // (before i18n is ready) still get a correct code. const htmlLang = ((document || 0).documentElement || 0).lang || ''; const localMap = { // Dutch (de → 'du' is the project's internal code, not a typo) de: 'du', 'de-de': 'du', 'de-at': 'du', 'de-ch': 'du', // French fr: 'fr', 'fr-fr': 'fr', 'fr-ca': 'fr', 'fr-be': 'fr', 'fr-ch': 'fr', // Chinese (Traditional) 'zh-hant': 'tw', 'zh-hant-hk': 'tw', 'zh-hant-tw': 'tw', 'zh-tw': 'tw', 'zh-hk': 'tw', // Chinese (Simplified) 'zh-hans': 'cn', 'zh-hans-cn': 'cn', 'zh-cn': 'cn', zh: 'cn', 'zh-sg': 'cn', // Japanese ja: 'jp', 'ja-jp': 'jp', // Korean ko: 'kr', 'ko-kr': 'kr', // Russian ru: 'ru', 'ru-ru': 'ru', // Ukrainian uk: 'uk', 'uk-ua': 'uk', // Belarusian be: 'be', 'be-by': 'be', // Bulgarian bg: 'bg', 'bg-bg': 'bg', // Spanish es: 'es', 'es-es': 'es', 'es-419': 'es', 'es-mx': 'es', // Portuguese pt: 'pt', 'pt-pt': 'pt', 'pt-br': 'pt', // Italian it: 'it', 'it-it': 'it', // Polish pl: 'pl', 'pl-pl': 'pl', // Dutch (nl → 'du') nl: 'du', 'nl-nl': 'du', 'nl-be': 'du', // Arabic ar: 'ar', 'ar-sa': 'ar', 'ar-ae': 'ar', 'ar-eg': 'ar', // Hindi hi: 'hi', 'hi-in': 'hi', // Indonesian id: 'id', 'id-id': 'id', // Nigerian (Pidgin) ng: 'ng', 'en-ng': 'ng', pcm: 'ng', 'pcm-ng': 'ng', // Turkish tr: 'tr', 'tr-tr': 'tr', // Vietnamese vi: 'vi', 'vi-vn': 'vi', // Uzbek uz: 'uz', 'uz-uz': 'uz', // Kazakh kk: 'kk', 'kk-kz': 'kk', // Kyrgyz ky: 'ky', }; return localMap[htmlLang.toLowerCase()] || 'en'; } function getLangForPage() { const lang = getLang(); _pageLang = langWords[lang] ? lang : 'en'; } /** @type {Object.} */ const _locks = {}; const lockGet = new Proxy(_locks, { get(target, prop) { return target[prop] || 0; }, set(_target, _prop, _val) { return true; }, }); const lockSet = new Proxy(_locks, { get(target, prop) { if (target[prop] > 1e9) target[prop] = 9; return (target[prop] = (target[prop] || 0) + 1); }, set(_target, _prop, _val) { return true; }, }); // note: xxxxxxxxxAsyncLock is not expected for calling multiple time in a short period. // it is just to split the process into microTasks. const videosElementProvidedPromise = new PromiseExternal(); const navigateFinishedPromise = new PromiseExternal(); let isRightTabsInserted = false; const rightTabsProvidedPromise = new PromiseExternal(); const infoExpanderElementProvidedPromise = new PromiseExternal(); const pluginsDetected = {}; let pluginDetectDebounceTimer = null; const pluginDetectObserver = new MutationObserver(mutations => { if (pluginDetectDebounceTimer) return; pluginDetectDebounceTimer = setTimeout(() => { pluginDetectDebounceTimer = null; processPluginDetectMutations(mutations); }, 50); }); const processPluginDetectMutations = mutations => { let changeOnRoot = false; const newPlugins = []; const attributeChangedSet = new Set(); for (const mutation of mutations) { if (mutation.target === document) changeOnRoot = true; let detected = ''; switch (mutation.attributeName) { case 'data-ytlstm-new-layout': case 'data-ytlstm-overlay-text-shadow': case 'data-ytlstm-theater-mode': detected = 'external.ytlstm'; // YouTube Livestreams Theater Mode attributeChangedSet.add(detected); break; } if (detected && !pluginsDetected[detected]) { pluginsDetected[detected] = true; newPlugins.push(detected); } } if (elements.flexy && attributeChangedSet.has('external.ytlstm')) { elements.flexy.setAttribute( 'tyt-external-ytlstm', qs('[data-ytlstm-theater-mode]') ? '1' : '0' ); } if (changeOnRoot) { // prevent change of document.body pluginDetectObserver.observe(document.body, { attributes: true, attributeFilter: [ 'data-ytlstm-new-layout', 'data-ytlstm-overlay-text-shadow', 'data-ytlstm-theater-mode', ], }); } for (const detected of newPlugins) { const pluginItem = plugin[`${detected}`]; if (pluginItem) { pluginItem.activate(); } else { console.warn(`No Plugin Activator for ${detected}`); } } }; const pluginAttributeFilter = [ 'data-ytlstm-new-layout', 'data-ytlstm-overlay-text-shadow', 'data-ytlstm-theater-mode', ]; pluginDetectObserver.observe(document.documentElement, { attributes: true, attributeFilter: pluginAttributeFilter, }); if (document.body) { pluginDetectObserver.observe(document.body, { attributes: true, attributeFilter: pluginAttributeFilter, }); } navigateFinishedPromise.then(() => { pluginDetectObserver.observe(document.documentElement, { attributes: true, attributeFilter: pluginAttributeFilter, }); if (document.body) { pluginDetectObserver.observe(document.body, { attributes: true, attributeFilter: pluginAttributeFilter, }); } }); const funcCanCollapse = function (_s) { // if (!s) return; const content = this.content || this.$.content; this.canToggle = this.shouldUseNumberOfLines && (this.alwaysCollapsed || this.collapsed || this.isToggled === false) ? this.alwaysToggleable || this.isToggled || (content && content.offsetHeight < content.scrollHeight) : this.alwaysToggleable || this.isToggled || (content && content.scrollHeight > this.collapsedHeight); }; const aoChatAttrChangeFn = async lockId => { if (lockGet['aoChatAttrAsyncLock'] !== lockId) return; const chatElm = elements.chat; const ytdFlexyElm = elements.flexy; // console.log(1882, chatElm, ytdFlexyElm) if (chatElm && ytdFlexyElm) { const isChatCollapsed = chatElm.hasAttribute000('collapsed'); if (isChatCollapsed) { ytdFlexyElm.setAttribute111('tyt-chat-collapsed', ''); } else { ytdFlexyElm.removeAttribute000('tyt-chat-collapsed'); } ytdFlexyElm.setAttribute111('tyt-chat', isChatCollapsed ? '-' : '+'); } }; // const aoInfoAttrChangeFn = async (lockId) => { // if (lockGet['aoInfoAttrAsyncLock'] !== lockId) return; // }; // const zoInfoAttrChangeFn = async (lockId) => { // if (lockGet['zoInfoAttrAsyncLock'] !== lockId) return; // }; const aoPlayListAttrChangeFn = async lockId => { if (lockGet['aoPlayListAttrAsyncLock'] !== lockId) return; const playlistElm = elements.playlist; const ytdFlexyElm = elements.flexy; // console.log(1882, chatElm, ytdFlexyElm) let doAttributeChange = 0; if (playlistElm && ytdFlexyElm) { if (playlistElm.closest('[hidden]')) { doAttributeChange = 2; } else if (playlistElm.hasAttribute000('collapsed')) { doAttributeChange = 2; } else { doAttributeChange = 1; } } else if (ytdFlexyElm) { doAttributeChange = 2; } if (doAttributeChange === 1) { if (ytdFlexyElm.getAttribute000('tyt-playlist-expanded') !== '') { ytdFlexyElm.setAttribute111('tyt-playlist-expanded', ''); } } else if (doAttributeChange === 2) { if (ytdFlexyElm.hasAttribute000('tyt-playlist-expanded')) { ytdFlexyElm.removeAttribute000('tyt-playlist-expanded'); } } }; const aoChat = new MutationObserver(() => { Promise.resolve(lockSet['aoChatAttrAsyncLock']).then(aoChatAttrChangeFn).catch(console.warn); }); // const aoInfo = new MutationObserver(()=>{ // Promise.resolve(lockSet['aoInfoAttrAsyncLock']).then(aoInfoAttrChangeFn).catch(console.warn); // }); // const zoInfo = new MutationObserver(()=>{ // Promise.resolve(lockSet['zoInfoAttrAsyncLock']).then(zoInfoAttrChangeFn).catch(console.warn); // }); const aoPlayList = new MutationObserver(() => { Promise.resolve(lockSet['aoPlayListAttrAsyncLock']) .then(aoPlayListAttrChangeFn) .catch(console.warn); }); let aoCommentThrottleTimer = null; let aoCommentPendingMutations = []; const aoComment = new MutationObserver(async mutations => { aoCommentPendingMutations.push(...mutations); if (aoCommentThrottleTimer) return; aoCommentThrottleTimer = setTimeout(() => { aoCommentThrottleTimer = null; const allMutations = aoCommentPendingMutations; aoCommentPendingMutations = []; processCommentMutations(allMutations); }, 50); }); const processCommentMutations = async mutations => { const commentsArea = elements.comments; const ytdFlexyElm = elements.flexy; //tyt-comments-video-id //tyt-comments-data-status // hidden if (!commentsArea) return; let bfHidden = false; let bfCommentsVideoId = false; let bfCommentDisabled = false; for (const mutation of mutations) { if (mutation.attributeName === 'hidden' && mutation.target === commentsArea) { bfHidden = true; } else if ( mutation.attributeName === 'tyt-comments-video-id' && mutation.target === commentsArea ) { bfCommentsVideoId = true; } else if ( mutation.attributeName === 'tyt-comments-data-status' && mutation.target === commentsArea ) { bfCommentDisabled = true; } } if (bfHidden) { if (!commentsArea.hasAttribute000('hidden')) { Promise.resolve(commentsArea) .then(eventMap['settingCommentsVideoId']) .catch(console.warn); } Promise.resolve(lockSet['removeKeepCommentsScrollerLock']) .then(removeKeepCommentsScroller) .catch(console.warn); } if ((bfHidden || bfCommentsVideoId || bfCommentDisabled) && ytdFlexyElm) { const commentsDataStatus = +commentsArea.getAttribute000('tyt-comments-data-status'); if (commentsDataStatus === 2) { ytdFlexyElm.setAttribute111('tyt-comment-disabled', ''); } else if (commentsDataStatus === 1) { ytdFlexyElm.removeAttribute000('tyt-comment-disabled'); } Promise.resolve(lockSet['checkCommentsShouldBeHiddenLock']) .then(eventMap['checkCommentsShouldBeHidden']) .catch(console.warn); const lockId = lockSet['rightTabReadyLock01']; await rightTabsProvidedPromise.then(); if (lockGet['rightTabReadyLock01'] !== lockId) return; if (elements.comments !== commentsArea) return; if (commentsArea.isConnected === false) return; // console.log(7932, 'comments'); if (commentsArea.closest('#tab-comments')) { const shouldTabVisible = !commentsArea.closest('[hidden]'); document .querySelector('[tyt-tab-content="#tab-comments"]') .classList.toggle('tab-btn-hidden', !shouldTabVisible); } } }; const ioComment = new IntersectionObserver( entries => { requestAnimationFrame(() => { for (const entry of entries) { const target = entry.target; const cnt = insp(target); if ( entry.isIntersecting && target instanceof HTMLElement_ && typeof cnt.calculateCanCollapse === 'function' ) { void lockSet['removeKeepCommentsScrollerLock']; cnt.calculateCanCollapse(true); target.setAttribute111('io-intersected', ''); const ytdFlexyElm = elements.flexy; if (ytdFlexyElm && !ytdFlexyElm.hasAttribute000('keep-comments-scroller')) { ytdFlexyElm.setAttribute111('keep-comments-scroller', ''); } } else if (target.hasAttribute000('io-intersected')) { target.removeAttribute000('io-intersected'); } } }); }, { threshold: [0], rootMargin: '100px', } ); let bFixForResizedTabLater = false; let lastRoRightTabsWidth = 0; let resizeDebounceTimer = null; const roRightTabs = new ResizeObserver(entries => { if (resizeDebounceTimer) return; resizeDebounceTimer = setTimeout(() => { resizeDebounceTimer = null; const entry = entries[entries.length - 1]; const width = Math.round(entry.borderBoxSize.inlineSize); if (lastRoRightTabsWidth !== width) { lastRoRightTabsWidth = width; if ((tabAStatus & 2) === 2) { bFixForResizedTabLater = false; Promise.resolve(1).then(eventMap['fixForTabDisplay']); } else { bFixForResizedTabLater = true; } } }, 100); // console.log('resize') }); let cachedTabLinks = null; let cachedTabContents = new Map(); const switchToTab = activeLink => { if (typeof activeLink === 'string') { activeLink = qs(`a[tyt-tab-content="${activeLink}"]`) || null; } const ytdFlexyElm = elements.flexy; if (!cachedTabLinks || cachedTabLinks.length === 0 || !cachedTabLinks[0].isConnected) { cachedTabLinks = qsAll('#material-tabs a[tyt-tab-content]'); cachedTabContents.clear(); } const links = cachedTabLinks; //console.log(701, activeLink) for (const link of links) { let content = cachedTabContents.get(link); if (!content || !content.isConnected) { content = qs(link.getAttribute000('tyt-tab-content')); if (content) cachedTabContents.set(link, content); } if (link && content) { if (link !== activeLink) { link.classList.remove('active'); content.classList.add('tab-content-hidden'); if (!content.hasAttribute000('tyt-hidden')) { content.setAttribute111('tyt-hidden', ''); // for https://greasyfork.org/en/scripts/456108 } } else { link.classList.add('active'); if (content.hasAttribute000('tyt-hidden')) { content.removeAttribute000('tyt-hidden'); // for https://greasyfork.org/en/scripts/456108 } content.classList.remove('tab-content-hidden'); } } } const switchingTo = activeLink ? activeLink.getAttribute000('tyt-tab-content') : ''; if (switchingTo) { lastTab = lastPanel = switchingTo; } if (ytdFlexyElm.getAttribute000('tyt-chat') === '') { ytdFlexyElm.removeAttribute000('tyt-chat'); } ytdFlexyElm.setAttribute111('tyt-tab', switchingTo); if (switchingTo) { bFixForResizedTabLater = false; Promise.resolve(0).then(eventMap['fixForTabDisplay']); } }; let tabAStatus = 0; const calculationFn = (r = 0, flag) => { const ytdFlexyElm = elements.flexy; if (!ytdFlexyElm) return r; if (flag & 1) { r |= 1; if (!ytdFlexyElm.hasAttribute000('theater')) r -= 1; } if (flag & 2) { r |= 2; if (!ytdFlexyElm.getAttribute000('tyt-tab')) r -= 2; } if (flag & 4) { r |= 4; if (ytdFlexyElm.getAttribute000('tyt-chat') !== '-') r -= 4; } if (flag & 8) { r |= 8; if (ytdFlexyElm.getAttribute000('tyt-chat') !== '+') r -= 8; } if (flag & 16) { r |= 16; if (!ytdFlexyElm.hasAttribute000('is-two-columns_')) r -= 16; } if (flag & 32) { r |= 32; if (!ytdFlexyElm.hasAttribute000('tyt-egm-panel_')) r -= 32; } if (flag & 64) { r |= 64; if (!document.fullscreenElement) r -= 64; } if (flag & 128) { r |= 128; if (!ytdFlexyElm.hasAttribute000('tyt-playlist-expanded')) r -= 128; } if (flag & 4096) { r |= 4096; if (ytdFlexyElm.getAttribute('tyt-external-ytlstm') !== '1') r -= 4096; } return r; }; function isTheater() { const ytdFlexyElm = elements.flexy; return ytdFlexyElm && ytdFlexyElm.hasAttribute000('theater'); } /** Check if zen theater overlay CSS is active (chat/comments become fixed overlays) */ function isZenTheaterOverlayActive() { try { const raw = localStorage.getItem('youtube_plus_settings'); if (!raw) return true; // defaults enable zen theater enhancements const s = JSON.parse(raw); if (s?.enableZenStyles === false) return false; if (s?.zenStyles?.theaterEnhancements === false) return false; return true; } catch { return true; } } function ytBtnCancelTheater() { // When zen theater overlay is active, chat/comments are fixed overlays. // Do NOT programmatically exit theater — the user controls it via 't' key. if (isZenTheaterOverlayActive()) return; if (isTheater()) { const sizeBtn = qs('ytd-watch-flexy #ytd-player button.ytp-size-button'); if (sizeBtn) sizeBtn.click(); } } function getSuitableElement(selector) { const elements = qsAll(selector); let j = -1, h = -1; for (let i = 0, l = elements.length; i < l; i++) { const d = elements[i].getElementsByTagName('*').length; if (d > h) { h = d; j = i; } } return j >= 0 ? elements[j] : null; } function ytBtnExpandChat() { const dom = getSuitableElement('ytd-live-chat-frame#chat'); const cnt = insp(dom); if (cnt && typeof cnt.collapsed === 'boolean') { if (typeof cnt.setCollapsedState === 'function') { cnt.setCollapsedState({ setLiveChatCollapsedStateAction: { collapsed: false, }, }); if (cnt.collapsed === false) return; } cnt.collapsed = false; if (cnt.collapsed === false) return; if (cnt.isHiddenByUser === true && cnt.collapsed === true) { cnt.isHiddenByUser = false; cnt.collapsed = false; } } let button = qs( 'ytd-live-chat-frame#chat[collapsed] > .ytd-live-chat-frame#show-hide-button' ); if (button) { button = button.querySelector000('div.yt-spec-touch-feedback-shape') || button.querySelector000('ytd-toggle-button-renderer'); if (button) button.click(); } } function ytBtnCollapseChat() { // When zen theater overlay is active, don't programmatically collapse chat — // it should remain visible as a transparent overlay panel. if (isZenTheaterOverlayActive() && isTheater()) return; const dom = getSuitableElement('ytd-live-chat-frame#chat'); const cnt = insp(dom); if (cnt && typeof cnt.collapsed === 'boolean') { if (typeof cnt.setCollapsedState === 'function') { cnt.setCollapsedState({ setLiveChatCollapsedStateAction: { collapsed: true, }, }); if (cnt.collapsed === true) return; } cnt.collapsed = true; if (cnt.collapsed === true) return; if (cnt.isHiddenByUser === false && cnt.collapsed === false) { cnt.isHiddenByUser = true; cnt.collapsed = true; } } let button = qs( 'ytd-live-chat-frame#chat:not([collapsed]) > .ytd-live-chat-frame#show-hide-button' ); if (button) { button = button.querySelector000('div.yt-spec-touch-feedback-shape') || button.querySelector000('ytd-toggle-button-renderer'); if (button) button.click(); } } function ytBtnEgmPanelCore(arr) { if (!arr) return; if (!('length' in arr)) arr = [arr]; const ytdFlexyElm = elements.flexy; if (!ytdFlexyElm) return; let actions = []; for (const entry of arr) { if (!entry) continue; const panelId = entry.panelId; const toHide = entry.toHide; const toShow = entry.toShow; if (toHide === true && !toShow) { actions.push({ changeEngagementPanelVisibilityAction: { targetId: panelId, visibility: 'ENGAGEMENT_PANEL_VISIBILITY_HIDDEN', }, }); } else if (toShow === true && !toHide) { actions.push({ showEngagementPanelEndpoint: { panelIdentifier: panelId, }, }); } if (actions.length > 0) { const cnt = insp(ytdFlexyElm); cnt.resolveCommand( { signalServiceEndpoint: { signal: 'CLIENT_SIGNAL', actions: actions, }, }, {}, false ); } actions = null; } } /* function ytBtnCloseEngagementPanel( s) { //ePanel.setAttribute('visibility',"ENGAGEMENT_PANEL_VISIBILITY_HIDDEN"); let panelId = s.getAttribute('target-id') scriptletDeferred.debounce(() => { document.dispatchEvent(new CustomEvent('tyt-engagement-panel-visibility-change', { detail: { panelId, toHide: true } })) }) } function ytBtnCloseEngagementPanels() { if (isEngagementPanelExpanded()) { for (const s of qsAll( `ytd-watch-flexy[tyt-tab] #panels.ytd-watch-flexy ytd-engagement-panel-section-list-renderer[target-id][visibility]:not([hidden])` )) { if (s.getAttribute('visibility') == "ENGAGEMENT_PANEL_VISIBILITY_EXPANDED") ytBtnCloseEngagementPanel(s); } } } */ function ytBtnCloseEngagementPanels() { const actions = []; for (const panelElm of qsAll( `ytd-watch-flexy[tyt-tab] #panels.ytd-watch-flexy ytd-engagement-panel-section-list-renderer[target-id][visibility]:not([hidden])` )) { if ( panelElm.getAttribute('visibility') === 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED' && !panelElm.closest('[hidden]') ) { actions.push({ panelId: panelElm.getAttribute000('target-id'), toHide: true, }); } } ytBtnEgmPanelCore(actions); } function ytBtnOpenPlaylist() { const cnt = insp(elements.playlist); if (cnt && typeof cnt.collapsed === 'boolean') { cnt.collapsed = false; } } function ytBtnClosePlaylist() { const cnt = insp(elements.playlist); if (cnt && typeof cnt.collapsed === 'boolean') { cnt.collapsed = true; } } const updateChatLocation498 = function () { if (this.is !== 'ytd-watch-grid') { secondaryInnerFn(() => { this.updatePageMediaQueries(); this.schedulePlayerSizeUpdate_(); }); } }; const mirrorNodeWS = new WeakMap(); const dummyNode = document.createElement('noscript'); const __j4836__ = Symbol(); const __j5744__ = Symbol(); // original element const __j5733__ = Symbol(); // __lastChanged__ const monitorDataChangedByDOMMutation = async function (_mutations) { const nodeWR = this; const node = kRef(nodeWR); if (!node) return; const cnt = insp(node); const __lastChanged__ = cnt[__j5733__]; const val = cnt.data ? cnt.data[__j4836__] || 1 : 0; if (__lastChanged__ !== val) { cnt[__j5733__] = val > 0 ? (cnt.data[__j4836__] = Date.now()) : 0; await Promise.resolve(); // required for making sufficient delay for data rendering attributeInc(node, 'tyt-data-change-counter'); // next macro task } }; const moChangeReflection = function (mutations) { const nodeWR = this; const node = kRef(nodeWR); if (!node) return; const originElement = kRef(node[__j5744__] || null) || null; if (!originElement) return; const cnt = insp(node); const oriCnt = insp(originElement); if (mutations) { let bfDataChangeCounter = false; for (const mutation of mutations) { if ( mutation.attributeName === 'tyt-clone-refresh-count' && mutation.target === originElement ) { bfDataChangeCounter = true; } else if ( mutation.attributeName === 'tyt-data-change-counter' && mutation.target === originElement ) { bfDataChangeCounter = true; } } if (bfDataChangeCounter && oriCnt.data) { node.replaceWith(dummyNode); cnt.data = Object.assign({}, oriCnt.data); dummyNode.replaceWith(node); } } }; const attributeInc = (elm, prop) => { let v = (+elm.getAttribute000(prop) || 0) + 1; if (v > 1e9) v = 9; elm.setAttribute000(prop, v); return v; }; /** * UC[-_a-zA-Z0-9+=.]{22} * https://support.google.com/youtube/answer/6070344?hl=en * The channel ID is the 24 character alphanumeric string that starts with 'UC' in the channel URL. */ const isChannelId = x => { if (typeof x === 'string' && x.length === 24) { return /UC[-_a-zA-Z0-9+=.]{22}/.test(x); } return false; }; const infoFix = lockId => { if (lockId !== null && lockGet['infoFixLock'] !== lockId) return; // console.log('((infoFix))') const infoExpander = elements.infoExpander; const infoContainer = (infoExpander ? infoExpander.parentNode : null) || qs('#tab-info'); const ytdFlexyElm = elements.flexy; if (!infoContainer || !ytdFlexyElm) return; // console.log(386, infoExpander, infoExpander.matches('#tab-info > [class]')) if (infoExpander) { const match = infoExpander.matches('#tab-info > [class]') || infoExpander.matches('#tab-info > [tyt-main-info]'); if (!match) return; } // const elms = [...document.querySelectorAll('ytd-watch-metadata.ytd-watch-flexy div[slot="extra-content"], ytd-watch-metadata.ytd-watch-flexy ytd-metadata-row-container-renderer')].filter(elm=>{ // if(elm.parentNode.closest('div[slot="extra-content"], ytd-metadata-row-container-renderer')) return false; // return true; // }); const requireElements = [ ...qsAll( 'ytd-watch-metadata.ytd-watch-flexy div[slot="extra-content"] > *, ytd-watch-metadata.ytd-watch-flexy #extra-content > *' ), ] .filter(elm => { return typeof elm.is == 'string'; }) .map(elm => { const is = elm.is; while (elm instanceof HTMLElement_) { const q = [...elm.querySelectorAll(is)].filter(e => insp(e).data); if (q.length >= 1) return q[0]; elm = elm.parentNode; } }) .filter(elm => !!elm && typeof elm.is === 'string'); // console.log(9162, requireElements) // if (!infoExpander && !requireElements.length) return; const source = requireElements.map(entry => { const inst = insp(entry); return { data: inst.data, tag: inst.is, elm: entry, }; }); let noscript_ = qs('noscript#aythl'); if (!noscript_) { noscript_ = document.createElement('noscript'); noscript_.id = 'aythl'; inPageRearrange = true; ytdFlexyElm.insertBefore000(noscript_, ytdFlexyElm.firstChild); inPageRearrange = false; } const noscript = noscript_; let requiredUpdate = false; const mirrorElmSet = new Set(); const targetParent = infoContainer; for (const { data, tag: tag, elm: s } of source) { let mirrorNode = mirrorNodeWS.get(s); mirrorNode = mirrorNode ? kRef(mirrorNode) : mirrorNode; if (!mirrorNode) { const cnt = insp(s); const cProto = cnt.constructor.prototype; const element = document.createElement(tag); noscript.appendChild(element); // appendChild to trigger .attached() mirrorNode = element; mirrorNode[__j5744__] = mWeakRef(s); const nodeWR = mWeakRef(mirrorNode); // if(!(insp(s)._dataChanged438)){ // insp(s)._dataChanged438 = async function(){ // await Promise.resolve(); // required for making sufficient delay for data rendering // attributeInc(originElement, 'tyt-data-change-counter'); // next macro task // moChangeReflection.call(nodeWR); // } // } new MutationObserver(moChangeReflection.bind(nodeWR)).observe(s, { attributes: true, attributeFilter: ['tyt-clone-refresh-count', 'tyt-data-change-counter'], }); s.jy8432 = 1; if ( !(cProto instanceof Node) && !cProto._dataChanged496 && typeof cProto._createPropertyObserver === 'function' ) { cProto._dataChanged496 = function () { const cnt = this; const node = cnt.hostElement || cnt; if (node.jy8432) { // console.log('hello _dataChanged496', this.is); // await Promise.resolve(); // required for making sufficient delay for data rendering attributeInc(node, 'tyt-data-change-counter'); // next macro task } }; cProto._createPropertyObserver('data', '_dataChanged496', undefined); } else if ( !(cProto instanceof Node) && !cProto._dataChanged496 && cProto.useSignals === true && insp(s).signalProxy ) { const dataSignal = cnt?.signalProxy?.signalCache?.data; if ( dataSignal && typeof dataSignal.setWithPath === 'function' && !dataSignal.setWithPath573 && !dataSignal.controller573 ) { dataSignal.controller573 = mWeakRef(cnt); dataSignal.setWithPath573 = dataSignal.setWithPath; dataSignal.setWithPath = function () { const cnt = kRef(this.controller573 || null) || null; cnt && typeof cnt._dataChanged496k === 'function' && Promise.resolve(cnt).then(cnt._dataChanged496k).catch(console.warn); return this.setWithPath573(...arguments); }; cProto._dataChanged496 = function () { const cnt = this; const node = cnt.hostElement || cnt; if (node.jy8432) { // console.log('hello _dataChanged496', this.is); // await Promise.resolve(); // required for making sufficient delay for data rendering attributeInc(node, 'tyt-data-change-counter'); // next macro task } }; cProto._dataChanged496k = cnt => cnt._dataChanged496(); } } if (!cProto._dataChanged496) { new MutationObserver( monitorDataChangedByDOMMutation.bind(mirrorNode[__j5744__]) ).observe(s, { attributes: true, childList: true, subtree: true }); } // new MutationObserver(moChangeReflection.bind(nodeWR)).observe(s, {attributes: true, childList: true, subtree: true}); mirrorNodeWS.set(s, nodeWR); requiredUpdate = true; } else { if (mirrorNode.parentNode !== targetParent) { requiredUpdate = true; } } if (!requiredUpdate) { const cloneNodeCnt = insp(mirrorNode); if (cloneNodeCnt.data !== data) { // if(mirrorNode.parentNode !== noscript){ // noscript.appendChild(mirrorNode); // } // mirrorNode.replaceWith(dummyNode); // cloneNodeCnt.data = data; // dummyNode.replaceWith(mirrorNode); requiredUpdate = true; } } mirrorElmSet.add(mirrorNode); source.mirrored = mirrorNode; } const mirroElmArr = [...mirrorElmSet]; mirrorElmSet.clear(); if (!requiredUpdate) { let e = infoExpander ? -1 : 0; // DOM Tree Check for (let n = targetParent.firstChild; n instanceof Node; n = n.nextSibling) { const target = e < 0 ? infoExpander : mirroElmArr[e]; e++; if (n !== target) { // target can be undefined if index overflow requiredUpdate = true; break; } } if (!requiredUpdate && e !== mirroElmArr.length + 1) requiredUpdate = true; } if (requiredUpdate) { if (infoExpander) { targetParent.assignChildren111(null, infoExpander, mirroElmArr); } else { targetParent.replaceChildren000(...mirroElmArr); } for (const mirrorElm of mirroElmArr) { // trigger data assignment and record refresh count by manual update const j = attributeInc(mirrorElm, 'tyt-clone-refresh-count'); const oriElm = kRef(mirrorElm[__j5744__] || null) || null; if (oriElm) { oriElm.setAttribute111('tyt-clone-refresh-count', j); } } } mirroElmArr.length = 0; source.length = 0; }; const layoutFix = lockId => { if (lockGet['layoutFixLock'] !== lockId) return; // console.log('((layoutFix))') const secondaryWrapper = qs( '#secondary-inner.style-scope.ytd-watch-flexy > secondary-wrapper' ); // console.log(3838, !!chatContainer, !!(secondaryWrapper && secondaryInner), secondaryInner?.firstChild, secondaryInner?.lastChild , secondaryWrapper?.parentNode === secondaryInner) if (secondaryWrapper) { const secondaryInner = secondaryWrapper.parentNode; const chatContainer = qs('#columns.style-scope.ytd-watch-flexy [tyt-chat-container]'); const hasExtraNodes = () => { for (let node = secondaryInner.firstChild; node; node = node.nextSibling) { if (node === secondaryWrapper) continue; if (node === chatContainer) continue; if (node.nodeType === 3 && !node.textContent.trim()) continue; // ignore whitespace return true; } return false; }; if (hasExtraNodes() || (chatContainer && !chatContainer.closest('secondary-wrapper'))) { // console.log(38381) const w = []; const w2 = []; for ( let node = secondaryInner.firstChild; node instanceof Node; node = node.nextSibling ) { if (node === chatContainer && chatContainer) { } else if (node === secondaryWrapper) { for ( let node2 = secondaryWrapper.firstChild; node2 instanceof Node; node2 = node2.nextSibling ) { if (node2 === chatContainer && chatContainer) { } else { if (node2.id === 'right-tabs' && chatContainer) { w2.push(chatContainer); } w2.push(node2); } } } else { w.push(node); } } // console.log('qww', w, w2) inPageRearrange = true; secondaryWrapper.replaceChildren000(...w, ...w2); inPageRearrange = false; const chatElm = elements.chat; const chatCnt = insp(chatElm); if ( chatCnt && typeof chatCnt.urlChanged === 'function' && secondaryWrapper.contains(chatElm) ) { // setTimeout(() => chatCnt.urlChanged, 136); if (typeof chatCnt.urlChangedAsync12 === 'function') { DEBUG_5085 && console.log('elements.chat urlChangedAsync12', 61); chatCnt.urlChanged(); } else { DEBUG_5085 && console.log('elements.chat urlChangedAsync12', 62); setTimeout(() => chatCnt.urlChanged(), 136); } } } } }; let lastPanel = ''; let lastTab = ''; // let fixInitialTabState = 0; let egmPanelsDebounceTimer = null; const aoEgmPanels = new MutationObserver(() => { // console.log(5094,3); if (egmPanelsDebounceTimer) return; egmPanelsDebounceTimer = setTimeout(() => { egmPanelsDebounceTimer = null; Promise.resolve(lockSet['updateEgmPanelsLock']).then(updateEgmPanels).catch(console.warn); }, 16); // ~60fps debounce }); const removeKeepCommentsScroller = async lockId => { if (lockGet['removeKeepCommentsScrollerLock'] !== lockId) return; await Promise.resolve(); if (lockGet['removeKeepCommentsScrollerLock'] !== lockId) return; const ytdFlexyFlm = elements.flexy; if (ytdFlexyFlm) { ytdFlexyFlm.removeAttribute000('keep-comments-scroller'); } }; const egmPanelsCache = new Set(); const updateEgmPanels = async lockId => { if (lockId !== lockGet['updateEgmPanelsLock']) return; await navigateFinishedPromise.then().catch(console.warn); if (lockId !== lockGet['updateEgmPanelsLock']) return; // console.log('updateEgmPanels::called'); const ytdFlexyElm = elements.flexy; if (!ytdFlexyElm) return; let newVisiblePanels = []; let newHiddenPanels = []; let allVisiblePanels = []; const panels = egmPanelsCache; for (const panelElm of panels) { if (!panelElm.isConnected) { egmPanelsCache.delete(panelElm); continue; } const visibility = panelElm.getAttribute000('visibility'); if (visibility === 'ENGAGEMENT_PANEL_VISIBILITY_HIDDEN' || panelElm.closest('[hidden]')) { if (panelElm.hasAttribute000('tyt-visible-at')) { panelElm.removeAttribute000('tyt-visible-at'); newHiddenPanels.push(panelElm); } } else if ( visibility === 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED' && !panelElm.closest('[hidden]') ) { const visibleAt = panelElm.getAttribute000('tyt-visible-at'); if (!visibleAt) { panelElm.setAttribute111('tyt-visible-at', Date.now()); newVisiblePanels.push(panelElm); } allVisiblePanels.push(panelElm); } } if (newVisiblePanels.length >= 1 && allVisiblePanels.length >= 2) { const targetVisible = newVisiblePanels[newVisiblePanels.length - 1]; const actions = []; for (const panelElm of allVisiblePanels) { if (panelElm === targetVisible) continue; actions.push({ panelId: panelElm.getAttribute000('target-id'), toHide: true, }); } if (actions.length >= 1) { ytBtnEgmPanelCore(actions); } } if (allVisiblePanels.length >= 1) { ytdFlexyElm.setAttribute111('tyt-egm-panel_', ''); } else { ytdFlexyElm.removeAttribute000('tyt-egm-panel_'); } newVisiblePanels.length = 0; newVisiblePanels = null; newHiddenPanels.length = 0; newHiddenPanels = null; allVisiblePanels.length = 0; allVisiblePanels = null; }; const checkElementExist = (css, exclude) => { const elms = window.YouTubeDOMCache ? window.YouTubeDOMCache.querySelectorAll(css, document) : qsAll(css); for (const p of elms) { if (!p.closest(exclude)) return p; } return null; }; let fixInitialTabStateK = 0; const { handleNavigateFactory } = (() => { let isLoadStartListened = false; function findLcComment(lc) { if (arguments.length === 1) { const element = qs( `#tab-comments ytd-comments ytd-comment-renderer #header-author a[href*="lc=${lc}"]` ); if (element) { const commentRendererElm = closestFromAnchor.call(element, 'ytd-comment-renderer'); if (commentRendererElm && lc) { return { lc, commentRendererElm, }; } } } else if (arguments.length === 0) { const element = qs( `#tab-comments ytd-comments ytd-comment-renderer > #linked-comment-badge span:not(:empty)` ); if (element) { const commentRendererElm = closestFromAnchor.call(element, 'ytd-comment-renderer'); if (commentRendererElm) { const header = _querySelector.call(commentRendererElm, '#header-author'); if (header) { const anchor = _querySelector.call(header, 'a[href*="lc="]'); if (anchor) { const href = anchor.getAttribute('href') || ''; const m = /[&?]lc=([\w_.-]+)/.exec(href); // dot = sub-comment if (m) { lc = m[1]; } } } } if (commentRendererElm && lc) { return { lc, commentRendererElm, }; } } } return null; } function lcSwapFuncA(targetLcId, currentLcId) { let done = 0; try { // console.log(currentLcId, targetLcId) const r1 = findLcComment(currentLcId).commentRendererElm; const r2 = findLcComment(targetLcId).commentRendererElm; if ( typeof insp(r1).data.linkedCommentBadge === 'object' && typeof insp(r2).data.linkedCommentBadge === 'undefined' ) { const p = Object.assign({}, insp(r1).data.linkedCommentBadge); if (((p || 0).metadataBadgeRenderer || 0).trackingParams) { delete p.metadataBadgeRenderer.trackingParams; } const v1 = findContentsRenderer(r1); const v2 = findContentsRenderer(r2); if ( v1.parent === v2.parent && (v2.parent.nodeName === 'YTD-COMMENTS' || v2.parent.nodeName === 'YTD-ITEM-SECTION-RENDERER') ) { } else { // currently not supported return false; } if (v2.index >= 0) { if (v2.parent.nodeName === 'YTD-COMMENT-REPLIES-RENDERER') { if (lcSwapFuncB(targetLcId, currentLcId, p)) { done = 1; } done = 1; } else { const v2pCnt = insp(v2.parent); const v2Conents = (v2pCnt.data || 0).contents || 0; if (!v2Conents) console.warn('v2Conents is not found'); v2pCnt.data = Object.assign({}, v2pCnt.data, { contents: [].concat( [v2Conents[v2.index]], v2Conents.slice(0, v2.index), v2Conents.slice(v2.index + 1) ), }); if (lcSwapFuncB(targetLcId, currentLcId, p)) { done = 1; } } } } } catch (e) { console.warn(e); } return done === 1; } function lcSwapFuncB(targetLcId, currentLcId, _p) { let done = 0; try { const r1 = findLcComment(currentLcId).commentRendererElm; const r1cnt = insp(r1); const r2 = findLcComment(targetLcId).commentRendererElm; const r2cnt = insp(r2); const r1d = r1cnt.data; const p = Object.assign({}, _p); r1d.linkedCommentBadge = null; delete r1d.linkedCommentBadge; const q = Object.assign({}, r1d); q.linkedCommentBadge = null; delete q.linkedCommentBadge; r1cnt.data = Object.assign({}, q); r2cnt.data = Object.assign({}, r2cnt.data, { linkedCommentBadge: p }); done = 1; } catch (e) { console.warn(e); } return done === 1; } const loadStartFx = async evt => { const media = (evt || 0).target || 0; if (media.nodeName === 'VIDEO' || media.nodeName === 'AUDIO') { } else return; const newMedia = media; const media1 = common.getMediaElement(0); // document.querySelector('#movie_player video[src]'); const media2 = common.getMediaElements(2); // document.querySelectorAll('ytd-browse[role="main"] video[src]'); if (media1 !== null && media2.length > 0) { if (newMedia !== media1 && media1.paused === false) { if (isVideoPlaying(media1)) { Promise.resolve(newMedia) .then(video => video.paused === false && video.pause()) .catch(console.warn); } } else if (newMedia === media1) { for (const s of media2) { if (s.paused === false) { Promise.resolve(s) .then(s => s.paused === false && s.pause()) .catch(console.warn); break; } } } else { Promise.resolve(media1) .then(video1 => video1.paused === false && video1.pause()) .catch(console.warn); } } }; const getBrowsableEndPoint = req => { let valid = false; let endpoint = req ? req.command : null; if ( endpoint && (endpoint.commandMetadata || 0).webCommandMetadata && endpoint.watchEndpoint ) { const videoId = endpoint.watchEndpoint.videoId; const url = endpoint.commandMetadata.webCommandMetadata.url; if (typeof videoId === 'string' && typeof url === 'string' && url.indexOf('lc=') > 0) { const m = /^\/watch\?v=([\w_-]+)&lc=([\w_.-]+)$/.exec(url); // dot = sub-comment if (m && m[1] === videoId) { /* { "style": "BADGE_STYLE_TYPE_SIMPLE", "label": "注目のコメント", "trackingParams": "XXXXXX" } */ const targetLc = findLcComment(m[2]); const currentLc = targetLc ? findLcComment() : null; if (targetLc && currentLc) { const done = targetLc.lc === currentLc.lc ? 1 : lcSwapFuncA(targetLc.lc, currentLc.lc) ? 1 : 0; if (done === 1) { common.xReplaceState(history.state, url); return; } } } } } /* { "type": 0, "command": endpoint, "form": { "tempData": {}, "reload": false } } */ if ( endpoint && (endpoint.commandMetadata || 0).webCommandMetadata && endpoint.browseEndpoint && isChannelId(endpoint.browseEndpoint.browseId) ) { valid = true; } else if ( endpoint && (endpoint.browseEndpoint || endpoint.searchEndpoint) && !endpoint.urlEndpoint && !endpoint.watchEndpoint ) { if (endpoint.browseEndpoint && endpoint.browseEndpoint.browseId === 'FEwhat_to_watch') { // valid = false; const playerMedia = common.getMediaElement(1); if (playerMedia && playerMedia.paused === false) valid = true; // home page } else if (endpoint.commandMetadata && endpoint.commandMetadata.webCommandMetadata) { const meta = endpoint.commandMetadata.webCommandMetadata; if (meta && /*meta.apiUrl &&*/ meta.url && meta.webPageType) { valid = true; } } } if (!valid) endpoint = null; return endpoint; }; const shouldUseMiniPlayer = () => { const isSubTypeExist = qs('ytd-page-manager#page-manager > ytd-browse[page-subtype]'); if (isSubTypeExist) return true; const movie_player = qsAll('#movie_player').filter(e => !e.closest('[hidden]'))[0]; if (movie_player) { const media = qsOne(movie_player, 'video[class], audio[class]'); if ( media && media.currentTime > 3 && media.duration - media.currentTime > 3 && media.paused === false ) { return true; } } return false; // return true; // return !!document.querySelector('ytd-page-manager#page-manager > ytd-browse[page-subtype]'); }; const conditionFulfillment = req => { const command = req ? req.command : null; DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 0801', command); if (!command) return; if (command && (command.commandMetadata || 0).webCommandMetadata && command.watchEndpoint) { } else if ( command && (command.commandMetadata || 0).webCommandMetadata && command.browseEndpoint && isChannelId(command.browseEndpoint.browseId) ) { } else if ( command && (command.browseEndpoint || command.searchEndpoint) && !command.urlEndpoint && !command.watchEndpoint ) { } else { return false; } DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 0802'); if (!shouldUseMiniPlayer()) return false; DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 0803'); /* // user would like to switch page immediately without playing the video; // attribute appear after playing video for more than 2s if (!document.head.dataset.viTime) return false; else { let currentVideo = common.getMediaElement(0); if (currentVideo && currentVideo.readyState > currentVideo.HAVE_CURRENT_DATA && currentVideo.currentTime > 2.2 && currentVideo.duration - 2.2 < currentVideo.currentTime) { // disable miniview browsing if the media is near to the end return false; } } */ if (pageType !== 'watch') return false; DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 0804'); // 2025.10.16 - ignore ytp-miniplayer-button existance // if (!checkElementExist('ytd-watch-flexy #player button.ytp-miniplayer-button.ytp-button', '[hidden]')) { // return false; // } // DEBUG_handleNavigateFactory && console.log("handleNavigateFactory - 0805"); return true; }; let u38 = 0; const fixChannelAboutPopup = async t38 => { let promise = new PromiseExternal(); const f = () => { promise && promise.resolve(); promise = null; }; document.addEventListener('yt-navigate-finish', f, false); await promise.then(); promise = null; document.removeEventListener('yt-navigate-finish', f, false); if (t38 !== u38) return; setTimeout(() => { const currentAbout = qsAll('ytd-about-channel-renderer').filter( e => !e.closest('[hidden]') )[0]; let okay = false; if (!currentAbout) okay = true; else { const popupContainer = currentAbout.closest('ytd-popup-container'); if (popupContainer) { const cnt = insp(popupContainer); let arr = null; try { arr = cnt.handleGetOpenedPopupsAction_(); } catch {} if (arr && arr.length === 0) okay = true; } else { okay = false; } } if (okay) { const descriptionModel = [...qsAll('yt-description-preview-view-model')].filter( e => !e.closest('[hidden]') )[0]; if (descriptionModel) { const button = [...descriptionModel.querySelectorAll('button')].filter( e => !e.closest('[hidden]') && `${e.textContent}`.trim().length > 0 )[0]; if (button) { button.click(); } } } }, 80); }; const handleNavigateFactory = handleNavigate => { return function (req) { if (u38 > 1e9) u38 = 9; const t38 = ++u38; const $this = this; const $arguments = arguments; let endpoint = null; if (conditionFulfillment(req)) { endpoint = getBrowsableEndPoint(req); DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 1000', req, endpoint); } DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 1001', req, endpoint); if (!endpoint || !shouldUseMiniPlayer()) return handleNavigate.apply($this, $arguments); // console.log('tabview-script-handleNavigate') const ytdAppElm = qs('ytd-app'); const ytdAppCnt = insp(ytdAppElm); let object = null; try { object = ytdAppCnt.data.response.currentVideoEndpoint.watchEndpoint || null; } catch { object = null; } DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 1002', object); if (typeof object !== 'object') object = null; const once = { once: true }; // browsers supporting async function can also use once option. if (object !== null && !('playlistId' in object)) { DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - 1003', object); let wObject = mWeakRef(object); const N = 3; let count = 0; /* rcb(b) => a = playlistId = undefinded var scb = function(a, b, c, d) { a.isInitialized() && (B("kevlar_miniplayer_navigate_to_shorts_killswitch") ? c || d ? ("watch" !== Xu(b) && "shorts" !== Xu(b) && os(a.miniplayerEl, "yt-cache-miniplayer-page-action", [b]), qs(a.miniplayerEl, "yt-deactivate-miniplayer-action")) : "watch" === Xu(b) && rcb(b) && (qt.getInstance().playlistWatchPageActivation = !0, a.activateMiniplayer(b)) : c ? ("watch" !== Xu(b) && os(a.miniplayerEl, "yt-cache-miniplayer-page-action", [b]), qs(a.miniplayerEl, "yt-deactivate-miniplayer-action")) : d ? qs(a.miniplayerEl, "yt-pause-miniplayer-action") : "watch" === Xu(b) && rcb(b) && (qt.getInstance().playlistWatchPageActivation = !0, a.activateMiniplayer(b))) }; */ Object.defineProperty(kRef(wObject) || {}, 'playlistId', { get() { DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - get', count); count++; if (count === N) { delete this.playlistId; } return '*'; }, set(value) { DEBUG_handleNavigateFactory && console.log('handleNavigateFactory - set', count, value); delete this.playlistId; // remove property definition this.playlistId = value; // assign as normal property }, enumerable: false, configurable: true, }); let playlistClearout = null; let timeoutid = 0; Promise.race([ new Promise(r => { timeoutid = setTimeout(r, 4000); }), new Promise(r => { playlistClearout = () => { if (timeoutid > 0) { clearTimeout(timeoutid); timeoutid = 0; } r(); }; document.addEventListener('yt-page-type-changed', playlistClearout, once); }), ]) .then(() => { if (timeoutid !== 0) { playlistClearout && document.removeEventListener('yt-page-type-changed', playlistClearout, once); timeoutid = 0; } playlistClearout = null; count = N - 1; const object = kRef(wObject); wObject = null; return object ? object.playlistId : null; }) .catch(console.warn); } if (!isLoadStartListened) { isLoadStartListened = true; document.addEventListener('loadstart', loadStartFx, true); } const endpointURL = `${endpoint?.commandMetadata?.webCommandMetadata?.url || ''}`; if ( endpointURL && endpointURL.endsWith('/about') && /\/channel\/UC[-_a-zA-Z0-9+=.]{22}\/about/.test(endpointURL) ) { fixChannelAboutPopup(t38); } handleNavigate.apply($this, $arguments); }; }; return { handleNavigateFactory }; })(); const common = (() => { let mediaModeLock = 0; const _getMediaElement = i => { if (mediaModeLock === 0) { const e = qs('.video-stream.html5-main-video') || qs('#movie_player video, #movie_player audio') || qs('body video[src], body audio[src]'); if (e) { if (e.nodeName === 'VIDEO') mediaModeLock = 1; else if (e.nodeName === 'AUDIO') mediaModeLock = 2; } } if (!mediaModeLock) return null; if (mediaModeLock === 1) { switch (i) { case 1: return 'ytd-player#ytd-player video[src]'; case 2: return 'ytd-browse[role="main"] video[src]'; case 0: default: return '#movie_player video[src]'; } } else if (mediaModeLock === 2) { switch (i) { case 1: return 'ytd-player#ytd-player audio.video-stream.html5-main-video[src]'; case 2: return 'ytd-browse[role="main"] audio.video-stream.html5-main-video[src]'; case 0: default: return '#movie_player audio.video-stream.html5-main-video[src]'; } } return null; }; return { xReplaceState(s, u) { try { history.replaceState(s, '', u); } catch { // in case error occurs if replaceState is replaced by any external script / extension } if (s.endpoint) { try { const ytdAppElm = qs('ytd-app'); const ytdAppCnt = insp(ytdAppElm); ytdAppCnt.replaceState(s.endpoint, '', u); } catch {} } }, getMediaElement(i) { const s = _getMediaElement(i) || ''; if (s) return qs(s); return null; }, getMediaElements(i) { const s = _getMediaElement(i) || ''; if (s) return qsAll(s); return []; }, }; })(); let inPageRearrange = false; let tmpLastVideoId = ''; // const nsMap = new Map(); const getCurrentVideoId = () => { const ytdFlexyElm = elements.flexy; const ytdFlexyCnt = insp(ytdFlexyElm); if (ytdFlexyCnt && typeof ytdFlexyCnt.videoId === 'string') return ytdFlexyCnt.videoId; if (ytdFlexyElm && typeof ytdFlexyElm.videoId === 'string') return ytdFlexyElm.videoId; return ''; }; const _holdInlineExpanderAlwaysExpanded = inlineExpanderCnt => { if (inlineExpanderCnt.alwaysShowExpandButton === true) { inlineExpanderCnt.alwaysShowExpandButton = false; } if (typeof (inlineExpanderCnt.collapseLabel || 0) === 'string') { inlineExpanderCnt.collapseLabel = ''; } if (typeof (inlineExpanderCnt.expandLabel || 0) === 'string') { inlineExpanderCnt.expandLabel = ''; } if (inlineExpanderCnt.showCollapseButton === true) { inlineExpanderCnt.showCollapseButton = false; } if (inlineExpanderCnt.showExpandButton === true) inlineExpanderCnt.showExpandButton = false; if (inlineExpanderCnt.expandButton instanceof HTMLElement_) { inlineExpanderCnt.expandButton = null; inlineExpanderCnt.expandButton.remove(); } }; const fixInlineExpanderDisplay = inlineExpanderCnt => { try { inlineExpanderCnt.updateIsAttributedExpanded(); } catch (e) { // Optional method - may not exist DEBUG_5084 && console.debug('[main] updateIsAttributedExpanded not available', e); } try { inlineExpanderCnt.updateIsFormattedExpanded(); } catch (e) { DEBUG_5084 && console.debug('[main] updateIsFormattedExpanded not available', e); } try { inlineExpanderCnt.updateTextOnSnippetTypeChange(); } catch (e) { DEBUG_5084 && console.debug('[main] updateTextOnSnippetTypeChange not available', e); } try { inlineExpanderCnt.updateStyles(); } catch (e) { DEBUG_5084 && console.debug('[main] updateStyles not available', e); } }; const setExpand = cnt => { if (typeof cnt.set === 'function') { cnt.set('isExpanded', true); if (typeof cnt.isExpandedChanged === 'function') cnt.isExpandedChanged(); } else if (cnt.isExpanded === false) { cnt.isExpanded = true; if (typeof cnt.isExpandedChanged === 'function') cnt.isExpandedChanged(); } }; const cloneMethods = { updateTextOnSnippetTypeChange() { if (this.isResetMutation === false) this.isResetMutation = true; if (this.isExpanded === true) this.isExpanded = false; setExpand(this, true); if (this.isResetMutation === false) this.isResetMutation = true; try { true || (this.isResetMutation && this.mutationCallback()); } catch (e) { console.error(e); } }, collapse() {}, computeExpandButtonOffset() { return 0; }, dataChanged() {}, }; const fixInlineExpanderMethods = inlineExpanderCnt => { if (inlineExpanderCnt && !inlineExpanderCnt.__$$idncjk8487$$__) { inlineExpanderCnt.__$$idncjk8487$$__ = true; inlineExpanderCnt.dataChanged = cloneMethods.dataChanged; inlineExpanderCnt.updateTextOnSnippetTypeChange = cloneMethods.updateTextOnSnippetTypeChange; if (typeof inlineExpanderCnt.collapse === 'function') { inlineExpanderCnt.collapse = cloneMethods.collapse; } if (typeof inlineExpanderCnt.computeExpandButtonOffset === 'function') { inlineExpanderCnt.computeExpandButtonOffset = cloneMethods.computeExpandButtonOffset; } // inlineExpanderCnt.hasAttributedStringText = true; if (typeof inlineExpanderCnt.isResetMutation === 'boolean') { inlineExpanderCnt.isResetMutation = true; } if (typeof inlineExpanderCnt.collapseLabel === 'string') { inlineExpanderCnt.collapseLabel = ''; } fixInlineExpanderDisplay(inlineExpanderCnt); // do the initial fix } }; const fixInlineExpanderContent = () => { // console.log(21886,1) const mainInfo = getMainInfo(); if (!mainInfo) return; // console.log(21886,2) const inlineExpanderElm = mainInfo.querySelector('ytd-text-inline-expander'); const inlineExpanderCnt = insp(inlineExpanderElm); fixInlineExpanderMethods(inlineExpanderCnt); // console.log(21886, 3) // if (inlineExpanderCnt && inlineExpanderCnt.isExpanded === true && plugin.autoExpandInfoDesc.activated) { // // inlineExpanderCnt.isExpandedChanged(); // // holdInlineExpanderAlwaysExpanded(inlineExpanderCnt); // } // if(inlineExpanderCnt){ // // console.log(21886,4, inlineExpanderCnt.isExpanded, inlineExpanderCnt.isTruncated) // if (inlineExpanderCnt.isExpanded === false && inlineExpanderCnt.isTruncated === true) { // // console.log(21881) // inlineExpanderCnt.isTruncated = false; // } // } }; const plugin = { minibrowser: { activated: false, toUse: true, // depends on shouldUseMiniPlayer() activate() { if (this.activated) return; const isPassiveArgSupport = typeof IntersectionObserver === 'function'; // https://caniuse.com/?search=observer // https://caniuse.com/?search=addEventListener%20passive if (!isPassiveArgSupport) return; this.activated = true; const ytdAppElm = qs('ytd-app'); const ytdAppCnt = insp(ytdAppElm); if (!ytdAppCnt) return; const cProto = ytdAppCnt.constructor.prototype; if (!cProto.handleNavigate) return; if (cProto.handleNavigate.__ma355__) return; cProto.handleNavigate = handleNavigateFactory(cProto.handleNavigate); cProto.handleNavigate.__ma355__ = 1; }, }, autoExpandInfoDesc: { activated: false, toUse: false, // false by default; once the expand is clicked, maintain the feature until the browser is closed. /** @type { MutationObserver | null } */ mo: null, promiseReady: new PromiseExternal(), moFn(lockId) { if (lockGet['autoExpandInfoDescAttrAsyncLock'] !== lockId) return; const mainInfo = getMainInfo(); if (!mainInfo) return; switch (((mainInfo || 0).nodeName || '').toLowerCase()) { case 'ytd-expander': if (mainInfo.hasAttribute000('collapsed')) { let success = false; try { insp(mainInfo).handleMoreTap(new Event('tap')); success = true; } catch {} if (success) mainInfo.setAttribute111('tyt-no-less-btn', ''); } break; case 'ytd-expandable-video-description-body-renderer': const inlineExpanderElm = mainInfo.querySelector('ytd-text-inline-expander'); const inlineExpanderCnt = insp(inlineExpanderElm); if (inlineExpanderCnt && inlineExpanderCnt.isExpanded === false) { setExpand(inlineExpanderCnt, true); // holdInlineExpanderAlwaysExpanded(inlineExpanderCnt); } break; } }, activate() { if (this.activated) return; this.moFn = this.moFn.bind(this); this.mo = new MutationObserver(() => { Promise.resolve(lockSet['autoExpandInfoDescAttrAsyncLock']) .then(this.moFn) .catch(console.warn); }); this.activated = true; this.promiseReady.resolve(); }, async onMainInfoSet(mainInfo) { await this.promiseReady.then(); if (mainInfo.nodeName.toLowerCase() === 'ytd-expander') { this.mo.observe(mainInfo, { attributes: true, attributeFilter: ['collapsed', 'attr-8ifv7'], }); } else { this.mo.observe(mainInfo, { attributes: true, attributeFilter: ['attr-8ifv7'] }); } mainInfo.incAttribute111('attr-8ifv7'); }, }, fullChannelNameOnHover: { activated: false, toUse: true, /** @type { MutationObserver | null } */ mo: null, /** @type { ResizeObserver | null} */ ro: null, promiseReady: new PromiseExternal(), checkResize: 0, mouseEnterFn(evt) { const target = evt ? evt.target : null; if (!(target instanceof HTMLElement_)) return; const metaDataElm = target.closest('ytd-watch-metadata'); metaDataElm.classList.remove('tyt-metadata-hover-resized'); this.checkResize = Date.now() + 300; metaDataElm.classList.add('tyt-metadata-hover'); // console.log('mouseEnter') }, mouseLeaveFn(evt) { const target = evt ? evt.target : null; if (!(target instanceof HTMLElement_)) return; const metaDataElm = target.closest('ytd-watch-metadata'); metaDataElm.classList.remove('tyt-metadata-hover-resized'); metaDataElm.classList.remove('tyt-metadata-hover'); // console.log('mouseLeaveFn') }, moFn(lockId) { if (lockGet['fullChannelNameOnHoverAttrAsyncLock'] !== lockId) return; const uploadInfo = qs('#primary.ytd-watch-flexy ytd-watch-metadata #upload-info'); if (!uploadInfo) return; const evtOpt = { passive: true, capture: false }; uploadInfo.removeEventListener('pointerenter', this.mouseEnterFn, evtOpt); uploadInfo.removeEventListener('pointerleave', this.mouseLeaveFn, evtOpt); uploadInfo.addEventListener('pointerenter', this.mouseEnterFn, evtOpt); uploadInfo.addEventListener('pointerleave', this.mouseLeaveFn, evtOpt); }, async onNavigateFinish() { await this.promiseReady.then(); const uploadInfo = qs('#primary.ytd-watch-flexy ytd-watch-metadata #upload-info'); if (!uploadInfo) return; this.mo.observe(uploadInfo, { attributes: true, attributeFilter: ['hidden', 'attr-3wb0k'], }); uploadInfo.incAttribute111('attr-3wb0k'); this.ro.observe(uploadInfo); }, activate() { if (this.activated) return; const isPassiveArgSupport = typeof IntersectionObserver === 'function'; // https://caniuse.com/?search=observer // https://caniuse.com/?search=addEventListener%20passive if (!isPassiveArgSupport) return; this.activated = true; this.mouseEnterFn = this.mouseEnterFn.bind(this); this.mouseLeaveFn = this.mouseLeaveFn.bind(this); this.moFn = this.moFn.bind(this); this.mo = new MutationObserver(() => { Promise.resolve(lockSet['fullChannelNameOnHoverAttrAsyncLock']) .then(this.moFn) .catch(console.warn); }); this.ro = new ResizeObserver(mutations => { if (Date.now() > this.checkResize) return; for (const mutation of mutations) { const uploadInfo = mutation.target; if (uploadInfo && mutation.contentRect.width > 0 && mutation.contentRect.height > 0) { const metaDataElm = uploadInfo.closest('ytd-watch-metadata'); if (metaDataElm.classList.contains('tyt-metadata-hover')) { metaDataElm.classList.add('tyt-metadata-hover-resized'); } break; } } }); this.promiseReady.resolve(); }, }, 'external.ytlstm': { activated: false, toUse: true, // depends on shouldUseMiniPlayer() activate() { if (this.activated) return; this.activated = true; document.documentElement.classList.add('external-ytlstm'); }, }, }; if (sessionStorage.__$$tmp_UseAutoExpandInfoDesc$$__) plugin.autoExpandInfoDesc.toUse = true; // let shouldFixInfo = false; const __attachedSymbol__ = Symbol(); const makeInitAttached = tag => { const inPageRearrange_ = inPageRearrange; inPageRearrange = false; for (const elm of qsAll(`${tag}`)) { const cnt = insp(elm) || 0; if (typeof cnt.attached498 === 'function' && !elm[__attachedSymbol__]) { Promise.resolve(elm).then(eventMap[`${tag}::attached`]).catch(console.warn); } } inPageRearrange = inPageRearrange_; }; const getGeneralChatElement = async () => { for (let i = 2; i-- > 0; ) { const t = qs('#columns.style-scope.ytd-watch-flexy ytd-live-chat-frame#chat'); if (t instanceof Element) return t; if (i > 0) { // try later await delayPn(200); } } return null; }; const nsTemplateObtain = () => { let nsTemplate = qs('ytd-watch-flexy noscript[ns-template]'); if (!nsTemplate) { nsTemplate = document.createElement('noscript'); nsTemplate.setAttribute('ns-template', ''); qs('ytd-watch-flexy').appendChild(nsTemplate); } return nsTemplate; }; const isPageDOM = (elm, selector) => { if (!elm || !(elm instanceof Element) || !elm.nodeName) return false; if (!elm.closest(selector)) return false; if (elm.isConnected !== true) return false; return true; }; const invalidFlexyParent = hostElement => { if (hostElement instanceof HTMLElement) { const hasFlexyParent = HTMLElement.prototype.closest.call(hostElement, 'ytd-watch-flexy'); // eg short if (!hasFlexyParent) return true; const currentFlexy = elements.flexy; if (currentFlexy && currentFlexy !== hasFlexyParent) return true; } return false; }; // const mutationComment = document.createComment('1'); // let mutationPromise = new PromiseExternal(); // const mutationPromiseObs = new MutationObserver(()=>{ // mutationPromise.resolve(); // mutationPromise = new PromiseExternal(); // }); // mutationPromiseObs.observe(mutationComment, {characterData: true}); let headerMutationObserver = null; let headerMutationTmpNode = null; const eventMap = { ceHack: () => { mLoaded.flag |= 2; document.documentElement.setAttribute111('tabview-loaded', mLoaded.makeString()); retrieveCE('ytd-watch-flexy') .then(eventMap['ytd-watch-flexy::defined']) .catch(console.warn); retrieveCE('ytd-expander').then(eventMap['ytd-expander::defined']).catch(console.warn); retrieveCE('ytd-watch-next-secondary-results-renderer') .then(eventMap['ytd-watch-next-secondary-results-renderer::defined']) .catch(console.warn); retrieveCE('ytd-comments-header-renderer') .then(eventMap['ytd-comments-header-renderer::defined']) .catch(console.warn); retrieveCE('ytd-live-chat-frame') .then(eventMap['ytd-live-chat-frame::defined']) .catch(console.warn); retrieveCE('ytd-comments').then(eventMap['ytd-comments::defined']).catch(console.warn); retrieveCE('ytd-engagement-panel-section-list-renderer') .then(eventMap['ytd-engagement-panel-section-list-renderer::defined']) .catch(console.warn); retrieveCE('ytd-watch-metadata') .then(eventMap['ytd-watch-metadata::defined']) .catch(console.warn); retrieveCE('ytd-playlist-panel-renderer') .then(eventMap['ytd-playlist-panel-renderer::defined']) .catch(console.warn); retrieveCE('ytd-expandable-video-description-body-renderer') .then(eventMap['ytd-expandable-video-description-body-renderer::defined']) .catch(console.warn); }, fixForTabDisplay: isResize => { // isResize is true if the layout is resized (not due to tab switching) // youtube components shall handle the resize issue. can skip some checkings. bFixForResizedTabLater = false; const runLowPriority = () => { for (const element of qsAll('[io-intersected]')) { const cnt = insp(element); if (element instanceof HTMLElement_ && typeof cnt.calculateCanCollapse === 'function') { try { cnt.calculateCanCollapse(true); } catch {} } } }; if (typeof requestIdleCallback === 'function') { requestIdleCallback(runLowPriority, { timeout: 100 }); } else { setTimeout(runLowPriority, 0); } if (!isResize && lastTab === '#tab-info') { // #tab-info is now shown. // to fix the sizing issue (description info cards in tab info) requestAnimationFrame(() => { for (const element of qsAll( '#tab-info ytd-video-description-infocards-section-renderer, #tab-info yt-chip-cloud-renderer, #tab-info ytd-horizontal-card-list-renderer, #tab-info yt-horizontal-list-renderer' )) { const cnt = insp(element); if (element instanceof HTMLElement_ && typeof cnt.notifyResize === 'function') { try { cnt.notifyResize(); } catch {} } } // to fix expand/collapse sizing issue (inline-expander in tab info) // for example, expand button is required but not shown as it was rendered in the hidden state for (const element of qsAll('#tab-info ytd-text-inline-expander')) { const cnt = insp(element); if (element instanceof HTMLElement_ && typeof cnt.resize === 'function') { cnt.resize(false); // reflow due to offsetWidth calling } fixInlineExpanderDisplay(cnt); // just in case } }); } if (!isResize && typeof lastTab === 'string' && lastTab.startsWith('#tab-')) { const tabContent = qs('.tab-content-cld:not(.tab-content-hidden)'); if (tabContent) { const renderers = tabContent.querySelectorAll('yt-chip-cloud-renderer'); for (const renderer of renderers) { const cnt = insp(renderer); if (typeof cnt.notifyResize === 'function') { try { cnt.notifyResize(); } catch {} } } } } }, 'ytd-watch-flexy::defined': cProto => { if ( !cProto.updateChatLocation498 && typeof cProto.updateChatLocation === 'function' && cProto.updateChatLocation.length === 0 ) { cProto.updateChatLocation498 = cProto.updateChatLocation; cProto.updateChatLocation = updateChatLocation498; } if ( !cProto.isTwoColumnsChanged498_ && typeof cProto.isTwoColumnsChanged_ === 'function' && cProto.isTwoColumnsChanged_.length === 2 ) { cProto.isTwoColumnsChanged498_ = cProto.isTwoColumnsChanged_; cProto.isTwoColumnsChanged_ = function (arg1, arg2, ...args) { const r = secondaryInnerFn(() => { const r = this.isTwoColumnsChanged498_(arg1, arg2, ...args); return r; }); return r; }; } if ( !cProto.defaultTwoColumnLayoutChanged498 && typeof cProto.defaultTwoColumnLayoutChanged === 'function' && cProto.defaultTwoColumnLayoutChanged.length === 0 ) { cProto.defaultTwoColumnLayoutChanged498 = cProto.defaultTwoColumnLayoutChanged; cProto.defaultTwoColumnLayoutChanged = function (...args) { const r = secondaryInnerFn(() => { const r = this.defaultTwoColumnLayoutChanged498(...args); return r; }); return r; }; } if ( !cProto.updatePlayerLocation498 && typeof cProto.updatePlayerLocation === 'function' && cProto.updatePlayerLocation.length === 0 ) { cProto.updatePlayerLocation498 = cProto.updatePlayerLocation; cProto.updatePlayerLocation = function (...args) { const r = secondaryInnerFn(() => { const r = this.updatePlayerLocation498(...args); return r; }); return r; }; } if ( !cProto.updateCinematicsLocation498 && typeof cProto.updateCinematicsLocation === 'function' && cProto.updateCinematicsLocation.length === 0 ) { cProto.updateCinematicsLocation498 = cProto.updateCinematicsLocation; cProto.updateCinematicsLocation = function (...args) { const r = secondaryInnerFn(() => { const r = this.updateCinematicsLocation498(...args); return r; }); return r; }; } if ( !cProto.updatePanelsLocation498 && typeof cProto.updatePanelsLocation === 'function' && cProto.updatePanelsLocation.length === 0 ) { cProto.updatePanelsLocation498 = cProto.updatePanelsLocation; cProto.updatePanelsLocation = function (...args) { const r = secondaryInnerFn(() => { const r = this.updatePanelsLocation498(...args); return r; }); return r; }; } if ( !cProto.swatcherooUpdatePanelsLocation498 && typeof cProto.swatcherooUpdatePanelsLocation === 'function' && cProto.swatcherooUpdatePanelsLocation.length === 6 ) { cProto.swatcherooUpdatePanelsLocation498 = cProto.swatcherooUpdatePanelsLocation; cProto.swatcherooUpdatePanelsLocation = function ( arg1, arg2, arg3, arg4, arg5, arg6, ...args ) { const r = secondaryInnerFn(() => { const r = this.swatcherooUpdatePanelsLocation498( arg1, arg2, arg3, arg4, arg5, arg6, ...args ); return r; }); return r; }; } if ( !cProto.updateErrorScreenLocation498 && typeof cProto.updateErrorScreenLocation === 'function' && cProto.updateErrorScreenLocation.length === 0 ) { cProto.updateErrorScreenLocation498 = cProto.updateErrorScreenLocation; cProto.updateErrorScreenLocation = function (...args) { const r = secondaryInnerFn(() => { const r = this.updateErrorScreenLocation498(...args); return r; }); return r; }; } if ( !cProto.updateFullBleedElementLocations498 && typeof cProto.updateFullBleedElementLocations === 'function' && cProto.updateFullBleedElementLocations.length === 0 ) { cProto.updateFullBleedElementLocations498 = cProto.updateFullBleedElementLocations; cProto.updateFullBleedElementLocations = function (...args) { const r = secondaryInnerFn(() => { const r = this.updateFullBleedElementLocations498(...args); return r; }); return r; }; } }, 'ytd-watch-next-secondary-results-renderer::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-watch-next-secondary-results-renderer::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-watch-next-secondary-results-renderer::detached']) .catch(console.warn); } return this.detached498(); }; } makeInitAttached('ytd-watch-next-secondary-results-renderer'); }, 'ytd-watch-next-secondary-results-renderer::attached': hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-watch-next-secondary-results-renderer::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; if ( hostElement instanceof HTMLElement_ && hostElement.matches('#columns #related ytd-watch-next-secondary-results-renderer') && !hostElement.matches( '#right-tabs ytd-watch-next-secondary-results-renderer, [hidden] ytd-watch-next-secondary-results-renderer' ) ) { elements.related = hostElement.closest('#related'); hostElement.setAttribute111('tyt-videos-list', ''); } // console.log('ytd-watch-next-secondary-results-renderer::attached', hostElement); }, 'ytd-watch-next-secondary-results-renderer::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-watch-next-secondary-results-renderer::detached'); if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; if (hostElement.hasAttribute000('tyt-videos-list')) { elements.related = null; hostElement.removeAttribute000('tyt-videos-list'); } DEBUG_5084 && console.log('ytd-watch-next-secondary-results-renderer::detached', hostElement); }, settingCommentsVideoId: hostElement => { if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } const cnt = insp(hostElement); const commentsArea = elements.comments; if ( commentsArea !== hostElement || hostElement.isConnected !== true || cnt.isAttached !== true || !cnt.data || cnt.hidden !== false ) { return; } const ytdFlexyElm = elements.flexy; const ytdFlexyCnt = ytdFlexyElm ? insp(ytdFlexyElm) : null; if (ytdFlexyCnt && ytdFlexyCnt.videoId) { hostElement.setAttribute111('tyt-comments-video-id', ytdFlexyCnt.videoId); } else { hostElement.removeAttribute000('tyt-comments-video-id'); } }, checkCommentsShouldBeHidden: lockId => { if (lockGet['checkCommentsShouldBeHiddenLock'] !== lockId) return; // commentsArea's attribute: tyt-comments-video-id // ytdFlexyElm's attribute: video-id const commentsArea = elements.comments; const ytdFlexyElm = elements.flexy; if (commentsArea && ytdFlexyElm && !commentsArea.hasAttribute000('hidden')) { const ytdFlexyCnt = insp(ytdFlexyElm); if (typeof ytdFlexyCnt.videoId === 'string') { const commentsVideoId = commentsArea.getAttribute('tyt-comments-video-id'); if (commentsVideoId && commentsVideoId !== ytdFlexyCnt.videoId) { commentsArea.setAttribute111('hidden', ''); // removeKeepCommentsScroller(); } } } }, 'ytd-comments::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-comments::attached']) .catch(console.warn); } // Promise.resolve(this.hostElement).then(eventMap['ytd-comments::dataChanged_']).catch(console.warn); return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-comments::detached']) .catch(console.warn); } // Promise.resolve(this.hostElement).then(eventMap['ytd-comments::dataChanged_']).catch(console.warn); return this.detached498(); }; } cProto._createPropertyObserver('data', '_dataChanged498', undefined); cProto._dataChanged498 = function () { // console.log('_dataChanged498', this.hostElement) Promise.resolve(this.hostElement) .then(eventMap['ytd-comments::_dataChanged498']) .catch(console.warn); }; // if (!cProto.dataChanged498_ && typeof cProto.dataChanged_ === 'function') { // cProto.dataChanged498_ = cProto.dataChanged_; // cProto.dataChanged_ = function () { // Promise.resolve(this.hostElement).then(eventMap['ytd-comments::dataChanged_']).catch(console.warn); // return this.dataChanged498_(); // } // } makeInitAttached('ytd-comments'); }, 'ytd-comments::_dataChanged498': hostElement => { // console.log(18984, hostElement.hasAttribute('tyt-comments-area')) if (!hostElement.hasAttribute000('tyt-comments-area')) return; let commentsDataStatus = 0; const cnt = insp(hostElement); const data = cnt ? cnt.data : null; const contents = data ? data.contents : null; if (data) { if (contents && contents.length === 1 && contents[0].messageRenderer) { commentsDataStatus = 2; } if (contents && contents.length > 1 && contents[0].commentThreadRenderer) { commentsDataStatus = 1; } } if (commentsDataStatus) { hostElement.setAttribute111('tyt-comments-data-status', commentsDataStatus); // ytdFlexyElm.setAttribute111('tyt-comment-disabled', '') } else { // ytdFlexyElm.removeAttribute000('tyt-comment-disabled') hostElement.removeAttribute000('tyt-comments-data-status'); } Promise.resolve(hostElement).then(eventMap['settingCommentsVideoId']).catch(console.warn); }, 'ytd-comments::attached': async hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-comments::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; if (!hostElement || hostElement.id !== 'comments') return; // if (!hostElement || hostElement.closest('[hidden]')) return; elements.comments = hostElement; Promise.resolve(hostElement).then(eventMap['settingCommentsVideoId']).catch(console.warn); aoComment.observe(hostElement, { attributes: true }); hostElement.setAttribute111('tyt-comments-area', ''); const lockId = lockSet['rightTabReadyLock02']; await rightTabsProvidedPromise.then(); if (lockGet['rightTabReadyLock02'] !== lockId) return; if (elements.comments !== hostElement) return; if (hostElement.isConnected === false) return; DEBUG_5085 && console.log(7932, 'comments'); // if(!elements.comments || elements.comments.isConnected === false) return; if (hostElement && !hostElement.closest('#right-tabs')) { qs('#tab-comments').assignChildren111(null, hostElement, null); } else { const shouldTabVisible = elements.comments && elements.comments.closest('#tab-comments') && !elements.comments.closest('[hidden]'); document .querySelector('[tyt-tab-content="#tab-comments"]') .classList.toggle('tab-btn-hidden', !shouldTabVisible); // document.querySelector('#tab-comments').classList.remove('tab-content-hidden') // document.querySelector('[tyt-tab-content="#tab-comments"]').classList.remove('tab-btn-hidden') Promise.resolve(lockSet['removeKeepCommentsScrollerLock']) .then(removeKeepCommentsScroller) .catch(console.warn); } TAB_AUTO_SWITCH_TO_COMMENTS && switchToTab('#tab-comments'); }, 'ytd-comments::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-comments::detached'); // console.log(858, hostElement) if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; if (hostElement.hasAttribute000('tyt-comments-area')) { // foComments.disconnect(); // foComments.takeRecords(); hostElement.removeAttribute000('tyt-comments-area'); // document.querySelector('#tab-comments').classList.add('tab-content-hidden') // document.querySelector('[tyt-tab-content="#tab-comments"]').classList.add('tab-btn-hidden') aoComment.disconnect(); aoComment.takeRecords(); elements.comments = null; document .querySelector('[tyt-tab-content="#tab-comments"]') .classList.add('tab-btn-hidden'); Promise.resolve(lockSet['removeKeepCommentsScrollerLock']) .then(removeKeepCommentsScroller) .catch(console.warn); } }, 'ytd-comments-header-renderer::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-comments-header-renderer::attached']) .catch(console.warn); } Promise.resolve(this.hostElement) .then(eventMap['ytd-comments-header-renderer::dataChanged']) .catch(console.warn); // force dataChanged on attached return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-comments-header-renderer::detached']) .catch(console.warn); } return this.detached498(); }; } if (!cProto.dataChanged498 && typeof cProto.dataChanged === 'function') { cProto.dataChanged498 = cProto.dataChanged; cProto.dataChanged = function () { Promise.resolve(this.hostElement) .then(eventMap['ytd-comments-header-renderer::dataChanged']) .catch(console.warn); return this.dataChanged498(); }; } makeInitAttached('ytd-comments-header-renderer'); }, 'ytd-comments-header-renderer::attached': hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-comments-header-renderer::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; if (!hostElement || !hostElement.classList.contains('ytd-item-section-renderer')) return; // console.log(12991, 'ytd-comments-header-renderer::attached') const targetElement = qs('[tyt-comments-area] ytd-comments-header-renderer'); if (hostElement === targetElement) { hostElement.setAttribute111('tyt-comments-header-field', ''); } else { const parentNode = hostElement.parentNode; if ( parentNode instanceof HTMLElement_ && parentNode.querySelector('[tyt-comments-header-field]') ) { hostElement.setAttribute111('tyt-comments-header-field', ''); } } }, 'ytd-comments-header-renderer::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-comments-header-renderer::detached'); if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; // console.log(12992, 'ytd-comments-header-renderer::detached') if (hostElement.hasAttribute000('field-of-cm-count')) { hostElement.removeAttribute000('field-of-cm-count'); const cmCount = qs('#tyt-cm-count'); if (cmCount && !qs('#tab-comments ytd-comments-header-renderer[field-of-cm-count]')) { cmCount.textContent = ''; } } if (hostElement.hasAttribute000('tyt-comments-header-field')) { hostElement.removeAttribute000('tyt-comments-header-field'); } }, 'ytd-comments-header-renderer::dataChanged': hostElement => { if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } const ytdFlexyElm = elements.flexy; let b = false; const cnt = insp(hostElement); if ( cnt && hostElement.closest('#tab-comments') && qs('#tab-comments ytd-comments-header-renderer') === hostElement ) { b = true; } else if ( hostElement instanceof HTMLElement_ && hostElement.parentNode instanceof HTMLElement_ && hostElement.parentNode.querySelector('[tyt-comments-header-field]') ) { b = true; } if (b) { hostElement.setAttribute111('tyt-comments-header-field', ''); ytdFlexyElm && ytdFlexyElm.removeAttribute000('tyt-comment-disabled'); } if ( hostElement.hasAttribute000('tyt-comments-header-field') && hostElement.isConnected === true ) { if (!headerMutationObserver) { headerMutationObserver = new MutationObserver( eventMap['ytd-comments-header-renderer::deferredCounterUpdate'] ); } headerMutationObserver.observe(hostElement.parentNode, { subtree: false, childList: true, }); if (!headerMutationTmpNode) { headerMutationTmpNode = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); } const tmpNode = headerMutationTmpNode; hostElement.insertAdjacentElement('afterend', tmpNode); tmpNode.remove(); } }, 'ytd-comments-header-renderer::deferredCounterUpdate': () => { const nodes = qsAll('#tab-comments ytd-comments-header-renderer[class]'); if (nodes.length === 1) { const hostElement = nodes[0]; const cnt = insp(hostElement); const data = cnt.data; if (!data) return; let ez = ''; if ( data.commentsCount && data.commentsCount.runs && data.commentsCount.runs.length >= 1 ) { let max = -1; const z = data.commentsCount.runs .map(e => { const c = e.text.replace(/\D+/g, '').length; if (c > max) max = c; return [e.text, c]; }) .filter(a => a[1] === max); if (z.length >= 1) { ez = z[0][0]; } } else if (data.countText && data.countText.runs && data.countText.runs.length >= 1) { let max = -1; const z = data.countText.runs .map(e => { const c = e.text.replace(/\D+/g, '').length; if (c > max) max = c; return [e.text, c]; }) .filter(a => a[1] === max); if (z.length >= 1) { ez = z[0][0]; } } const cmCount = qs('#tyt-cm-count'); if (ez) { hostElement.setAttribute111('field-of-cm-count', ''); cmCount && (cmCount.textContent = ez.trim()); } else { hostElement.removeAttribute000('field-of-cm-count'); cmCount && (cmCount.textContent = ''); console.warn('no text for #tyt-cm-count'); } } }, 'ytd-expander::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-expander::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-expander::detached']) .catch(console.warn); } return this.detached498(); }; } if (!cProto.calculateCanCollapse498 && typeof cProto.calculateCanCollapse === 'function') { cProto.calculateCanCollapse498 = cProto.calculateCanCollapse; cProto.calculateCanCollapse = funcCanCollapse; } if (!cProto.childrenChanged498 && typeof cProto.childrenChanged === 'function') { cProto.childrenChanged498 = cProto.childrenChanged; cProto.childrenChanged = function () { Promise.resolve(this.hostElement) .then(eventMap['ytd-expander::childrenChanged']) .catch(console.warn); return this.childrenChanged498(); }; } /* console.log('ytd-expander::defined 01'); CustomElementRegistry.prototype.get.call(customElements, 'ytd-expander').prototype.connectedCallback = connectedCallbackY(CustomElementRegistry.prototype.get.call(customElements, 'ytd-expander').prototype.connectedCallback) CustomElementRegistry.prototype.get.call(customElements, 'ytd-expander').prototype.disconnectedCallback = disconnectedCallbackY(CustomElementRegistry.prototype.get.call(customElements, 'ytd-expander').prototype.disconnectedCallback) console.log('ytd-expander::defined 02'); */ makeInitAttached('ytd-expander'); }, 'ytd-expander::childrenChanged': hostElement => { if ( hostElement instanceof Node && hostElement.hasAttribute000('hidden') && hostElement.hasAttribute000('tyt-main-info') && hostElement.firstElementChild ) { hostElement.removeAttribute('hidden'); } }, 'ytd-expandable-video-description-body-renderer::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-expandable-video-description-body-renderer::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-expandable-video-description-body-renderer::detached']) .catch(console.warn); } return this.detached498(); }; } makeInitAttached('ytd-expandable-video-description-body-renderer'); }, 'ytd-expandable-video-description-body-renderer::attached': async hostElement => { if ( hostElement instanceof HTMLElement_ && isPageDOM(hostElement, '[tyt-info-renderer]') && !hostElement.matches('[tyt-main-info]') ) { elements.infoExpander = hostElement; // console.log(1299, hostElement.parentNode, isRightTabsInserted) infoExpanderElementProvidedPromise.resolve(); hostElement.setAttribute111('tyt-main-info', ''); if (plugin.autoExpandInfoDesc.toUse) { plugin.autoExpandInfoDesc.onMainInfoSet(hostElement); } const lockId = lockSet['rightTabReadyLock03']; await rightTabsProvidedPromise.then(); if (lockGet['rightTabReadyLock03'] !== lockId) return; if (elements.infoExpander !== hostElement) return; if (hostElement.isConnected === false) return; elements.infoExpander.classList.add('tyt-main-info'); // add a classname for it const infoExpander = elements.infoExpander; // const infoExpanderBack = elements.infoExpanderBack; // console.log(5438,infoExpander, qt); // const dummy = document.createElement('noscript'); // dummy.setAttribute000('id', 'info-expander-vid'); // dummy.setAttribute000('video-id', getCurrentVideoId()); // infoExpander.insertBefore000(dummy, infoExpander.firstChild); // aoInfo.observe(infoExpander, { attributes: true, attributeFilter: ['tyt-display-for', 'tyt-video-id'] }); // zoInfo.observe(infoExpanderBack, { attributes: true, attributeFilter: ['hidden', 'attr-w20ts'], childList: true, subtree: true}); // new MutationObserver(()=>{ // console.log(591499) // }).observe(infoExpanderBack, {childList: true, subtree: true}) const inlineExpanderElm = infoExpander.querySelector('ytd-text-inline-expander'); if (inlineExpanderElm) { const mo = new MutationObserver(() => { const p = qs('#tab-info ytd-text-inline-expander'); sessionStorage.__$$tmp_UseAutoExpandInfoDesc$$__ = p && p.hasAttribute('is-expanded') ? '1' : ''; if (p) fixInlineExpanderContent(); }); mo.observe(inlineExpanderElm, { attributes: ['is-expanded', 'attr-6v8qu', 'hidden'], subtree: true, }); // hidden + subtree to trigger the fn by delayedUpdate inlineExpanderElm.incAttribute111('attr-6v8qu'); const cnt = insp(inlineExpanderElm); if (cnt) fixInlineExpanderDisplay(cnt); } if (infoExpander && !infoExpander.closest('#right-tabs')) { const tabInfoElm = qs('#tab-info'); if (tabInfoElm) tabInfoElm.assignChildren111(null, infoExpander, null); } else { if (qs('[tyt-tab-content="#tab-info"]')) { const shouldTabVisible = elements.infoExpander && elements.infoExpander.closest('#tab-info'); document .querySelector('[tyt-tab-content="#tab-info"]') .classList.toggle('tab-btn-hidden', !shouldTabVisible); } } Promise.resolve(lockSet['infoFixLock']).then(infoFix).catch(console.warn); // required when the page is switched from channel to watch // if (infoExpander && infoExpander.closest('#right-tabs')) Promise.resolve(lockSet['infoFixLock']).then(infoFix).catch(console.warn); // infoExpanderBack.incAttribute111('attr-w20ts'); // return; } DEBUG_5084 && console.log(5084, 'ytd-expandable-video-description-body-renderer::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; if (isPageDOM(hostElement, '#tab-info [tyt-main-info]')) { // const cnt = insp(hostElement); // if(cnt.data){ // cnt.data = Object.assign({}, cnt.data); // } } else if (!hostElement.closest('#tab-info')) { const bodyRenderer = hostElement; let bodyRendererNew = qs( 'ytd-expandable-video-description-body-renderer[tyt-info-renderer]' ); if (!bodyRendererNew) { bodyRendererNew = document.createElement( 'ytd-expandable-video-description-body-renderer' ); bodyRendererNew.setAttribute('tyt-info-renderer', ''); nsTemplateObtain().appendChild(bodyRendererNew); } // document.querySelector('#tab-info').assignChildren111(null, bodyRendererNew, null); const cnt = insp(bodyRendererNew); cnt.data = Object.assign({}, insp(bodyRenderer).data); const inlineExpanderElm = bodyRendererNew.querySelector('ytd-text-inline-expander'); const inlineExpanderCnt = insp(inlineExpanderElm); fixInlineExpanderMethods(inlineExpanderCnt); // insp(bodyRendererNew).data = insp(bodyRenderer).data; // if((bodyRendererNew.hasAttribute('hidden')?1:0)^(bodyRenderer.hasAttribute('hidden')?1:0)){ // if(bodyRenderer.hasAttribute('hidden')) bodyRendererNew.setAttribute('hidden', ''); // else bodyRendererNew.removeAttribute('hidden'); // } elements.infoExpanderRendererBack = bodyRenderer; elements.infoExpanderRendererFront = bodyRendererNew; bodyRenderer.setAttribute('tyt-info-renderer-back', ''); bodyRendererNew.setAttribute('tyt-info-renderer-front', ''); // elements.infoExpanderBack = {{ytd-expander}}; } }, 'ytd-expandable-video-description-body-renderer::detached': async hostElement => { if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; // console.log(5992, hostElement) if (hostElement.hasAttribute000('tyt-main-info')) { DEBUG_5084 && console.log(5084, 'ytd-expandable-video-description-body-renderer::detached'); elements.infoExpander = null; hostElement.removeAttribute000('tyt-main-info'); } }, 'ytd-expander::attached': async hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; // console.log(4959, hostElement) if ( hostElement instanceof HTMLElement_ && hostElement.matches('[tyt-comments-area] #contents ytd-expander#expander') && !hostElement.matches('[hidden] ytd-expander#expander') ) { hostElement.setAttribute111('tyt-content-comment-entry', ''); ioComment.observe(hostElement); } // -------------- // else if (hostElement instanceof HTMLElement_ && hostElement.matches('ytd-expander#expander.style-scope.ytd-expandable-video-description-body-renderer')) { // // && !hostElement.matches('#right-tabs ytd-expander#expander, [hidden] ytd-expander#expander') // console.log(5084, 'ytd-expander::attached'); // const bodyRenderer = hostElement.closest('ytd-expandable-video-description-body-renderer'); // let bodyRendererNew = document.querySelector('ytd-expandable-video-description-body-renderer[tyt-info-renderer]'); // if (!bodyRendererNew) { // bodyRendererNew = document.createElement('ytd-expandable-video-description-body-renderer'); // bodyRendererNew.setAttribute('tyt-info-renderer', ''); // nsTemplateObtain().appendChild(bodyRendererNew); // } // // document.querySelector('#tab-info').assignChildren111(null, bodyRendererNew, null); // insp(bodyRendererNew).data = insp(bodyRenderer).data; // // if((bodyRendererNew.hasAttribute('hidden')?1:0)^(bodyRenderer.hasAttribute('hidden')?1:0)){ // // if(bodyRenderer.hasAttribute('hidden')) bodyRendererNew.setAttribute('hidden', ''); // // else bodyRendererNew.removeAttribute('hidden'); // // } // elements.infoExpanderRendererBack = bodyRenderer; // elements.infoExpanderRendererFront = bodyRendererNew; // bodyRenderer.setAttribute('tyt-info-renderer-back','') // bodyRendererNew.setAttribute('tyt-info-renderer-front','') // elements.infoExpanderBack = hostElement; // } // -------------- // console.log('ytd-expander::attached', hostElement); }, 'ytd-expander::detached': hostElement => { // if (inPageRearrange) return; if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; // console.log(5992, hostElement) if (hostElement.hasAttribute000('tyt-content-comment-entry')) { ioComment.unobserve(hostElement); hostElement.removeAttribute000('tyt-content-comment-entry'); } else if (hostElement.hasAttribute000('tyt-main-info')) { DEBUG_5084 && console.log(5084, 'ytd-expander::detached'); elements.infoExpander = null; hostElement.removeAttribute000('tyt-main-info'); } // console.log('ytd-expander::detached', hostElement); }, 'ytd-live-chat-frame::defined': cProto => { let _lastDomAction = 0; if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { _lastDomAction = Date.now(); // console.log('chat868-attached', Date.now()); if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-live-chat-frame::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { _lastDomAction = Date.now(); // console.log('chat868-detached', Date.now()); if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-live-chat-frame::detached']) .catch(console.warn); } return this.detached498(); }; } if ( typeof cProto.urlChanged === 'function' && !cProto.urlChanged66 && !cProto.urlChangedAsync12 && cProto.urlChanged.length === 0 ) { cProto.urlChanged66 = cProto.urlChanged; let ath = 0; cProto.urlChangedAsync12 = async function () { await this.__urlChangedAsyncT689__; const t = (ath = (ath & 1073741823) + 1); const chatframe = this.chatframe || (this.$ || 0).chatframe || 0; if (chatframe instanceof HTMLIFrameElement) { if (chatframe.contentDocument === null) { await Promise.resolve('#').catch(console.warn); if (t !== ath) return; } await new Promise(resolve => setTimeout_(resolve, 1)).catch(console.warn); // neccessary for Brave if (t !== ath) return; const isBlankPage = !this.data || this.collapsed; const p1 = new Promise(resolve => setTimeout_(resolve, 706)).catch(console.warn); const p2 = new Promise(resolve => { new IntersectionObserver((entries, observer) => { for (const entry of entries) { const rect = entry.boundingClientRect || 0; if (isBlankPage || (rect.width > 0 && rect.height > 0)) { observer.disconnect(); resolve('#'); break; } } }).observe(chatframe); }).catch(console.warn); await Promise.race([p1, p2]); if (t !== ath) return; } this.urlChanged66(); }; cProto.urlChanged = function () { const t = (this.__urlChangedAsyncT688__ = (this.__urlChangedAsyncT688__ & 1073741823) + 1); nextBrowserTick(() => { if (t !== this.__urlChangedAsyncT688__) return; this.urlChangedAsync12(); }); }; } makeInitAttached('ytd-live-chat-frame'); }, 'ytd-live-chat-frame::attached': async hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-live-chat-frame::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; if (!hostElement || hostElement.id !== 'chat') return; const lockId = lockSet['ytdLiveAttachedLock']; const chatElem = await getGeneralChatElement(); if (lockGet['ytdLiveAttachedLock'] !== lockId) return; if (chatElem === hostElement) { elements.chat = chatElem; aoChat.observe(chatElem, { attributes: true }); const isFlexyReady = elements.flexy instanceof Element; chatElem.setAttribute111('tyt-active-chat-frame', isFlexyReady ? 'CF' : 'C'); const chatContainer = chatElem ? chatElem.closest('#chat-container') || chatElem : null; if (chatContainer && !chatContainer.hasAttribute000('tyt-chat-container')) { for (const p of qsAll('[tyt-chat-container]')) { p.removeAttribute000('[tyt-chat-container]'); } chatContainer.setAttribute111('tyt-chat-container', ''); } const cnt = insp(hostElement); const q = cnt.__urlChangedAsyncT688__; const p = (cnt.__urlChangedAsyncT689__ = new PromiseExternal()); setTimeout_(() => { if (p !== cnt.__urlChangedAsyncT689__) return; if (cnt.isAttached === true && hostElement.isConnected === true) { p.resolve(); if (q === cnt.__urlChangedAsyncT688__) { cnt.urlChanged(); } } }, 320); Promise.resolve(lockSet['layoutFixLock']).then(layoutFix); } else { console.warn('Issue found in ytd-live-chat-frame::attached', chatElem, hostElement); } }, 'ytd-live-chat-frame::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-live-chat-frame::detached'); if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; if (hostElement.hasAttribute000('tyt-active-chat-frame')) { aoChat.disconnect(); aoChat.takeRecords(); hostElement.removeAttribute000('tyt-active-chat-frame'); elements.chat = null; const ytdFlexyElm = elements.flexy; if (ytdFlexyElm) { ytdFlexyElm.removeAttribute000('tyt-chat-collapsed'); ytdFlexyElm.setAttribute111('tyt-chat', ''); } } }, 'ytd-engagement-panel-section-list-renderer::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-engagement-panel-section-list-renderer::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-engagement-panel-section-list-renderer::detached']) .catch(console.warn); } return this.detached498(); }; } makeInitAttached('ytd-engagement-panel-section-list-renderer'); }, 'ytd-engagement-panel-section-list-renderer::bindTarget': hostElement => { if ( hostElement.matches( '#panels.ytd-watch-flexy > ytd-engagement-panel-section-list-renderer[target-id][visibility]' ) ) { hostElement.setAttribute111('tyt-egm-panel', ''); egmPanelsCache.add(hostElement); Promise.resolve(lockSet['updateEgmPanelsLock']).then(updateEgmPanels).catch(console.warn); aoEgmPanels.observe(hostElement, { attributes: true, attributeFilter: ['visibility', 'hidden'], }); // console.log(5094, 2, 'ytd-engagement-panel-section-list-renderer::attached', hostElement); } }, 'ytd-engagement-panel-section-list-renderer::attached': hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-engagement-panel-section-list-renderer::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; // console.log('ytd-engagement-panel-section-list-renderer::attached', hostElement) // console.log(5094, 1, 'ytd-engagement-panel-section-list-renderer::attached', hostElement); if ( !hostElement.matches( '#panels.ytd-watch-flexy > ytd-engagement-panel-section-list-renderer' ) ) { return; } if (hostElement.hasAttribute000('target-id') && hostElement.hasAttribute000('visibility')) { Promise.resolve(hostElement) .then(eventMap['ytd-engagement-panel-section-list-renderer::bindTarget']) .catch(console.warn); } else { hostElement.setAttribute000('tyt-egm-panel-jclmd', ''); moEgmPanelReady.observe(hostElement, { attributes: true, attributeFilter: ['visibility', 'target-id'], }); } }, 'ytd-engagement-panel-section-list-renderer::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-engagement-panel-section-list-renderer::detached'); if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; if (hostElement.hasAttribute000('tyt-egm-panel')) { hostElement.removeAttribute000('tyt-egm-panel'); Promise.resolve(lockSet['updateEgmPanelsLock']).then(updateEgmPanels).catch(console.warn); } else if (hostElement.hasAttribute000('tyt-egm-panel-jclmd')) { hostElement.removeAttribute000('tyt-egm-panel-jclmd'); moEgmPanelReadyClearFn(); } }, 'ytd-watch-metadata::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-watch-metadata::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-watch-metadata::detached']) .catch(console.warn); } return this.detached498(); }; } makeInitAttached('ytd-watch-metadata'); }, 'ytd-watch-metadata::attached': hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-watch-metadata::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; if (plugin.fullChannelNameOnHover.activated) { plugin.fullChannelNameOnHover.onNavigateFinish(); } }, 'ytd-watch-metadata::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-watch-metadata::detached'); if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; }, 'ytd-playlist-panel-renderer::defined': cProto => { if (!cProto.attached498 && typeof cProto.attached === 'function') { cProto.attached498 = cProto.attached; cProto.attached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-playlist-panel-renderer::attached']) .catch(console.warn); } return this.attached498(); }; } if (!cProto.detached498 && typeof cProto.detached === 'function') { cProto.detached498 = cProto.detached; cProto.detached = function () { if (!inPageRearrange) { Promise.resolve(this.hostElement) .then(eventMap['ytd-playlist-panel-renderer::detached']) .catch(console.warn); } return this.detached498(); }; } makeInitAttached('ytd-playlist-panel-renderer'); }, 'ytd-playlist-panel-renderer::attached': hostElement => { if (invalidFlexyParent(hostElement)) return; // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-playlist-panel-renderer::attached'); if (hostElement instanceof Element) hostElement[__attachedSymbol__] = true; if ( !(hostElement instanceof HTMLElement_) || !(hostElement.classList.length > 0) || hostElement.closest('noscript') ) { return; } if (hostElement.isConnected !== true) return; // if (hostElement.__connectedFlg__ !== 4) return; // hostElement.__connectedFlg__ = 5; elements.playlist = hostElement; aoPlayList.observe(hostElement, { attributes: true, attributeFilter: ['hidden', 'collapsed', 'attr-1y6nu'], }); hostElement.incAttribute111('attr-1y6nu'); }, 'ytd-playlist-panel-renderer::detached': hostElement => { // if (inPageRearrange) return; DEBUG_5084 && console.log(5084, 'ytd-playlist-panel-renderer::detached'); if (!(hostElement instanceof HTMLElement_) || hostElement.closest('noscript')) return; if (hostElement.isConnected !== false) return; // if (hostElement.__connectedFlg__ !== 8) return; // hostElement.__connectedFlg__ = 9; }, _yt_playerProvided: () => { mLoaded.flag |= 4; document.documentElement.setAttribute111('tabview-loaded', mLoaded.makeString()); }, relatedElementProvided: target => { if (target.closest('[hidden]')) return; elements.related = target; videosElementProvidedPromise.resolve(); }, onceInfoExpanderElementProvidedPromised: () => { const ytdFlexyElm = elements.flexy; if (ytdFlexyElm) { ytdFlexyElm.setAttribute111('hide-default-text-inline-expander', ''); } }, refreshSecondaryInner: lockId => { if (lockGet['refreshSecondaryInnerLock'] !== lockId) return; /* ytd-watch-flexy:not([panels-beside-player]):not([fixed-panels]) #panels-full-bleed-container.ytd-watch-flexy{ display: none;} #player-full-bleed-container.ytd-watch-flexy{ position: relative; flex: 1;} */ const ytdFlexyElm = elements.flexy; // if(ytdFlexyElm && ytdFlexyElm.matches('ytd-watch-flexy[fixed-panels][theater]')){ // // ytdFlexyElm.fixedPanels = true; // ytdFlexyElm.removeAttribute000('fixed-panels'); // } if ( ytdFlexyElm && ytdFlexyElm.matches( 'ytd-watch-flexy[theater][full-bleed-player]:not([full-bleed-no-max-width-columns])' ) ) { // ytdFlexyElm.fullBleedNoMaxWidthColumns = true; ytdFlexyElm.setAttribute111('full-bleed-no-max-width-columns', ''); } const related = elements.related; if (related && related.isConnected && !related.closest('#right-tabs #tab-videos')) { qs('#tab-videos').assignChildren111(null, related, null); } const infoExpander = elements.infoExpander; if ( infoExpander && infoExpander.isConnected && !infoExpander.closest('#right-tabs #tab-info') ) { qs('#tab-info').assignChildren111(null, infoExpander, null); } else { // if (infoExpander && ytdFlexyElm && shouldFixInfo) { // shouldFixInfo = false; // Promise.resolve(lockSet['infoFixLock']).then(infoFix).catch(console.warn); // } } const commentsArea = elements.comments; if (commentsArea) { const isConnected = commentsArea.isConnected; if (isConnected && !commentsArea.closest('#right-tabs #tab-comments')) { const tab = qs('#tab-comments'); tab.assignChildren111(null, commentsArea, null); } else { // if (!isConnected || tab.classList.contains('tab-content-hidden')) removeKeepCommentsScroller(); } } }, 'yt-navigate-finish': _evt => { // Performance: the global document-subtree observer is expensive on home/feed/playlist. // Toggle it based on whether the watch player is present. if (typeof shouldActivateMoOverall === 'function') { if (shouldActivateMoOverall()) { activateMoOverall(); } else { deactivateMoOverall(); } } const ytdAppElm = qs('ytd-page-manager#page-manager.style-scope.ytd-app'); const ytdAppCnt = insp(ytdAppElm); pageType = ytdAppCnt ? (ytdAppCnt.data || 0).page : null; if (!qs('ytd-watch-flexy #player')) return; // shouldFixInfo = true; // console.log('yt-navigate-finish') const flexyArr = qsAll('ytd-watch-flexy').filter( e => !e.closest('[hidden]') && e.querySelector('#player') ); if (flexyArr.length === 1) { // const lockId = lockSet['yt-navigate-finish-videos']; elements.flexy = flexyArr[0]; if (isRightTabsInserted) { Promise.resolve(lockSet['refreshSecondaryInnerLock']) .then(eventMap['refreshSecondaryInner']) .catch(console.warn); Promise.resolve(lockSet['removeKeepCommentsScrollerLock']) .then(removeKeepCommentsScroller) .catch(console.warn); } else { navigateFinishedPromise.resolve(); if (plugin.minibrowser.toUse) plugin.minibrowser.activate(); if (plugin.autoExpandInfoDesc.toUse) plugin.autoExpandInfoDesc.activate(); if (plugin.fullChannelNameOnHover.toUse) plugin.fullChannelNameOnHover.activate(); } const chat = elements.chat; if (chat instanceof Element) { chat.setAttribute111('tyt-active-chat-frame', 'CF'); // chat and flexy ready } const infoExpander = elements.infoExpander; if (infoExpander && infoExpander.closest('#right-tabs')) { Promise.resolve(lockSet['infoFixLock']).then(infoFix).catch(console.warn); } Promise.resolve(lockSet['layoutFixLock']).then(layoutFix); if (plugin.fullChannelNameOnHover.activated) { plugin.fullChannelNameOnHover.onNavigateFinish(); } } }, onceInsertRightTabs: () => { // if(lockId !== lockGet['yt-navigate-finish-videos']) return; const related = elements.related; let rightTabs = qs('#right-tabs'); if (!qs('#right-tabs') && related) { getLangForPage(); const docTmp = document.createElement('template'); docTmp.innerHTML = createHTML(getTabsHTML()); const newElm = docTmp.content.firstElementChild; if (newElm !== null) { inPageRearrange = true; related.parentNode.insertBefore000(newElm, related); inPageRearrange = false; } rightTabs = newElm; rightTabs .querySelector('[tyt-tab-content="#tab-comments"]') .classList.add('tab-btn-hidden'); const secondaryWrapper = document.createElement('secondary-wrapper'); secondaryWrapper.classList.add('tabview-secondary-wrapper'); secondaryWrapper.id = 'secondary-inner-wrapper'; const secondaryInner = qs('#secondary-inner.style-scope.ytd-watch-flexy'); if (!secondaryInner) return; inPageRearrange = true; secondaryWrapper.replaceChildren000(...secondaryInner.childNodes); secondaryInner.insertBefore000(secondaryWrapper, secondaryInner.firstChild); inPageRearrange = false; rightTabs .querySelector('#material-tabs') .addEventListener('click', eventMap['tabs-btn-click'], true); inPageRearrange = true; if (!rightTabs.closest('secondary-wrapper')) secondaryWrapper.appendChild000(rightTabs); inPageRearrange = false; } if (rightTabs) { isRightTabsInserted = true; const ioTabBtns = new IntersectionObserver( entries => { for (const entry of entries) { const rect = entry.boundingClientRect; entry.target.classList.toggle('tab-btn-visible', rect.width && rect.height); } }, { rootMargin: '0px' } ); for (const btn of qsAll('.tab-btn[tyt-tab-content]')) { ioTabBtns.observe(btn); } if (!related.closest('#right-tabs')) { qs('#tab-videos').assignChildren111(null, related, null); } const infoExpander = elements.infoExpander; if (infoExpander && !infoExpander.closest('#right-tabs')) { qs('#tab-info').assignChildren111(null, infoExpander, null); } const commentsArea = elements.comments; if (commentsArea && !commentsArea.closest('#right-tabs')) { qs('#tab-comments').assignChildren111(null, commentsArea, null); } rightTabsProvidedPromise.resolve(); roRightTabs.disconnect(); roRightTabs.observe(rightTabs); const ytdFlexyElm = elements.flexy; const aoFlexy = new MutationObserver(eventMap['aoFlexyFn']); aoFlexy.observe(ytdFlexyElm, { attributes: true }); // Promise.resolve(lockSet['tabsStatusCorrectionLock']).then(eventMap['tabsStatusCorrection']).catch(console.warn); Promise.resolve(lockSet['fixInitialTabStateLock']) .then(eventMap['fixInitialTabStateFn']) .catch(console.warn); ytdFlexyElm.incAttribute111('attr-7qlsy'); // tabsStatusCorrectionLock and video-id } }, aoFlexyFn: () => { Promise.resolve(lockSet['checkCommentsShouldBeHiddenLock']) .then(eventMap['checkCommentsShouldBeHidden']) .catch(console.warn); Promise.resolve(lockSet['refreshSecondaryInnerLock']) .then(eventMap['refreshSecondaryInner']) .catch(console.warn); Promise.resolve(lockSet['tabsStatusCorrectionLock']) .then(eventMap['tabsStatusCorrection']) .catch(console.warn); const videoId = getCurrentVideoId(); if (videoId !== tmpLastVideoId) { tmpLastVideoId = videoId; Promise.resolve(lockSet['updateOnVideoIdChangedLock']) .then(eventMap['updateOnVideoIdChanged']) .catch(console.warn); } }, twoColumnChanged10: lockId => { if (lockId !== lockGet['twoColumnChanged10Lock']) return; for (const continuation of qsAll( '#tab-videos ytd-watch-next-secondary-results-renderer ytd-continuation-item-renderer' )) { if (continuation.closest('[hidden]')) continue; const cnt = insp(continuation); if (typeof cnt.showButton === 'boolean') { if (cnt.showButton === false) continue; cnt.showButton = false; const behavior = cnt.ytRendererBehavior || cnt; if (typeof behavior.invalidate === 'function') { behavior.invalidate(!1); } } } }, tabsStatusCorrection: lockId => { if (lockId !== lockGet['tabsStatusCorrectionLock']) return; const ytdFlexyElm = elements.flexy; if (!ytdFlexyElm) return; const p = tabAStatus; const q = calculationFn(p, 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 4096); let resetForPanelDisappeared = false; // Store theater mode state before fullscreen changes const wasTheaterBeforeFullscreen = (p & 1) === 1; const isEnteringFullscreen = (p & 64) === 0 && (q & 64) === 64; const isExitingFullscreen = (p & 64) === 64 && (q & 64) === 0; if (p !== q) { let actioned = false; let special = 0; if (plugin['external.ytlstm'].activated) { if (q & 64) { // ignore fullscreen - but preserve theater state if (isEnteringFullscreen && wasTheaterBeforeFullscreen) { // Preserve theater mode when entering fullscreen setTimeout(() => { if (isTheater()) { // Theater mode is still active, no action needed } else { // Theater mode was lost, restore it const sizeBtn = qs('ytd-watch-flexy #ytd-player button.ytp-size-button'); if (sizeBtn && !isTheater()) { sizeBtn.click(); } } }, 300); } } else if ( (p & (1 | 2 | 4 | 8 | 16 | 4096)) === (1 | 0 | 0 | 8 | 16 | 4096) && (q & (1 | 2 | 4 | 8 | 16 | 4096)) === (1 | 0 | 4 | 0 | 16 | 4096) ) { special = 3; } else if ((q & (1 | 16)) === (1 | 16) && qs('[data-ytlstm-theater-mode]')) { special = 1; } else if ( (q & (1 | 8 | 16)) === (1 | 8 | 16) && qs('[is-two-columns_][theater][tyt-chat="+"]') ) { special = 2; } } else { // Standard behavior - preserve theater mode during fullscreen transitions if (isExitingFullscreen && wasTheaterBeforeFullscreen) { // Restore theater mode after exiting fullscreen setTimeout(() => { if (!isTheater()) { const sizeBtn = qs('ytd-watch-flexy #ytd-player button.ytp-size-button'); if (sizeBtn) { sizeBtn.click(); } } }, 300); } } if (special) { // special } else if ((p & 128) === 0 && (q & 128) === 128) { lastPanel = 'playlist'; } else if ((p & 8) === 0 && (q & 8) === 8) { lastPanel = 'chat'; } else if ( (((p & 4) === 4 && (q & (4 | 8)) === (0 | 0)) || ((p & 8) === 8 && (q & (4 | 8)) === (0 | 0))) && lastPanel === 'chat' ) { // 24 -> 16 = -8; 'd' lastPanel = lastTab || ''; resetForPanelDisappeared = true; } else if ((p & (4 | 8)) === 8 && (q & (4 | 8)) === 4 && lastPanel === 'chat') { // click close lastPanel = lastTab || ''; resetForPanelDisappeared = true; } else if ((p & 128) === 128 && (q & 128) === 0 && lastPanel === 'playlist') { lastPanel = lastTab || ''; resetForPanelDisappeared = true; } tabAStatus = q; if (special) { if (special === 1) { if (ytdFlexyElm.getAttribute('tyt-chat') !== '+') { ytBtnExpandChat(); } if (ytdFlexyElm.getAttribute('tyt-tab')) { switchToTab(null); } } else if (special === 2) { ytBtnCollapseChat(); } else if (special === 3) { ytBtnCancelTheater(); if (lastTab) { switchToTab(lastTab); } } return; } let bFixForResizedTab = false; if ((q ^ 2) === 2 && bFixForResizedTabLater) { bFixForResizedTab = true; } if (((p & 16) === 16) & ((q & 16) === 0)) { Promise.resolve(lockSet['twoColumnChanged10Lock']) .then(eventMap['twoColumnChanged10']) .catch(console.warn); } if (((p & 2) === 2) ^ ((q & 2) === 2) && (q & 2) === 2) { bFixForResizedTab = true; } // p->q +2 if ((p & 2) === 0 && (q & 2) === 2 && (p & 128) === 128 && (q & 128) === 128) { lastPanel = lastTab || ''; ytBtnClosePlaylist(); actioned = true; } // p->q +8 if ( (p & (8 | 128)) === (0 | 128) && (q & (8 | 128)) === (8 | 128) && lastPanel === 'chat' ) { lastPanel = lastTab || ''; ytBtnClosePlaylist(); actioned = true; } if ( (p & (1 | 2 | 4 | 8 | 16 | 32 | 64 | 128)) === (1 | 2 | 0 | 8 | 16) && (q & (1 | 2 | 4 | 8 | 16 | 32 | 64 | 128)) === (0 | 2 | 0 | 8 | 16) ) { // external.ytlstm case lastPanel = lastTab || ''; ytBtnCollapseChat(); actioned = true; } // p->q +128 if ( (p & (2 | 128)) === (2 | 0) && (q & (2 | 128)) === (2 | 128) && lastPanel === 'playlist' ) { switchToTab(null); actioned = true; } // p->q +128 if ( (p & (8 | 128)) === (8 | 0) && (q & (8 | 128)) === (8 | 128) && lastPanel === 'playlist' ) { lastPanel = lastTab || ''; ytBtnCollapseChat(); actioned = true; } // p->q +128 if ((p & (1 | 16 | 128)) === (1 | 16) && (q & (1 | 16 | 128)) === (1 | 16 | 128)) { ytBtnCancelTheater(); actioned = true; } // p->q +1 if ((p & (1 | 16 | 128)) === (16 | 128) && (q & (1 | 16 | 128)) === (1 | 16 | 128)) { lastPanel = lastTab || ''; ytBtnClosePlaylist(); actioned = true; } if ((q & 64) === 64) { actioned = false; } else if ((p & 64) === 64 && (q & 64) === 0) { // p->q -64 if ((q & 32) === 32) { ytBtnCloseEngagementPanels(); } if ((q & (2 | 8)) === (2 | 8)) { if (lastPanel === 'chat') { switchToTab(null); actioned = true; } else if (lastPanel) { ytBtnCollapseChat(); actioned = true; } } } else if ( (p & (1 | 2 | 8 | 16 | 32)) === (1 | 0 | 0 | 16 | 0) && (q & (1 | 2 | 8 | 16 | 32)) === (1 | 0 | 8 | 16 | 0) ) { // p->q +8 ytBtnCancelTheater(); actioned = true; } else if ( (p & (1 | 16 | 32)) === (0 | 16 | 0) && (q & (1 | 16 | 32)) === (0 | 16 | 32) && (q & (2 | 8)) > 0 ) { // p->q +32 if (q & 2) { switchToTab(null); actioned = true; } if (q & 8) { ytBtnCollapseChat(); actioned = true; } } else if ( (p & (1 | 16 | 8 | 2)) === (16 | 8) && (q & (1 | 16 | 8 | 2)) === 16 && (q & 128) === 0 ) { // p->q -8 if (lastTab) { switchToTab(lastTab); actioned = true; } } else if ((p & 1) === 0 && (q & 1) === 1) { // p->q +1 if ((q & 32) === 32) { ytBtnCloseEngagementPanels(); } if ((p & 9) === 8 && (q & 9) === 9) { ytBtnCollapseChat(); } switchToTab(null); actioned = true; } else if ((p & 3) === 1 && (q & 3) === 3) { // p->q +2 ytBtnCancelTheater(); actioned = true; } else if ((p & 10) === 2 && (q & 10) === 10) { // p->q +8 switchToTab(null); actioned = true; } else if ((p & (8 | 32)) === (0 | 32) && (q & (8 | 32)) === (8 | 32)) { // p->q +8 ytBtnCloseEngagementPanels(); actioned = true; } else if ((p & (2 | 32)) === (0 | 32) && (q & (2 | 32)) === (2 | 32)) { // p->q +2 ytBtnCloseEngagementPanels(); actioned = true; } else if ((p & (2 | 8)) === (0 | 8) && (q & (2 | 8)) === (2 | 8)) { // p->q +2 ytBtnCollapseChat(); actioned = true; // if( lastPanel && (p & (1|16) === 16) && (q & (1 | 16 | 8 | 2)) === (16) ){ // switchToTab(lastTab) // actioned = true; // } } else if ((p & 1) === 1 && (q & (1 | 32)) === (0 | 0)) { // p->q -1 if (lastPanel === 'chat') { ytBtnExpandChat(); actioned = true; } else if (lastPanel === lastTab && lastTab) { switchToTab(lastTab); actioned = true; } } // 24 20 // 8 16 4 16 if (!actioned && (q & 128) === 128) { lastPanel = 'playlist'; if ((q & 2) === 2) { switchToTab(null); actioned = true; } } let shouldDoAutoFix = false; if ((p & 2) === 2 && (q & (2 | 128)) === (0 | 128)) { // p->q -2 } else if ((p & 8) === 8 && (q & (8 | 128)) === (0 | 128)) { // p->q -8 } else if ( !actioned && (p & (1 | 16)) === 16 && (q & (1 | 16 | 8 | 2 | 32 | 64)) === (16 | 0 | 0) ) { shouldDoAutoFix = true; } else if ((q & (1 | 2 | 4 | 8 | 16 | 32 | 64 | 128)) === (4 | 16)) { shouldDoAutoFix = true; } if (shouldDoAutoFix) { if (lastPanel === 'chat') { ytBtnExpandChat(); actioned = true; } else if (lastPanel === 'playlist') { ytBtnOpenPlaylist(); actioned = true; } else if (lastTab) { switchToTab(lastTab); actioned = true; } else if (resetForPanelDisappeared) { // if lastTab is undefined Promise.resolve(lockSet['fixInitialTabStateLock']) .then(eventMap['fixInitialTabStateFn']) .catch(console.warn); actioned = true; } } if (bFixForResizedTab) { bFixForResizedTabLater = false; Promise.resolve(0).then(eventMap['fixForTabDisplay']).catch(console.warn); } if (((p & 16) === 16) ^ ((q & 16) === 16)) { Promise.resolve(lockSet['infoFixLock']).then(infoFix).catch(console.warn); Promise.resolve(lockSet['removeKeepCommentsScrollerLock']) .then(removeKeepCommentsScroller) .catch(console.warn); Promise.resolve(lockSet['layoutFixLock']).then(layoutFix).catch(console.warn); } } }, updateOnVideoIdChanged: lockId => { if (lockId !== lockGet['updateOnVideoIdChangedLock']) return; const videoId = tmpLastVideoId; if (!videoId) return; const bodyRenderer = elements.infoExpanderRendererBack; const bodyRendererNew = elements.infoExpanderRendererFront; if (bodyRendererNew && bodyRenderer) { insp(bodyRendererNew).data = insp(bodyRenderer).data; // if ((bodyRendererNew.hasAttribute('hidden') ? 1 : 0) ^ (bodyRenderer.hasAttribute('hidden') ? 1 : 0)) { // if (bodyRenderer.hasAttribute('hidden')) bodyRendererNew.setAttribute('hidden', ''); // else bodyRendererNew.removeAttribute('hidden'); // } } Promise.resolve(lockSet['infoFixLock']).then(infoFix).catch(console.warn); }, fixInitialTabStateFn: async lockId => { // console.log('fixInitialTabStateFn 0a'); if (lockGet['fixInitialTabStateLock'] !== lockId) return; // console.log('fixInitialTabStateFn 0b'); const delayTime = fixInitialTabStateK > 0 ? 200 : 1; await delayPn(delayTime); if (lockGet['fixInitialTabStateLock'] !== lockId) return; // console.log('fixInitialTabStateFn 0c'); const kTab = qs('[tyt-tab]'); const qTab = !kTab || kTab.getAttribute('tyt-tab') === '' ? checkElementExist('ytd-watch-flexy[is-two-columns_]', '[hidden]') : null; if (checkElementExist('ytd-playlist-panel-renderer#playlist', '[hidden], [collapsed]')) { DEBUG_5085 && console.log('fixInitialTabStateFn 1p'); switchToTab(null); } else if (checkElementExist('ytd-live-chat-frame#chat', '[hidden], [collapsed]')) { DEBUG_5085 && console.log('fixInitialTabStateFn 1a'); switchToTab(null); if (checkElementExist('ytd-watch-flexy[theater]', '[hidden]')) { ytBtnCollapseChat(); } } else if (qTab) { const hasTheater = qTab.hasAttribute('theater'); if (!hasTheater) { DEBUG_5085 && console.log('fixInitialTabStateFn 1b'); const btn0 = qs('.tab-btn-visible'); // or default button if (btn0) { switchToTab(btn0); } else { switchToTab(null); } } else { DEBUG_5085 && console.log('fixInitialTabStateFn 1c'); switchToTab(null); } } else { DEBUG_5085 && console.log('fixInitialTabStateFn 1z'); } // console.log('fixInitialTabStateFn 0d'); fixInitialTabStateK++; }, 'tabs-btn-click': evt => { const target = evt.target; if ( target instanceof HTMLElement_ && target.classList.contains('tab-btn') && target.hasAttribute000('tyt-tab-content') ) { evt.preventDefault(); evt.stopPropagation(); evt.stopImmediatePropagation(); const activeLink = target; switchToTab(activeLink); } }, }; Promise.all([videosElementProvidedPromise, navigateFinishedPromise]) .then(eventMap['onceInsertRightTabs']) .catch(console.warn); Promise.all([navigateFinishedPromise, infoExpanderElementProvidedPromise]) .then(eventMap['onceInfoExpanderElementProvidedPromised']) .catch(console.warn); const isCustomElementsProvided = typeof customElements !== 'undefined' && typeof (customElements || 0).whenDefined === 'function'; const promiseForCustomYtElementsReady = isCustomElementsProvided ? Promise.resolve(0) : new Promise(callback => { const EVENT_KEY_ON_REGISTRY_READY = 'ytI-ce-registry-created'; if (typeof customElements === 'undefined') { if (!('__CE_registry' in document)) { // https://github.com/webcomponents/polyfills/ Object.defineProperty(document, '__CE_registry', { get() { // return undefined }, set(nv) { if (typeof nv == 'object') { delete this.__CE_registry; this.__CE_registry = nv; this.dispatchEvent(new CustomEvent(EVENT_KEY_ON_REGISTRY_READY)); } return true; }, enumerable: false, configurable: true, }); } let eventHandler = _evt => { document.removeEventListener(EVENT_KEY_ON_REGISTRY_READY, eventHandler, false); const f = callback; callback = null; eventHandler = null; f(); }; document.addEventListener(EVENT_KEY_ON_REGISTRY_READY, eventHandler, false); } else { callback(); } }); const _retrieveCE = async nodeName => { try { isCustomElementsProvided || (await promiseForCustomYtElementsReady); await customElements.whenDefined(nodeName); } catch (e) { console.warn(e); } }; const retrieveCE = async nodeName => { try { isCustomElementsProvided || (await promiseForCustomYtElementsReady); await customElements.whenDefined(nodeName); const dummy = qs(nodeName) || document.createElement(nodeName); const cProto = insp(dummy).constructor.prototype; return cProto; } catch (e) { console.warn(e); } }; const moOverallRes = { _yt_playerProvided: () => (window || 0)._yt_player || 0 || 0, }; let promiseWaitNext = null; const moOverall = new MutationObserver(() => { if (promiseWaitNext) { promiseWaitNext.resolve(); promiseWaitNext = null; } if (typeof moOverallRes._yt_playerProvided === 'function') { const r = moOverallRes._yt_playerProvided(); if (r) { moOverallRes._yt_playerProvided = r; eventMap._yt_playerProvided(); } } }); // Performance: observing the entire document subtree is expensive on home/feed/playlist. // Enable it only when the watch player exists. let moOverallActive = false; const shouldActivateMoOverall = () => { try { return !!qs('ytd-watch-flexy #player'); } catch { return false; } }; const activateMoOverall = () => { if (moOverallActive) return; moOverall.observe(document, { subtree: true, childList: true }); moOverallActive = true; }; const deactivateMoOverall = () => { if (!moOverallActive) return; moOverall.disconnect(); moOverallActive = false; }; if (shouldActivateMoOverall()) { activateMoOverall(); } const moEgmPanelReady = new MutationObserver(mutations => { for (const mutation of mutations) { const target = mutation.target; if (!target.hasAttribute000('tyt-egm-panel-jclmd')) continue; if (target.hasAttribute000('target-id') && target.hasAttribute000('visibility')) { target.removeAttribute000('tyt-egm-panel-jclmd'); moEgmPanelReadyClearFn(); Promise.resolve(target) .then(eventMap['ytd-engagement-panel-section-list-renderer::bindTarget']) .catch(console.warn); } } }); const moEgmPanelReadyClearFn = () => { if (qs('[tyt-egm-panel-jclmd]') === null) { moEgmPanelReady.takeRecords(); moEgmPanelReady.disconnect(); } }; document.addEventListener('yt-navigate-finish', eventMap['yt-navigate-finish'], false); document.addEventListener( 'animationstart', evt => { const f = eventMap[evt.animationName]; if (typeof f === 'function') f(evt.target); }, capturePassive ); // console.log('hi122') mLoaded.flag |= 1; document.documentElement.setAttribute111('tabview-loaded', mLoaded.makeString()); promiseForCustomYtElementsReady.then(eventMap['ceHack']).catch(console.warn); _executionFinished = 1; } catch (e) { console.error('error 0xF491', e); } }; const styles = { main: ` @keyframes relatedElementProvided{0%{background-position-x:3px;}100%{background-position-x:4px;}} html[tabview-loaded="icp"] #related.ytd-watch-flexy{animation:relatedElementProvided 1ms linear 0s 1 normal forwards;} html[tabview-loaded="icp"] #right-tabs #related.ytd-watch-flexy,html[tabview-loaded="icp"] [hidden] #related.ytd-watch-flexy,html[tabview-loaded="icp"] #right-tabs ytd-expander#expander,html[tabview-loaded="icp"] [hidden] ytd-expander#expander,html[tabview-loaded="icp"] ytd-comments ytd-expander#expander{animation:initial;} #secondary.ytd-watch-flexy{position:relative;} #secondary-inner.style-scope.ytd-watch-flexy{height:100%;} #secondary-inner secondary-wrapper{display:flex;flex-direction:column;flex-wrap:nowrap;box-sizing:border-box;padding:0;margin:0;border:0;height:100%;max-height:calc(100vh - var(--ytd-toolbar-height,56px));position:absolute;top:0;right:0;left:0;contain:strict;padding:var(--ytd-margin-6x) var(--ytd-margin-6x) var(--ytd-margin-6x) 0;} #right-tabs{position:relative;display:flex;padding:0;margin:0;flex-grow:1;flex-direction:column;} [tyt-tab=""] #right-tabs{flex-grow:0;} [tyt-tab=""] #right-tabs .tab-content{border:0;} #right-tabs .tab-content{flex-grow:1;} ytd-watch-flexy[hide-default-text-inline-expander] #primary.style-scope.ytd-watch-flexy ytd-text-inline-expander{display:none;} ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden{--comment-pre-load-sizing:90px;visibility:collapse;z-index:-1;position:absolute!important;left:2px;top:2px;width:var(--comment-pre-load-sizing)!important;height:var(--comment-pre-load-sizing)!important;display:block!important;pointer-events:none!important;overflow:hidden;contain:strict;border:0;margin:0;padding:0;} ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden ytd-comments#comments>ytd-item-section-renderer#sections{display:block!important;overflow:hidden;height:var(--comment-pre-load-sizing);width:var(--comment-pre-load-sizing);contain:strict;border:0;margin:0;padding:0;} ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden ytd-comments#comments>ytd-item-section-renderer#sections>#contents{display:flex!important;flex-direction:row;gap:60px;overflow:hidden;height:var(--comment-pre-load-sizing);width:var(--comment-pre-load-sizing);contain:strict;border:0;margin:0;padding:0;} ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden ytd-comments#comments #contents{--comment-pre-load-display:none;} ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden ytd-comments#comments #contents>*:only-of-type,ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden ytd-comments#comments #contents>*:last-child{--comment-pre-load-display:block;} ytd-watch-flexy:not([keep-comments-scroller]) #tab-comments.tab-content-hidden ytd-comments#comments #contents>*{display:var(--comment-pre-load-display)!important;} ytd-watch-flexy #tab-comments:not(.tab-content-hidden){pointer-events:auto!important;} ytd-watch-flexy #tab-comments:not(.tab-content-hidden) *{pointer-events:auto!important;} ytd-watch-flexy #tab-comments:not(.tab-content-hidden) button,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) yt-button-renderer,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) a,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) tp-yt-paper-button,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) [role="button"],ytd-watch-flexy #tab-comments:not(.tab-content-hidden) yt-button-shape{pointer-events:auto!important;} ytd-watch-flexy #tab-comments tp-yt-paper-button{white-space:normal;word-break:break-word;max-width:100%;overflow-wrap:break-word;} ytd-watch-flexy #tab-comments:not(.tab-content-hidden) ytd-comment-action-buttons-renderer,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) ytd-button-renderer,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) #action-buttons,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) ytd-menu-renderer,ytd-watch-flexy #tab-comments:not(.tab-content-hidden) yt-dropdown-menu{pointer-events:auto!important;} #right-tabs #material-tabs{position:relative;display:flex;padding:0;border:1px solid var(--ytd-searchbox-legacy-border-color);overflow:hidden;} [tyt-tab] #right-tabs #material-tabs{border-radius:12px;} [tyt-tab^="#"] #right-tabs #material-tabs{border-radius:12px 12px 0 0;} ytd-watch-flexy:not([is-two-columns_]) #right-tabs #material-tabs{outline:0;} #right-tabs #material-tabs a.tab-btn[tyt-tab-content]>*{pointer-events:none;} #right-tabs #material-tabs a.tab-btn[tyt-tab-content]>.font-size-right{pointer-events:initial;display:none;} ytd-watch-flexy #right-tabs .tab-content{padding:0;box-sizing:border-box;display:block;border:1px solid var(--ytd-searchbox-legacy-border-color);border-top:0;position:relative;top:0;display:flex;flex-direction:row;overflow:hidden;border-radius:0 0 12px 12px;} ytd-watch-flexy:not([is-two-columns_]) #right-tabs .tab-content{height:100%;} ytd-watch-flexy #right-tabs .tab-content-cld{box-sizing:border-box;position:relative;display:block;width:100%;overflow:auto;--tab-content-padding:var(--ytd-margin-4x);padding:var(--tab-content-padding);contain:layout paint;will-change:scroll-position;} .tab-content-cld,#right-tabs,.tab-content{transition:none;animation:none;} ytd-watch-flexy #right-tabs .tab-content-cld::-webkit-scrollbar{width:8px;height:8px;} ytd-watch-flexy #right-tabs .tab-content-cld::-webkit-scrollbar-track{background:transparent;} ytd-watch-flexy #right-tabs .tab-content-cld::-webkit-scrollbar-thumb{background:rgba(144,144,144,.5);border-radius:4px;} ytd-watch-flexy #right-tabs .tab-content-cld::-webkit-scrollbar-thumb:hover{background:rgba(170,170,170,.7);} #right-tabs #emojis.ytd-commentbox{inset:auto 0 auto 0;width:auto;} ytd-watch-flexy[is-two-columns_] #right-tabs .tab-content-cld{height:100%;width:100%;contain:size layout paint style;position:absolute;} ytd-watch-flexy #right-tabs .tab-content-cld.tab-content-hidden{display:none;width:100%;contain:size layout paint style;} @supports (color:var(--tabview-tab-btn-define)){ ytd-watch-flexy #right-tabs .tab-btn{background:var(--yt-spec-general-background-a);} html{--tyt-tab-btn-flex-grow:1;--tyt-tab-btn-flex-basis:0%;--tyt-tab-bar-color-1-def:#ff4533;--tyt-tab-bar-color-2-def:var(--yt-brand-light-red);--tyt-tab-bar-color-1:var(--main-color,var(--tyt-tab-bar-color-1-def));--tyt-tab-bar-color-2:var(--main-color,var(--tyt-tab-bar-color-2-def));} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content]{flex:var(--tyt-tab-btn-flex-grow) 1 var(--tyt-tab-btn-flex-basis);position:relative;display:inline-block;text-decoration:none;text-transform:uppercase;--tyt-tab-btn-color:var(--yt-spec-text-secondary);color:var(--tyt-tab-btn-color);text-align:center;padding:14px 8px 10px;border:0;border-bottom:4px solid transparent;font-weight:500;font-size:12px;line-height:18px;cursor:pointer;transition:border 200ms linear 100ms;background-color:var(--ytd-searchbox-legacy-button-color);text-transform:var(--yt-button-text-transform,inherit);user-select:none!important;overflow:hidden;white-space:nowrap;text-overflow:clip;} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content]>svg{height:18px;padding-right:0;vertical-align:bottom;opacity:.5;margin-right:0;color:var(--yt-button-color,inherit);fill:var(--iron-icon-fill-color,currentcolor);stroke:var(--iron-icon-stroke-color,none);pointer-events:none;} ytd-watch-flexy #right-tabs .tab-btn{--tabview-btn-txt-ml:8px;} ytd-watch-flexy[tyt-comment-disabled] #right-tabs .tab-btn[tyt-tab-content="#tab-comments"]{--tabview-btn-txt-ml:0;} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content]>svg+span{margin-left:var(--tabview-btn-txt-ml);} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content].active{font-weight:500;outline:0;--tyt-tab-btn-color:var(--yt-spec-text-primary);background-color:var(--ytd-searchbox-legacy-button-focus-color);border-bottom:2px var(--tyt-tab-bar-color-2) solid;} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content].active svg{opacity:.9;} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content]:not(.active):hover{background-color:var(--ytd-searchbox-legacy-button-hover-color);--tyt-tab-btn-color:var(--yt-spec-text-primary);} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content]:not(.active):hover svg{opacity:.9;} ytd-watch-flexy #right-tabs .tab-btn[tyt-tab-content].tab-btn-hidden{display:none;} ytd-watch-flexy[tyt-comment-disabled] #right-tabs .tab-btn[tyt-tab-content="#tab-comments"],ytd-watch-flexy[tyt-comment-disabled] #right-tabs .tab-btn[tyt-tab-content="#tab-comments"]:hover{--tyt-tab-btn-color:var(--yt-spec-icon-disabled);} ytd-watch-flexy[tyt-comment-disabled] #right-tabs .tab-btn[tyt-tab-content="#tab-comments"] span#tyt-cm-count:empty{display:none;} ytd-watch-flexy #right-tabs .tab-btn span#tyt-cm-count:empty::after{display:inline-block;width:4em;text-align:left;font-size:inherit;color:currentColor;transform:scaleX(.8);}} @supports (color:var(--tyt-cm-count-define)){ ytd-watch-flexy{--tyt-x-loading-content-letter-spacing:2px;} html{--tabview-text-loading:"Loading";--tabview-text-fetching:"Fetching";--tabview-panel-loading:var(--tabview-text-loading);} ytd-watch-flexy #right-tabs .tab-btn span#tyt-cm-count:empty::after{content:var(--tabview-text-loading);letter-spacing:var(--tyt-x-loading-content-letter-spacing);}} @supports (color:var(--tabview-font-size-btn-define)){ .font-size-right{display:inline-flex;flex-direction:column;position:absolute;right:0;top:0;bottom:0;width:16px;padding:4px 0;justify-content:space-evenly;align-content:space-evenly;pointer-events:none;} html body ytd-watch-flexy.style-scope .font-size-btn{user-select:none!important;} .font-size-btn{--tyt-font-size-btn-display:none;display:var(--tyt-font-size-btn-display,none);width:12px;height:12px;color:var(--yt-spec-text-secondary);background-color:var(--yt-spec-badge-chip-background);box-sizing:border-box;cursor:pointer;transform-origin:left top;margin:0;padding:0;position:relative;font-family:'Menlo','Lucida Console','Monaco','Consolas',monospace;line-height:100%;font-weight:900;transition:background-color 90ms linear,color 90ms linear;pointer-events:all;} .font-size-btn:hover{background-color:var(--yt-spec-text-primary);color:var(--yt-spec-general-background-a);} @supports (zoom:.5){ .tab-btn .font-size-btn{--tyt-font-size-btn-display:none;} .tab-btn.active:hover .font-size-btn{--tyt-font-size-btn-display:inline-block;} body ytd-watch-flexy:not([is-two-columns_]) #columns.ytd-watch-flexy{flex-direction:column;} body ytd-watch-flexy:not([is-two-columns_]) #secondary.ytd-watch-flexy{display:block;width:100%;box-sizing:border-box;} body ytd-watch-flexy:not([is-two-columns_]) #secondary.ytd-watch-flexy secondary-wrapper{padding-left:var(--ytd-margin-6x);contain:content;height:initial;} body ytd-watch-flexy:not([is-two-columns_]) #secondary.ytd-watch-flexy secondary-wrapper #right-tabs{overflow:auto;} [tyt-chat="+"] { --tyt-chat-grow: 1;} [tyt-chat="+"] secondary-wrapper>[tyt-chat-container]{flex-grow:var(--tyt-chat-grow);flex-shrink:0;display:flex;flex-direction:column;} [tyt-chat="+"] secondary-wrapper>[tyt-chat-container]>#chat{flex-grow:var(--tyt-chat-grow);} ytd-watch-flexy[is-two-columns_]:not([theater]):not([full-bleed-player]) #columns.style-scope.ytd-watch-flexy{min-height:calc(100vh - var(--ytd-toolbar-height,56px));} ytd-watch-flexy[is-two-columns_]:not([full-bleed-player]) ytd-live-chat-frame#chat{min-height:initial!important;height:initial!important;} ytd-watch-flexy[tyt-tab^="#"]:not([is-two-columns_]):not([tyt-chat="+"]) #right-tabs{min-height:var(--ytd-watch-flexy-chat-max-height);} body ytd-watch-flexy:not([is-two-columns_]) #chat.ytd-watch-flexy{margin-top:0;} body ytd-watch-flexy:not([is-two-columns_]) ytd-watch-metadata.ytd-watch-flexy{margin-bottom:0;} ytd-watch-metadata.ytd-watch-flexy ytd-metadata-row-container-renderer{display:none;} #tab-info [show-expand-button] #expand-sizer.ytd-text-inline-expander{visibility:initial;} #tab-info #collapse.button.ytd-text-inline-expander {display: none;} #tab-info #social-links.style-scope.ytd-video-description-infocards-section-renderer>#left-arrow-container.ytd-video-description-infocards-section-renderer>#left-arrow,#tab-info #social-links.style-scope.ytd-video-description-infocards-section-renderer>#right-arrow-container.ytd-video-description-infocards-section-renderer>#right-arrow{border:6px solid transparent;opacity:.65;} #tab-info #social-links.style-scope.ytd-video-description-infocards-section-renderer>#left-arrow-container.ytd-video-description-infocards-section-renderer>#left-arrow:hover,#tab-info #social-links.style-scope.ytd-video-description-infocards-section-renderer>#right-arrow-container.ytd-video-description-infocards-section-renderer>#right-arrow:hover{opacity:1;} #tab-info #social-links.style-scope.ytd-video-description-infocards-section-renderer>div#left-arrow-container::before{content:'';background:transparent;width:40px;display:block;height:40px;position:absolute;left:-20px;top:0;z-index:-1;} #tab-info #social-links.style-scope.ytd-video-description-infocards-section-renderer>div#right-arrow-container::before{content:'';background:transparent;width:40px;display:block;height:40px;position:absolute;right:-20px;top:0;z-index:-1;} body ytd-watch-flexy[is-two-columns_][tyt-egm-panel_] #columns.style-scope.ytd-watch-flexy #panels.style-scope.ytd-watch-flexy{flex-grow:1;flex-shrink:0;display:flex;flex-direction:column;} body ytd-watch-flexy[is-two-columns_][tyt-egm-panel_] #columns.style-scope.ytd-watch-flexy #panels.style-scope.ytd-watch-flexy ytd-engagement-panel-section-list-renderer[target-id][visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"]{height:initial;max-height:initial;min-height:initial;flex-grow:1;flex-shrink:0;display:flex;flex-direction:column;} secondary-wrapper [visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"] ytd-transcript-renderer:not(:empty),secondary-wrapper [visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"] #body.ytd-transcript-renderer:not(:empty),secondary-wrapper [visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"] #content.ytd-transcript-renderer:not(:empty){flex-grow:1;height:initial;max-height:initial;min-height:initial;} secondary-wrapper #content.ytd-engagement-panel-section-list-renderer{position:relative;} secondary-wrapper #content.ytd-engagement-panel-section-list-renderer>[panel-target-id]:only-child{contain:style size;} secondary-wrapper #content.ytd-engagement-panel-section-list-renderer ytd-transcript-segment-list-renderer.ytd-transcript-search-panel-renderer{flex-grow:1;contain:strict;} secondary-wrapper #content.ytd-engagement-panel-section-list-renderer ytd-transcript-segment-renderer.style-scope.ytd-transcript-segment-list-renderer{contain:layout paint style;} secondary-wrapper #content.ytd-engagement-panel-section-list-renderer ytd-transcript-segment-renderer.style-scope.ytd-transcript-segment-list-renderer>.segment{contain:layout paint style;} body ytd-watch-flexy[theater] #secondary.ytd-watch-flexy{margin-top:var(--ytd-margin-3x);padding-top:0;} body ytd-watch-flexy[theater] secondary-wrapper{margin-top:0;padding-top:0;} body ytd-watch-flexy[theater] #chat.ytd-watch-flexy{margin-bottom:var(--ytd-margin-2x);} ytd-watch-flexy[theater] #right-tabs .tab-btn[tyt-tab-content]{padding:8px 4px 6px;border-bottom:0 solid transparent;} ytd-watch-flexy[theater] #playlist.ytd-watch-flexy{margin-bottom:var(--ytd-margin-2x);} ytd-watch-flexy[theater] ytd-playlist-panel-renderer[collapsible][collapsed] .header.ytd-playlist-panel-renderer{padding:6px 8px;} #tab-comments ytd-comments#comments [field-of-cm-count]{margin-top:0;} #tab-info>ytd-expandable-video-description-body-renderer{margin-bottom:var(--ytd-margin-3x);} #tab-info [class]:last-child{margin-bottom:0;padding-bottom:0;} #tab-info ytd-rich-metadata-row-renderer ytd-rich-metadata-renderer{max-width:initial;} ytd-watch-flexy[is-two-columns_] secondary-wrapper #chat.ytd-watch-flexy{margin-bottom:var(--ytd-margin-3x);} ytd-watch-flexy[tyt-tab] tp-yt-paper-tooltip{white-space:nowrap;contain:content;} ytd-watch-info-text tp-yt-paper-tooltip.style-scope.ytd-watch-info-text{margin-bottom:-300px;margin-top:-96px;} [hide-default-text-inline-expander] #bottom-row #description.ytd-watch-metadata{font-size:1.2rem;line-height:1.8rem;} [hide-default-text-inline-expander] #bottom-row #description.ytd-watch-metadata yt-animated-rolling-number{font-size:inherit;} [hide-default-text-inline-expander] #bottom-row #description.ytd-watch-metadata #info-container.style-scope.ytd-watch-info-text{align-items:center;} ytd-watch-flexy[hide-default-text-inline-expander]{--tyt-bottom-watch-metadata-margin:6px;} [hide-default-text-inline-expander] #bottom-row #description.ytd-watch-metadata>#description-inner.ytd-watch-metadata{margin:6px 12px;} [hide-default-text-inline-expander] ytd-watch-metadata[title-headline-xs] h1.ytd-watch-metadata{font-size:1.8rem;} ytd-watch-flexy[is-two-columns_][hide-default-text-inline-expander] #below.style-scope.ytd-watch-flexy ytd-merch-shelf-renderer{padding:0;border:0;margin:0;} ytd-watch-flexy[is-two-columns_][hide-default-text-inline-expander] #below.style-scope.ytd-watch-flexy ytd-watch-metadata.ytd-watch-flexy{margin-bottom:6px;} #tab-info yt-video-attribute-view-model .yt-video-attribute-view-model--horizontal .yt-video-attribute-view-model__link-container .yt-video-attribute-view-model__hero-section{flex-shrink:0;} #tab-info yt-video-attribute-view-model .yt-video-attribute-view-model__overflow-menu{background:var(--yt-emoji-picker-category-background-color);border-radius:99px;} #tab-info yt-video-attribute-view-model .yt-video-attribute-view-model--image-square.yt-video-attribute-view-model--image-large .yt-video-attribute-view-model__hero-section{max-height:128px;} #tab-info yt-video-attribute-view-model .yt-video-attribute-view-model--image-large .yt-video-attribute-view-model__hero-section{max-width:128px;} #tab-info ytd-reel-shelf-renderer #items.yt-horizontal-list-renderer ytd-reel-item-renderer.yt-horizontal-list-renderer{max-width:142px;} ytd-watch-info-text#ytd-watch-info-text.style-scope.ytd-watch-metadata #view-count.style-scope.ytd-watch-info-text,ytd-watch-info-text#ytd-watch-info-text.style-scope.ytd-watch-metadata #date-text.style-scope.ytd-watch-info-text{align-items:center;} ytd-watch-info-text:not([detailed]) #info.ytd-watch-info-text a.yt-simple-endpoint.yt-formatted-string{pointer-events:none;} body ytd-app>ytd-popup-container>tp-yt-iron-dropdown>#contentWrapper>[slot="dropdown-content"]{backdrop-filter:none;} #tab-info [tyt-clone-refresh-count]{overflow:visible!important;} #tab-info #items.ytd-horizontal-card-list-renderer yt-video-attribute-view-model.ytd-horizontal-card-list-renderer{contain:layout;} #tab-info #thumbnail-container.ytd-structured-description-channel-lockup-renderer,#tab-info ytd-media-lockup-renderer[is-compact] #thumbnail-container.ytd-media-lockup-renderer{flex-shrink:0;} secondary-wrapper ytd-donation-unavailable-renderer{--ytd-margin-6x:var(--ytd-margin-2x);--ytd-margin-5x:var(--ytd-margin-2x);--ytd-margin-4x:var(--ytd-margin-2x);--ytd-margin-3x:var(--ytd-margin-2x);} [tyt-no-less-btn] #less{display:none;} .tyt-metadata-hover-resized #purchase-button,.tyt-metadata-hover-resized #sponsor-button,.tyt-metadata-hover-resized #analytics-button,.tyt-metadata-hover-resized #subscribe-button{display:none!important;} .tyt-metadata-hover #upload-info{max-width:max-content;min-width:max-content;flex-basis:100vw;flex-shrink:0;} .tyt-info-invisible{display:none;} [tyt-playlist-expanded] secondary-wrapper>ytd-playlist-panel-renderer#playlist{overflow:auto;flex-shrink:1;flex-grow:1;max-height:unset!important;} [tyt-playlist-expanded] secondary-wrapper>ytd-playlist-panel-renderer#playlist>#container{max-height:unset!important;} secondary-wrapper ytd-playlist-panel-renderer{--ytd-margin-6x:var(--ytd-margin-3x);} #tab-info ytd-structured-description-playlist-lockup-renderer[collections] #playlist-thumbnail.style-scope.ytd-structured-description-playlist-lockup-renderer{max-width:100%;} #tab-info ytd-structured-description-playlist-lockup-renderer[collections] #lockup-container.ytd-structured-description-playlist-lockup-renderer{padding:1px;} #tab-info ytd-structured-description-playlist-lockup-renderer[collections] #thumbnail.ytd-structured-description-playlist-lockup-renderer{outline:1px solid rgba(127,127,127,.5);} ytd-live-chat-frame#chat[collapsed] ytd-message-renderer~#show-hide-button.ytd-live-chat-frame>ytd-toggle-button-renderer.ytd-live-chat-frame{padding:0;} ytd-watch-flexy{--tyt-bottom-watch-metadata-margin:12px;} ytd-watch-flexy[rounded-info-panel],ytd-watch-flexy[rounded-player-large]{--tyt-rounded-a1:12px;} #bottom-row.style-scope.ytd-watch-metadata .item.ytd-watch-metadata{margin-right:var(--tyt-bottom-watch-metadata-margin,12px);margin-top:var(--tyt-bottom-watch-metadata-margin,12px);} #cinematics{contain:layout style size;} ytd-watch-flexy[is-two-columns_]{contain:layout style;} .yt-spec-touch-feedback-shape--touch-response .yt-spec-touch-feedback-shape__fill{background-color:transparent;} /* plugin: external.ytlstm */ body[data-ytlstm-theater-mode] #secondary-inner[class] > secondary-wrapper[class]:not(#chat-container):not(#chat) {display: flex !important;} body[data-ytlstm-theater-mode] secondary-wrapper {all: unset;height: 100vh;} body[data-ytlstm-theater-mode] #right-tabs {display: none;} body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] [tyt-chat="+"] {--tyt-chat-grow: unset;} body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] #columns.style-scope.ytd-watch-flexy, body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] #secondary.style-scope.ytd-watch-flexy, body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] #secondary-inner.style-scope.ytd-watch-flexy, body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] secondary-wrapper, body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] #chat-container.style-scope, body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] [tyt-chat-container].style-scope {pointer-events: none;} body[data-ytlstm-theater-mode] [data-ytlstm-chat-over-video] #chat[class] {pointer-events: auto;} .playlist-items.ytd-playlist-panel-renderer {background-color: transparent !important;} @supports (color: var(--tyt-fix-20251124)) { #below ytd-watch-metadata .ytTextCarouselItemViewModelImageType { height: 16px; width: 16px;} #below ytd-watch-metadata yt-text-carousel-item-view-model { column-gap: 6px;} #below ytd-watch-metadata ytd-watch-info-text#ytd-watch-info-text { font-size: inherit; line-height: inherit;} /* Fix: video tab thumbnails (yt-lockup-view-model) too large in side panel */ #tab-videos yt-lockup-view-model{max-width:100%;contain:layout paint;} #tab-videos yt-lockup-view-model .yt-lockup-view-model__content-image,#tab-videos yt-lockup-view-model .yt-lockup-view-model__content-image img,#tab-videos yt-lockup-view-model .yt-lockup-view-model__content-image yt-image{max-width:175px;max-height:94px;width:175px;height:auto;object-fit:cover;border-radius:8px;flex-shrink:0;} #tab-videos yt-lockup-view-model .yt-lockup-view-model--horizontal{display:flex;gap:8px;align-items:flex-start;} #tab-videos yt-lockup-view-model .yt-lockup-view-model--horizontal .yt-lockup-view-model__content-image{flex-shrink:0;width:175px;} #tab-videos yt-lockup-view-model .yt-lockup-view-model--horizontal .yt-lockup-view-model__metadata{flex:1;min-width:0;overflow:hidden;} #tab-videos ytd-video-renderer[use-search-ui] #thumbnail.ytd-video-renderer,#tab-videos ytd-compact-video-renderer #thumbnail{max-width:175px;width:175px;flex-shrink:0;} /* ── LCP Performance: safe content-visibility hints (no contain:layout to preserve sticky) ── */ ytd-browse[page-subtype="home"] #contents.ytd-rich-grid-renderer>ytd-rich-item-renderer:nth-child(n+9){content-visibility:auto;contain-intrinsic-size:auto 360px;} ytd-playlist-video-list-renderer #contents>ytd-playlist-video-renderer:nth-child(n+10){content-visibility:auto;contain-intrinsic-size:auto 90px;} ytd-watch-next-secondary-results-renderer ytd-compact-video-renderer:nth-child(n+5){content-visibility:auto;contain-intrinsic-size:auto 94px;} `, }; (async () => { // ------------------------------------------------------------------------ nextBrowserTick ------------------------------------------------------------------------ /* eslint-disable no-unused-expressions, no-var */ var nextBrowserTick = void 0 !== nextBrowserTick && nextBrowserTick.version >= 2 ? nextBrowserTick : (() => { 'use strict'; const e = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this; let t = !0; if ( !(function n(s) { return s ? (t = !1) : e.postMessage && !e.importScripts && e.addEventListener ? (e.addEventListener('message', n, !1), e.postMessage('$$$', '*'), e.removeEventListener('message', n, !1), t) : void 0; })() ) { return void console.warn('Your browser environment cannot use nextBrowserTick'); } const n = (async () => {}).constructor; let s = null; const o = new Map(), { floor: r, random: i } = Math; let l; do { l = `$$nextBrowserTick$$${(i() + 8).toString().slice(2)}$$`; } while (l in e); const a = l, c = a.length + 9; e[a] = 1; e.addEventListener( 'message', e => { if (0 !== o.size) { const t = (e || 0).data; if ('string' == typeof t && t.length === c && e.source === (e.target || 1)) { const e = o.get(t); e && ('p' === t[0] && (s = null), o.delete(t), e()); } } }, !1 ); const d = (t = o) => { if (t === o) { if (s) return s; let t; do { t = `p${a}${r(314159265359 * i() + 314159265359).toString(36)}`; } while (o.has(t)); return ( (s = new n(e => { o.set(t, e); })), e.postMessage(t, '*'), (t = null), s ); } { let n; do { n = `f${a}${r(314159265359 * i() + 314159265359).toString(36)}`; } while (o.has(n)); (o.set(n, t), e.postMessage(n, '*')); } }; return ((d.version = 2), d); })(); /* eslint-enable no-unused-expressions, no-var */ // ------------------------------------------------------------------------ nextBrowserTick ------------------------------------------------------------------------ const communicationKey = `ck-${Date.now()}-${Math.floor(Math.random() * 314159265359 + 314159265359).toString(36)}`; /** @type {globalThis.PromiseConstructor} */ const Promise = (async () => {})().constructor; // YouTube hacks Promise in WaterFox Classic and "Promise.resolve(0)" nevers resolve. if (!document.documentElement) { await Promise.resolve(0); while (!document.documentElement) { await new Promise(resolve => nextBrowserTick(resolve)).then().catch(console.warn); } } const sourceURL = 'debug://tabview-youtube/tabview.execution.js'; const textContent = `(${executionScript})("${communicationKey}");${'\n\n'}//# sourceURL=${sourceURL}${'\n'}`; // const isMyScriptInChromeRuntime = () => typeof GM === 'undefined' && typeof ((((window || 0).chrome || 0).runtime || 0).getURL) === 'function'; // const isGMAvailable = () => typeof GM !== 'undefined' && !isMyScriptInChromeRuntime(); // if(isMyScriptInChromeRuntime()){ // let button = document.createElement('button'); // button.setAttribute('onclick', textContent); // button.click(); // button = null; // }else{ // GM_addElement('script', { // textContent: textContent // }); // } let button = document.createElement('button'); button.setAttribute('onclick', createHTML(textContent)); // max size 10 million bytes button.click(); button = null; const style = document.createElement('style'); const sourceURLMainCSS = 'debug://tabview-youtube/tabview.main.css'; const cssContent = `${styles['main'].trim()}${'\n\n'}/*# sourceURL=${sourceURLMainCSS} */${'\n'}`; // Avoid referencing GM_addStyle directly to prevent "not defined" errors in some environments const gmAddStyle = (typeof window !== 'undefined' && window['GM_addStyle']) || null; if (typeof gmAddStyle === 'function') { gmAddStyle(cssContent); } else { style.textContent = cssContent; document.documentElement.appendChild(style); } const applyTabviewI18nVars = () => { const root = document.documentElement; if (!root) return; const i18n = typeof window !== 'undefined' ? window.YouTubePlusI18n : null; const translate = (key, fallback) => { if (i18n && typeof i18n.t === 'function') { const value = i18n.t(key); if (value && value !== key) return value; } return fallback; }; const toCssString = value => { const text = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `"${text}"`; }; root.style.setProperty('--tabview-text-loading', toCssString(translate('loading', 'Loading'))); root.style.setProperty( '--tabview-text-fetching', toCssString(translate('fetching', 'Fetching')) ); }; const applyTabviewI18nTabs = () => { const container = document.querySelector('#right-tabs'); if (!container) return false; const i18n = typeof window !== 'undefined' ? window.YouTubePlusI18n : null; const translate = (key, fallback) => { if (i18n && typeof i18n.t === 'function') { const value = i18n.t(key); if (value && value !== key) return value; } return fallback; }; const labels = [ { selector: '#tab-btn1 span', key: 'info', fallback: 'Info' }, { selector: '#tab-btn4 span', key: 'videos', fallback: 'Videos' }, { selector: '#tab-btn5 span', key: 'playlist', fallback: 'Playlist' }, ]; for (const { selector, key, fallback } of labels) { const label = container.querySelector(selector); if (label) label.textContent = translate(key, fallback); } return true; }; const scheduleTabviewI18nTabs = () => { let attempts = 0; const tryApply = () => { if (applyTabviewI18nTabs()) return; if (attempts < 20) { attempts += 1; setTimeout(tryApply, 250); } }; tryApply(); }; const refreshTabviewI18n = () => { applyTabviewI18nVars(); scheduleTabviewI18nTabs(); }; let tabviewI18nListenerBound = false; const bindTabviewI18n = () => { let attempts = 0; const tryBind = () => { const i18n = typeof window !== 'undefined' ? window.YouTubePlusI18n : null; if (i18n && typeof i18n.t === 'function') { refreshTabviewI18n(); if (!tabviewI18nListenerBound && typeof i18n.onLanguageChange === 'function') { i18n.onLanguageChange(refreshTabviewI18n); tabviewI18nListenerBound = true; } return; } if (attempts < 120) { attempts += 1; setTimeout(tryBind, 500); } }; tryBind(); }; // Also react to global i18n lifecycle events for modules that initialize later. if (typeof window !== 'undefined') { window.addEventListener('youtube-plus-i18n-ready', refreshTabviewI18n, { passive: true }); window.addEventListener('youtube-plus-language-changed', refreshTabviewI18n, { passive: true, }); } bindTabviewI18n(); scheduleTabviewI18nTabs(); // Initialize lazy loading for non-critical features if (typeof window !== 'undefined' && window.YouTubePlusLazyLoader) { const { register, loadOnIdle } = window.YouTubePlusLazyLoader; // Register deferred initialization for heavy modules // These will be initialized when their features are needed // Stats module - load when channel page is detected register( 'stats-module', () => { if (window.YouTubeStatsReady) { console.log('[YouTube+] Stats module already initialized'); return; } console.log('[YouTube+] Stats module initialization deferred'); // Stats is already loaded but we mark it as lazy-ready window.YouTubeStatsReady = true; }, { priority: 3, delay: 1000 } ); // Download modal - load when download button is clicked register( 'download-module', () => { console.log('[YouTube+] Download module ready'); }, { priority: 4, delay: 500 } ); // Playlist search - load when playlist page detected register( 'playlist-search-module', () => { console.log('[YouTube+] Playlist search ready'); }, { priority: 2, delay: 800 } ); // Thumbnail overlay - load when video page detected register( 'thumbnail-module', () => { console.log('[YouTube+] Thumbnail overlay ready'); }, { priority: 3, delay: 600 } ); // Load all non-critical features during browser idle time loadOnIdle(2000); } })(); // --- MODULE: i18n.js --- /** * YouTube+ Internationalization (i18n) System - v3.2 * Unified i18n system with integrated loader * Supports all major YouTube interface languages * @module i18n * @version 3.2 */ (function () { 'use strict'; // ============================================================================ // I18N LOADER (merged from i18n-loader.js) // ============================================================================ const GITHUB_CONFIG = { owner: 'diorhc', repo: 'YTP', branch: 'main', basePath: 'locales', }; const CDN_URLS = { github: `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.basePath}`, jsdelivr: `https://cdn.jsdelivr.net/gh/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}@${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.basePath}`, }; // Translation files shipped with the project (and embedded by embed-translations.js). // Any other YouTube UI language will map to the closest language below (usually English). const AVAILABLE_LANGUAGES = ['en', 'ru', 'kr', 'fr', 'du', 'cn', 'tw', 'jp', 'tr', 'es', 'pt', 'de', 'it', 'pl', 'uk', 'ar', 'hi', 'id', 'vi', 'uz', 'kk', 'ky', 'be', 'bg', 'az']; const LANGUAGE_NAMES = { en: 'English', ru: 'Русский', kr: '한국어', fr: 'Français', du: 'Nederlands', cn: '简体中文', tw: '繁體中文', jp: '日本語', tr: 'Türkçe', es: 'Español', pt: 'Português', de: 'Deutsch', it: 'Italiano', pl: 'Polski', uk: 'Українська', sv: 'Svenska', no: 'Norsk', da: 'Dansk', fi: 'Suomi', cs: 'Čeština', sk: 'Slovenčina', hu: 'Magyar', ro: 'Română', bg: 'Български', hr: 'Hrvatski', sr: 'Српски', sl: 'Slovenščina', el: 'Ελληνικά', lt: 'Lietuvių', lv: 'Latviešu', et: 'Eesti', mk: 'Македонски', sq: 'Shqip', bs: 'Bosanski', is: 'Íslenska', ca: 'Català', eu: 'Euskara', gl: 'Galego', ar: 'العربية', he: 'עברית', fa: 'فارسی', sw: 'Kiswahili', zu: 'isiZulu', af: 'Afrikaans', am: 'አማርኛ', hi: 'हिन्दी', th: 'ไทย', vi: 'Tiếng Việt', id: 'Bahasa Indonesia', ms: 'Bahasa Melayu', bn: 'বাংলা', ta: 'தமிழ்', te: 'తెలుగు', mr: 'मराठी', gu: 'ગુજરાતી', kn: 'ಕನ್ನಡ', ml: 'മലയാളം', pa: 'ਪੰਜਾਬੀ', fil: 'Filipino', km: 'ភាសាខ្មែរ', lo: 'ລາວ', my: 'မြန်မာ', ne: 'नेपाली', si: 'සිංහල', az: 'Azərbaycanca', be: 'Беларуская', hy: 'Հայերեն', ka: 'ქართული', kk: 'Қазақ', ky: 'Кыргызча', mn: 'Монгол', tg: 'Тоҷикӣ', uz: 'Oʻzbekcha', }; const LANGUAGE_FALLBACKS = { es: 'es', 'es-es': 'es', 'es-mx': 'es', 'es-419': 'es', pt: 'pt', 'pt-br': 'pt', 'pt-pt': 'pt', de: 'de', 'de-de': 'de', 'de-at': 'de', 'de-ch': 'de', it: 'it', pl: 'pl', uk: 'uk', 'uk-ua': 'uk', ar: 'ar', 'ar-sa': 'ar', 'ar-ae': 'ar', 'ar-eg': 'ar', hi: 'hi', 'hi-in': 'hi', th: 'en', 'th-th': 'en', vi: 'vi', 'vi-vn': 'vi', id: 'id', 'id-id': 'id', ms: 'en', 'ms-my': 'en', sv: 'en', 'sv-se': 'en', no: 'en', 'nb-no': 'en', 'nn-no': 'en', da: 'en', 'da-dk': 'en', fi: 'en', 'fi-fi': 'en', cs: 'en', 'cs-cz': 'en', sk: 'en', 'sk-sk': 'en', hu: 'en', 'hu-hu': 'en', ro: 'en', 'ro-ro': 'en', bg: 'bg', 'bg-bg': 'bg', hr: 'en', 'hr-hr': 'en', sr: 'ru', 'sr-rs': 'ru', sl: 'en', 'sl-si': 'en', el: 'en', 'el-gr': 'en', he: 'en', 'he-il': 'en', iw: 'en', fa: 'en', 'fa-ir': 'en', bn: 'en', 'bn-in': 'en', ta: 'en', 'ta-in': 'en', te: 'en', 'te-in': 'en', mr: 'en', 'mr-in': 'en', gu: 'en', 'gu-in': 'en', kn: 'en', 'kn-in': 'en', ml: 'en', 'ml-in': 'en', pa: 'en', 'pa-in': 'en', fil: 'en', 'fil-ph': 'en', tl: 'en', km: 'en', lo: 'en', my: 'en', ne: 'en', si: 'en', sw: 'en', 'sw-ke': 'en', zu: 'en', af: 'en', am: 'en', az: 'az', 'az-az': 'az', be: 'be', 'be-by': 'be', hy: 'ru', ka: 'en', kk: 'kk', 'kk-kz': 'kk', ky: 'ky', mn: 'ru', tg: 'ru', uz: 'uz', 'uz-uz': 'uz', lt: 'en', 'lt-lt': 'en', lv: 'en', 'lv-lv': 'en', et: 'en', 'et-ee': 'en', mk: 'ru', sq: 'en', bs: 'en', is: 'en', ca: 'es', eu: 'es', gl: 'es', }; const translationsCache = new Map(); const loadingPromises = new Map(); /** * Fetch translation from CDN or embedded source * @param {string} lang - Language code * @returns {Promise} Translation object */ async function fetchTranslation(lang) { // Use embedded translations if available (fast local fallback) try { if (typeof window !== 'undefined' && window.YouTubePlusEmbeddedTranslations) { const embedded = window.YouTubePlusEmbeddedTranslations[lang]; if (embedded) { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug( '[YouTube+][i18n]', `Using embedded translations for ${lang}` ); return embedded; } } } catch (e) { console.warn('[YouTube+][i18n]', 'Error reading embedded translations', e); } // Try raw GitHub first — often contains the latest changes and avoids // CDN caching delays. If that fails, fall back to jsDelivr with a // lightweight cache-bust query param to reduce the chance of stale // responses from the CDN. try { const rawUrl = `${CDN_URLS.github}/${lang}.json`; const response = await fetch(rawUrl, { cache: 'default', headers: { Accept: 'application/json' }, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (firstErr) { try { const cdnUrl = `${CDN_URLS.jsdelivr}/${lang}.json?_=${Date.now()}`; console.warn( '[YouTube+][i18n]', `Raw GitHub fetch failed, trying jsDelivr (with cache-bust): ${cdnUrl}` ); const response = await fetch(cdnUrl, { cache: 'no-cache', headers: { Accept: 'application/json' }, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (err) { console.error( '[YouTube+][i18n]', `Failed to fetch translations for ${lang}:`, err, firstErr ); throw err; } } } /** * Load translations for a language (with caching) * @param {string} lang - Language code * @returns {Promise} Translation object */ function loadTranslationsFromLoader(lang) { const languageCode = AVAILABLE_LANGUAGES.includes(lang) ? lang : 'en'; if (translationsCache.has(languageCode)) return translationsCache.get(languageCode); if (loadingPromises.has(languageCode)) return loadingPromises.get(languageCode); const loadPromise = (async () => { try { const translations = await fetchTranslation(languageCode); // Quick sanity check: warn if common UI keys are missing from fetched translations try { const missing = []; ['loading', 'fetching'].forEach(k => { if (!Object.prototype.hasOwnProperty.call(translations, k)) missing.push(k); }); if (missing.length > 0) { console.warn( '[YouTube+][i18n]', `Translations for ${languageCode} missing keys: ${missing.join(', ')} (source may be stale)` ); } } catch { /* ignore sanity-check errors */ } translationsCache.set(languageCode, translations); loadingPromises.delete(languageCode); return translations; } catch (error) { loadingPromises.delete(languageCode); if (languageCode !== 'en') return loadTranslationsFromLoader('en'); throw error; } })(); loadingPromises.set(languageCode, loadPromise); return loadPromise; } // ============================================================================ // I18N CORE SYSTEM // ============================================================================ /** * Current language * @type {string} */ let currentLanguage = 'en'; /** * Loaded translations for current language * @type {Object} */ let translations = {}; /** * English fallback translations (loaded once). * @type {Object} */ let fallbackTranslationsEn = {}; /** * Translation cache * @type {Map} */ const translationCache = new Map(); /** * Language change listeners * @type {Set} */ const languageChangeListeners = new Set(); /** * Loading state * @type {Promise|null} */ let loadingPromise = null; /** * Emit a global browser event for i18n lifecycle hooks. * @param {string} name - Event name * @param {Object} detail - Event payload */ function emitI18nEvent(name, detail = {}) { try { if (typeof window === 'undefined') return; window.dispatchEvent(new CustomEvent(name, { detail })); } catch { try { if (typeof window === 'undefined') return; window.dispatchEvent(new Event(name)); } catch { /* no-op */ } } } // Language mapping for common locale codes - extended to support all YouTube languages const languageMap = { ko: 'kr', 'ko-kr': 'kr', fr: 'fr', 'fr-fr': 'fr', 'fr-ca': 'fr', 'fr-be': 'fr', 'fr-ch': 'fr', nl: 'du', 'nl-nl': 'du', 'nl-be': 'du', zh: 'cn', 'zh-cn': 'cn', 'zh-hans': 'cn', 'zh-sg': 'cn', 'zh-tw': 'tw', 'zh-hk': 'tw', 'zh-hant': 'tw', ja: 'jp', 'ja-jp': 'jp', tr: 'tr', 'tr-tr': 'tr', ru: 'ru', 'ru-ru': 'ru', en: 'en', 'en-us': 'en', 'en-gb': 'en', 'en-au': 'en', 'en-ca': 'en', 'en-in': 'en', ...Object.fromEntries(Object.entries(LANGUAGE_FALLBACKS).map(([key, fallback]) => [key, fallback])), }; /** * Check if a language code maps to a primary supported language * @param {string} langCode - Language code to check * @returns {string} Mapped language code */ function mapToSupportedLanguage(langCode) { const lower = langCode.toLowerCase(); // Direct match in language map if (languageMap[lower]) { return languageMap[lower]; } // Direct match in shipped translations if (AVAILABLE_LANGUAGES.includes(lower)) { return lower; } // Check first two characters const shortCode = lower.substr(0, 2); if (languageMap[shortCode]) { return languageMap[shortCode]; } if (AVAILABLE_LANGUAGES.includes(shortCode)) { return shortCode; } // Check fallbacks if (LANGUAGE_FALLBACKS[lower]) { return LANGUAGE_FALLBACKS[lower]; } if (LANGUAGE_FALLBACKS[shortCode]) { return LANGUAGE_FALLBACKS[shortCode]; } // Default to English return 'en'; } /** * Detect user's language preference with extended support * @returns {string} Language code */ function detectLanguage() { try { // Try YouTube's language setting first (from HTML lang attribute) const ytLang = document.documentElement.lang || document.querySelector('html')?.getAttribute('lang'); if (ytLang) { const mapped = mapToSupportedLanguage(ytLang); return mapped; } // Try YouTube's hl parameter from URL try { const urlParams = new URLSearchParams(window.location.search); const hlParam = urlParams.get('hl'); if (hlParam) { const mapped = mapToSupportedLanguage(hlParam); return mapped; } } catch {} // Try to get YouTube's internal language setting try { const ytConfig = window.ytcfg || window.yt?.config_; if (ytConfig && typeof ytConfig.get === 'function') { const hl = ytConfig.get('HL') || ytConfig.get('GAPI_LOCALE'); if (hl) { const mapped = mapToSupportedLanguage(hl); return mapped; } } } catch {} // Fallback to browser language const browserLang = navigator.language || navigator.userLanguage || 'en'; const mapped = mapToSupportedLanguage(browserLang); return mapped; } catch (error) { console.error('[YouTube+][i18n]', 'Error detecting language:', error); return 'en'; } } /** * Load translations for current language * @returns {Promise} Success status */ async function loadTranslations() { if (loadingPromise) { await loadingPromise; return true; } loadingPromise = (async () => { try { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug( '[YouTube+][i18n]', `Loading translations for ${currentLanguage}...` ); translations = await loadTranslationsFromLoader(currentLanguage); // Ensure we always have English fallback available (best-effort). // Skip the async fetch when embedded English translations are already // bundled — this avoids a network round-trip on every page load. if (!fallbackTranslationsEn || Object.keys(fallbackTranslationsEn).length === 0) { try { const embeddedEn = typeof window !== 'undefined' && window.YouTubePlusEmbeddedTranslations && window.YouTubePlusEmbeddedTranslations['en']; if (embeddedEn && typeof embeddedEn === 'object') { fallbackTranslationsEn = embeddedEn; } else { fallbackTranslationsEn = await loadTranslationsFromLoader('en'); } } catch { fallbackTranslationsEn = {}; } } translationCache.clear(); // Clear cache on new load window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug( '[YouTube+][i18n]', `✓ Loaded ${Object.keys(translations).length} translations for ${currentLanguage}` ); return true; } catch (error) { console.error('[YouTube+][i18n]', 'Failed to load translations:', error); // Use English as fallback if (currentLanguage !== 'en') { currentLanguage = 'en'; return loadTranslations(); } return false; } finally { loadingPromise = null; } })(); return loadingPromise; } /** * Translate a key with optional placeholders * @param {string} key - Translation key * @param {Object} [params] - Parameters to replace in translation * @returns {string} Translated string */ function translate(key, params = {}) { // Check cache const cacheKey = `${key}:${JSON.stringify(params)}`; if (translationCache.has(cacheKey)) { return translationCache.get(cacheKey); } // Get translation let text = translations[key]; // Fallback to English if current language misses the key if (!text) { const enText = fallbackTranslationsEn ? fallbackTranslationsEn[key] : undefined; if (enText) { text = enText; } else { // Only warn if translations have been loaded and key is still missing everywhere if (Object.keys(translations).length > 0) { console.warn('[YouTube+][i18n]', `Missing translation for key: ${key}`); } text = key; } } // Replace parameters if (Object.keys(params).length > 0) { Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); }); } // Cache result translationCache.set(cacheKey, text); return text; } /** * Get current language * @returns {string} Language code */ function getLanguage() { return currentLanguage; } /** * Set language and reload translations * @param {string} lang - Language code * @returns {Promise} Success status */ async function setLanguage(lang) { if (lang === currentLanguage) { return true; } const oldLang = currentLanguage; currentLanguage = lang; try { const success = await loadTranslations(); if (success) { // Notify listeners languageChangeListeners.forEach(listener => { try { listener(currentLanguage, oldLang); } catch (error) { console.error('[YouTube+][i18n]', 'Error in language change listener:', error); } }); emitI18nEvent('youtube-plus-language-changed', { language: currentLanguage, previousLanguage: oldLang, }); } return success; } catch (error) { console.error('[YouTube+][i18n]', 'Failed to change language:', error); currentLanguage = oldLang; // Revert return false; } } /** * Get all translations for current language * @returns {Object} All translations */ function getAllTranslations() { return { ...translations }; } /** * Get available languages * @returns {string[]} Array of language codes */ function getAvailableLanguages() { return AVAILABLE_LANGUAGES; } /** * Check if translation exists for key * @param {string} key - Translation key * @returns {boolean} True if exists */ function hasTranslation(key) { return translations[key] !== undefined; } /** * Add translation dynamically * @param {string} key - Translation key * @param {string} value - Translation value */ function addTranslation(key, value) { translations[key] = value; translationCache.clear(); // Clear cache } /** * Add translations for current language * @param {Object} newTranslations - Object with translations */ function addTranslations(newTranslations) { Object.assign(translations, newTranslations); translationCache.clear(); // Clear cache } /** * Register language change listener * @param {Function} callback - Callback function(newLang, oldLang) */ function onLanguageChange(callback) { languageChangeListeners.add(callback); return () => languageChangeListeners.delete(callback); } /** * Format numbers according to locale * @param {number} num - Number to format * @param {Object} [options] - Intl.NumberFormat options * @returns {string} Formatted number */ function formatNumber(num, options = {}) { try { const lang = getLanguage(); const localeMap = {ru: 'ru-RU', kr: 'ko-KR', fr: 'fr-FR', du: 'nl-NL', cn: 'zh-CN', tw: 'zh-TW', jp: 'ja-JP', tr: 'tr-TR'}; const locale = localeMap[lang] || 'en-US'; return new Intl.NumberFormat(locale, options).format(num); } catch (error) { console.error('[YouTube+][i18n]', 'Error formatting number:', error); return String(num); } } /** * Format date according to locale * @param {Date|number|string} date - Date to format * @param {Object} [options] - Intl.DateTimeFormat options * @returns {string} Formatted date */ function formatDate(date, options = {}) { try { const lang = getLanguage(); const localeMap = {ru: 'ru-RU', kr: 'ko-KR', fr: 'fr-FR', du: 'nl-NL', cn: 'zh-CN', tw: 'zh-TW', jp: 'ja-JP', tr: 'tr-TR'}; const locale = localeMap[lang] || 'en-US'; const dateObj = date instanceof Date ? date : new Date(date); return new Intl.DateTimeFormat(locale, options).format(dateObj); } catch (error) { console.error('[YouTube+][i18n]', 'Error formatting date:', error); return String(date); } } /** * Pluralize a word based on count and language * @param {number} count - Count value * @param {string} singular - Singular form * @param {string} plural - Plural form * @param {string} [few] - Few form (for Russian, etc.) * @returns {string} Appropriate form */ function pluralize(count, singular, plural, few = null) { const lang = getLanguage(); // Russian pluralization if (lang === 'ru' && few) { const mod10 = count % 10; const mod100 = count % 100; if (mod10 === 1 && mod100 !== 11) { return singular; } if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { return few; } return plural; } // Default English-like pluralization return count === 1 ? singular : plural; } /** * Clear translation cache */ function clearCache() { translationCache.clear(); } /** * Get cache statistics * @returns {Object} Cache statistics */ function getCacheStats() { return { size: translationCache.size, currentLanguage, availableLanguages: getAvailableLanguages(), translationsLoaded: Object.keys(translations).length, }; } // Initialize async function initialize() { try { currentLanguage = detectLanguage(); window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug( '[YouTube+][i18n]', `Detected language: ${currentLanguage} (${LANGUAGE_NAMES[currentLanguage] || currentLanguage})` ); // Load translations await loadTranslations(); emitI18nEvent('youtube-plus-i18n-ready', { language: currentLanguage, }); } catch (error) { console.error('[YouTube+][i18n]', 'Initialization error:', error); currentLanguage = 'en'; } } // Export API const i18nAPI = { // Core functions t: translate, translate, getLanguage, setLanguage, detectLanguage, // Advanced functions getAllTranslations, getAvailableLanguages, hasTranslation, addTranslation, addTranslations, onLanguageChange, // Formatting functions formatNumber, formatDate, pluralize, // Cache management clearCache, getCacheStats, // Internal functions loadTranslations, initialize, }; // Expose to window for global access if (typeof window !== 'undefined') { window.YouTubePlusI18n = i18nAPI; // Expose loader API for backward compatibility window.YouTubePlusI18nLoader = { loadTranslations: loadTranslationsFromLoader, AVAILABLE_LANGUAGES, LANGUAGE_NAMES, CDN_URLS, }; // Also expose as part of YouTubeUtils if it exists if (window.YouTubeUtils) { window.YouTubeUtils.i18n = i18nAPI; window.YouTubeUtils.t = translate; window.YouTubeUtils.getLanguage = getLanguage; } } // Module export for ES6 if (typeof module !== 'undefined' && module.exports) { module.exports = i18nAPI; } // Auto-initialize initialize().then(() => { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+][i18n]', 'i18n system initialized successfully'); }); })(); // --- MODULE: settings-helpers.js --- /** * Settings Modal Helpers * Helper functions to reduce complexity of settings modal creation */ /* global GM_getValue */ /** * Creates the sidebar navigation HTML * @param {Function} t - Translation function * @returns {string} Sidebar HTML */ function createSettingsSidebar(t) { return `

${t('settingsTitle')}

${createNavItem('basic', t('basicTab'), createBasicIcon(), true)} ${createNavItem('advanced', t('advancedTab'), createAdvancedIcon())} ${createNavItem('experimental', t('experimentalTab'), createExperimentalIcon())} ${createNavItem('voting', tr(t, 'votingTab', 'Voting'), createVotingIcon())} ${createNavItem('report', t('reportTab'), createReportIcon())} ${createNavItem('about', t('aboutTab'), createAboutIcon())}
`; } /** * Creates a single navigation item * @param {string} section - Section identifier * @param {string} label - Nav item label * @param {string} icon - SVG icon * @param {boolean} active - Whether this item is active * @returns {string} Nav item HTML */ function createNavItem(section, label, icon, active = false) { const activeClass = active ? ' active' : ''; return `
${icon} ${label}
`; } /** * SVG icon creators */ function createBasicIcon() { return ` `; } function createAdvancedIcon() { return ` `; } function createExperimentalIcon() { return ` `; } function createReportIcon() { return ` `; } function createAboutIcon() { return ` `; } function createVotingIcon() { return ` `; } /** * Creates a settings checkbox item * @param {string} label - Item label * @param {string} description - Item description * @param {string} setting - Setting data attribute * @param {boolean} checked - Whether checkbox is checked * @returns {string} Settings item HTML */ function createSettingsItem(label, description, setting, checked) { const inputId = `ytp-plus-setting-${setting}`; return `
${description}
`; } /** * Creates the download site option section * @param {Object} site - Site configuration * @param {Function} _t - Translation function (unused, kept for API consistency) * @returns {string} Download site HTML */ function createDownloadSiteOption(site, _t) { const { key, name, description, checked, hasControls, controls } = site; const inputId = `download-site-${key}`; return `
${hasControls ? `
${controls}
` : ''}
`; } /** * Creates External Downloader customization controls * @param {Object} customization - External downloader customization settings * @param {Function} t - Translation function * @returns {string} Controls HTML */ function createExternalDownloaderControls(customization, t) { const name = customization?.name || 'SSYouTube'; const url = customization?.url || 'https://ssyoutube.com/watch?v={videoId}'; return `
`; } /** * Creates YTDL controls * @returns {string} Controls HTML */ function createYTDLControls() { return `
`; } /** * Creates the download submenu with all site options * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} Download submenu HTML */ function createDownloadSubmenu(settings, t) { const display = settings.enableDownload ? 'block' : 'none'; const sites = [ { key: 'externalDownloader', name: settings.downloadSiteCustomization?.externalDownloader?.name || 'SSYouTube', description: t('customDownloader'), checked: settings.downloadSites?.externalDownloader, hasControls: true, controls: createExternalDownloaderControls( settings.downloadSiteCustomization?.externalDownloader, t ), }, { key: 'ytdl', name: t('byYTDL'), description: t('customDownload'), checked: settings.downloadSites?.ytdl, hasControls: true, controls: createYTDLControls(), }, { key: 'direct', name: t('directDownload'), description: t('directDownloadDesc'), checked: settings.downloadSites?.direct, hasControls: false, }, ]; return `
${sites.map(site => createDownloadSiteOption(site, t)).join('')}
`; } /** * Small translation helper with fallback. * @param {Function} t - Translation function * @param {string} key - Translation key * @param {string} fallback - Fallback text if key is missing * @returns {string} */ function tr(t, key, fallback) { try { const v = t(key); if (typeof v === 'string' && v && v !== key) return v; } catch {} return fallback; } /** * Creates the styles submenu (style.js feature flags) * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} */ function createStyleSubmenu(settings, t) { const display = settings.enableZenStyles ? 'block' : 'none'; const rows = [ { label: tr(t, 'zenStyleThumbnailHoverLabel', 'Thumbnail hover preview'), desc: tr(t, 'zenStyleThumbnailHoverDesc', 'Enlarge inline preview player on hover'), key: 'zenStyles.thumbnailHover', value: settings.zenStyles?.thumbnailHover, }, { label: tr(t, 'zenStyleImmersiveSearchLabel', 'Immersive search'), desc: tr(t, 'zenStyleImmersiveSearchDesc', 'Centered searchbox experience when focused'), key: 'zenStyles.immersiveSearch', value: settings.zenStyles?.immersiveSearch, }, { label: tr(t, 'zenStyleHideVoiceSearchLabel', 'Hide Voice Search'), desc: tr(t, 'zenStyleHideVoiceSearchDesc', 'Remove microphone button from the header'), key: 'zenStyles.hideVoiceSearch', value: settings.zenStyles?.hideVoiceSearch, }, { label: tr(t, 'zenStyleTransparentHeaderLabel', 'Transparent Header'), desc: tr(t, 'zenStyleTransparentHeaderDesc', 'Make the top header transparent'), key: 'zenStyles.transparentHeader', value: settings.zenStyles?.transparentHeader, }, { label: tr(t, 'zenStyleHideSideGuideLabel', 'Hide Side Guide'), desc: tr(t, 'zenStyleHideSideGuideDesc', 'Completely hide the sidebar guide'), key: 'zenStyles.hideSideGuide', value: settings.zenStyles?.hideSideGuide, }, { label: tr(t, 'zenStyleCleanSideGuideLabel', 'Clean Side Guide'), desc: tr(t, 'zenStyleCleanSideGuideDesc', 'Remove Premium/Sports/Settings from sidebar'), key: 'zenStyles.cleanSideGuide', value: settings.zenStyles?.cleanSideGuide, }, { label: tr(t, 'zenStyleFixFeedLayoutLabel', 'Fix Feed Layout'), desc: tr(t, 'zenStyleFixFeedLayoutDesc', 'Improve video grid layout on home page'), key: 'zenStyles.fixFeedLayout', value: settings.zenStyles?.fixFeedLayout, }, { label: tr(t, 'zenStyleBetterCaptionsLabel', 'Better Captions'), desc: tr(t, 'zenStyleBetterCaptionsDesc', 'Enhanced subtitle styling with blur backdrop'), key: 'zenStyles.betterCaptions', value: settings.zenStyles?.betterCaptions, }, { label: tr(t, 'zenStylePlayerBlurLabel', 'Player Controls Blur'), desc: tr(t, 'zenStylePlayerBlurDesc', 'Add blur effect to player controls'), key: 'zenStyles.playerBlur', value: settings.zenStyles?.playerBlur, }, { label: tr(t, 'zenStyleTheaterEnhancementsLabel', 'Theater Enhancements'), desc: tr( t, 'zenStyleTheaterEnhancementsDesc', 'Floating comments panel and improved theater mode' ), key: 'zenStyles.theaterEnhancements', value: settings.zenStyles?.theaterEnhancements, }, { label: tr(t, 'zenStyleMiscLabel', 'Misc Enhancements'), desc: tr(t, 'zenStyleMiscDesc', 'Compact feed, hover menus, and other minor improvements'), key: 'zenStyles.misc', value: settings.zenStyles?.misc, }, ]; return `
${rows.map(r => createSettingsItem(r.label, r.desc, r.key, r.value)).join('')}
`; } /** * Creates the speed control submenu (hotkey customization) * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} */ function createSpeedControlSubmenu(settings, t) { const display = settings.enableSpeedControl ? 'block' : 'none'; const decrease = (settings.speedControlHotkeys?.decrease || 'g').slice(0, 1).toLowerCase(); const increase = (settings.speedControlHotkeys?.increase || 'h').slice(0, 1).toLowerCase(); const reset = (settings.speedControlHotkeys?.reset || 'b').slice(0, 1).toLowerCase(); return `
${tr(t, 'speedHotkeysTitle', 'Keyboard hotkeys')}
${tr( t, 'speedHotkeysDesc', 'Use single-letter shortcuts to decrease/increase/reset playback speed' )}
`; } /** * Creates the loop control submenu (hotkey customization for A → B) * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} */ function createLoopSubmenu(settings, t) { const display = settings.enableLoop ? 'block' : 'none'; const setPointA = (settings.loopHotkeys?.setPointA || 'k').slice(0, 1).toLowerCase(); const setPointB = (settings.loopHotkeys?.setPointB || 'l').slice(0, 1).toLowerCase(); const resetPoints = (settings.loopHotkeys?.resetPoints || 'o').slice(0, 1).toLowerCase(); return `
${tr(t, 'loopSegmentTitle', 'Loop A → B')}
${tr( t, 'loopSegmentDesc', 'Repeat a custom segment of the video (A → B)' )}
`; } /** * Creates the basic settings section * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} Basic section HTML */ function createBasicSettingsSection(settings, t) { const downloadEnabled = !!settings.enableDownload; const styleEnabled = settings.enableZenStyles !== false; const speedEnabled = !!settings.enableSpeedControl; return `
${tr( t, 'zenStylesDesc', 'Optional UI tweaks and cosmetic improvements' )}
${createStyleSubmenu(settings, t)}
${t('speedControlDesc')}
${createSpeedControlSubmenu(settings, t)} ${createSettingsItem(t('screenshotButton'), t('screenshotButtonDesc'), 'enableScreenshot', settings.enableScreenshot)}
${t('downloadButtonDesc')}
${createDownloadSubmenu(settings, t)}
`; } /** * Creates the about section with logo * @returns {string} About section HTML */ function createAboutSection() { return ` `; } /** * Gets YouTube Music settings from localStorage or defaults * @returns {Object} YouTube Music settings */ function getMusicSettings() { const defaults = { enableMusic: true, immersiveSearchStyles: true, hoverStyles: true, playerSidebarStyles: true, centeredPlayerStyles: true, playerBarStyles: true, centeredPlayerBarStyles: true, miniPlayerStyles: true, scrollToTopStyles: true, }; // Prefer userscript-global storage so youtube.com and music.youtube.com share the setting. try { if (typeof GM_getValue !== 'undefined') { const stored = GM_getValue('youtube-plus-music-settings', null); if (typeof stored === 'string' && stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === 'object') { const merged = { ...defaults }; if (typeof parsed.enableMusic === 'boolean') merged.enableMusic = parsed.enableMusic; for (const key of Object.keys(defaults)) { if (key === 'enableMusic') continue; if (typeof parsed[key] === 'boolean') merged[key] = parsed[key]; } // Legacy flags mapping if (typeof parsed.enableImmersiveSearch === 'boolean') { merged.immersiveSearchStyles = parsed.enableImmersiveSearch; } if (typeof parsed.enableSidebarHover === 'boolean') { merged.hoverStyles = parsed.enableSidebarHover; } if (typeof parsed.enableCenteredPlayer === 'boolean') { merged.centeredPlayerStyles = parsed.enableCenteredPlayer; } if (typeof parsed.enableScrollToTop === 'boolean') { merged.scrollToTopStyles = parsed.enableScrollToTop; } return merged; } } } } catch {} try { const stored = localStorage.getItem('youtube-plus-music-settings'); if (stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === 'object') { const merged = { ...defaults }; if (typeof parsed.enableMusic === 'boolean') merged.enableMusic = parsed.enableMusic; for (const key of Object.keys(defaults)) { if (key === 'enableMusic') continue; if (typeof parsed[key] === 'boolean') merged[key] = parsed[key]; } // Legacy flags mapping if (typeof parsed.enableImmersiveSearch === 'boolean') { merged.immersiveSearchStyles = parsed.enableImmersiveSearch; } if (typeof parsed.enableSidebarHover === 'boolean') { merged.hoverStyles = parsed.enableSidebarHover; } if (typeof parsed.enableCenteredPlayer === 'boolean') { merged.centeredPlayerStyles = parsed.enableCenteredPlayer; } if (typeof parsed.enableScrollToTop === 'boolean') { merged.scrollToTopStyles = parsed.enableScrollToTop; } // Backward-compat: enable if any legacy flags are enabled const legacyEnabled = !!( parsed.enableMusicStyles || parsed.enableMusicEnhancements || parsed.enableImmersiveSearch || parsed.enableSidebarHover || parsed.enableCenteredPlayer || parsed.enableScrollToTop ); if (legacyEnabled && typeof parsed.enableMusic !== 'boolean') merged.enableMusic = true; return merged; } } } catch (e) { console.warn('[YouTube+] Failed to load music settings:', e); } return defaults; } /** * Creates the advanced settings section. * Note: other modules may append additional items to this section. * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} Advanced section HTML */ function createAdvancedSettingsSection(settings, t) { const musicSettings = getMusicSettings(); const musicEnabled = !!musicSettings.enableMusic; const enhancedEnabled = settings.enableEnhanced !== false; // Enhanced features settings with defaults const enhancedSettings = { enablePlayAll: settings.enablePlayAll !== false, enableResumeTime: settings.enableResumeTime !== false, enableZoom: settings.enableZoom !== false, enableThumbnail: settings.enableThumbnail !== false, enablePlaylistSearch: settings.enablePlaylistSearch !== false, enableScrollToTopButton: settings.enableScrollToTopButton !== false, }; return ` `; } /** * Creates the experimental settings section with YouTube Music options * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} Experimental section HTML */ function createExperimentalSettingsSection(_settings, _t) { return ` `; } /** * Creates the voting section * @param {Object} _settings - Settings object * @param {Function} t - Translation function * @returns {string} Voting section HTML */ function createVotingSection(_settings, t) { return ` `; } /** * Creates the main content area * @param {Object} settings - Settings object * @param {Function} t - Translation function * @returns {string} Main content HTML */ function createMainContent(settings, t) { return `
${createBasicSettingsSection(settings, t)} ${createAdvancedSettingsSection(settings, t)} ${createExperimentalSettingsSection(settings, t)} ${createVotingSection(settings, t)} ${createAboutSection()}
`; } // Export helper functions to window if (typeof window !== 'undefined') { window.YouTubePlusSettingsHelpers = { createSettingsSidebar, createMainContent, createSettingsItem, createDownloadSiteOption, createBasicSettingsSection, createAdvancedSettingsSection, createExperimentalSettingsSection, createVotingSection, getMusicSettings, }; } // --- MODULE: modal-handlers.js --- /** * Modal Event Handlers * Extracted from createSettingsModal to reduce complexity */ /* global GM_setValue, GM_getValue */ // DOM cache helpers with fallback const qs = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.get === 'function') { return window.YouTubeDOMCache.get(selector); } return document.querySelector(selector); }; /** * Safely set a setting by path (supports dot notation) * @param {Record} settings * @param {string} path * @param {any} value */ const setSettingByPath = (settings, path, value) => { if (!settings || typeof settings !== 'object') return; if (!path || typeof path !== 'string') return; // Fast path: simple key if (!path.includes('.')) { settings[path] = value; return; } const keys = path.split('.').filter(Boolean); if (!keys.length) return; const lastKey = keys.pop(); if (!lastKey) return; let cur = settings; for (const k of keys) { if (!Object.prototype.hasOwnProperty.call(cur, k) || typeof cur[k] !== 'object' || !cur[k]) { cur[k] = {}; } cur = cur[k]; } cur[lastKey] = value; }; /** * Initialize download sites settings * @param {Object} settings - Settings object */ const initializeDownloadSites = settings => { if (!settings.downloadSites) { settings.downloadSites = { externalDownloader: true, ytdl: true, direct: true }; } // Migrate old key if present if ( settings.downloadSites && Object.prototype.hasOwnProperty.call(settings.downloadSites, 'y2mate') ) { if (!Object.prototype.hasOwnProperty.call(settings.downloadSites, 'externalDownloader')) { settings.downloadSites.externalDownloader = settings.downloadSites.y2mate; } delete settings.downloadSites.y2mate; } }; /** * Toggle download site controls visibility * @param {HTMLInputElement} checkbox - Checkbox element */ const toggleDownloadSiteControls = checkbox => { try { const container = checkbox.closest('.download-site-option'); if (container) { const controls = container.querySelector('.download-site-controls'); if (controls) { controls.style.display = checkbox.checked ? 'block' : 'none'; } } } catch (err) { console.warn('[YouTube+] toggle download-site-controls failed:', err); } }; /** * Save settings safely * @param {Function} saveSettings - Save function */ const safelySaveSettings = saveSettings => { try { saveSettings(); } catch (err) { console.warn('[YouTube+] autosave downloadSite toggle failed:', err); } }; /** * Handle download site checkbox toggle * @param {HTMLElement} target - Checkbox element * @param {string} key - Site key (y2mate, ytdl, direct) * @param {Object} settings - Settings object * @param {Function} markDirty - Function to mark modal as dirty * @param {Function} saveSettings - Function to save settings */ const handleDownloadSiteToggle = (target, key, settings, markDirty, saveSettings) => { initializeDownloadSites(settings); const checkbox = /** @type {HTMLInputElement} */ (target); settings.downloadSites[key] = checkbox.checked; try { markDirty(); } catch {} toggleDownloadSiteControls(checkbox); rebuildDownloadDropdown(settings); safelySaveSettings(saveSettings); }; /** * Handle Download button live toggle * @param {Object} context - Context object with methods */ const handleDownloadButtonToggle = context => { const { settings, getElement, addDownloadButton } = context; const controls = getElement('.ytp-right-controls'); const existing = getElement('.ytp-download-button', false); if (settings.enableDownload) { // create button if missing if (controls && !existing) addDownloadButton(controls); } else { // remove button + dropdown if present if (existing) existing.remove(); const dropdown = qs('.download-options'); if (dropdown) dropdown.remove(); } }; /** * Handle Speed Control live toggle * @param {Object} context - Context object with methods */ const handleSpeedControlToggle = context => { const { settings, getElement, addSpeedControlButton } = context; const controls = getElement('.ytp-right-controls'); const existing = getElement('.speed-control-btn', false); if (settings.enableSpeedControl) { if (controls && !existing) addSpeedControlButton(controls); } else { if (existing) existing.remove(); const speedOptions = qs('.speed-options'); if (speedOptions) speedOptions.remove(); } }; /** * Update global settings exposure * @param {Object} settings - Settings object */ const updateGlobalSettings = settings => { if (typeof window !== 'undefined' && window.youtubePlus) { window.youtubePlus.settings = window.youtubePlus.settings || settings; } }; /** * Apply setting changes live to the UI * @param {string} setting - Setting key * @param {Object} context - Context object with methods */ const applySettingLive = (setting, context) => { const { settings, refreshDownloadButton } = context; try { // Update page elements (show/hide buttons, dropdowns) if (context.updatePageBasedOnSettings) { context.updatePageBasedOnSettings(); } // Dispatch to specific handlers if (setting === 'enableDownload') { handleDownloadButtonToggle(context); } else if (setting === 'enableSpeedControl') { handleSpeedControlToggle(context); } // Ensure visibility state updates if (refreshDownloadButton) { refreshDownloadButton(); } } catch (innerErr) { console.warn('[YouTube+] live apply specific toggle failed:', innerErr); } // Expose updated settings globally for other modules updateGlobalSettings(settings); }; /** * Handle simple setting checkbox toggle * @param {HTMLElement} target - Checkbox element * @param {string} setting - Setting key * @param {Object} settings - Settings object * @param {Object} context - Context object with methods * @param {Function} markDirty - Function to mark modal as dirty * @param {Function} saveSettings - Function to save settings * @param {HTMLElement} modal - Modal element */ const handleSimpleSettingToggle = ( target, setting, settings, context, markDirty, saveSettings, modal ) => { const checked = /** @type {HTMLInputElement} */ (target).checked; setSettingByPath(settings, setting, checked); // Mark modal as dirty try { markDirty(); } catch {} // Apply settings immediately try { applySettingLive(setting, context); } catch (err) { console.warn('[YouTube+] apply settings live failed:', err); } // Persist immediately try { saveSettings(); } catch (err) { console.warn('[YouTube+] autosave simple setting failed:', err); } // Show/hide submenu for Download if (setting === 'enableDownload') { const submenu = modal.querySelector('.download-submenu'); if (submenu) { submenu.style.display = checked ? 'block' : 'none'; } const toggleBtn = modal.querySelector('.ytp-plus-submenu-toggle[data-submenu="download"]'); if (toggleBtn instanceof HTMLElement) { if (checked) { toggleBtn.removeAttribute('disabled'); toggleBtn.setAttribute('aria-expanded', 'true'); toggleBtn.style.display = 'inline-flex'; } else { toggleBtn.setAttribute('disabled', ''); toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.style.display = 'none'; } } } // Show/hide submenu for Zen Styles if (setting === 'enableZenStyles') { const submenu = modal.querySelector('.style-submenu'); if (submenu) { submenu.style.display = checked ? 'block' : 'none'; } const toggleBtn = modal.querySelector('.ytp-plus-submenu-toggle[data-submenu="style"]'); if (toggleBtn instanceof HTMLElement) { if (checked) { toggleBtn.removeAttribute('disabled'); toggleBtn.setAttribute('aria-expanded', 'true'); toggleBtn.style.display = 'inline-flex'; } else { toggleBtn.setAttribute('disabled', ''); toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.style.display = 'none'; } } } // Show/hide submenu for Speed Control if (setting === 'enableSpeedControl') { const submenu = modal.querySelector('.speed-submenu'); if (submenu) { submenu.style.display = checked ? 'block' : 'none'; } const toggleBtn = modal.querySelector('.ytp-plus-submenu-toggle[data-submenu="speed"]'); if (toggleBtn instanceof HTMLElement) { if (checked) { toggleBtn.removeAttribute('disabled'); toggleBtn.setAttribute('aria-expanded', 'true'); toggleBtn.style.display = 'inline-flex'; } else { toggleBtn.setAttribute('disabled', ''); toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.style.display = 'none'; } } } // Show/hide submenu for Enhanced Features if (setting === 'enableEnhanced') { const submenu = modal.querySelector('.enhanced-submenu'); if (submenu) { submenu.style.display = checked ? 'block' : 'none'; } const toggleBtn = modal.querySelector('.ytp-plus-submenu-toggle[data-submenu="enhanced"]'); if (toggleBtn instanceof HTMLElement) { if (checked) { toggleBtn.removeAttribute('disabled'); toggleBtn.setAttribute('aria-expanded', 'true'); toggleBtn.style.display = 'inline-flex'; } else { toggleBtn.setAttribute('disabled', ''); toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.style.display = 'none'; } } } // Show/hide submenu for Loop if (setting === 'enableLoop') { const submenu = modal.querySelector('.loop-submenu'); if (submenu) { submenu.style.display = checked ? 'block' : 'none'; } const toggleBtn = modal.querySelector('.ytp-plus-submenu-toggle[data-submenu="loop"]'); if (toggleBtn instanceof HTMLElement) { if (checked) { toggleBtn.removeAttribute('disabled'); toggleBtn.setAttribute('aria-expanded', 'true'); toggleBtn.style.display = 'inline-flex'; } else { toggleBtn.setAttribute('disabled', ''); toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.style.display = 'none'; } } } }; /** * Handle download site customization input * @param {HTMLElement} target - Input element * @param {string} site - Site key * @param {string} field - Field name (name or url) * @param {Object} settings - Settings object * @param {Function} markDirty - Function to mark modal as dirty * @param {Function} t - Translation function */ /** * Initialize download site customization settings * @param {Object} settings - Settings object */ const initializeDownloadCustomization = settings => { if (!settings.downloadSiteCustomization) { settings.downloadSiteCustomization = { externalDownloader: { name: 'SSYouTube', url: 'https://ssyoutube.com/watch?v={videoId}' }, }; } // Migrate previous customization if ( settings.downloadSiteCustomization && Object.prototype.hasOwnProperty.call(settings.downloadSiteCustomization, 'y2mate') ) { if ( !Object.prototype.hasOwnProperty.call( settings.downloadSiteCustomization, 'externalDownloader' ) ) { settings.downloadSiteCustomization.externalDownloader = settings.downloadSiteCustomization.y2mate; } delete settings.downloadSiteCustomization.y2mate; } }; /** * Initialize specific download site settings * @param {Object} settings - Settings object * @param {string} site - Site key */ const initializeDownloadSite = (settings, site) => { if (!settings.downloadSiteCustomization[site]) { settings.downloadSiteCustomization[site] = { name: '', url: '' }; } }; /** * Get fallback name for download site * @param {string} site - Site key * @param {Function} t - Translation function * @returns {string} Fallback name */ const getDownloadSiteFallbackName = (site, t) => { if (site === 'externalDownloader') return 'SSYouTube'; if (site === 'ytdl') return t('byYTDL'); return t('directDownload'); }; /** * Update download site name in UI * @param {HTMLElement} target - Input element * @param {string} site - Site key * @param {Function} t - Translation function */ const updateDownloadSiteName = (target, site, t) => { const nameDisplay = target.closest('.download-site-option')?.querySelector('.download-site-name'); if (nameDisplay) { const inputValue = /** @type {HTMLInputElement} */ (target).value; const fallbackName = getDownloadSiteFallbackName(site, t); nameDisplay.textContent = inputValue || fallbackName; } }; /** * Rebuild download dropdown in UI * @param {Object} settings - Settings object */ const rebuildDownloadDropdown = settings => { try { if ( typeof window !== 'undefined' && window.youtubePlus && typeof window.youtubePlus.rebuildDownloadDropdown === 'function' ) { window.youtubePlus.settings = window.youtubePlus.settings || settings; window.youtubePlus.rebuildDownloadDropdown(); } } catch (err) { console.warn('[YouTube+] rebuildDownloadDropdown call failed:', err); } }; /** * Handle download site input change * @param {HTMLElement} target - Input element * @param {string} site - Site key (y2mate, ytdl, direct) * @param {string} field - Field name (name, url) * @param {Object} settings - Settings object * @param {Function} markDirty - Function to mark modal as dirty * @param {Function} t - Translation function */ const handleDownloadSiteInput = (target, site, field, settings, markDirty, t) => { initializeDownloadCustomization(settings); initializeDownloadSite(settings, site); settings.downloadSiteCustomization[site][field] = /** @type {HTMLInputElement} */ (target).value; try { markDirty(); } catch {} if (field === 'name') { updateDownloadSiteName(target, site, t); } rebuildDownloadDropdown(settings); }; /** * Handle Y2Mate save button * @param {HTMLElement} target - Button element * @param {Object} settings - Settings object * @param {Function} saveSettings - Function to save settings * @param {Function} showNotification - Function to show notification * @param {Function} t - Translation function */ /** * Ensure external downloader settings structure exists * @param {Object} settings - Settings object */ const ensureExternalDownloaderStructure = settings => { if (!settings.downloadSiteCustomization) { settings.downloadSiteCustomization = { externalDownloader: { name: 'SSYouTube', url: 'https://ssyoutube.com/watch?v={videoId}' }, }; } if (!settings.downloadSiteCustomization.externalDownloader) { settings.downloadSiteCustomization.externalDownloader = { name: '', url: '' }; } }; /** * Read external downloader input values from container * @param {HTMLElement} container - Container element * @param {Object} settings - Settings object */ const readExternalDownloaderInputs = (container, settings) => { const nameInput = container.querySelector( 'input.download-site-input[data-site="externalDownloader"][data-field="name"]' ); const urlInput = container.querySelector( 'input.download-site-input[data-site="externalDownloader"][data-field="url"]' ); if (nameInput) settings.downloadSiteCustomization.externalDownloader.name = nameInput.value; if (urlInput) settings.downloadSiteCustomization.externalDownloader.url = urlInput.value; }; /** * Trigger rebuild of the download dropdown if available */ const triggerRebuildDropdown = () => { try { if ( typeof window !== 'undefined' && window.youtubePlus && typeof window.youtubePlus.rebuildDownloadDropdown === 'function' ) { window.youtubePlus.rebuildDownloadDropdown(); } } catch (err) { console.warn('[YouTube+] rebuildDownloadDropdown call failed:', err); } }; const handleExternalDownloaderSave = (target, settings, saveSettings, showNotification, t) => { ensureExternalDownloaderStructure(settings); const container = target.closest('.download-site-option'); if (container) { readExternalDownloaderInputs(container, settings); } saveSettings(); if (window.youtubePlus) { window.youtubePlus.settings = window.youtubePlus.settings || settings; } triggerRebuildDropdown(); try { const msg = (t && typeof t === 'function' && t('externalDownloaderSettingsSaved')) || t('y2mateSettingsSaved'); showNotification(msg); } catch { showNotification('Settings saved'); } }; /** * Reset external downloader to default values * @param {Object} settings - Settings object */ const resetExternalDownloaderToDefaults = settings => { ensureExternalDownloaderStructure(settings); settings.downloadSiteCustomization.externalDownloader = { name: 'SSYouTube', url: 'https://ssyoutube.com/watch?v={videoId}', }; }; /** * Update Y2Mate modal inputs * @param {HTMLElement} container - Container element * @param {Object} settings - Settings object */ const updateExternalDownloaderModalInputs = (container, settings) => { const nameInput = container.querySelector( 'input.download-site-input[data-site="externalDownloader"][data-field="name"]' ); const urlInput = container.querySelector( 'input.download-site-input[data-site="externalDownloader"][data-field="url"]' ); const nameDisplay = container.querySelector('.download-site-name'); const edSettings = settings.downloadSiteCustomization.externalDownloader; if (nameInput) nameInput.value = edSettings.name; if (urlInput) urlInput.value = edSettings.url; if (nameDisplay) nameDisplay.textContent = edSettings.name; }; /** * Handle Y2Mate reset button * @param {HTMLElement} modal - Modal element * @param {Object} settings - Settings object * @param {Function} saveSettings - Function to save settings * @param {Function} showNotification - Function to show notification * @param {Function} t - Translation function */ const handleExternalDownloaderReset = (modal, settings, saveSettings, showNotification, t) => { resetExternalDownloaderToDefaults(settings); const container = modal.querySelector('.download-site-option'); if (container) { updateExternalDownloaderModalInputs(container, settings); } saveSettings(); if (window.youtubePlus) { window.youtubePlus.settings = window.youtubePlus.settings || settings; } triggerRebuildDropdown(); try { const msg = (t && typeof t === 'function' && t('externalDownloaderReset')) || t('y2mateReset'); showNotification(msg); } catch { showNotification('Settings reset'); } }; /** * Handle sidebar navigation * @param {HTMLElement} navItem - Navigation item element * @param {HTMLElement} modal - Modal element */ const handleSidebarNavigation = (navItem, modal) => { const { dataset } = navItem; const { section } = dataset; modal .querySelectorAll('.ytp-plus-settings-nav-item') .forEach(item => item.classList.remove('active')); modal.querySelectorAll('.ytp-plus-settings-section').forEach(s => s.classList.add('hidden')); navItem.classList.add('active'); const targetSection = modal.querySelector( `.ytp-plus-settings-section[data-section="${section}"]` ); if (targetSection) targetSection.classList.remove('hidden'); // Init before/after slider when voting section becomes visible if (section === 'voting' && window.YouTubePlus?.Voting?.initSlider) { // Use rAF so the section is truly visible before measuring dimensions requestAnimationFrame(() => window.YouTubePlus.Voting.initSlider()); } // Persist active nav section so it can be restored on next modal open try { localStorage.setItem('ytp-plus-active-nav-section', section); } catch {} }; /** * Handle YouTube Music settings toggle * @param {HTMLElement} target - Checkbox element * @param {string} setting - Setting key * @param {Function} showNotification - Function to show notification * @param {Function} t - Translation function */ const handleMusicSettingToggle = (target, setting, showNotification, t) => { try { const defaults = { enableMusic: true, immersiveSearchStyles: true, hoverStyles: true, playerSidebarStyles: true, centeredPlayerStyles: true, playerBarStyles: true, centeredPlayerBarStyles: true, miniPlayerStyles: true, scrollToTopStyles: true, }; const allowedKeys = new Set(Object.keys(defaults)); if (!allowedKeys.has(setting)) return; // Load current music settings (prefer GM storage for cross-subdomain sync) /** @type {Record} */ let musicSettings = { ...defaults }; try { if (typeof GM_getValue !== 'undefined') { const stored = GM_getValue('youtube-plus-music-settings', null); if (typeof stored === 'string' && stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === 'object') musicSettings = { ...musicSettings, ...parsed }; } } } catch {} try { const stored = localStorage.getItem('youtube-plus-music-settings'); if (stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === 'object') musicSettings = { ...musicSettings, ...parsed }; } } catch {} musicSettings[setting] = /** @type {HTMLInputElement} */ (target).checked; // UI: toggle visibility of music submenu card when main switch changes try { if (setting === 'enableMusic') { const enabled = !!musicSettings.enableMusic; const root = /** @type {HTMLElement|null} */ ( target.closest('.ytp-plus-settings-section') || target.closest('.ytp-plus-settings-panel') ); if (root) { const submenu = root.querySelector('.music-submenu[data-submenu="music"]'); if (submenu instanceof HTMLElement) { submenu.style.display = enabled ? 'block' : 'none'; } const toggleBtn = root.querySelector('.ytp-plus-submenu-toggle[data-submenu="music"]'); if (toggleBtn instanceof HTMLElement) { if (enabled) { toggleBtn.removeAttribute('disabled'); toggleBtn.style.display = 'inline-flex'; } else { toggleBtn.setAttribute('disabled', ''); toggleBtn.style.display = 'none'; } toggleBtn.setAttribute('aria-expanded', enabled ? 'true' : 'false'); } } } } catch {} // Save to localStorage localStorage.setItem('youtube-plus-music-settings', JSON.stringify(musicSettings)); // Save to userscript-global storage so youtube.com and music.youtube.com share settings. try { if (typeof GM_setValue !== 'undefined') { GM_setValue('youtube-plus-music-settings', JSON.stringify(musicSettings)); } } catch {} // Apply changes if YouTubeMusic module is available if (typeof window !== 'undefined' && window.YouTubeMusic) { if (window.YouTubeMusic.saveSettings) { window.YouTubeMusic.saveSettings(musicSettings); } if (window.YouTubeMusic.applySettingsChanges) { window.YouTubeMusic.applySettingsChanges(); } } // Show notification if (showNotification && t) { showNotification(t('musicSettingsSaved')); } } catch { console.warn('[YouTube+] handleMusicSettingToggle failed'); } }; /** * Check if a setting is a YouTube Music setting * @param {string} setting - Setting key * @returns {boolean} True if it's a music setting */ const isMusicSetting = setting => { return ( setting === 'enableMusic' || setting === 'immersiveSearchStyles' || setting === 'hoverStyles' || setting === 'playerSidebarStyles' || setting === 'centeredPlayerStyles' || setting === 'playerBarStyles' || setting === 'centeredPlayerBarStyles' || setting === 'miniPlayerStyles' || setting === 'scrollToTopStyles' ); }; // Export handlers if (typeof window !== 'undefined') { window.YouTubePlusModalHandlers = { handleDownloadSiteToggle, handleSimpleSettingToggle, handleDownloadSiteInput, handleExternalDownloaderSave, handleExternalDownloaderReset, handleSidebarNavigation, applySettingLive, handleMusicSettingToggle, isMusicSetting, }; } // --- MODULE: download.js --- /** * YouTube+ Download Module * Unified download system with button UI and download functionality * @version 3.0 */ (function () { 'use strict'; const isRelevantRoute = () => { try { const path = location.pathname || ''; return path === '/watch' || path.startsWith('/shorts'); } catch { return false; } }; const onDomReady = cb => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', cb, { once: true }); } else { cb(); } }; // DOM cache helpers with fallback const $ = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.get === 'function') { return window.YouTubeDOMCache.get(selector); } return document.querySelector(selector); }; const $$ = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.getAll === 'function') { return window.YouTubeDOMCache.getAll(selector); } return document.querySelectorAll(selector); }; // Check dependencies if (typeof YouTubeUtils === 'undefined') { console.error('[YouTube+ Download] YouTubeUtils not found!'); return; } // Create a custom glassmorphic subtitle dropdown control function createSubtitleSelect() { const subtitleSelect = document.createElement('div'); subtitleSelect.setAttribute('role', 'listbox'); Object.assign(subtitleSelect.style, { position: 'relative', width: '100%', marginBottom: '8px', fontSize: '14px', color: '#fff', cursor: 'pointer', }); const _ssDisplay = document.createElement('div'); Object.assign(_ssDisplay.style, { padding: '10px 12px', borderRadius: '10px', background: 'linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02))', border: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', backdropFilter: 'blur(6px)', boxShadow: '0 4px 18px rgba(0,0,0,0.35) inset', }); const _ssLabel = document.createElement('div'); _ssLabel.style.flex = '1'; _ssLabel.style.overflow = 'hidden'; _ssLabel.style.textOverflow = 'ellipsis'; _ssLabel.style.whiteSpace = 'nowrap'; _ssLabel.textContent = t('loading'); const _ssChevron = document.createElement('div'); _ssChevron.textContent = '▾'; _ssChevron.style.opacity = '0.8'; _ssDisplay.appendChild(_ssLabel); _ssDisplay.appendChild(_ssChevron); const _ssList = document.createElement('div'); Object.assign(_ssList.style, { position: 'absolute', top: 'calc(100% + 8px)', left: '0', right: '0', maxHeight: '220px', overflowY: 'auto', borderRadius: '10px', background: 'linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.02))', border: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 8px 30px rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: '9999', display: 'none', }); subtitleSelect.appendChild(_ssDisplay); subtitleSelect.appendChild(_ssList); _ssList.addEventListener('click', e => { const item = e.target?.closest?.('[data-value]'); if (!item || !_ssList.contains(item)) return; subtitleSelect.value = item.dataset.value; _ssList.style.display = 'none'; }); _ssList.addEventListener('mouseover', e => { const item = e.target?.closest?.('[data-value]'); if (!item || !_ssList.contains(item)) return; item.style.background = 'rgba(255,255,255,0.02)'; }); _ssList.addEventListener('mouseout', e => { const item = e.target?.closest?.('[data-value]'); if (!item || !_ssList.contains(item)) return; const related = e.relatedTarget; if (related && item.contains(related)) return; item.style.background = 'transparent'; }); subtitleSelect._options = []; subtitleSelect._value = ''; subtitleSelect._disabled = false; subtitleSelect.setPlaceholder = text => { _ssLabel.textContent = text || ''; subtitleSelect._options = []; _ssList.innerHTML = ''; subtitleSelect._value = ''; }; subtitleSelect.setOptions = options => { subtitleSelect._options = options || []; _ssList.innerHTML = ''; subtitleSelect._options.forEach(opt => { const item = document.createElement('div'); item.textContent = opt.text; item.dataset.value = String(opt.value); Object.assign(item.style, { padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid rgba(255,255,255,0.02)', color: '#fff', }); _ssList.appendChild(item); }); if (subtitleSelect._options.length > 0) { subtitleSelect.value = String(subtitleSelect._options[0].value); } else { subtitleSelect._value = ''; _ssLabel.textContent = t('noSubtitles'); } }; Object.defineProperty(subtitleSelect, 'value', { get() { return subtitleSelect._value; }, set(v) { subtitleSelect._value = String(v); const found = subtitleSelect._options.find(o => String(o.value) === subtitleSelect._value); _ssLabel.textContent = found ? found.text : ''; }, }); Object.defineProperty(subtitleSelect, 'disabled', { get() { return subtitleSelect._disabled; }, set(v) { subtitleSelect._disabled = !!v; _ssDisplay.style.opacity = subtitleSelect._disabled ? '0.5' : '1'; subtitleSelect.style.pointerEvents = subtitleSelect._disabled ? 'none' : 'auto'; }, }); _ssDisplay.addEventListener('click', () => { if (subtitleSelect._disabled) return; _ssList.style.display = _ssList.style.display === 'none' ? '' : 'none'; }); const _ac = new AbortController(); document.addEventListener( 'click', e => { if (!subtitleSelect.contains(e.target)) _ssList.style.display = 'none'; }, { signal: _ac.signal } ); subtitleSelect.destroy = () => _ac.abort(); return subtitleSelect; } const { NotificationManager } = YouTubeUtils; // Translation helper: dynamically resolve central i18n (embedded) at call time // to avoid missing translations due to initialization order. Falls back to // YouTubeUtils.t if present, otherwise returns the key (with simple params). function t(key, params = {}) { try { if (typeof window !== 'undefined') { if (window.YouTubePlusI18n && typeof window.YouTubePlusI18n.t === 'function') { return window.YouTubePlusI18n.t(key, params); } if (window.YouTubeUtils && typeof window.YouTubeUtils.t === 'function') { return window.YouTubeUtils.t(key, params); } } } catch { // ignore and fall back } // Minimal fallback: return key with simple interpolation const str = String(key || ''); if (!params || Object.keys(params).length === 0) return str; let result = str; for (const [k, v] of Object.entries(params)) result = result.split(`{${k}}`).join(String(v)); return result; } // Initialize logger (logger is defined in build order before this module) /* global YouTubePlusLogger */ const logger = typeof YouTubePlusLogger !== 'undefined' && YouTubePlusLogger ? YouTubePlusLogger.createLogger('Download') : { debug: () => {}, info: () => {}, warn: console.warn.bind(console), error: console.error.bind(console), }; /** * Download Configuration */ const DownloadConfig = { // TubeInsights API endpoints (mp3yt.is backend) API: { KEY_URL: 'https://cnv.cx/v2/sanity/key', CONVERT_URL: 'https://cnv.cx/v2/converter', }, // HTTP headers for API requests HEADERS: { 'Content-Type': 'application/json', Origin: 'https://mp3yt.is', Accept: '*/*', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', }, // Available video qualities (144p to 4K) VIDEO_QUALITIES: ['144', '240', '360', '480', '720', '1080', '1440', '2160'], // Available audio bitrates (kbps) AUDIO_BITRATES: ['64', '128', '192', '256', '320'], // Default download options DEFAULTS: { format: 'video', // 'video' or 'audio' videoQuality: '1080', audioBitrate: '320', embedThumbnail: true, }, }; /** * Get current YouTube video ID * @returns {string|null} Video ID or null */ function getVideoId() { const params = new URLSearchParams(window.location.search); return params.get('v') || null; } /** * Get current video URL * @returns {string} Full video URL */ function getVideoUrl() { const videoId = getVideoId(); return videoId ? `https://www.youtube.com/watch?v=${videoId}` : window.location.href; } /** * Get video title from page * @returns {string} Video title or 'video' */ function getVideoTitle() { try { const titleElement = $('h1.ytd-video-primary-info-renderer yt-formatted-string') || $('h1.title yt-formatted-string') || $('ytd-watch-metadata h1'); return titleElement ? titleElement.textContent.trim() : 'video'; } catch { return 'video'; } } /** * Sanitize filename for safe file system operations * @param {string} filename - Original filename * @returns {string} Sanitized filename */ function sanitizeFilename(filename) { return filename .replace(/[<>:"/\\|?*]/g, '') .replace(/\s+/g, ' ') .trim() .substring(0, 200); // Limit length } /** * Format bytes to human-readable string * @param {number} bytes - Byte count * @returns {string} Formatted string (e.g., "8.5 MB") */ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } /** * Create GM_xmlhttpRequest wrapper with callbacks * @param {Object} options - Request options * @param {Function} resolve - Promise resolve function * @param {Function} reject - Promise reject function * @returns {Object} GM_xmlhttpRequest options */ function createGmRequestOptions(options, resolve, reject) { return { ...options, onload: response => { if (options.onload) options.onload(response); resolve(response); }, onerror: error => { if (options.onerror) options.onerror(error); reject(error); }, ontimeout: () => { if (options.ontimeout) options.ontimeout(); reject(new Error('Request timeout')); }, }; } /** * Build response-like object from fetch response * @param {Response} resp - Fetch response * @returns {Object} Response-like object */ function buildResponseObject(resp) { return { status: resp.status, statusText: resp.statusText, finalUrl: resp.url, headers: {}, responseText: null, response: null, }; } /** * Try to extract text from response * @param {Response} resp - Fetch response * @param {Object} responseLike - Response-like object to populate */ async function extractResponseText(resp, responseLike) { try { responseLike.responseText = await resp.text(); } catch { responseLike.responseText = null; } } /** * Try to extract blob from response if needed * @param {Response} resp - Fetch response * @param {Object} responseLike - Response-like object to populate * @param {string} responseType - Expected response type */ async function extractResponseBlob(resp, responseLike, responseType) { if (responseType === 'blob') { try { responseLike.response = await resp.blob(); } catch { responseLike.response = null; } } } /** * Execute fetch-based request as fallback * @param {Object} options - Request options * @returns {Promise} Response object */ async function executeFetchFallback(options) { const fetchOpts = { method: options.method || 'GET', headers: options.headers || {}, body: options.data || options.body || undefined, }; const resp = await fetch(options.url, fetchOpts); const responseLike = buildResponseObject(resp); await extractResponseText(resp, responseLike); await extractResponseBlob(resp, responseLike, options.responseType); if (options.onload) options.onload(responseLike); return responseLike; } /** * Promise wrapper for GM_xmlhttpRequest * @param {Object} options - Request options * @returns {Promise} Response object */ function gmXmlHttpRequest(options) { return new Promise((resolve, reject) => { // Prefer GM_xmlhttpRequest (userscript/extension context) because it can bypass CORS. if (typeof GM_xmlhttpRequest !== 'undefined') { GM_xmlhttpRequest(createGmRequestOptions(options, resolve, reject)); return; } // Fallback for page context: try using fetch(). Note: fetch() is subject to CORS and // may fail where GM_xmlhttpRequest would succeed. This fallback attempts to mimic // a similar response shape used by the rest of the code. (async () => { try { const responseLike = await executeFetchFallback(options); resolve(responseLike); } catch (err) { if (options.onerror) options.onerror(err); reject(err); } })(); }); } /** * Create square album art from YouTube thumbnail * @param {string} thumbnailUrl - Thumbnail URL * @returns {Promise} Album art blob */ function createSquareAlbumArt(thumbnailUrl) { return new Promise((resolve, reject) => { const img = document.createElement('img'); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); const size = Math.min(img.width, img.height); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('Failed to get canvas context')); return; } const sx = (img.width - size) / 2; const sy = (img.height - size) / 2; ctx.drawImage(img, sx, sy, size, size, 0, 0, size, size); canvas.toBlob( blob => { if (blob) resolve(blob); else reject(new Error('Failed to create blob')); }, 'image/jpeg', 0.95 ); }; img.onerror = () => reject(new Error('Failed to load thumbnail')); img.src = thumbnailUrl; }); } /** * Embed album art and metadata into MP3 file * Requires ID3Writer library (browser-id3-writer) * * @param {Blob} mp3Blob - Original MP3 blob * @param {Blob} albumArtBlob - Album art image blob * @param {Object} metadata - Metadata (title, artist, album) * @returns {Promise} MP3 blob with embedded metadata */ async function embedAlbumArtToMP3(mp3Blob, albumArtBlob, metadata) { try { if (typeof window.ID3Writer === 'undefined') { logger.warn('ID3Writer not available, skipping album art embedding'); return mp3Blob; } const arrayBuffer = await mp3Blob.arrayBuffer(); const writer = new window.ID3Writer(arrayBuffer); // Set metadata if (metadata.title) { writer.setFrame('TIT2', metadata.title); } if (metadata.artist) { writer.setFrame('TPE1', [metadata.artist]); } if (metadata.album) { writer.setFrame('TALB', metadata.album); } // Embed album art if (albumArtBlob) { const coverArrayBuffer = await albumArtBlob.arrayBuffer(); writer.setFrame('APIC', { type: 3, // Cover (front) data: coverArrayBuffer, description: 'Cover', }); } writer.addTag(); /* global Blob */ return new Blob([writer.arrayBuffer], { type: 'audio/mpeg' }); } catch (error) { logger.error('Error embedding album art:', error); return mp3Blob; } } /** * Get available subtitles for a video * @param {string} videoId - YouTube video ID * @returns {Promise} Subtitle data */ /** * Fetch player data from YouTube API * @param {string} videoId - Video ID * @returns {Promise} Player data response * @private */ async function fetchPlayerData(videoId) { const response = await gmXmlHttpRequest({ method: 'POST', url: 'https://www.youtube.com/youtubei/v1/player', headers: { 'Content-Type': 'application/json', 'User-Agent': DownloadConfig.HEADERS['User-Agent'], }, data: JSON.stringify({ context: { client: { clientName: 'WEB', clientVersion: '2.20240304.00.00', }, }, videoId, }), }); if (response.status !== 200) { throw new Error(`Failed to get player data: ${response.status}`); } return JSON.parse(response.responseText); } /** * Build subtitle URL with format parameter * @param {string} baseUrl - Base subtitle URL * @returns {string} Complete subtitle URL * @private */ function buildSubtitleUrl(baseUrl) { if (!baseUrl.includes('fmt=')) { return `${baseUrl}&fmt=srv1`; } return baseUrl; } /** * Parse caption tracks into subtitle objects * @param {Array} captionTracks - Caption track data * @returns {Array} Subtitle objects * @private */ function parseCaptionTracks(captionTracks) { return captionTracks.map(track => ({ name: track.name?.simpleText || track.languageCode, languageCode: track.languageCode, url: buildSubtitleUrl(track.baseUrl), isAutoGenerated: track.kind === 'asr', })); } /** * Parse translation languages into subtitle objects * @param {Array} translationLanguages - Translation language data * @param {string} baseUrl - Base URL for translations * @returns {Array} Auto-translation subtitle objects * @private */ function parseTranslationLanguages(translationLanguages, baseUrl) { return translationLanguages.map(lang => ({ name: lang.languageName?.simpleText || lang.languageCode, languageCode: lang.languageCode, baseUrl: baseUrl || '', isAutoGenerated: true, })); } /** * Create empty subtitle result * @param {string} videoId - Video ID * @param {string} videoTitle - Video title * @returns {Object} Empty subtitle result * @private */ function createEmptySubtitleResult(videoId, videoTitle) { return { videoId, videoTitle, subtitles: [], autoTransSubtitles: [], }; } /** * Get subtitles for a video * @param {string} videoId - Video ID * @returns {Promise} Subtitle data or null on error */ async function getSubtitles(videoId) { try { const data = await fetchPlayerData(videoId); const videoTitle = data.videoDetails?.title || 'video'; const captions = data.captions?.playerCaptionsTracklistRenderer; if (!captions) { return createEmptySubtitleResult(videoId, videoTitle); } const captionTracks = captions.captionTracks || []; const translationLanguages = captions.translationLanguages || []; const baseUrl = captionTracks[0]?.baseUrl || ''; return { videoId, videoTitle, subtitles: parseCaptionTracks(captionTracks), autoTransSubtitles: parseTranslationLanguages(translationLanguages, baseUrl), }; } catch (error) { logger.error('Error getting subtitles:', error); return null; } } /** * Parse subtitle XML to cues * @param {string} xml - XML subtitle content * @returns {Array} Array of cues */ function parseSubtitleXML(xml) { const cues = []; const textTagRegex = /]*>([\s\S]*?)<\/text>/gi; let match; while ((match = textTagRegex.exec(xml)) !== null) { const start = parseFloat(match[1] || '0'); const duration = parseFloat(match[2] || '0'); let text = match[3] || ''; // Remove CDATA text = text.replace(//g, '$1'); // Decode HTML entities text = decodeHTMLEntities(text.trim()); cues.push({ start, duration, text }); } return cues; } /** * Decode HTML entities * @param {string} text - Text with HTML entities * @returns {string} Decoded text */ function decodeHTMLEntities(text) { const entities = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ', }; let decoded = text; for (const [entity, char] of Object.entries(entities)) { decoded = decoded.replace(new RegExp(entity, 'g'), char); } // Decode numeric entities decoded = decoded.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10))); decoded = decoded.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)) ); return decoded; } /** * Convert cues to SRT format * @param {Array} cues - Array of cues * @returns {string} SRT formatted text */ function convertToSRT(cues) { let srt = ''; cues.forEach((cue, index) => { const startTime = formatSRTTime(cue.start); const endTime = formatSRTTime(cue.start + cue.duration); const text = cue.text.replace(/\n/g, ' ').trim(); srt += `${index + 1}\n`; srt += `${startTime} --> ${endTime}\n`; srt += `${text}\n\n`; }); return srt; } /** * Format time for SRT (HH:MM:SS,mmm) * @param {number} seconds - Time in seconds * @returns {string} Formatted time */ function formatSRTTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const milliseconds = Math.floor((seconds % 1) * 1000); return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')},${String(milliseconds).padStart(3, '0')}`; } /** * Convert cues to plain text * @param {Array} cues - Array of cues * @returns {string} Plain text */ function convertToTXT(cues) { return cues.map(cue => cue.text.trim()).join('\n'); } /** * Download subtitle file * @param {Object} options - Download options * @param {string} options.videoId - Video ID * @param {string} options.url - Subtitle URL * @param {string} options.languageCode - Language code * @param {string} options.languageName - Language name * @param {string} [options.format='srt'] - Format: 'srt', 'txt', 'xml' * @param {string} [options.translateTo] - Target language code for translation * @returns {Promise} */ async function downloadSubtitle(options = {}) { const { videoId, url: baseUrl, languageCode, languageName, format = 'srt', translateTo = null, } = options; if (!videoId || !baseUrl) { throw new Error('Video ID and URL are required'); } const title = getVideoTitle(); // Build subtitle URL let subtitleUrl = baseUrl; if (!subtitleUrl.includes('fmt=')) { subtitleUrl += '&fmt=srv1'; } if (translateTo) { subtitleUrl += `&tlang=${translateTo}`; } NotificationManager.show(t('subtitleDownloading'), { duration: 2000, type: 'info', }); try { // Download XML const response = await gmXmlHttpRequest({ method: 'GET', url: subtitleUrl, headers: { 'User-Agent': DownloadConfig.HEADERS['User-Agent'], Referer: 'https://www.youtube.com/', }, }); if (response.status !== 200) { throw new Error(`Failed to download subtitle: ${response.status}`); } const xmlText = response.responseText; if (!xmlText || xmlText.length === 0) { throw new Error('Empty subtitle response'); } let content; let extension; if (format === 'xml') { content = xmlText; extension = 'xml'; } else { const cues = parseSubtitleXML(xmlText); if (cues.length === 0) { throw new Error('No subtitle cues found'); } if (format === 'srt') { content = convertToSRT(cues); extension = 'srt'; } else if (format === 'txt') { content = convertToTXT(cues); extension = 'txt'; } else { content = xmlText; extension = 'xml'; } } // Create filename const langSuffix = translateTo ? `${languageCode}-${translateTo}` : languageCode; const filename = sanitizeFilename(`${title} - ${languageName} (${langSuffix}).${extension}`); // Download file const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); NotificationManager.show(t('subtitleDownloaded'), { duration: 3000, type: 'success', }); logger.debug('Subtitle downloaded:', filename); } catch (error) { logger.error('Error downloading subtitle:', error); NotificationManager.show(`${t('subtitleDownloadFailed')} ${error.message}`, { duration: 5000, type: 'error', }); throw error; } } /** * Download video or audio from YouTube * * This is the main download function that uses TubeInsights API (mp3yt.is) * to convert and download YouTube videos/audio. * * @param {Object} options - Download options * @param {string} [options.format='video'] - Format: 'video' or 'audio' * @param {string} [options.quality='1080'] - Video quality: '144', '240', '360', '480', '720', '1080', '1440', '2160' * @param {string} [options.audioBitrate='320'] - Audio bitrate: '64', '128', '192', '256', '320' * @param {boolean} [options.embedThumbnail=true] - Embed thumbnail in audio file (requires ID3Writer) * @param {Function} [options.onProgress=null] - Progress callback (progress) => void * @returns {Promise} Resolves when download completes * * @example * // Download 1080p video * await downloadVideo({ format: 'video', quality: '1080' }); * * // Download 320kbps audio with album art * await downloadVideo({ * format: 'audio', * audioBitrate: '320', * embedThumbnail: true * }); */ async function downloadVideo(options = {}) { const { format = DownloadConfig.DEFAULTS.format, quality = DownloadConfig.DEFAULTS.videoQuality, audioBitrate = DownloadConfig.DEFAULTS.audioBitrate, embedThumbnail = DownloadConfig.DEFAULTS.embedThumbnail, onProgress = null, } = options; const videoId = getVideoId(); if (!videoId) { throw new Error('Video ID not found'); } const videoUrl = getVideoUrl(); const title = getVideoTitle(); // Show loading notification NotificationManager.show(t('startingDownload'), { duration: 2000, type: 'info', }); try { // Step 1: Get API key from TubeInsights endpoint logger.debug('Fetching API key...'); const keyResponse = await gmXmlHttpRequest({ method: 'GET', url: DownloadConfig.API.KEY_URL, headers: DownloadConfig.HEADERS, }); if (keyResponse.status !== 200) { throw new Error(`Failed to get API key: ${keyResponse.status}`); } const keyData = JSON.parse(keyResponse.responseText); if (!keyData || !keyData.key) { throw new Error('API key not found in response'); } const { key } = keyData; logger.debug('API key obtained'); // Step 2: Prepare conversion payload let payload; if (format === 'video') { // Use VP9 codec for 1440p and above, H264 for lower qualities const codec = parseInt(quality, 10) > 1080 ? 'vp9' : 'h264'; payload = { link: videoUrl, format: 'mp4', audioBitrate: '128', videoQuality: quality, filenameStyle: 'pretty', vCodec: codec, }; } else { payload = { link: videoUrl, format: 'mp3', audioBitrate, filenameStyle: 'pretty', }; } // Step 3: Request conversion logger.debug('Requesting conversion...', payload); const customHeaders = { ...DownloadConfig.HEADERS, key, }; const downloadResponse = await gmXmlHttpRequest({ method: 'POST', url: DownloadConfig.API.CONVERT_URL, headers: customHeaders, data: JSON.stringify(payload), }); if (downloadResponse.status !== 200) { throw new Error(`Conversion failed: ${downloadResponse.status}`); } const apiDownloadInfo = JSON.parse(downloadResponse.responseText); logger.debug('Conversion response:', apiDownloadInfo); if (!apiDownloadInfo.url) { throw new Error('No download URL received from API'); } // Step 4: Download the file logger.debug('Downloading file from:', apiDownloadInfo.url); return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === 'undefined') { // Fallback: open in new tab logger.warn('GM_xmlhttpRequest not available, opening in new tab'); window.open(apiDownloadInfo.url, '_blank'); resolve(); return; } GM_xmlhttpRequest({ method: 'GET', url: apiDownloadInfo.url, responseType: 'blob', headers: { 'User-Agent': DownloadConfig.HEADERS['User-Agent'], Referer: 'https://mp3yt.is/', Accept: '*/*', }, onprogress: progress => { if (onProgress) { onProgress({ loaded: progress.loaded, total: progress.total, percent: progress.total ? Math.round((progress.loaded / progress.total) * 100) : 0, }); } }, onload: async response => { if (response.status === 200 && response.response) { let blob = response.response; if (blob.size === 0) { reject(new Error(t('zeroBytesError'))); return; } window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug(`[Download] File downloaded: ${formatBytes(blob.size)}`); // Embed thumbnail for audio files if (format === 'audio' && embedThumbnail) { try { logger.debug('Embedding album art...'); const thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; const albumArt = await createSquareAlbumArt(thumbnailUrl); blob = await embedAlbumArtToMP3(blob, albumArt, { title }); logger.debug('Album art embedded successfully'); } catch (error) { logger.error('Failed to embed album art:', error); // Continue with download even if album art embedding fails } } // Create download link and trigger download const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; const filename = apiDownloadInfo.filename || `${title}.${format === 'video' ? 'mp4' : 'mp3'}`; a.download = sanitizeFilename(filename); document.body.appendChild(a); a.click(); document.body.removeChild(a); // Clean up blob URL after download setTimeout(() => URL.revokeObjectURL(blobUrl), 100); NotificationManager.show(t('downloadCompleted'), { duration: 3000, type: 'success', }); logger.debug('Download completed:', filename); resolve(); } else { reject(new Error(`Download failed: ${response.status}`)); } }, onerror: () => reject(new Error('Download failed - network error')), ontimeout: () => reject(new Error('Download timeout')), }); }); } catch (error) { logger.error('Error:', error); NotificationManager.show(`${t('downloadFailed')} ${error.message}`, { duration: 5000, type: 'error', }); throw error; } } /** * Initialize module * This module doesn't create any UI, just exposes the API */ // --- Modal UI for Direct Download (lightweight, self-contained) --- let _modalElements = null; function createTabButtons(onTabChange) { const tabContainer = document.createElement('div'); Object.assign(tabContainer.style, { display: 'flex', gap: '8px', padding: '12px', justifyContent: 'center', alignItems: 'center', background: 'transparent', }); const videoTab = document.createElement('button'); videoTab.textContent = t('videoTab'); videoTab.dataset.format = 'video'; const audioTab = document.createElement('button'); audioTab.textContent = t('audioTab'); audioTab.dataset.format = 'audio'; const subTab = document.createElement('button'); subTab.textContent = t('subtitleTab'); subTab.dataset.format = 'subtitle'; [videoTab, audioTab, subTab].forEach(btn => { Object.assign(btn.style, { flex: 'initial', padding: '8px 18px', border: '1px solid rgba(255,255,255,0.06)', background: 'transparent', cursor: 'pointer', fontSize: '13px', fontWeight: '600', transition: 'all 0.18s ease', color: '#666', borderRadius: '999px', }); // Accessibility & artifact prevention btn.type = 'button'; btn.style.outline = 'none'; btn.style.userSelect = 'none'; btn.setAttribute('aria-pressed', 'false'); }); function setActive(btn) { // Reset all to inactive style [videoTab, audioTab, subTab].forEach(b => { b.style.background = 'transparent'; b.style.color = '#666'; b.style.border = '1px solid rgba(255,255,255,0.06)'; b.style.boxShadow = 'none'; b.setAttribute('aria-pressed', 'false'); }); // Active look: green for main, white text. Object.assign(btn.style, { background: '#10c56a', color: '#fff', border: '1px solid rgba(0,0,0,0.06)', boxShadow: '0 1px 0 rgba(0,0,0,0.04) inset', }); btn.setAttribute('aria-pressed', 'true'); // Notify consumer about tab change (guarded to avoid throwing during early render) try { onTabChange(btn.dataset.format); } catch { // ignore - avoids visual glitches if consumer manipulates DOM before it's fully appended } } // Add click handlers that also remove focus to prevent outline artifacts [videoTab, audioTab, subTab].forEach(btn => { btn.addEventListener('click', () => { setActive(btn); try { btn.blur(); } catch { /* ignore */ } }); }); tabContainer.appendChild(videoTab); tabContainer.appendChild(audioTab); tabContainer.appendChild(subTab); // Set initial active tab after buttons are appended to DOM to avoid first-render artifacts // setTimeout 0 yields the same-tick deferred execution without blocking setTimeout(() => setActive(videoTab), 0); return tabContainer; } function buildModalForm() { // Quality selection container (we will render custom pill buttons into this div) const qualitySelect = document.createElement('div'); qualitySelect.role = 'radiogroup'; // allow using .value property like the select element qualitySelect.value = DownloadConfig.DEFAULTS.videoQuality; Object.assign(qualitySelect.style, { display: 'flex', flexWrap: 'wrap', gap: '10px', padding: '12px 6px', borderRadius: '10px', width: '100%', alignItems: 'center', justifyContent: 'center', background: 'transparent', }); const embedCheckbox = document.createElement('input'); embedCheckbox.type = 'checkbox'; embedCheckbox.checked = DownloadConfig.DEFAULTS.embedThumbnail; const embedLabel = document.createElement('label'); embedLabel.style.fontSize = '13px'; embedLabel.style.display = 'flex'; embedLabel.style.alignItems = 'center'; embedLabel.style.gap = '6px'; embedLabel.style.color = '#fff'; // Keep the embed thumbnail option always enabled but hidden from the UI embedLabel.style.display = 'none'; embedLabel.appendChild(embedCheckbox); embedLabel.appendChild(document.createTextNode(t('embedThumbnail'))); const subtitleWrapper = document.createElement('div'); subtitleWrapper.style.display = 'none'; const subtitleSelect = createSubtitleSelect(); // Subtitle format buttons (SRT/TXT/XML) rendered as pill buttons const formatSelect = document.createElement('div'); formatSelect.role = 'radiogroup'; formatSelect.value = 'srt'; Object.assign(formatSelect.style, { display: 'flex', gap: '8px', padding: '6px 0', borderRadius: '6px', width: '100%', alignItems: 'center', justifyContent: 'center', background: 'transparent', }); ['srt', 'txt', 'xml'].forEach(fmt => { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.value = fmt; btn.textContent = fmt.toUpperCase(); Object.assign(btn.style, { padding: '6px 12px', borderRadius: '999px', border: '1px solid rgba(255,255,255,0.08)', background: 'rgba(255,255,255,0.02)', color: '#fff', cursor: 'pointer', fontSize: '13px', fontWeight: '600', }); btn.addEventListener('click', () => { Array.from(formatSelect.children).forEach(c => { c.style.background = 'transparent'; c.style.color = '#fff'; c.style.border = '1px solid rgba(255,255,255,0.08)'; }); btn.style.background = '#111'; btn.style.color = '#10c56a'; btn.style.border = '1px solid rgba(16,197,106,0.15)'; formatSelect.value = fmt; }); formatSelect.appendChild(btn); }); // select default const _defaultFmtBtn = Array.from(formatSelect.children).find( c => c.dataset.value === formatSelect.value ); if (_defaultFmtBtn) _defaultFmtBtn.click(); subtitleWrapper.appendChild(subtitleSelect); subtitleWrapper.appendChild(formatSelect); const cancelBtn = document.createElement('button'); cancelBtn.textContent = t('cancel'); Object.assign(cancelBtn.style, { padding: '8px 16px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: '#fff', }); const downloadBtn = document.createElement('button'); downloadBtn.textContent = t('download'); Object.assign(downloadBtn.style, { padding: '8px 20px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: '600', }); const progressWrapper = document.createElement('div'); progressWrapper.style.display = 'none'; progressWrapper.style.marginTop = '12px'; const progressBar = document.createElement('div'); Object.assign(progressBar.style, { width: '100%', height: '3px', background: '#e0e0e0', borderRadius: '5px', overflow: 'hidden', marginBottom: '6px', }); const progressFill = document.createElement('div'); Object.assign(progressFill.style, { width: '0%', height: '100%', background: '#1a73e8', transition: 'width 200ms linear', }); progressBar.appendChild(progressFill); const progressText = document.createElement('div'); progressText.style.fontSize = '12px'; progressText.style.color = '#666'; progressWrapper.appendChild(progressBar); progressWrapper.appendChild(progressText); return { qualitySelect, embedLabel, subtitleWrapper, subtitleSelect, formatSelect, cancelBtn, downloadBtn, progressWrapper, progressFill, progressText, }; } /** * Disable form controls during download * @param {Object} formParts - Form elements */ function disableFormControls(formParts) { try { if (formParts.qualitySelect) formParts.qualitySelect.disabled = true; if (formParts.downloadBtn) { formParts.downloadBtn.disabled = true; formParts.downloadBtn.style.opacity = '0.5'; formParts.downloadBtn.style.cursor = 'not-allowed'; } if (formParts.cancelBtn) formParts.cancelBtn.disabled = true; } catch (e) { console.error('Error disabling form controls:', e); } } /** * Enable form controls after download * @param {Object} formParts - Form elements */ function enableFormControls(formParts) { try { if (formParts.qualitySelect) formParts.qualitySelect.disabled = false; if (formParts.downloadBtn) formParts.downloadBtn.disabled = false; if (formParts.cancelBtn) formParts.cancelBtn.disabled = false; // Reset button styles to ensure they're clickable if (formParts.downloadBtn) { formParts.downloadBtn.style.opacity = '1'; formParts.downloadBtn.style.cursor = 'pointer'; formParts.downloadBtn.style.pointerEvents = 'auto'; } } catch (e) { console.error('Error enabling form controls:', e); } } /** * Initialize progress display * @param {Object} formParts - Form elements */ function initializeProgress(formParts) { formParts.progressWrapper.style.display = ''; formParts.progressFill.style.width = '0%'; formParts.progressText.textContent = t('starting'); } /** * Handle subtitle download * @param {Object} formParts - Form elements * @param {Function} getSubtitlesData - Function to get subtitles data */ async function handleSubtitleDownload(formParts, getSubtitlesData) { const subtitlesData = getSubtitlesData(); const selectedIndex = parseInt(formParts.subtitleSelect.value, 10); const subtitle = subtitlesData.all[selectedIndex]; const subtitleFormat = formParts.formatSelect.value; if (!subtitle) { throw new Error(t('noSubtitleSelected')); } const videoId = getVideoId(); await downloadSubtitle({ videoId, url: subtitle.url, languageCode: subtitle.languageCode, languageName: subtitle.name, format: subtitleFormat, translateTo: subtitle.translateTo || null, }); } /** * Handle video/audio download * @param {Object} formParts - Form elements * @param {string} format - Download format */ async function handleMediaDownload(formParts, format) { const opts = { format, quality: formParts.qualitySelect.value, audioBitrate: formParts.qualitySelect.value, embedThumbnail: format === 'audio', onProgress: p => { formParts.progressFill.style.width = `${p.percent || 0}%`; formParts.progressText.textContent = `${p.percent || 0}% • ${formatBytes(p.loaded || 0)} / ${p.total ? formatBytes(p.total) : '—'}`; }, }; await downloadVideo(opts); } /** * Complete download and close modal * @param {Object} formParts - Form elements */ function completeDownload(formParts) { formParts.progressText.textContent = t('completed'); setTimeout(() => closeModal(), 800); } /** * Handle download error * @param {Object} formParts - Form elements * @param {Error} err - Error object */ function handleDownloadError(formParts, err) { const errorMsg = err?.message || 'Unknown error'; formParts.progressText.textContent = `${t('downloadFailed')} ${errorMsg}`; formParts.progressText.style.color = '#ff5555'; // Ensure controls are re-enabled even if something goes wrong enableFormControls(formParts); // Add a safety timeout to force re-enable after 500ms setTimeout(() => { try { enableFormControls(formParts); } catch (e) { console.error('Failed to re-enable controls:', e); } }, 500); // Reset progress text color after 3 seconds setTimeout(() => { formParts.progressText.style.color = '#fff'; }, 3000); } function wireModalEvents(formParts, activeFormatGetter, getSubtitlesData) { formParts.cancelBtn.addEventListener('click', () => closeModal()); formParts.downloadBtn.addEventListener('click', async () => { // Prevent multiple simultaneous downloads if (formParts.downloadBtn.disabled) return; disableFormControls(formParts); initializeProgress(formParts); const format = activeFormatGetter(); try { if (format === 'subtitle') { await handleSubtitleDownload(formParts, getSubtitlesData); } else { await handleMediaDownload(formParts, format); } completeDownload(formParts); } catch (err) { console.error('[Download Error]:', err); handleDownloadError(formParts, err); } finally { // Extra safety: ensure controls are re-enabled setTimeout(() => { if (formParts.downloadBtn && !formParts.downloadBtn.disabled) { return; // Already enabled } enableFormControls(formParts); }, 1000); } }); } /** * Load subtitles into the provided form parts and fill subtitlesData * Separated from createModalUI to reduce function length for linting. */ async function loadSubtitlesForForm(formParts, subtitlesData) { const videoId = getVideoId(); if (!videoId) return; formParts.subtitleSelect.setPlaceholder(t('loading')); formParts.subtitleSelect.disabled = true; try { const data = await getSubtitles(videoId); if (!data) { formParts.subtitleSelect.setPlaceholder(t('noSubtitles')); return; } subtitlesData.original = data.subtitles; subtitlesData.translated = data.autoTransSubtitles.map(autot => ({ ...autot, url: data.subtitles[0]?.url || '', translateTo: autot.languageCode, })); subtitlesData.all = [...subtitlesData.original, ...subtitlesData.translated]; if (subtitlesData.all.length === 0) { formParts.subtitleSelect.setPlaceholder(t('noSubtitles')); return; } const opts = subtitlesData.all.map((sub, idx) => ({ value: idx, text: sub.name + (sub.translateTo ? t('autoTranslateSuffix') : ''), })); formParts.subtitleSelect.setOptions(opts); formParts.subtitleSelect.disabled = false; } catch (err) { logger.error('Failed to load subtitles:', err); formParts.subtitleSelect.setPlaceholder(t('subtitleLoadError')); } } /** * Update quality/options UI depending on active format. * Extracted from createModalUI to satisfy max-lines-per-function. */ function updateQualityOptionsForForm(formParts, activeFormat, subtitlesData) { if (activeFormat === 'subtitle') { formParts.qualitySelect.style.display = 'none'; formParts.embedLabel.style.display = 'none'; formParts.subtitleWrapper.style.display = 'block'; loadSubtitlesForForm(formParts, subtitlesData); return; } if (activeFormat === 'video') { formParts.qualitySelect.style.display = 'flex'; formParts.embedLabel.style.display = 'none'; formParts.subtitleWrapper.style.display = 'none'; // Render custom pill buttons for video qualities, split low/high and show VP9 label formParts.qualitySelect.innerHTML = ''; const lowQuals = DownloadConfig.VIDEO_QUALITIES.filter(q => parseInt(q, 10) <= 1080); const highQuals = DownloadConfig.VIDEO_QUALITIES.filter(q => parseInt(q, 10) > 1080); function makeQualityButton(q) { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.value = q; btn.textContent = `${q}p`; Object.assign(btn.style, { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '8px 12px', borderRadius: '999px', border: '1px solid rgba(255,255,255,0.08)', background: 'rgba(255,255,255,0.02)', color: '#fff', cursor: 'pointer', fontSize: '13px', fontWeight: '600', }); btn.addEventListener('click', () => { Array.from(formParts.qualitySelect.children).forEach(c => { if (c.dataset && c.dataset.value) { c.style.background = 'transparent'; c.style.color = '#fff'; c.style.border = '1px solid rgba(255,255,255,0.08)'; } }); btn.style.background = '#111'; btn.style.color = '#10c56a'; btn.style.border = '1px solid rgba(16,197,106,0.15)'; formParts.qualitySelect.value = q; }); return btn; } lowQuals.forEach(q => formParts.qualitySelect.appendChild(makeQualityButton(q))); if (highQuals.length > 0) { const labelWrap = document.createElement('div'); Object.assign(labelWrap.style, { display: 'flex', alignItems: 'center', gap: '12px', width: '100%', margin: '8px 0', }); const lineLeft = document.createElement('div'); lineLeft.style.flex = '1'; lineLeft.style.borderTop = '1px solid rgba(255,255,255,0.06)'; const label = document.createElement('div'); label.textContent = t('vp9Label'); Object.assign(label.style, { fontSize: '12px', color: 'rgba(255,255,255,0.7)', padding: '0 8px', }); const lineRight = document.createElement('div'); lineRight.style.flex = '1'; lineRight.style.borderTop = '1px solid rgba(255,255,255,0.06)'; labelWrap.appendChild(lineLeft); labelWrap.appendChild(label); labelWrap.appendChild(lineRight); formParts.qualitySelect.appendChild(labelWrap); highQuals.forEach(q => formParts.qualitySelect.appendChild(makeQualityButton(q))); } // select default formParts.qualitySelect.value = DownloadConfig.DEFAULTS.videoQuality; const defaultBtn = Array.from(formParts.qualitySelect.children).find( c => c.dataset && c.dataset.value === formParts.qualitySelect.value ); if (defaultBtn) defaultBtn.click(); return; } // audio formParts.qualitySelect.style.display = 'flex'; formParts.embedLabel.style.display = 'flex'; formParts.subtitleWrapper.style.display = 'none'; // Render pill buttons for audio bitrates formParts.qualitySelect.innerHTML = ''; DownloadConfig.AUDIO_BITRATES.forEach(b => { const btn = document.createElement('button'); btn.type = 'button'; btn.dataset.value = b; btn.textContent = `${b} kbps`; Object.assign(btn.style, { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '8px 12px', borderRadius: '999px', border: '1px solid rgba(255,255,255,0.08)', background: 'rgba(255,255,255,0.02)', color: '#fff', cursor: 'pointer', fontSize: '13px', fontWeight: '600', }); btn.addEventListener('click', () => { Array.from(formParts.qualitySelect.children).forEach(c => { c.style.background = 'transparent'; c.style.color = '#fff'; c.style.border = '1px solid rgba(255,255,255,0.08)'; }); btn.style.background = '#111'; btn.style.color = '#10c56a'; btn.style.border = '1px solid rgba(16,197,106,0.15)'; formParts.qualitySelect.value = b; }); formParts.qualitySelect.appendChild(btn); }); formParts.qualitySelect.value = DownloadConfig.DEFAULTS.audioBitrate; const defaultAudioBtn = Array.from(formParts.qualitySelect.children).find( c => c.dataset.value === formParts.qualitySelect.value ); if (defaultAudioBtn) defaultAudioBtn.click(); // Do not show the embed thumbnail control in the UI; embedding is always enabled formParts.embedLabel.style.display = 'none'; } function createModalUI() { if (_modalElements) return _modalElements; let activeFormat = 'video'; const subtitlesData = { all: [], original: [], translated: [] }; const overlay = document.createElement('div'); Object.assign(overlay.style, { position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: '999999', }); const box = document.createElement('div'); Object.assign(box.style, { width: '420px', maxWidth: '94%', background: 'rgba(20,20,20,0.64)', color: '#fff', borderRadius: '12px', boxShadow: '0 8px 40px rgba(0,0,0,0.6)', fontFamily: 'Arial, sans-serif', border: '1px solid rgba(255,255,255,0.06)', backdropFilter: 'blur(8px)', }); const formParts = buildModalForm(); const tabContainer = createTabButtons(format => { activeFormat = format; updateQualityOptionsForForm(formParts, activeFormat, subtitlesData); }); const content = document.createElement('div'); content.style.padding = '16px'; content.appendChild(formParts.qualitySelect); content.appendChild(formParts.embedLabel); content.appendChild(formParts.subtitleWrapper); content.appendChild(formParts.progressWrapper); const btnRow = document.createElement('div'); Object.assign(btnRow.style, { display: 'flex', gap: '8px', padding: '16px', justifyContent: 'center', }); btnRow.appendChild(formParts.cancelBtn); btnRow.appendChild(formParts.downloadBtn); box.appendChild(tabContainer); box.appendChild(content); box.appendChild(btnRow); overlay.appendChild(box); updateQualityOptionsForForm(formParts, activeFormat, subtitlesData); wireModalEvents( formParts, () => activeFormat, () => subtitlesData ); _modalElements = { overlay, box, ...formParts }; return _modalElements; } function openModal() { const els = createModalUI(); if (!els) return; try { if (!document.body.contains(els.overlay)) document.body.appendChild(els.overlay); } catch { /* ignore */ } } function closeModal() { if (!_modalElements) return; try { // Clean up subtitle select listener to prevent document click leak const ss = _modalElements.overlay?.querySelector('[role="listbox"]'); if (ss && typeof ss.destroy === 'function') ss.destroy(); if (_modalElements.overlay && _modalElements.overlay.parentNode) { _modalElements.overlay.parentNode.removeChild(_modalElements.overlay); } } catch { /* ignore */ } _modalElements = null; } // ============================================================================ // DOWNLOAD BUTTON UI (merged from download-button.js) // ============================================================================ /** * Helper to wait for download API to be available * @param {number} timeout - Timeout in milliseconds * @returns {Promise} Download API or undefined */ const waitForDownloadAPI = timeout => new Promise(resolve => { const interval = 200; let waited = 0; if (typeof window.YouTubePlusDownload !== 'undefined') { return resolve(window.YouTubePlusDownload); } const id = setInterval(() => { waited += interval; if (typeof window.YouTubePlusDownload !== 'undefined') { clearInterval(id); return resolve(window.YouTubePlusDownload); } if (waited >= timeout) { clearInterval(id); return resolve(undefined); } }, interval); }); /** * Fallback clipboard copy for older browsers * @param {string} text - Text to copy * @param {Function} tFn - Translation function * @param {Object} notificationMgr - Notification manager */ const fallbackCopyToClipboard = (text, tFn, notificationMgr) => { const input = document.createElement('input'); input.value = text; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); notificationMgr.show(tFn('copiedToClipboard'), { duration: 2000, type: 'success', }); }; /** * Build URL from template * @param {string} template - URL template * @param {string} videoId - Video ID * @param {string} videoUrl - Full video URL * @returns {string} Built URL */ const buildUrl = (template, videoId, videoUrl) => (template || '') .replace('{videoId}', videoId || '') .replace('{videoUrl}', encodeURIComponent(videoUrl || '')); /** * Create download button element * @param {Function} tFn - Translation function * @returns {HTMLElement} Button element */ const createButtonElement = tFn => { const button = document.createElement('div'); button.className = 'ytp-button ytp-download-button'; button.setAttribute('title', tFn('downloadOptions')); button.setAttribute('tabindex', '0'); button.setAttribute('role', 'button'); button.setAttribute('aria-haspopup', 'true'); button.setAttribute('aria-expanded', 'false'); button.innerHTML = ` `; return button; }; /** * Position dropdown below button (batched with RAF) * @param {HTMLElement} button - Button element * @param {HTMLElement} dropdown - Dropdown element */ const positionDropdown = (() => { let rafId = null; let pendingButton = null; let pendingDropdown = null; const applyPosition = () => { if (!pendingButton || !pendingDropdown) return; const rect = pendingButton.getBoundingClientRect(); const left = Math.max(8, rect.left + rect.width / 2 - 75); const bottom = Math.max(8, window.innerHeight - rect.top + 12); pendingDropdown.style.left = `${left}px`; pendingDropdown.style.bottom = `${bottom}px`; rafId = null; pendingButton = null; pendingDropdown = null; }; return (button, dropdown) => { pendingButton = button; pendingDropdown = dropdown; if (rafId !== null) return; // Already scheduled rafId = requestAnimationFrame(applyPosition); }; })(); /** * Download Site Actions - Handle different types of downloads */ const createDownloadActions = (tFn, ytUtils) => { /** * Handle direct download */ const handleDirectDownload = async () => { const api = await waitForDownloadAPI(2000); if (!api) { console.error('[YouTube+] Direct download module not loaded'); ytUtils.NotificationManager.show(tFn('directDownloadModuleNotAvailable'), { duration: 3000, type: 'error', }); return; } try { if (typeof api.openModal === 'function') { api.openModal(); return; } if (typeof api.downloadVideo === 'function') { await api.downloadVideo({ format: 'video', quality: '1080' }); return; } } catch (err) { console.error('[YouTube+] Direct download invocation failed:', err); } ytUtils.NotificationManager.show(tFn('directDownloadModuleNotAvailable'), { duration: 3000, type: 'error', }); }; /** * Handle YTDL download - copies URL to clipboard and opens YTDL * @param {string} url - YTDL URL */ const handleYTDLDownload = url => { const videoId = new URLSearchParams(location.search).get('v'); const videoUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}` : location.href; // Copy to clipboard navigator.clipboard .writeText(videoUrl) .then(() => { ytUtils.NotificationManager.show(tFn('copiedToClipboard'), { duration: 2000, type: 'success', }); }) .catch(() => { fallbackCopyToClipboard(videoUrl, tFn, ytUtils.NotificationManager); }); // Open YTDL in new tab window.open(url, '_blank'); }; /** * Helper to open download site or trigger direct download * @param {string} url - Download URL * @param {boolean} isYTDL - Whether this is YTDL download * @param {boolean} isDirect - Whether this is direct download * @param {HTMLElement} dropdown - Dropdown element to hide * @param {HTMLElement} button - Button element */ const openDownloadSite = (url, isYTDL, isDirect, dropdown, button) => { dropdown.classList.remove('visible'); button.setAttribute('aria-expanded', 'false'); if (isDirect) { handleDirectDownload(); return; } if (isYTDL) { handleYTDLDownload(url); return; } window.open(url, '_blank'); }; return { handleDirectDownload, handleYTDLDownload, openDownloadSite }; }; /** * Download Sites Configuration Builder * @param {Function} tFn - Translation function * @returns {Function} Builder function */ const createDownloadSitesBuilder = tFn => { return (customization, enabledSites, videoId, videoUrl) => { const baseSites = [ { key: 'externalDownloader', name: customization?.externalDownloader?.name || 'SSYouTube', url: buildUrl( customization?.externalDownloader?.url || `https://ssyoutube.com/watch?v={videoId}`, videoId, videoUrl ), isYTDL: false, isDirect: false, }, { key: 'ytdl', name: 'by YTDL', url: `http://localhost:5005`, isYTDL: true, isDirect: false, }, { key: 'direct', name: tFn('directDownload'), url: '#', isYTDL: false, isDirect: true, }, ]; const downloadSites = baseSites.filter(s => enabledSites[s.key] !== false); return { baseSites, downloadSites }; }; }; /** * Create dropdown options element * @param {Array} downloadSites - Download sites configuration * @param {HTMLElement} button - Button element * @param {Function} openDownloadSiteFn - Click handler * @returns {HTMLElement} Dropdown element */ const createDropdownOptions = (downloadSites, button, openDownloadSiteFn) => { const options = document.createElement('div'); options.className = 'download-options'; options.setAttribute('role', 'menu'); const list = document.createElement('div'); list.className = 'download-options-list'; downloadSites.forEach(site => { const opt = document.createElement('div'); opt.className = 'download-option-item'; opt.textContent = site.name; opt.setAttribute('role', 'menuitem'); opt.setAttribute('tabindex', '0'); opt.dataset.url = site.url; opt.dataset.isYtdl = site.isYTDL ? 'true' : 'false'; opt.dataset.isDirect = site.isDirect ? 'true' : 'false'; list.appendChild(opt); }); const handleOptionActivate = item => { if (!item) return; openDownloadSiteFn( item.dataset.url, item.dataset.isYtdl === 'true', item.dataset.isDirect === 'true', options, button ); }; list.addEventListener('click', e => { const item = e.target?.closest?.('.download-option-item'); if (!item || !list.contains(item)) return; handleOptionActivate(item); }); list.addEventListener('keydown', e => { const item = e.target?.closest?.('.download-option-item'); if (!item || !list.contains(item)) return; if (e.key === 'Enter' || e.key === ' ') { handleOptionActivate(item); } }); options.appendChild(list); return options; }; /** * Setup dropdown hover behavior with event delegation * Uses WeakMap to store timers per button/dropdown pair */ const setupDropdownHoverBehavior = (() => { let initialized = false; const dropdownTimers = new WeakMap(); const getTimer = element => dropdownTimers.get(element); const setTimer = (element, timerId) => dropdownTimers.set(element, timerId); const clearTimer = element => { const timerId = getTimer(element); if (timerId !== undefined) { clearTimeout(timerId); dropdownTimers.delete(element); } }; const showDropdown = (button, dropdown) => { clearTimer(button); clearTimer(dropdown); positionDropdown(button, dropdown); dropdown.classList.add('visible'); button.setAttribute('aria-expanded', 'true'); }; const hideDropdown = (button, dropdown) => { clearTimer(button); clearTimer(dropdown); const timerId = setTimeout(() => { dropdown.classList.remove('visible'); button.setAttribute('aria-expanded', 'false'); }, 180); setTimer(button, timerId); }; const initDelegation = () => { if (initialized) return; initialized = true; // Mouseenter/mouseleave delegation on document with capture phase document.addEventListener( 'mouseenter', e => { const button = e.target?.closest?.('.ytp-download-button'); if (button) { const dropdown = $('.download-options'); if (dropdown) { clearTimer(button); clearTimer(dropdown); showDropdown(button, dropdown); } return; } const dropdown = e.target?.closest?.('.download-options'); if (dropdown) { const button = $('.ytp-download-button'); if (button) { clearTimer(button); clearTimer(dropdown); showDropdown(button, dropdown); } } }, true ); document.addEventListener( 'mouseleave', e => { const button = e.target?.closest?.('.ytp-download-button'); if (button) { const dropdown = $('.download-options'); if (dropdown) { clearTimer(button); clearTimer(dropdown); const timerId = setTimeout(() => hideDropdown(button, dropdown), 180); setTimer(button, timerId); } return; } const dropdown = e.target?.closest?.('.download-options'); if (dropdown) { const button = $('.ytp-download-button'); if (button) { clearTimer(button); clearTimer(dropdown); const timerId = setTimeout(() => hideDropdown(button, dropdown), 180); setTimer(dropdown, timerId); } } }, true ); // Keydown delegation for Enter/Space on button document.addEventListener('keydown', e => { const button = e.target?.closest?.('.ytp-download-button'); if (!button) return; if (e.key === 'Enter' || e.key === ' ') { const dropdown = $('.download-options'); if (!dropdown) return; if (dropdown.classList.contains('visible')) { hideDropdown(button, dropdown); } else { showDropdown(button, dropdown); } } }); }; // Return function that just initializes delegation once return () => { initDelegation(); }; })(); /** * Download Button Manager - Handles download button creation and dropdown management * @param {Object} config - Configuration object * @param {Object} config.settings - Settings object * @param {Function} config.t - Translation function * @param {Function} config.getElement - Get element function * @param {Object} config.YouTubeUtils - YouTube utilities * @returns {Object} Download button manager API */ const createDownloadButtonManager = config => { const { settings, t: tFn, getElement, YouTubeUtils: ytUtils } = config; const actions = createDownloadActions(tFn, ytUtils); const buildDownloadSites = createDownloadSitesBuilder(tFn); /** * Add download button to controls * @param {HTMLElement} controls - Controls container */ const addDownloadButton = controls => { if (!settings.enableDownload) return; try { const existingBtn = controls.querySelector('.ytp-download-button'); if (existingBtn) existingBtn.remove(); } catch { // ignore } const videoId = new URLSearchParams(location.search).get('v'); const videoUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}` : location.href; const customization = settings.downloadSiteCustomization || { externalDownloader: { name: 'SSYouTube', url: 'https://ssyoutube.com/watch?v={videoId}' }, }; const enabledSites = settings.downloadSites || { externalDownloader: true, ytdl: true, direct: true, }; const { downloadSites } = buildDownloadSites(customization, enabledSites, videoId, videoUrl); const button = createButtonElement(tFn); if (downloadSites.length === 1) { const singleSite = downloadSites[0]; button.style.cursor = 'pointer'; const tempDropdown = document.createElement('div'); button.addEventListener('click', () => actions.openDownloadSite( singleSite.url, singleSite.isYTDL, singleSite.isDirect, tempDropdown, button ) ); controls.insertBefore(button, controls.firstChild); return; } const dropdown = createDropdownOptions(downloadSites, button, actions.openDownloadSite); const existingDownload = $('.download-options'); if (existingDownload) existingDownload.remove(); try { document.body.appendChild(dropdown); } catch { button.appendChild(dropdown); } setupDropdownHoverBehavior(button, dropdown); try { if (typeof window !== 'undefined') { window.youtubePlus = window.youtubePlus || {}; window.youtubePlus.downloadButtonManager = window.youtubePlus.downloadButtonManager || {}; window.youtubePlus.downloadButtonManager.addDownloadButton = controlsArg => addDownloadButton(controlsArg); window.youtubePlus.downloadButtonManager.refreshDownloadButton = () => { try { const btn = $('.ytp-download-button'); const dd = $('.download-options'); // If we should show downloads but the elements are missing, attempt to recreate if (settings.enableDownload && (!btn || !dd)) { try { const controlsEl = $('.ytp-right-controls'); if (controlsEl) { // recreate button + dropdown addDownloadButton(controlsEl); } } catch { /* ignore recreation errors */ } } if (settings.enableDownload) { if (btn) btn.style.display = ''; if (dd) dd.style.display = ''; } else { if (btn) btn.style.display = 'none'; if (dd) dd.style.display = 'none'; } } catch { /* ignore */ } }; window.youtubePlus.rebuildDownloadDropdown = () => { try { const controlsEl = $('.ytp-right-controls'); if (!controlsEl) return; window.youtubePlus.downloadButtonManager.addDownloadButton(controlsEl); window.youtubePlus.settings = window.youtubePlus.settings || settings; } catch (e) { console.warn('[YouTube+] rebuildDownloadDropdown failed:', e); } }; } } catch (e) { console.warn('[YouTube+] expose rebuildDownloadDropdown failed:', e); } controls.insertBefore(button, controls.firstChild); }; /** * Refresh download button visibility based on settings */ const refreshDownloadButton = () => { const button = getElement('.ytp-download-button'); let dropdown = $('.download-options'); // If downloads are enabled but the dropdown/button are missing, recreate them if (settings.enableDownload && (!button || !dropdown)) { try { const controlsEl = $('.ytp-right-controls'); if (controlsEl) { addDownloadButton(controlsEl); // re-query after creation dropdown = $('.download-options'); } } catch (e) { logger && logger.warn && logger.warn('[YouTube+] recreate download button failed:', e); } } if (settings.enableDownload) { if (button) button.style.display = ''; if (dropdown) dropdown.style.display = ''; } else { if (button) button.style.display = 'none'; if (dropdown) dropdown.style.display = 'none'; } }; return { addDownloadButton, refreshDownloadButton, }; }; // ============================================================================ // MODULE INITIALIZATION // ============================================================================ let initialized = false; function init() { if (initialized) return; initialized = true; try { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+ Download] Unified module loaded'); window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug( '[YouTube+ Download] Use window.YouTubePlusDownload.downloadVideo() to download' ); window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+ Download] Button manager available'); } catch {} } // Export public API if (typeof window !== 'undefined') { window.YouTubePlusDownload = { downloadVideo, // Subtitle functions getSubtitles, downloadSubtitle, // Utility functions getVideoId, getVideoUrl, getVideoTitle, sanitizeFilename, formatBytes, // Configuration DownloadConfig, // UI: open modal for user selection openModal, // Initialize (called automatically) init, }; // Export button manager for basic.js window.YouTubePlusDownloadButton = { createDownloadButtonManager }; } // Export module to global scope for module loader if (typeof window !== 'undefined') { window.YouTubeDownload = { init, openModal, getVideoId, getVideoTitle, version: '3.0', }; } const ensureInit = () => { if (!isRelevantRoute()) return; if (typeof requestIdleCallback === 'function') { requestIdleCallback(init, { timeout: 1500 }); } else { setTimeout(init, 0); } }; onDomReady(ensureInit); if (window.YouTubeUtils?.cleanupManager?.registerListener) { YouTubeUtils.cleanupManager.registerListener(document, 'yt-navigate-finish', ensureInit, { passive: true, }); } else { document.addEventListener('yt-navigate-finish', ensureInit, { passive: true }); } })(); // --- MODULE: enhanced.js --- // Shared DOM helpers - defined at file scope for use across all IIFEs and functions const _getDOMCache = () => typeof window !== 'undefined' && window.YouTubeDOMCache; /** * Query single element with optional caching * @param {string} sel - CSS selector * @param {Element|Document} [ctx] - Context element * @returns {Element|null} */ const $ = (sel, ctx) => _getDOMCache()?.querySelector(sel, ctx) || (ctx || document).querySelector(sel); /** * Query all elements with optional caching * @param {string} sel - CSS selector * @param {Element|Document} [ctx] - Context element * @returns {Element[]} */ const $$ = (sel, ctx) => _getDOMCache()?.querySelectorAll(sel, ctx) || Array.from((ctx || document).querySelectorAll(sel)); /** * Get element by ID with optional caching * @param {string} id - Element ID * @returns {Element|null} */ const byId = id => _getDOMCache()?.getElementById(id) || document.getElementById(id); // $, $$, byId are defined above and used throughout const onDomReady = (() => { let ready = document.readyState !== 'loading'; const queue = []; const run = () => { ready = true; while (queue.length) { const cb = queue.shift(); try { cb(); } catch (e) { console.warn('[YouTube+] DOMReady callback error:', e); } } }; if (!ready) { document.addEventListener('DOMContentLoaded', run, { once: true }); } return cb => { if (ready) { cb(); } else { queue.push(cb); } }; })(); // Enhanced Tabviews (function () { 'use strict'; // Use centralized i18n from YouTubePlusI18n or YouTubeUtils const _getLanguage = () => { if (window.YouTubePlusI18n?.getLanguage) return window.YouTubePlusI18n.getLanguage(); if (window.YouTubeUtils?.getLanguage) return window.YouTubeUtils.getLanguage(); const htmlLang = document.documentElement.lang || 'en'; return htmlLang.startsWith('ru') ? 'ru' : 'en'; }; const t = (key, params = {}) => { if (window.YouTubePlusI18n?.t) return window.YouTubePlusI18n.t(key, params); if (window.YouTubeUtils?.t) return window.YouTubeUtils.t(key, params); // Fallback for initialization phase if (!key) return ''; let result = String(key); for (const [k, v] of Object.entries(params || {})) { result = result.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); } return result; }; // No local alias needed here; modules may use global YouTubeUtils.getLanguage when required /** * Configuration object for scroll-to-top button * @type {Object} * @property {boolean} enabled - Whether the feature is enabled * @property {string} storageKey - LocalStorage key for settings */ const config = { enabled: (() => { try { const settings = localStorage.getItem('youtube_plus_settings'); if (settings) { const parsed = JSON.parse(settings); return parsed.enableScrollToTopButton !== false; } } catch (e) { console.warn('[YouTube+] Config read error:', e); } return true; })(), storageKey: 'youtube_top_button_settings', }; let universalScrollHandler = null; let universalScrollContainer = null; const getUniversalScrollContainer = () => { try { const host = window.location.hostname; const candidates = []; if (host === 'music.youtube.com') { // YouTube Music uses custom layout elements – try multiple containers // The main scrollable area on YouTube Music is typically #layout or the app-layout itself const appLayout = document.querySelector('ytmusic-app-layout'); if (appLayout) { // Check the direct scroll container inside app-layout const layoutContent = appLayout.querySelector('#layout'); if (layoutContent) candidates.push(layoutContent); // Also try the app-layout itself (sometimes it's the scroll host) candidates.push(appLayout); } candidates.push( document.querySelector('ytmusic-browse-response #contents'), document.querySelector('ytmusic-section-list-renderer'), document.querySelector('ytmusic-tabbed-page #content'), document.querySelector('ytmusic-app-layout #content'), document.querySelector('#content'), document.querySelector('ytmusic-app') ); } else if (host === 'studio.youtube.com') { // YouTube Studio uses different layout containers candidates.push( $('ytcp-entity-page #scrollable-content'), $('ytcp-app #content'), $('#main-content'), $('#content'), $('#main'), $('ytcp-app') ); } candidates.push(document.scrollingElement, document.documentElement, document.body); for (const el of candidates) { if (!el) continue; if (el.scrollHeight > el.clientHeight + 50) return el; } // Fallback: if no scrollable container found yet, return window-level // for music/studio since they may use window scroll if (host === 'music.youtube.com' || host === 'studio.youtube.com') { return document.scrollingElement || document.documentElement; } } catch {} return document.scrollingElement || document.documentElement; }; let universalWindowScrollHandler = null; const removeUniversalButton = () => { try { const btn = byId('universal-top-button'); if (btn) btn.remove(); } catch {} try { if (universalScrollHandler && universalScrollContainer) { universalScrollContainer.removeEventListener('scroll', universalScrollHandler); } } catch {} try { if (universalWindowScrollHandler) { window.removeEventListener('scroll', universalWindowScrollHandler); } } catch {} universalScrollHandler = null; universalScrollContainer = null; universalWindowScrollHandler = null; }; let musicSideScrollHandler = null; let musicSideScrollContainer = null; const getMusicSidePanelContainer = () => { if (window.location.hostname !== 'music.youtube.com') return null; // Direct selectors for the queue/side panel content const directSelectors = [ 'ytmusic-player-queue #contents', 'ytmusic-player-queue', '#side-panel #contents', '#side-panel', 'ytmusic-tab-renderer[page-type="MUSIC_PAGE_TYPE_QUEUE"] #contents', 'ytmusic-queue #automix-contents', 'ytmusic-queue #contents', ]; for (const sel of directSelectors) { try { const el = document.querySelector(sel); if (el && el.scrollHeight > el.clientHeight + 30) return el; } catch {} } // Try within specific roots const roots = [ document.querySelector('ytmusic-player-page'), document.querySelector('ytmusic-app-layout'), document.querySelector('ytmusic-app'), ]; const selectors = [ '#side-panel', '#right-content', 'ytmusic-player-queue', 'ytmusic-queue', 'ytmusic-tab-renderer[selected] #contents', '.side-panel', ]; for (const root of roots) { if (!root) continue; for (const sel of selectors) { try { const el = root.querySelector(sel); if (el && el.scrollHeight > el.clientHeight + 30) return el; } catch {} } } return null; }; const removeMusicSideButton = () => { try { const btn = byId('music-side-top-button'); if (btn) btn.remove(); } catch {} try { if (musicSideScrollHandler && musicSideScrollContainer) { musicSideScrollContainer.removeEventListener('scroll', musicSideScrollHandler); } } catch {} musicSideScrollHandler = null; musicSideScrollContainer = null; }; const cleanupTopButtons = () => { try { const rightButton = byId('right-tabs-top-button'); if (rightButton) rightButton.remove(); } catch {} try { const playlistButton = byId('playlist-panel-top-button'); if (playlistButton) playlistButton.remove(); } catch {} removeMusicSideButton(); removeUniversalButton(); try { $$('#right-tabs .tab-content-cld').forEach(tab => { if (tab && tab._topButtonScrollHandler) { tab.removeEventListener('scroll', tab._topButtonScrollHandler); tab._topButtonScrollHandler = null; } }); } catch {} try { // #right-tabs itself may be the scroll host on single-column layout const rightTabsEl = document.getElementById('right-tabs'); if (rightTabsEl) { if (rightTabsEl._topButtonScrollHandler) { rightTabsEl.removeEventListener('scroll', rightTabsEl._topButtonScrollHandler); rightTabsEl._topButtonScrollHandler = null; } if (rightTabsEl._scrollCleanup) { rightTabsEl._scrollCleanup(); rightTabsEl._scrollCleanup = null; } } } catch {} try { const playlistScroll = $('ytd-playlist-panel-renderer #items'); if (playlistScroll && playlistScroll._topButtonScrollHandler) { playlistScroll.removeEventListener('scroll', playlistScroll._topButtonScrollHandler); playlistScroll._topButtonScrollHandler = null; } } catch {} }; let tabChangesObserver = null; let watchInitToken = 0; let isTabClickListenerAttached = false; let tabDelegationHandler = null; let tabDelegationRegistered = false; let tabCheckTimeoutId = null; let playlistPanelCheckTimeoutId = null; const isWatchPage = () => window.location.pathname === '/watch'; const isShortsPage = () => window.location.pathname.startsWith('/shorts'); const shouldInitReturnDislike = () => isWatchPage() || isShortsPage(); const isTopButton = el => el && (el.id === 'right-tabs-top-button' || el.id === 'universal-top-button' || el.id === 'playlist-panel-top-button' || el.id === 'music-side-top-button'); const handleTopButtonActivate = button => { try { if (!button) return; if (button.id === 'right-tabs-top-button') { // Always use direct DOM query here — class-based selectors may be stale in cache const activeTab = document.querySelector( '#right-tabs .tab-content-cld:not(.tab-content-hidden)' ); const rightTabsEl = document.getElementById('right-tabs'); // On single-column layout #right-tabs is the actual scroll host (overflow:auto), // so prefer scrolling it when it already has a positive scrollTop. const scrollTarget = rightTabsEl && rightTabsEl.scrollTop > 0 ? rightTabsEl : activeTab && activeTab.scrollTop > 0 ? activeTab : activeTab || rightTabsEl; if (scrollTarget) { if ('scrollBehavior' in document.documentElement.style) { scrollTarget.scrollTo({ top: 0, behavior: 'smooth' }); } else { scrollTarget.scrollTop = 0; } button.setAttribute('aria-label', t('scrolledToTop') || 'Scrolled to top'); setTimeout(() => { button.setAttribute('aria-label', t('scrollToTop')); }, 1000); } return; } if (button.id === 'universal-top-button') { // Always re-detect container on Music/Studio since SPA navigation changes it const host = window.location.hostname; const isMusic = host === 'music.youtube.com'; const isStudio = host === 'studio.youtube.com'; const target = isMusic || isStudio ? getUniversalScrollContainer() : universalScrollContainer || getUniversalScrollContainer(); // Try multiple scroll strategies for YouTube Music const scrollToTop = el => { if ('scrollBehavior' in document.documentElement.style) { el.scrollTo({ top: 0, behavior: 'smooth' }); } else { el.scrollTop = 0; } }; if ( target === window || target === document || target === document.body || target === document.documentElement ) { window.scrollTo({ top: 0, behavior: 'smooth' }); } else if (target && typeof target.scrollTo === 'function') { scrollToTop(target); } // For YouTube Music: also scroll window and common inner containers if (isMusic) { window.scrollTo({ top: 0, behavior: 'smooth' }); // Scroll all potentially scrollable music containers const musicContainers = [ document.querySelector('ytmusic-app-layout #layout'), document.querySelector('ytmusic-app-layout'), document.querySelector('ytmusic-browse-response #contents'), document.querySelector('ytmusic-section-list-renderer'), ]; for (const c of musicContainers) { if (c && c.scrollTop > 0) { scrollToTop(c); } } } return; } if (button.id === 'playlist-panel-top-button') { const playlistPanel = $('ytd-playlist-panel-renderer'); const scrollContainer = playlistPanel ? $('#items', playlistPanel) : null; if (scrollContainer) { if ('scrollBehavior' in document.documentElement.style) { scrollContainer.scrollTo({ top: 0, behavior: 'smooth' }); } else { scrollContainer.scrollTop = 0; } } return; } if (button.id === 'music-side-top-button') { // Always re-detect since panel content changes with navigation const target = getMusicSidePanelContainer() || musicSideScrollContainer; if (target) { if ('scrollBehavior' in document.documentElement.style) { target.scrollTo({ top: 0, behavior: 'smooth' }); } else { target.scrollTop = 0; } } } } catch (error) { console.error('[YouTube+][Enhanced] Error scrolling to top:', error); } }; const setupTopButtonDelegation = (() => { let attached = false; return () => { if (attached) return; attached = true; const delegator = window.YouTubePlusEventDelegation; if (delegator?.on) { delegator.on(document, 'click', '.top-button', (ev, target) => { if (isTopButton(target)) handleTopButtonActivate(target); }); delegator.on(document, 'keydown', '.top-button', (ev, target) => { if (!isTopButton(target)) return; if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); handleTopButtonActivate(target); } }); } else { document.addEventListener( 'click', ev => { const target = ev.target?.closest?.('.top-button'); if (isTopButton(target)) handleTopButtonActivate(target); }, true ); document.addEventListener( 'keydown', ev => { const target = ev.target?.closest?.('.top-button'); if (!isTopButton(target)) return; if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); handleTopButtonActivate(target); } }, true ); } }; })(); const clearTimeoutSafe = id => { if (id) clearTimeout(id); return null; }; /** * Adds CSS styles for scroll-to-top button and scrollbars * @returns {void} */ const addStyles = () => { if (byId('custom-styles')) return; const style = document.createElement('style'); style.id = 'custom-styles'; style.textContent = ` :root{--scrollbar-width:8px;--scrollbar-track:transparent;--scrollbar-thumb:rgba(144,144,144,.5);--scrollbar-thumb-hover:rgba(170,170,170,.7);--scrollbar-thumb-active:rgba(190,190,190,.9);} ::-webkit-scrollbar{width:var(--scrollbar-width)!important;height:var(--scrollbar-width)!important;} ::-webkit-scrollbar-track{background:var(--scrollbar-track)!important;border-radius:4px!important;} ::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb)!important;border-radius:4px!important;transition:background .2s!important;} ::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)!important;} ::-webkit-scrollbar-thumb:active{background:var(--scrollbar-thumb-active)!important;} ::-webkit-scrollbar-corner{background:transparent!important;} html,body,#content,#guide-content,#secondary,#comments,#chat,ytd-comments,ytd-watch-flexy,ytd-browse,ytd-search,ytd-playlist-panel-renderer,#right-tabs,.tab-content-cld,ytmusic-app-layout{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track);} html[dark]{--scrollbar-thumb:rgba(144,144,144,.4);--scrollbar-thumb-hover:rgba(170,170,170,.6);--scrollbar-thumb-active:rgba(190,190,190,.8);} .top-button{position:fixed;bottom:16px;right:16px;width:40px;height:40px;background:var(--yt-top-btn-bg,rgba(0,0,0,.7));color:var(--yt-top-btn-color,#fff);border:none;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2100;opacity:0;visibility:hidden;transition:all .3s cubic-bezier(0.4, 0, 0.2, 1);backdrop-filter:blur(12px) saturate(180%);-webkit-backdrop-filter:blur(12px) saturate(180%);border:1px solid var(--yt-top-btn-border,rgba(255,255,255,.1));background:rgba(255,255,255,.12);box-shadow:0 8px 32px 0 rgba(31,38,135,.18);} .top-button:hover{background:var(--yt-top-btn-hover,rgba(0,0,0,.15));transform:translateY(-2px) scale(1.07);box-shadow:0 8px 32px rgba(0,0,0,.25);} .top-button:active{transform:translateY(-1px) scale(1.03);} .top-button:focus{outline:2px solid rgba(255,255,255,0.5);outline-offset:2px;} .top-button.visible{opacity:1;visibility:visible;} .top-button svg{transition:transform .2s ease;} .top-button:hover svg{transform:translateY(-1px) scale(1.1);} html[dark]{--yt-top-btn-bg:rgba(255,255,255,.10);--yt-top-btn-color:#fff;--yt-top-btn-border:rgba(255,255,255,.18);--yt-top-btn-hover:rgba(255,255,255,.18);} html:not([dark]){--yt-top-btn-bg:rgba(255,255,255,.12);--yt-top-btn-color:#222;--yt-top-btn-border:rgba(0,0,0,.08);--yt-top-btn-hover:rgba(255,255,255,.18);} #right-tabs .top-button{position:absolute;z-index:1000;} ytd-watch-flexy:not([tyt-tab^="#"]) #right-tabs .top-button{display:none;} ytd-playlist-panel-renderer .top-button{position:absolute;z-index:1000;} ytd-watch-flexy[flexy] #movie_player, ytd-watch-flexy[flexy] #movie_player .html5-video-container, ytd-watch-flexy[flexy] .html5-main-video{width:100%!important; max-width:100%!important;} ytd-watch-flexy[flexy] .html5-main-video{height:auto!important; max-height:100%!important; object-fit:contain!important; transform:none!important;} ytd-watch-flexy[flexy] #player-container-outer, ytd-watch-flexy[flexy] #movie_player{display:flex!important; align-items:center!important; justify-content:center!important;} /* Return YouTube Dislike button styling */ dislike-button-view-model button{min-width:fit-content!important;width:auto!important;} dislike-button-view-model .yt-spec-button-shape-next__button-text-content{display:inline-flex!important;align-items:center!important;justify-content:center!important;} #ytp-plus-dislike-text{display:inline-block!important;visibility:visible!important;opacity:1!important;margin-left:6px!important;font-size:1.4rem!important;line-height:2rem!important;font-weight:500!important;} ytd-segmented-like-dislike-button-renderer dislike-button-view-model button{min-width:fit-content!important;} ytd-segmented-like-dislike-button-renderer .yt-spec-button-shape-next__button-text-content{min-width:2.4rem!important;} /* Shorts-specific dislike button styling */ ytd-reel-video-renderer dislike-button-view-model #ytp-plus-dislike-text{font-size:1.2rem!important;line-height:1.8rem!important;margin-left:4px!important;} ytd-reel-video-renderer dislike-button-view-model button{padding:8px 12px!important;min-width:auto!important;} ytd-shorts dislike-button-view-model .yt-spec-button-shape-next__button-text-content{display:inline-flex!important;min-width:auto!important;} `; (document.head || document.documentElement).appendChild(style); }; /** * Updates button visibility based on scroll position * @param {HTMLElement} scrollContainer - The container being scrolled * @param {HTMLElement} button - The button element * @returns {void} */ const handleScroll = (scrollContainer, button) => { try { if (!button || !scrollContainer) return; button.classList.toggle('visible', scrollContainer.scrollTop > 100); } catch (error) { console.error('[YouTube+][Enhanced] Error in handleScroll:', error); } }; /** * Sets up scroll event listener on active tab with debouncing for performance * Uses IntersectionObserver when possible for better performance * @returns {void} */ const setupScrollListener = (() => { let timeout; return () => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { try { // Clean up old listeners first $$('.tab-content-cld').forEach(tab => { if (tab._topButtonScrollHandler) { tab.removeEventListener('scroll', tab._topButtonScrollHandler); delete tab._topButtonScrollHandler; } // Clean up IntersectionObserver if exists if (tab._scrollObserver) { tab._scrollObserver.disconnect(); delete tab._scrollObserver; } // Use ScrollManager if available window.YouTubePlusScrollManager?.removeAllListeners?.(tab); }); // Also remove any direct #right-tabs scroll handler from a previous run try { const prevRtEl = document.getElementById('right-tabs'); if (prevRtEl) { if (prevRtEl._topButtonScrollHandler) { prevRtEl.removeEventListener('scroll', prevRtEl._topButtonScrollHandler); delete prevRtEl._topButtonScrollHandler; } if (prevRtEl._scrollCleanup) { prevRtEl._scrollCleanup(); delete prevRtEl._scrollCleanup; } } } catch {} // Always use direct DOM query — class-based ':not(.tab-content-hidden)' selectors // can return a stale cached element (the previously-active tab, which is still in // the DOM but now hidden). A direct query guarantees the correct live result. const activeTab = document.querySelector( '#right-tabs .tab-content-cld:not(.tab-content-hidden)' ); const button = byId('right-tabs-top-button'); if (activeTab && button) { // On single-column layouts, #right-tabs itself has overflow:auto and acts as // the scroll host. In that case the individual tab
never gets scrollTop>0. // Detect which element is actually scrollable and attach the listener there. const rightTabsEl = document.getElementById('right-tabs'); const rtIsScrollHost = rightTabsEl && rightTabsEl !== activeTab && rightTabsEl.scrollHeight > rightTabsEl.clientHeight + 10; const scrollTarget = rtIsScrollHost ? rightTabsEl : activeTab; // Use ScrollManager if available for better performance if (window.YouTubePlusScrollManager) { const cleanup = window.YouTubePlusScrollManager.addScrollListener( scrollTarget, () => handleScroll(scrollTarget, button), { debounce: 100, runInitial: true } ); scrollTarget._scrollCleanup = cleanup; } else { // Fallback to manual debouncing const debounceFunc = typeof YouTubeUtils !== 'undefined' && YouTubeUtils.debounce ? YouTubeUtils.debounce : (fn, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; }; const scrollHandler = debounceFunc(() => handleScroll(scrollTarget, button), 100); scrollTarget._topButtonScrollHandler = scrollHandler; scrollTarget.addEventListener('scroll', scrollHandler, { passive: true, capture: false, }); handleScroll(scrollTarget, button); } } } catch (error) { console.error('[YouTube+][Enhanced] Error in setupScrollListener:', error); } }, 100); }; })(); /** * Creates and appends scroll-to-top button with error handling * @returns {void} */ const createButton = () => { try { setupTopButtonDelegation(); const rightTabs = $('#right-tabs'); if (!rightTabs || byId('right-tabs-top-button')) return; if (!config.enabled) return; const button = document.createElement('button'); button.id = 'right-tabs-top-button'; button.className = 'top-button'; button.title = t('scrollToTop'); button.setAttribute('aria-label', t('scrollToTop')); button.innerHTML = ''; rightTabs.style.position = 'relative'; rightTabs.appendChild(button); setupScrollListener(); } catch (error) { console.error('[YouTube+][Enhanced] Error creating button:', error); } }; /** * Creates universal scroll-to-top button for pages * @returns {void} */ const createUniversalButton = () => { try { setupTopButtonDelegation(); if (byId('universal-top-button')) return; if (!config.enabled) return; const rawContainer = getUniversalScrollContainer(); const scrollContainer = rawContainer === document.scrollingElement || rawContainer === document.documentElement || rawContainer === document.body ? window : rawContainer; universalScrollContainer = scrollContainer; const button = document.createElement('button'); button.id = 'universal-top-button'; button.className = 'top-button'; button.title = t('scrollToTop'); button.setAttribute('aria-label', t('scrollToTop')); button.innerHTML = ''; // Ensure the button is above YouTube Music/Studio overlays const host = window.location.hostname; if (host === 'music.youtube.com' || host === 'studio.youtube.com') { button.style.zIndex = '10000'; } document.body.appendChild(button); // Setup scroll listener for the active container const debounceFunc = typeof YouTubeUtils !== 'undefined' && YouTubeUtils.debounce ? YouTubeUtils.debounce : (fn, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; }; const scrollHandler = debounceFunc(() => { const offset = scrollContainer === window ? window.scrollY : scrollContainer.scrollTop; button.classList.toggle('visible', offset > 100); }, 100); universalScrollHandler = scrollHandler; scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }); const initialOffset = scrollContainer === window ? window.scrollY : scrollContainer.scrollTop; button.classList.toggle('visible', initialOffset > 100); // For YouTube Music/Studio: listen on multiple scroll targets // since the actual scrollable container may differ per page if (host === 'music.youtube.com' || host === 'studio.youtube.com') { // Cache music containers to avoid repeated DOM queries on every scroll event let _musicContainersCache = null; let _musicCacheTime = 0; const getMusicContainers = () => { const now = Date.now(); if (_musicContainersCache && now - _musicCacheTime < 5000) return _musicContainersCache; _musicContainersCache = [ document.querySelector('ytmusic-app-layout #layout'), document.querySelector('ytmusic-app-layout'), document.querySelector('ytmusic-browse-response #contents'), document.querySelector('ytmusic-section-list-renderer'), scrollContainer !== window ? scrollContainer : null, ].filter(Boolean); _musicCacheTime = now; return _musicContainersCache; }; const musicScrollCheck = debounceFunc(() => { let anyScrolled = window.scrollY > 100; if (!anyScrolled) { for (const c of getMusicContainers()) { if (c.scrollTop > 100) { anyScrolled = true; break; } } } button.classList.toggle('visible', anyScrolled); }, 100); // Listen on window + key music containers window.addEventListener('scroll', musicScrollCheck, { passive: true }); universalWindowScrollHandler = musicScrollCheck; // Also attach to known music containers as they become available const attachMusicScrollListeners = () => { const targets = [ document.querySelector('ytmusic-app-layout #layout'), document.querySelector('ytmusic-app-layout'), ]; for (const target of targets) { if (target && !target._ytpScrollAttached) { target._ytpScrollAttached = true; target.addEventListener('scroll', musicScrollCheck, { passive: true }); } } }; attachMusicScrollListeners(); // Re-attach after navigation setTimeout(attachMusicScrollListeners, 1000); setTimeout(attachMusicScrollListeners, 3000); } } catch (error) { console.error('[YouTube+][Enhanced] Error creating universal button:', error); } }; /** * Creates scroll-to-top button for playlist panel * @returns {void} */ const createPlaylistPanelButton = () => { try { setupTopButtonDelegation(); const playlistPanel = $('ytd-playlist-panel-renderer'); if (!playlistPanel || byId('playlist-panel-top-button')) return; if (!config.enabled) return; const button = document.createElement('button'); button.id = 'playlist-panel-top-button'; button.className = 'top-button'; button.title = t('scrollToTop'); button.setAttribute('aria-label', t('scrollToTop')); button.innerHTML = ''; const scrollContainer = $('#items', playlistPanel); if (!scrollContainer) return; // Ensure the playlist panel is positioned so absolute children are anchored inside it playlistPanel.style.position = playlistPanel.style.position || 'relative'; // Force the button to be positioned inside the playlist panel (override global fixed) button.style.position = 'absolute'; button.style.bottom = '16px'; button.style.right = '16px'; button.style.zIndex = '1000'; playlistPanel.appendChild(button); // Setup scroll listener const debounceFunc = typeof YouTubeUtils !== 'undefined' && YouTubeUtils.debounce ? YouTubeUtils.debounce : (fn, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; }; const scrollHandler = debounceFunc(() => handleScroll(scrollContainer, button), 100); scrollContainer._topButtonScrollHandler = scrollHandler; scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }); handleScroll(scrollContainer, button); // Hide the button when the playlist panel is collapsed/hidden. // Use ResizeObserver + MutationObserver to detect layout/attribute changes. const updateVisibility = () => { try { // If panel not connected or explicitly hidden, hide the button if (!playlistPanel.isConnected || playlistPanel.hidden) { button.style.display = 'none'; return; } // Use offsetParent check (cheaper than getComputedStyle) - null means hidden if (playlistPanel.offsetParent === null && playlistPanel.style.position !== 'fixed') { button.style.display = 'none'; return; } // If bounding box is too small (collapsed), hide button const { width, height } = playlistPanel.getBoundingClientRect(); if (width < 40 || height < 40) { button.style.display = 'none'; return; } // If items container cannot scroll or has no height, hide button if ( !scrollContainer || scrollContainer.offsetHeight === 0 || scrollContainer.scrollHeight === 0 ) { button.style.display = 'none'; return; } // Otherwise keep normal display and let handleScroll control visibility class button.style.display = ''; } catch { // On error, prefer hiding to avoid stray UI try { button.style.display = 'none'; } catch {} } }; // Observe size changes let ro = null; try { if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(updateVisibility); ro.observe(playlistPanel); if (scrollContainer) ro.observe(scrollContainer); } } catch { ro = null; } // Observe attribute/class changes const mo = new MutationObserver(updateVisibility); try { mo.observe(playlistPanel, { attributes: true, attributeFilter: ['class', 'style', 'hidden'], }); } catch {} // Initial visibility pass updateVisibility(); // Register cleanup with YouTubeUtils.cleanupManager when available try { if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.register(() => { try { if (ro) ro.disconnect(); } catch {} try { mo.disconnect(); } catch {} }); } } catch {} } catch (error) { console.error('[YouTube+][Enhanced] Error creating playlist panel button:', error); } }; /** * Creates scroll-to-top button for YouTube Music side panel * @returns {void} */ const createMusicSidePanelButton = () => { try { if (window.location.hostname !== 'music.youtube.com') return; setupTopButtonDelegation(); if (byId('music-side-top-button')) return; if (!config.enabled) return; const panel = getMusicSidePanelContainer(); if (!panel) { // Retry after a delay since YouTube Music loads content dynamically setTimeout(() => { if (!byId('music-side-top-button') && config.enabled) { const retryPanel = getMusicSidePanelContainer(); if (retryPanel) createMusicSidePanelButton(); } }, 2000); return; } const button = document.createElement('button'); button.id = 'music-side-top-button'; button.className = 'top-button'; button.title = t('scrollToTop'); button.setAttribute('aria-label', t('scrollToTop')); button.innerHTML = ''; panel.style.position = panel.style.position || 'relative'; button.style.position = 'absolute'; button.style.bottom = '16px'; button.style.right = '16px'; button.style.zIndex = '1000'; panel.appendChild(button); const debounceFunc = typeof YouTubeUtils !== 'undefined' && YouTubeUtils.debounce ? YouTubeUtils.debounce : (fn, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; }; const scrollHandler = debounceFunc(() => { button.classList.toggle('visible', panel.scrollTop > 100); }, 100); musicSideScrollContainer = panel; musicSideScrollHandler = scrollHandler; panel.addEventListener('scroll', scrollHandler, { passive: true }); button.classList.toggle('visible', panel.scrollTop > 100); } catch (error) { console.error('[YouTube+][Enhanced] Error creating music side button:', error); } }; // --- Return YouTube Dislike integration --- const RETURN_DISLIKE_API = 'https://returnyoutubedislikeapi.com/votes'; const DISLIKE_CACHE_TTL = 10 * 60 * 1000; // 10 minutes const dislikeCache = new Map(); // videoId -> { value, expiresAt } let dislikeObserver = null; let dislikePollTimer = null; const formatCompactNumber = number => { try { return new Intl.NumberFormat(_getLanguage() || 'en', { notation: 'compact', compactDisplay: 'short', }).format(Number(number) || 0); } catch { return String(number || 0); } }; const DISLIKE_CACHE_MAX_SIZE = 50; const fetchDislikes = async videoId => { if (!videoId) return 0; const cached = dislikeCache.get(videoId); if (cached && Date.now() < cached.expiresAt) return cached.value; // Evict expired entries if cache grows too large if (dislikeCache.size > DISLIKE_CACHE_MAX_SIZE) { const now = Date.now(); for (const [key, entry] of dislikeCache) { if (now >= entry.expiresAt) dislikeCache.delete(key); } // If still too large, remove oldest entries if (dislikeCache.size > DISLIKE_CACHE_MAX_SIZE) { const iter = dislikeCache.keys(); while (dislikeCache.size > DISLIKE_CACHE_MAX_SIZE / 2) { const next = iter.next(); if (next.done) break; dislikeCache.delete(next.value); } } } // Try GM_xmlhttpRequest first (userscript env). Fallback to fetch with timeout. try { if (typeof GM_xmlhttpRequest !== 'undefined') { const text = await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => reject(new Error('timeout')), 8000); GM_xmlhttpRequest({ method: 'GET', url: `${RETURN_DISLIKE_API}?videoId=${encodeURIComponent(videoId)}`, timeout: 8000, headers: { Accept: 'application/json' }, onload: r => { clearTimeout(timeoutId); if (r.status >= 200 && r.status < 300) resolve(r.responseText); else reject(new Error(`HTTP ${r.status}`)); }, onerror: e => { clearTimeout(timeoutId); reject(e || new Error('network')); }, ontimeout: () => { clearTimeout(timeoutId); reject(new Error('timeout')); }, }); }); const parsed = JSON.parse(text || '{}'); const val = Number(parsed.dislikes || 0) || 0; dislikeCache.set(videoId, { value: val, expiresAt: Date.now() + DISLIKE_CACHE_TTL }); return val; } // fallback to fetch const controller = new AbortController(); const id = setTimeout(() => controller.abort(), 8000); try { const resp = await fetch(`${RETURN_DISLIKE_API}?videoId=${encodeURIComponent(videoId)}`, { method: 'GET', cache: 'no-cache', signal: controller.signal, headers: { Accept: 'application/json' }, }); clearTimeout(id); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const json = await resp.json(); const val = Number(json.dislikes || 0) || 0; dislikeCache.set(videoId, { value: val, expiresAt: Date.now() + DISLIKE_CACHE_TTL }); return val; } finally { clearTimeout(id); } } catch { // on any error, return 0 but don't throw return 0; } }; const getVideoIdForDislike = () => { try { const urlObj = new URL(window.location.href); const pathname = urlObj.pathname || ''; if (pathname.startsWith('/shorts/')) return pathname.slice(8); if (pathname.startsWith('/clip/')) { const meta = $("meta[itemprop='videoId'], meta[itemprop='identifier']"); return meta?.getAttribute('content') || null; } return urlObj.searchParams.get('v'); } catch { return null; } }; const getButtonsContainer = () => { return ( $('ytd-menu-renderer.ytd-watch-metadata > div#top-level-buttons-computed') || $('ytd-menu-renderer.ytd-video-primary-info-renderer > div') || $('#menu-container #top-level-buttons-computed') || null ); }; /** * Get dislike button for Shorts page * @returns {HTMLElement|null} Dislike button element */ const getDislikeButtonShorts = () => { // Try to find the active reel first const activeReel = $('ytd-reel-video-renderer[is-active]'); if (activeReel) { const btn = $('dislike-button-view-model', activeReel) || $('like-button-view-model', activeReel) ?.parentElement?.querySelector('[aria-label*="islike"]') ?.closest('button')?.parentElement || $('#dislike-button', activeReel); if (btn) return btn; } // Fallback: find in the shorts player container const shortsContainer = $('ytd-shorts'); if (shortsContainer) { const btn = $('dislike-button-view-model', shortsContainer) || $('#dislike-button', shortsContainer); if (btn) return btn; } // Last resort: global search return $('dislike-button-view-model') || $('#dislike-button') || null; }; /** * Get dislike button from buttons container * @param {HTMLElement} buttons - Buttons container * @returns {HTMLElement|null} Dislike button element */ const getDislikeButtonFromContainer = buttons => { if (!buttons) return null; // Check for segmented like/dislike button (newer YouTube layout) const segmented = buttons.querySelector('ytd-segmented-like-dislike-button-renderer'); if (segmented) { const dislikeViewModel = segmented.querySelector('dislike-button-view-model') || segmented.querySelector('#segmented-dislike-button') || segmented.children[1]; if (dislikeViewModel) return dislikeViewModel; } // Check for standalone dislike view-model button const viewModel = buttons.querySelector('dislike-button-view-model'); if (viewModel) return viewModel; // Fallback: try to find by button label or position const dislikeBtn = buttons.querySelector('button[aria-label*="islike"]') || buttons.querySelector('button[aria-label*="Не нравится"]'); if (dislikeBtn) { return dislikeBtn.closest('dislike-button-view-model') || dislikeBtn.parentElement; } // Last resort: second child in container return buttons.children && buttons.children[1] ? buttons.children[1] : null; }; const getDislikeButton = () => { // Handle Shorts variants and main page segmented buttons const isShorts = window.location.pathname.startsWith('/shorts'); if (isShorts) { return getDislikeButtonShorts(); } const buttons = getButtonsContainer(); return getDislikeButtonFromContainer(buttons); }; const getOrCreateDislikeText = dislikeButton => { if (!dislikeButton) return null; // Check if our custom text already exists (prevent duplicates) const existingCustom = dislikeButton.querySelector('#ytp-plus-dislike-text'); if (existingCustom) return existingCustom; // Try to find existing text container in various YouTube button structures const textSpan = dislikeButton.querySelector('span.yt-core-attributed-string:not(#ytp-plus-dislike-text)') || dislikeButton.querySelector('#text') || dislikeButton.querySelector('yt-formatted-string') || dislikeButton.querySelector('span[role="text"]:not(#ytp-plus-dislike-text)') || dislikeButton.querySelector('.yt-spec-button-shape-next__button-text-content'); // If native text exists, use it directly to avoid duplication if (textSpan && textSpan.id !== 'ytp-plus-dislike-text') { textSpan.id = 'ytp-plus-dislike-text'; return textSpan; } // For view-model buttons, find the proper container const viewModelHost = dislikeButton.closest('ytDislikeButtonViewModelHost') || dislikeButton; const buttonShape = viewModelHost.querySelector('button-view-model button') || viewModelHost.querySelector('button[aria-label]') || dislikeButton.querySelector('button') || dislikeButton; // Check if text container already exists let textContainer = buttonShape.querySelector( '.yt-spec-button-shape-next__button-text-content' ); // Create a dedicated span with proper styling to match like button // Use min-width to prevent CLS when count loads const created = document.createElement('span'); created.id = 'ytp-plus-dislike-text'; created.setAttribute('role', 'text'); created.className = 'yt-core-attributed-string yt-core-attributed-string--white-space-no-wrap'; const isShorts = window.location.pathname.startsWith('/shorts'); // Added min-width to reserve space and prevent CLS created.style.cssText = isShorts ? 'margin-left: 4px; font-size: 1.2rem; line-height: 1.8rem; font-weight: 500; min-width: 1.5em; display: inline-block; text-align: center;' : 'margin-left: 6px; font-size: 1.4rem; line-height: 2rem; font-weight: 500; min-width: 2em; display: inline-block; text-align: center;'; try { if (!textContainer) { // Create text container if it doesn't exist (matching like button structure) textContainer = document.createElement('div'); textContainer.className = 'yt-spec-button-shape-next__button-text-content'; textContainer.appendChild(created); buttonShape.appendChild(textContainer); } else { textContainer.appendChild(created); } // Ensure button has proper width buttonShape.style.minWidth = 'auto'; buttonShape.style.width = 'auto'; if (viewModelHost !== dislikeButton) { viewModelHost.style.minWidth = 'auto'; } } catch (e) { console.warn('YTP: Failed to create dislike text:', e); } return created; }; const setDislikeDisplay = (dislikeButton, count) => { try { const container = getOrCreateDislikeText(dislikeButton); if (!container) return; const formatted = formatCompactNumber(count); if (container.innerText !== String(formatted)) { container.innerText = String(formatted); // Ensure the text is visible and properly styled container.style.display = 'inline-block'; container.style.visibility = 'visible'; container.style.opacity = '1'; // Make sure parent button container is wide enough const buttonShape = container.closest('button') || dislikeButton.querySelector('button'); if (buttonShape) { buttonShape.style.minWidth = 'fit-content'; buttonShape.style.width = 'auto'; } } } catch (e) { console.warn('YTP: Failed to set dislike display:', e); } }; const setupDislikeObserver = dislikeButton => { if (!dislikeButton) return; if (dislikeObserver) { dislikeObserver.disconnect(); dislikeObserver = null; } // Don't observe if we already have text displayed const existingText = dislikeButton.querySelector('#ytp-plus-dislike-text'); if (existingText?.textContent && existingText.textContent !== '0') { return; } dislikeObserver = new MutationObserver(() => { // on any mutation, update displayed cached value const vid = getVideoIdForDislike(); const cached = dislikeCache.get(vid); if (cached) { const btn = getDislikeButton(); if (btn) setDislikeDisplay(btn, cached.value); } }); try { dislikeObserver.observe(dislikeButton, { childList: true, subtree: true, attributes: true }); } catch {} }; const initReturnDislike = async () => { try { // avoid multiple polls if (dislikePollTimer) return; // Use MutationObserver instead of setInterval for better performance const checkButton = async () => { const btn = getDislikeButton(); if (btn) { if (dislikePollTimer) { dislikePollTimer.disconnect(); dislikePollTimer = null; } const vid = getVideoIdForDislike(); const val = await fetchDislikes(vid); setDislikeDisplay(btn, val); setupDislikeObserver(btn); return true; } return false; }; // Check immediately if (await checkButton()) return; // Set up observer for button appearance - use targeted childList only (no subtree) const isShorts = window.location.pathname.startsWith('/shorts'); const maxTime = 10000; // 10 seconds timeout const startTime = Date.now(); dislikePollTimer = new MutationObserver(async () => { if (Date.now() - startTime > maxTime) { dislikePollTimer.disconnect(); dislikePollTimer = null; return; } await checkButton(); }); // Observe more targeted containers to reduce mutation callbacks const targetEl = isShorts ? $('#shorts-container') : $('ytd-watch-flexy #below'); if (targetEl) { dislikePollTimer.observe(targetEl, { childList: true, subtree: true }); } else { // Fallback: use a short interval instead of expensive body observer const pollId = setInterval(async () => { if (Date.now() - startTime > maxTime) { clearInterval(pollId); return; } if (await checkButton()) clearInterval(pollId); }, 500); // Register so the global cleanup manager can stop it during navigation teardown window.YouTubeUtils?.cleanupManager?.registerInterval?.(pollId); } } catch { // ignore } }; const cleanupReturnDislike = () => { try { if (dislikePollTimer) { if (typeof dislikePollTimer.disconnect === 'function') { dislikePollTimer.disconnect(); } else if (typeof dislikePollTimer === 'number') { clearInterval(dislikePollTimer); } dislikePollTimer = null; } if (dislikeObserver) { dislikeObserver.disconnect(); dislikeObserver = null; } // Remove all created dislike text spans $$('#ytp-plus-dislike-text').forEach(el => { try { if (el.parentNode) el.parentNode.removeChild(el); } catch {} }); // Clear cache to free memory dislikeCache.clear(); } catch (e) { console.warn('YTP: Dislike cleanup error:', e); } }; /** * Observes DOM changes to detect tab switches * @returns {MutationObserver|null} The created observer or null on error */ const observeTabChanges = () => { try { const observer = new MutationObserver(mutations => { try { if ( mutations.some( m => m.type === 'attributes' && m.attributeName === 'class' && m.target instanceof Element && m.target.classList.contains('tab-content-cld') ) ) { setTimeout(setupScrollListener, 100); } } catch (error) { console.error('[YouTube+][Enhanced] Error in mutation observer:', error); } }); const rightTabs = $('#right-tabs'); if (rightTabs) { observer.observe(rightTabs, { attributes: true, subtree: true, attributeFilter: ['class'], }); return observer; } return null; } catch (error) { console.error('[YouTube+][Enhanced] Error in observeTabChanges:', error); return null; } }; /** * Check if current page needs universal button * @returns {boolean} */ const needsUniversalButton = () => { const host = window.location.hostname; // Always show on Music and Studio if (host === 'music.youtube.com' || host === 'studio.youtube.com') return true; if (isWatchPage() || isShortsPage()) return false; const path = window.location.pathname; const { search } = window.location; // Search results page if (path === '/results' && search.includes('search_query=')) return true; // Playlist page if (path === '/playlist' && search.includes('list=')) return true; // Home/Feed pages if (path === '/' || path === '/feed/subscriptions') return true; return true; }; /** * Handles click events on tab buttons * @param {Event} e - Click event * @returns {void} */ const handleTabButtonClick = e => { try { const { target } = /** @type {{ target: HTMLElement }} */ (e); const tabButton = target?.closest?.('.tab-btn[tyt-tab-content]'); if (tabButton) { setTimeout(setupScrollListener, 100); } } catch (error) { console.error('[YouTube+][Enhanced] Error in click handler:', error); } }; /** * Sets up event listeners for tab button clicks * @returns {void} */ const setupEvents = () => { try { if (isTabClickListenerAttached) return; const delegator = window.YouTubePlusEventDelegation; if (delegator?.on) { tabDelegationHandler = (ev, target) => { void ev; if (!target) return; setTimeout(setupScrollListener, 100); }; delegator.on(document, 'click', '.tab-btn[tyt-tab-content]', tabDelegationHandler, { capture: true, }); tabDelegationRegistered = true; } else { document.addEventListener('click', handleTabButtonClick, true); } isTabClickListenerAttached = true; } catch (error) { console.error('[YouTube+][Enhanced] Error in setupEvents:', error); } }; const cleanupEvents = () => { try { if (!isTabClickListenerAttached) return; const delegator = window.YouTubePlusEventDelegation; if (tabDelegationRegistered && delegator?.off && tabDelegationHandler) { delegator.off(document, 'click', '.tab-btn[tyt-tab-content]', tabDelegationHandler); } else { document.removeEventListener('click', handleTabButtonClick, true); } tabDelegationHandler = null; tabDelegationRegistered = false; isTabClickListenerAttached = false; } catch (error) { console.error('[YouTube+][Enhanced] Error cleaning up events:', error); } }; const stopWatchEnhancements = () => { watchInitToken++; tabCheckTimeoutId = clearTimeoutSafe(tabCheckTimeoutId); playlistPanelCheckTimeoutId = clearTimeoutSafe(playlistPanelCheckTimeoutId); try { tabChangesObserver?.disconnect?.(); } catch {} tabChangesObserver = null; cleanupEvents(); try { cleanupReturnDislike(); } catch {} }; const startWatchEnhancements = () => { if (!config.enabled) return; if (!isWatchPage()) return; const token = ++watchInitToken; setupEvents(); const maxTabAttempts = 40; const checkForTabs = (attempt = 0) => { if (token !== watchInitToken) return; if (!isWatchPage()) return; if ($('#right-tabs')) { createButton(); try { tabChangesObserver?.disconnect?.(); } catch {} tabChangesObserver = observeTabChanges(); return; } if (attempt >= maxTabAttempts) return; tabCheckTimeoutId = setTimeout(() => checkForTabs(attempt + 1), 250); }; const maxPlaylistPanelAttempts = 30; const checkForPlaylistPanel = (attempt = 0) => { if (token !== watchInitToken) return; if (!isWatchPage()) return; try { const playlistPanel = $('ytd-playlist-panel-renderer'); if (playlistPanel && !byId('playlist-panel-top-button')) { createPlaylistPanelButton(); return; } } catch (error) { console.error('[YouTube+][Enhanced] Error checking for playlist panel:', error); } if (attempt >= maxPlaylistPanelAttempts) return; playlistPanelCheckTimeoutId = setTimeout(() => checkForPlaylistPanel(attempt + 1), 300); }; checkForTabs(); checkForPlaylistPanel(); }; /** * Initialize scroll-to-top button module * @returns {void} */ const init = () => { try { addStyles(); const checkPageType = () => { try { if (needsUniversalButton() && !byId('universal-top-button')) { createUniversalButton(); } if (window.location.hostname === 'music.youtube.com' && !byId('music-side-top-button')) { createMusicSidePanelButton(); } } catch (error) { console.error('[YouTube+][Enhanced] Error checking page type:', error); } }; const onNavigate = () => { stopWatchEnhancements(); checkPageType(); if (shouldInitReturnDislike()) { try { initReturnDislike(); } catch (e) { console.warn('[YouTube+] initReturnDislike error:', e); } } // Watch-specific UI only initializes on /watch startWatchEnhancements(); }; // Initial run onNavigate(); // Listen for navigation changes (YouTube is SPA) if (window.YouTubeUtils?.cleanupManager?.registerListener) { YouTubeUtils.cleanupManager.registerListener( document, 'yt-navigate-finish', () => setTimeout(onNavigate, 200), { passive: true } ); } else { window.addEventListener('yt-navigate-finish', () => { setTimeout(onNavigate, 200); }); } // For YouTube Music: also listen on popstate and observe #side-panel appearance if (window.location.hostname === 'music.youtube.com') { window.addEventListener('popstate', () => setTimeout(onNavigate, 200)); // Observe DOM for side-panel becoming scrollable const sidePanelObserver = new MutationObserver(() => { if (!byId('music-side-top-button') && config.enabled) { createMusicSidePanelButton(); } }); const observeTarget = $('ytmusic-app-layout') || $('ytmusic-app') || document.body; if (observeTarget) { sidePanelObserver.observe(observeTarget, { childList: true, subtree: true, }); } } } catch (error) { console.error('[YouTube+][Enhanced] Error in initialization:', error); } }; const scheduleInit = () => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(init, { timeout: 4000 }); } else { setTimeout(init, 0); } }; window.addEventListener('youtube-plus-settings-updated', e => { try { const nextEnabled = e?.detail?.enableScrollToTopButton !== false; if (nextEnabled === config.enabled) return; config.enabled = nextEnabled; if (!config.enabled) { cleanupTopButtons(); stopWatchEnhancements(); return; } addStyles(); if (needsUniversalButton() && !byId('universal-top-button')) { createUniversalButton(); } if (window.location.hostname === 'music.youtube.com' && !byId('music-side-top-button')) { createMusicSidePanelButton(); } startWatchEnhancements(); } catch {} }); onDomReady(scheduleInit); })(); // Styles (function () { try { const host = typeof location === 'undefined' ? '' : location.hostname; if (!host) return; if (!/(^|\.)youtube\.com$/.test(host) && !/\.youtube\.google/.test(host)) return; const SETTINGS_KEY = 'youtube_plus_settings'; const STYLE_ELEMENT_ID = 'ytp-zen-features-style'; const NON_CRITICAL_STYLE_ID = 'ytp-zen-features-style-noncritical'; const STYLE_MANAGER_KEY = 'zen-features-style'; let nonCriticalTimer = null; const DEFAULTS = { enableZenStyles: true, // legacy (kept for backward compat) hideSideGuide: false, zenStyles: { thumbnailHover: true, immersiveSearch: true, hideVoiceSearch: true, transparentHeader: true, hideSideGuide: true, cleanSideGuide: false, fixFeedLayout: true, betterCaptions: true, playerBlur: true, theaterEnhancements: true, misc: true, }, }; const loadSettings = () => { /** @type {any} */ let parsed = null; try { const raw = localStorage.getItem(SETTINGS_KEY); if (raw) parsed = JSON.parse(raw); } catch (e) { console.warn('[YouTube+] Zen settings parse error:', e); } const merged = { ...DEFAULTS, ...(parsed && typeof parsed === 'object' ? parsed : null), }; merged.zenStyles = { ...DEFAULTS.zenStyles, ...(merged.zenStyles && typeof merged.zenStyles === 'object' ? merged.zenStyles : null), }; // Backward compat: if legacy hideSideGuide is set, also enable the style flag. if (merged.hideSideGuide === true && merged.zenStyles.hideSideGuide !== true) { merged.zenStyles.hideSideGuide = true; } return merged; }; const CSS_BLOCKS = { thumbnailHover: ` /* yt-thumbnail hover */ #inline-preview-player {transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) 1s !important; transform: scale(1) !important;} #video-preview-container:has(#inline-preview-player) {transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; border-radius: 1.2em !important; overflow: hidden !important; transform: scale(1) !important;} #video-preview-container:has(#inline-preview-player):hover {transform: scale(1.25) !important; box-shadow: #0008 0px 0px 60px !important; transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) 2s !important;} ytd-app #content {opacity: 1 !important; transition: opacity 0.3s ease-in-out !important;} ytd-app:has(#video-preview-container:hover) #content {opacity: 0.5 !important; transition: opacity 4s ease-in-out 1s !important;} `, immersiveSearch: ` /* yt-Immersive search */ #page-manager, yt-searchbox {transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.35) !important;} #masthead yt-searchbox button[aria-label="Search"] {display: none !important;} .ytSearchboxComponentInputBox {border-radius: 2em !important;} yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) {position: relative !important; left: 0vw !important; top: -30vh !important; height: 40px !important; max-width: 600px !important; transform: scale(1) !important;} @media only screen and (min-width: 1400px) {yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) { height: 60px !important; max-width: 700px !important; transform: scale(1.1) !important;}} yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) .ytSearchboxComponentInputBox, yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) #i0 {background-color: #fffb !important; box-shadow: black 0 0 30px !important;} @media (prefers-color-scheme: dark) { yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) .ytSearchboxComponentInputBox, yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) #i0 {background-color: #000b !important;} } yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) #i0 {margin-top: 10px !important;} @media only screen and (min-width: 1400px) {yt-searchbox:has(.ytSearchboxComponentInputBoxHasFocus) #i0 {margin-top: 30px !important;}} .ytd-masthead #center:has(.ytSearchboxComponentInputBoxHasFocus) {height: 100vh !important; width: 100vw !important; left: 0 !important; top: 0 !important; position: fixed !important; justify-content: center !important; align-items: center !important;} #content:has(.ytSearchboxComponentInputBoxHasFocus) #page-manager {filter: blur(20px) !important; transform: scale(1.05) !important;} `, hideVoiceSearch: ` /* No voice search button */ #voice-search-button {display: none !important;} `, transparentHeader: ` /* Transparent header */ #masthead-container, #background.ytd-masthead { background-color: transparent !important; } `, hideSideGuide: ` /* Hide side guide */ ytd-mini-guide-renderer, [theater=""] #contentContainer::after {display: none !important;} tp-yt-app-drawer > #contentContainer:not([opened=""]), #contentContainer:not([opened=""]) #guide-content, ytd-mini-guide-renderer, ytd-mini-guide-entry-renderer {background-color: var(--yt-spec-text-primary-inverse) !important; background: var(--yt-spec-text-primary-inverse) !important;} #content:not(:has(#contentContainer[opened=""])) #page-manager {margin-left: 0 !important;} ytd-app:not([guide-persistent-and-visible=""]) tp-yt-app-drawer > #contentContainer {background-color: var(--yt-spec-text-primary-inverse) !important;} ytd-alert-with-button-renderer {align-items: center !important; justify-content: center !important;} `, cleanSideGuide: ` /* Clean side guide */ ytd-guide-section-renderer:has([title="YouTube Premium"]), ytd-guide-renderer #footer {display: none !important;} ytd-guide-section-renderer, ytd-guide-collapsible-section-entry-renderer {border: none !important;} `, fixFeedLayout: ` /* Fix new feed layout */ ytd-rich-item-renderer[rendered-from-rich-grid] { @media only screen and (min-width: 1400px) { --ytd-rich-grid-items-per-row: 4 !important; @media only screen and (min-width: 1700px) { --ytd-rich-grid-items-per-row: 5 !important; @media only screen and (min-width: 2180px) {--ytd-rich-grid-items-per-row: 6 !important;}}}} ytd-rich-item-renderer[is-in-first-column="\"] { margin-left: calc(var(--ytd-rich-grid-item-margin) / 2) !important;}#contents { padding-left: calc(var(--ytd-rich-grid-item-margin) / 2 + var(--ytd-rich-grid-gutter-margin)) !important;} `, betterCaptions: ` /* Better captions */ .caption-window { backdrop-filter: blur(10px) brightness(70%) !important; border-radius: 1em !important; padding: 1em !important; box-shadow: #0008 0 0 20px !important; width: fit-content !important; } .ytp-caption-segment { background: none !important; } `, playerBlur: ` /* Player controls blur */ .ytp-left-controls .ytp-play-button, .ytp-left-controls .ytp-volume-area, .ytp-left-controls .ytp-time-display.notranslate > span, .ytp-left-controls .ytp-chapter-container > button, .ytp-left-controls .ytp-prev-button, .ytp-left-controls .ytp-next-button, .ytp-right-controls, .ytp-time-wrapper, .ytPlayerQuickActionButtonsHost, .ytPlayerQuickActionButtonsHostCompactControls, .ytPlayerQuickActionButtonsHostDisableBackdropFilter { backdrop-filter: blur(5px) !important; background-color: #0004 !important; } .ytp-popup { backdrop-filter: blur(10px) !important; background-color: #0007 !important; } `, theaterEnhancements: ` /* Zen view comments (from zeninternet) */ /* Hide secondary column visually but break containment so fixed children can escape */ ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #columns #secondary { display: block !important;width: 0 !important;min-width: 0 !important;max-width: 0 !important;padding: 0 !important;margin: 0 !important;border: 0 !important;overflow: visible !important;pointer-events: none !important;flex: 0 0 0px !important;contain: none !important; } ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #secondary-inner { overflow: visible !important;contain: none !important;position: static !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #secondary-inner secondary-wrapper, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #secondary-inner .tabview-secondary-wrapper { contain: none !important;overflow: visible !important;position: static !important;max-height: none !important;height: auto !important;padding: 0 !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #right-tabs { display: block !important;overflow: visible !important;contain: none !important;position: static !important;width: 0 !important;height: 0 !important;padding: 0 !important;margin: 0 !important;border: 0 !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #right-tabs > header { display: none !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #right-tabs .tab-content { display: block !important;overflow: visible !important;contain: none !important;position: static !important;width: 0 !important;height: 0 !important;padding: 0 !important;margin: 0 !important;border: 0 !important;} /* Break containment on tab-comments so its fixed-position child can escape */ /* Extra .tab-content-hidden selector to beat main.js specificity (line 5169) */ ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments.tab-content-hidden, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments.tab-content-cld { contain: none !important;overflow: visible !important;position: static !important;display: block !important;visibility: visible !important;width: 0 !important;height: 0 !important;padding: 0 !important;margin: 0 !important;z-index: auto !important;pointer-events: none !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments.tab-content-hidden ytd-comments#comments > ytd-item-section-renderer#sections, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments.tab-content-hidden ytd-comments#comments > ytd-item-section-renderer#sections > #contents, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments.tab-content-hidden ytd-comments#comments #contents { contain: none !important;width: auto !important;height: auto !important;max-height: none !important;overflow: visible !important;visibility: visible !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-comments.tab-content-hidden ytd-comments#comments #contents > * { display: block !important;} /* Hide other tabs content */ ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-info, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-videos, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #tab-list { display: none !important;} /* Comments overlay panel */ ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) ytd-comments { visibility: visible !important;display: block !important;background-color: var(--yt-live-chat-shimmer-background-color) !important;backdrop-filter: blur(20px) !important;padding: 0 2em !important;border-radius: 2em 0 0 2em !important;max-height: calc(100vh - 120px) !important;overflow-y: auto !important;position: fixed !important;z-index: 2000 !important;top: 3vh !important;right: -42em !important;width: 40em !important;height: 90vh !important;opacity: 0 !important;pointer-events: auto !important;transition: opacity 0.4s ease, right 0.4s ease !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) ytd-comments:hover { opacity: 1 !important;right: 0 !important;} /* Transparent overlay chat — fixed panel */ ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) [tyt-chat-container], ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat-container { contain: none !important;overflow: visible !important;position: static !important;display: block !important;pointer-events: none !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat { visibility: visible !important;display: block !important;position: fixed !important;top: 3vh !important;right: 0 !important;width: 400px !important;height: calc(100vh - 120px) !important;max-height: calc(100vh - 120px) !important;z-index: 2001 !important;opacity: 0.85 !important;pointer-events: auto !important;border-radius: 2em 0 0 2em !important;overflow: hidden !important;backdrop-filter: blur(20px) !important;transition: opacity 0.4s ease !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat[collapsed] { visibility: visible !important;display: block !important;position: fixed !important;top: 3vh !important;right: 0 !important;width: 400px !important;height: calc(100vh - 120px) !important;max-height: calc(100vh - 120px) !important;z-index: 2001 !important;opacity: 0.85 !important;pointer-events: auto !important;overflow: hidden !important;border-radius: 2em 0 0 2em !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat[collapsed] > #show-hide-button, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat[collapsed] > .ytd-live-chat-frame#show-hide-button { display: none !important;visibility: hidden !important;opacity: 0 !important;pointer-events: none !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat[collapsed] iframe { display: block !important;visibility: visible !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #chat iframe { height: 100% !important;width: 100% !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) yt-live-chat-renderer { background: transparent !important;} /* Ambient mode: fix black bars in theater */ ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #cinematics-container, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #cinematics { position: absolute !important;top: 0 !important;left: 0 !important;width: 100% !important;height: 100% !important;overflow: hidden !important;pointer-events: none !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #cinematics canvas, ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #cinematics video { position: absolute !important;top: 50% !important;left: 50% !important;transform: translate(-50%, -50%) scale(1.2) !important;min-width: 100% !important;min-height: 100% !important;object-fit: cover !important;} ytd-watch-flexy:is([theater],[full-bleed-player]):not([fullscreen]) #player-full-bleed-container { overflow: hidden !important;} ytd-watch-flexy[fullscreen] ytd-live-chat-frame { background-color: var(--app-drawer-content-container-background-color) !important;} `, misc: ` /* Compact feed – reduced spacing, hover menus, inline details */ ytd-rich-item-renderer { margin-bottom: 15px !important;} ytd-rich-item-renderer[rendered-from-rich-grid] { --ytd-rich-item-row-usable-width: calc(100% - var(--ytd-rich-grid-gutter-margin) * 1) !important;} ytd-rich-item-renderer #metadata.ytd-video-meta-block { flex-direction: row !important;} ytd-rich-item-renderer #metadata.ytd-video-meta-block #metadata-line span:nth-child(3) { height: 1em !important;margin-left: 1em !important;} ytd-rich-grid-media { border-radius: 1.2em;height: 100% !important;} ytd-rich-grid-media ytd-menu-renderer #button { opacity: 0 !important;transition: opacity 0.3s ease-in-out !important;} ytd-rich-grid-media:hover ytd-menu-renderer #button { opacity: 1 !important;} /* Show video meta on hover */ #content #dismissible:hover ytd-video-meta-block { opacity: 1 !important;} #frosted-glass { display: none !important;} `, // CLS Prevention styles - always loaded to reserve space for dynamic elements clsPrevention: ` /* CLS Prevention - Reserve space for dynamic elements */ #ytp-plus-dislike-text { min-width: 1.5em;display: inline-block !important;} /* Contain layout only for our own panels (not YouTube layout elements) */ .ytp-plus-stats-panel, .ytp-plus-modal-content { contain: layout style;} /* Prevent layout shifts from search box animations */ yt-searchbox { will-change: transform;} /* Reduce CLS from late-loading channel avatars */ #owner #avatar { min-width: 40px; min-height: 40px; } /* Reserve space for action buttons to prevent shift */ ytd-menu-renderer.ytd-watch-metadata { min-height: 36px; } /* Subscribe button stability */ ytd-subscribe-button-renderer { min-width: 90px; } `, }; const buildCriticalCss = settings => { const z = settings?.zenStyles || {}; let css = CSS_BLOCKS.clsPrevention; // Always include CLS prevention if (z.hideSideGuide) css += CSS_BLOCKS.hideSideGuide; if (z.fixFeedLayout) css += CSS_BLOCKS.fixFeedLayout; // theaterEnhancements in critical so overlay CSS applies immediately on DOMContentLoaded // (previously non-critical, could take up to 5s to appear on theater mode switch) if (z.theaterEnhancements) css += CSS_BLOCKS.theaterEnhancements; return css.trim(); }; const buildNonCriticalCss = settings => { const z = settings?.zenStyles || {}; let css = ''; if (z.thumbnailHover) css += CSS_BLOCKS.thumbnailHover; if (z.immersiveSearch) css += CSS_BLOCKS.immersiveSearch; if (z.hideVoiceSearch) css += CSS_BLOCKS.hideVoiceSearch; if (z.transparentHeader) css += CSS_BLOCKS.transparentHeader; if (z.cleanSideGuide) css += CSS_BLOCKS.cleanSideGuide; if (z.betterCaptions) css += CSS_BLOCKS.betterCaptions; if (z.playerBlur) css += CSS_BLOCKS.playerBlur; if (z.misc) css += CSS_BLOCKS.misc; return css.trim(); }; const removeStyles = () => { try { if (window.YouTubeUtils?.StyleManager?.remove) { window.YouTubeUtils.StyleManager.remove(STYLE_MANAGER_KEY); } } catch {} if (nonCriticalTimer) { if (typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') { try { window.cancelIdleCallback(nonCriticalTimer); } catch {} } else { clearTimeout(nonCriticalTimer); } nonCriticalTimer = null; } const el = document.getElementById(STYLE_ELEMENT_ID); if (el) { try { el.remove(); } catch {} } const ncEl = document.getElementById(NON_CRITICAL_STYLE_ID); if (ncEl) { try { ncEl.remove(); } catch {} } }; const applyNonCriticalStyles = css => { if (!css) { const ncEl = document.getElementById(NON_CRITICAL_STYLE_ID); if (ncEl) ncEl.remove(); return; } let ncEl = document.getElementById(NON_CRITICAL_STYLE_ID); if (!ncEl) { ncEl = document.createElement('style'); ncEl.id = NON_CRITICAL_STYLE_ID; (document.head || document.documentElement).appendChild(ncEl); } ncEl.textContent = css; }; const applyStyles = settings => { const enabled = settings?.enableZenStyles !== false; if (!enabled) { removeStyles(); return; } const criticalCss = buildCriticalCss(settings); const nonCriticalCss = buildNonCriticalCss(settings); if (!criticalCss && !nonCriticalCss) { removeStyles(); return; } try { if (window.YouTubeUtils?.StyleManager?.add) { window.YouTubeUtils.StyleManager.add(STYLE_MANAGER_KEY, criticalCss || ''); // Ensure legacy `; // Append into centralized notification container (created if missing) const _containerId = 'youtube-enhancer-notification-container'; let _container = document.getElementById(_containerId); if (!_container) { _container = document.createElement('div'); _container.id = _containerId; _container.className = 'youtube-enhancer-notification-container'; try { document.body.appendChild(_container); } catch { document.body.appendChild(notification); } } try { _container.insertBefore(notification, _container.firstChild); } catch { document.body.appendChild(notification); } const removeNotification = () => { // use explicit slide-out animation so it exits downward like the entry notification.style.animation = 'slideOutToBottom 0.35s ease-in forwards'; setTimeout(() => notification.remove(), 360); }; // Event handlers const installBtn = notification.querySelector('#update-install-btn'); if (installBtn) { installBtn.addEventListener('click', () => { const success = installUpdate(updateDetails); if (success) { removeNotification(); setTimeout(() => utils.showNotification(t('installing')), 500); } else { utils.showNotification(t('manualInstallHint'), 'error', 5000); window.open('https://greasyfork.org/en/scripts/537017-youtube', '_blank'); } }); } const dismissBtn = notification.querySelector('#update-dismiss-btn'); if (dismissBtn) { dismissBtn.addEventListener('click', () => { if (updateDetails?.version) { sessionStorage.setItem('update_dismissed', updateDetails.version); } removeNotification(); }); } const closeBtn = notification.querySelector('#update-close-btn'); if (closeBtn) { closeBtn.addEventListener('click', () => { if (updateDetails?.version) { sessionStorage.setItem('update_dismissed', updateDetails.version); } removeNotification(); }); } // Auto-dismiss setTimeout(() => { if (notification.isConnected) removeNotification(); }, UPDATE_CONFIG.notificationDuration); }; /** * Validate update URL * @param {string} url - URL to validate * @throws {Error} If URL is invalid */ const validateUpdateUrl = url => { const parsedUrl = new URL(url); if (parsedUrl.protocol !== 'https:') { throw new Error('Update URL must use HTTPS'); } if (!parsedUrl.hostname.includes('greasyfork.org')) { throw new Error('Update URL must be from greasyfork.org'); } }; /** * Fetch update metadata with timeout protection. Accepts a URL so callers can * request alternate endpoints (for example the .user.js auto-install URL) as a * fallback when the primary metadata does not include a usable version. * @param {string} [url=UPDATE_CONFIG.updateUrl] - URL to fetch metadata from * @returns {Promise} Metadata text */ const fetchUpdateMetadata = async (url = UPDATE_CONFIG.updateUrl) => { // Use GM_xmlhttpRequest if available to avoid CORS issues. const fetchMeta = async requestUrl => { if (typeof GM_xmlhttpRequest !== 'undefined') { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => reject(new Error('Update check timeout')), 10000); GM_xmlhttpRequest({ method: 'GET', url: requestUrl, timeout: 10000, headers: { Accept: 'text/plain', 'User-Agent': 'YouTube+ UpdateChecker' }, onload: response => { clearTimeout(timeoutId); if (response.status >= 200 && response.status < 300) resolve(response.responseText); else reject(new Error(`HTTP ${response.status}: ${response.statusText}`)); }, onerror: e => { clearTimeout(timeoutId); reject(new Error(`Network error: ${e}`)); }, ontimeout: () => { clearTimeout(timeoutId); reject(new Error('Update check timeout')); }, }); }); } // Fallback to fetch with AbortController timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(requestUrl, { method: 'GET', cache: 'no-cache', signal: controller.signal, headers: { Accept: 'text/plain', 'User-Agent': 'YouTube+ UpdateChecker' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); return await res.text(); } finally { clearTimeout(timeoutId); } }; return fetchMeta(url); }; /** * Handle update availability check results * @param {Object} updateDetails - Update details object * @param {boolean} force - Whether check was forced */ const handleUpdateResult = (updateDetails, force) => { const shouldShowNotification = updateState.updateAvailable && (force || sessionStorage.getItem('update_dismissed') !== updateDetails.version); if (shouldShowNotification) { showUpdateNotification(updateDetails); window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug(`YouTube + Update available: ${updateDetails.version}`); return; } if (force) { const message = updateState.updateAvailable ? t('updateAvailableMsg').replace('{version}', updateDetails.version) : t('upToDateMsg').replace('{version}', UPDATE_CONFIG.currentVersion); utils.showNotification(message); } }; /** * Determine if error is transient and retryable * @param {Error} error - Error object * @returns {boolean} True if error is transient */ const isTransientError = error => { return ( error.name === 'AbortError' || error.name === 'NetworkError' || (error.message && error.message.includes('fetch')) || (error.message && error.message.includes('network')) ); }; /** * Fetch changelog for a specific version from GreasyFork * @param {string} version - Version to fetch changelog for * @returns {Promise} Changelog text */ const fetchChangelog = async version => { try { const lang = getLanguage(); const url = `https://greasyfork.org/${lang}/scripts/537017-youtube/versions`; const fetchPage = async requestUrl => { if (typeof GM_xmlhttpRequest !== 'undefined') { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => reject(new Error('Changelog fetch timeout')), 10000); GM_xmlhttpRequest({ method: 'GET', url: requestUrl, timeout: 10000, headers: { Accept: 'text/html' }, onload: response => { clearTimeout(timeoutId); if (response.status >= 200 && response.status < 300) resolve(response.responseText); else reject(new Error(`HTTP ${response.status}`)); }, onerror: _e => { clearTimeout(timeoutId); reject(new Error('Network error')); }, ontimeout: () => { clearTimeout(timeoutId); reject(new Error('Timeout')); }, }); }); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(requestUrl, { method: 'GET', cache: 'no-cache', signal: controller.signal, headers: { Accept: 'text/html' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); } finally { clearTimeout(timeoutId); } }; const html = await fetchPage(url); // Parse changelog from HTML // Look for version link followed by changelog span // Structure: v2.4.4 ... ... const escapedVersion = version.replace(/\./g, '\\.'); // Match anchor tag content that contains the version number (handling prefixes like 'v', 'вер. ', etc.) const versionRegex = new RegExp( `>[^<]*?${escapedVersion}[\\s\\S]*?class="version-changelog"[^>]*>([\\s\\S]*?)`, 'i' ); const match = html.match(versionRegex); if (match && match[1]) { let changelog = match[1].trim(); // Convert HTML breaks/paragraphs to newlines and strip tags changelog = changelog .replace(//gi, '\n') .replace(/<\/p>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); // Clean up whitespace changelog = changelog .split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .join('\n'); return changelog || ''; } return ''; } catch (error) { console.warn('[YouTube+][Update] Failed to fetch changelog:', error.message); return ''; } }; /** * Retrieve update details trying primary metadata endpoint first and * falling back to the auto-install .user.js URL when necessary. * @returns {Promise} Parsed updateDetails object */ const retrieveUpdateDetails = async () => { // Attempt primary metadata fetch let metaText = await fetchUpdateMetadata(UPDATE_CONFIG.updateUrl); let details = utils.parseMetadata(metaText); if (!details.version) { try { const fallbackText = await fetchUpdateMetadata(UPDATE_CONFIG.autoInstallUrl); const fallbackDetails = utils.parseMetadata(fallbackText); if (fallbackDetails.version) { details = fallbackDetails; metaText = fallbackText; } } catch (fallbackErr) { if (typeof console !== 'undefined' && console.warn) { console.warn('[YouTube+][Update] Fallback metadata fetch failed:', fallbackErr.message); } } } // Fetch changelog from GreasyFork versions page and store separately if (details.version) { try { const changelog = await fetchChangelog(details.version); // Keep original metadata description but expose fetched changelog on a separate property details.changelog = typeof changelog === 'string' && changelog.length > 0 ? changelog : ''; } catch (changelogErr) { console.warn('[YouTube+][Update] Failed to fetch changelog:', changelogErr.message); details.changelog = ''; } } else { details.changelog = ''; } return details; }; /** * Check for updates with URL validation, timeout protection, and retry logic * @param {boolean} force - Force update check even if recently checked * @param {number} retryCount - Current retry attempt (for internal use) * @returns {Promise} */ /** * Check if update check should proceed * @param {boolean} force - Force update check * @returns {boolean} True if should proceed */ const shouldCheckForUpdates = (force, now) => { if (!UPDATE_CONFIG.enabled || updateState.checkInProgress) { return false; } return force || now - updateState.lastCheck >= UPDATE_CONFIG.checkInterval; }; /** * Validate update configuration * @returns {boolean} True if valid * @throws {Error} If configuration is invalid */ const validateUpdateConfiguration = () => { try { validateUpdateUrl(UPDATE_CONFIG.updateUrl); return true; } catch (urlError) { console.error('[YouTube+][Update]', 'Invalid update URL configuration:', urlError); throw urlError; } }; /** * Process successful update details * @param {Object} updateDetails - Update details * @param {boolean} force - Force flag * @param {number} now - Current timestamp * @returns {void} */ const processUpdateDetails = (updateDetails, force, now) => { updateState.lastCheck = now; updateState.lastVersion = updateDetails.version; updateState.updateDetails = updateDetails; const comparison = utils.compareVersions(UPDATE_CONFIG.currentVersion, updateDetails.version); updateState.updateAvailable = comparison < 0; handleUpdateResult(updateDetails, force); utils.saveSettings(); // Auto-install if configured and update wasn't dismissed if (updateState.updateAvailable && UPDATE_CONFIG.autoInstallOnCheck) { try { const dismissed = sessionStorage.getItem('update_dismissed'); if (dismissed !== updateDetails.version) { const started = installUpdate(updateDetails); if (started) { // Persist that we've acted on this update so we don't keep reopening it markUpdateDismissed(updateDetails); try { utils.showNotification(t('installing')); } catch {} } else { console.warn( '[YouTube+][Update] Auto-install could not be initiated for', updateDetails.downloadUrl ); } } } catch (e) { console.error('[YouTube+][Update] Auto-installation failed:', e); } } }; /** * Handle missing update information * @param {boolean} force - Force flag * @returns {void} */ const handleMissingUpdateInfo = force => { updateState.updateAvailable = false; if (force) { utils.showNotification( t('updateCheckFailed').replace('{msg}', t('noUpdateInfo')), 'error', 4000 ); } }; /** * Handle retry logic for update check * @param {Error} error - Error object * @param {boolean} force - Force flag * @param {number} retryCount - Current retry count * @returns {Promise} */ const handleUpdateRetry = async (error, force, retryCount) => { const MAX_RETRIES = 2; const RETRY_DELAY = 2000; if (isTransientError(error) && retryCount < MAX_RETRIES) { console.warn( `[YouTube+][Update] Retry ${retryCount + 1}/${MAX_RETRIES} after error:`, error.message ); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * Math.pow(2, retryCount))); return checkForUpdates(force, retryCount + 1); } console.error('[YouTube+][Update] Check failed after retries:', error); if (force) { utils.showNotification(t('updateCheckFailed').replace('{msg}', error.message), 'error', 4000); } }; /** * Check for available updates * @param {boolean} force - Force update check * @param {number} retryCount - Retry count * @returns {Promise} */ const checkForUpdates = async (force = false, retryCount = 0) => { const now = Date.now(); if (!shouldCheckForUpdates(force, now)) { return; } updateState.checkInProgress = true; try { validateUpdateConfiguration(); const updateDetails = await retrieveUpdateDetails(); if (updateDetails.version) { processUpdateDetails(updateDetails, force, now); } else { handleMissingUpdateInfo(force); } } catch (error) { await handleUpdateRetry(error, force, retryCount); } finally { updateState.checkInProgress = false; } }; // Optimized settings UI const addUpdateSettings = () => { const aboutSection = YouTubeUtils.querySelector( '.ytp-plus-settings-section[data-section="about"]' ); if (!aboutSection || YouTubeUtils.querySelector('.update-settings-container')) return; const updateContainer = document.createElement('div'); updateContainer.className = 'update-settings-container'; updateContainer.style.cssText = ` padding: 16px; margin-top: 20px; border-radius: 12px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); -webkit-backdrop-filter: blur(10px) saturate(120%); backdrop-filter: blur(10px) saturate(120%); box-shadow: 0 6px 20px rgba(6, 10, 20, 0.45); `; const lastCheckTime = utils.formatTimeAgo(updateState.lastCheck); updateContainer.innerHTML = `

${t('enhancedExperience')}

${t('currentVersion')} ${UPDATE_CONFIG.currentVersion}
${t('lastChecked')}: ${lastCheckTime} ${ updateState.lastVersion && updateState.lastVersion !== UPDATE_CONFIG.currentVersion ? `
${t('latestAvailable')}: ${updateState.lastVersion}` : '' }
${ updateState.updateAvailable ? `
${t('updateAvailable')}
` : `
${t('upToDate')}
` }
`; aboutSection.appendChild(updateContainer); // Event listeners with optimization const attachClickHandler = (id, handler) => { const element = document.getElementById(id); if (element) YouTubeUtils.cleanupManager.registerListener(element, 'click', handler); }; // Destructure event parameter to prefer destructuring attachClickHandler('manual-update-check', async ({ target }) => { const button = /** @type {HTMLElement} */ (target); const originalHTML = button.innerHTML; button.innerHTML = ` ${t('checkingForUpdates')} `; button.disabled = true; await checkForUpdates(true); setTimeout(() => { button.innerHTML = originalHTML; button.disabled = false; }, 1000); }); attachClickHandler('install-update-btn', () => { const success = installUpdate(); if (success) { utils.showNotification(t('installing')); } else { utils.showNotification(t('manualInstallHint'), 'error', 5000); window.open('https://greasyfork.org/en/scripts/537017-youtube', '_blank'); } }); attachClickHandler('open-update-page', () => { utils.showNotification(t('updatePageFallback')); window.open('https://greasyfork.org/en/scripts/537017-youtube', '_blank'); }); }; // Optimized initialization /** * Setup initial and periodic update checks * @returns {void} */ const setupUpdateChecks = () => { // Initial check with delay setTimeout(() => checkForUpdates(), 3000); // Periodic checks - register interval in cleanupManager const intervalId = setInterval(() => checkForUpdates(), UPDATE_CONFIG.checkInterval); YouTubeUtils.cleanupManager.registerInterval(intervalId); window.addEventListener('beforeunload', () => clearInterval(intervalId)); }; /** * Setup settings modal event listener * @returns {void} */ const setupSettingsObserver = () => { document.addEventListener('youtube-plus-settings-modal-opened', () => { setTimeout(addUpdateSettings, 100); }); }; /** * Setup click handler for about section * @returns {void} */ const setupAboutClickHandler = () => { const clickHandler = ({ target }) => { const el = /** @type {HTMLElement} */ (target); if (el.classList?.contains('ytp-plus-settings-nav-item') && el.dataset?.section === 'about') { setTimeout(addUpdateSettings, 50); } }; YouTubeUtils.cleanupManager.registerListener(document, 'click', clickHandler, { passive: true, capture: true, }); }; /** * Log initialization status * @returns {void} */ const logInitialization = () => { try { if (window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug) { YouTubeUtils.logger.debug('YouTube + Update Checker initialized', { version: UPDATE_CONFIG.currentVersion, enabled: UPDATE_CONFIG.enabled, lastCheck: new Date(updateState.lastCheck).toLocaleString(), updateAvailable: updateState.updateAvailable, }); } } catch {} }; /** * Initialize update checker * @returns {void} */ const init = () => { utils.loadSettings(); setupUpdateChecks(); setupSettingsObserver(); setupAboutClickHandler(); logInitialization(); }; // Start if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); // --- MODULE: music.js --- /** * YouTube Music Enhancement Module * Provides UI improvements and features for YouTube Music * @module music * @version 2.3 * * Features: * - Scroll-to-top button with smart container detection * - Enhanced navigation styles (centered search, immersive mode) * - Sidebar hover effects and player enhancements * - Health monitoring and automatic recovery * - SPA navigation support with debounced updates */ /* global GM_addStyle, GM_getValue, GM_addValueChangeListener */ (function () { 'use strict'; if (typeof location !== 'undefined' && location.hostname !== 'music.youtube.com') { return; } // DOM cache helper with fallback const qs = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.get === 'function') { return window.YouTubeDOMCache.get(selector); } return document.querySelector(selector); }; /** * Read YouTube Music settings from localStorage with defaults. * Kept in sync with defaults in settings UI. */ const MUSIC_SETTINGS_DEFAULTS = { enableMusic: true, immersiveSearchStyles: true, hoverStyles: true, playerSidebarStyles: true, centeredPlayerStyles: true, playerBarStyles: true, centeredPlayerBarStyles: true, miniPlayerStyles: true, }; function mergeMusicSettings(parsed) { const merged = { ...MUSIC_SETTINGS_DEFAULTS }; if (!parsed || typeof parsed !== 'object') return merged; if (typeof parsed.enableMusic === 'boolean') merged.enableMusic = parsed.enableMusic; for (const key of Object.keys(MUSIC_SETTINGS_DEFAULTS)) { if (key === 'enableMusic') continue; if (typeof parsed[key] === 'boolean') merged[key] = parsed[key]; } // Legacy flags mapping if (typeof parsed.enableImmersiveSearch === 'boolean') { merged.immersiveSearchStyles = parsed.enableImmersiveSearch; } if (typeof parsed.enableSidebarHover === 'boolean') { merged.hoverStyles = parsed.enableSidebarHover; } if (typeof parsed.enableCenteredPlayer === 'boolean') { merged.centeredPlayerStyles = parsed.enableCenteredPlayer; } // Backward-compat: if legacy flags exist and enableMusic wasn't set, infer enableMusic const legacyEnabled = !!( parsed.enableMusicStyles || parsed.enableMusicEnhancements || parsed.enableImmersiveSearch || parsed.enableSidebarHover || parsed.enableCenteredPlayer ); if (legacyEnabled && typeof parsed.enableMusic !== 'boolean') merged.enableMusic = true; return merged; } function readMusicSettings() { // Prefer userscript-global storage so youtube.com and music.youtube.com share the setting. try { if (typeof GM_getValue !== 'undefined') { const stored = GM_getValue('youtube-plus-music-settings', null); if (typeof stored === 'string' && stored) { const parsed = JSON.parse(stored); return mergeMusicSettings(parsed); } } } catch { // fall back to localStorage } try { const stored = localStorage.getItem('youtube-plus-music-settings'); if (!stored) return { ...MUSIC_SETTINGS_DEFAULTS }; const parsed = JSON.parse(stored); return mergeMusicSettings(parsed); } catch { return { ...MUSIC_SETTINGS_DEFAULTS }; } } function isMusicModuleEnabled(settings) { return !!(settings && settings.enableMusic); } // Scroll-to-top is now handled globally by enhanced.js // This function is kept for backward compatibility but always returns false function isScrollToTopEnabled() { return false; } /** * Mutable settings snapshot for live-apply. * @type {ReturnType} */ let musicSettingsSnapshot = readMusicSettings(); /** @type {HTMLStyleElement|null} */ let musicStyleEl = null; /** @type {MutationObserver|null} */ let observer = null; /** @type {number|null} */ let healthCheckIntervalId = null; /** @type {(() => void)|null} */ let detachNavigationListeners = null; /** * Enhanced styles for YouTube Music interface * Includes: navigation cleanup, immersive search, sidebar effects, centered player, etc. * @type {string} * @const */ const enhancedStyles = ` /* Remove borders and shadows from nav/guide when bauhaus sidenav is enabled */ ytmusic-app-layout[is-bauhaus-sidenav-enabled] #nav-bar-background.ytmusic-app-layout { border-bottom: none !important; box-shadow: none !important; } ytmusic-app-layout[is-bauhaus-sidenav-enabled] #nav-bar-divider.ytmusic-app-layout { border-top: none !important; } ytmusic-app-layout[is-bauhaus-sidenav-enabled] #mini-guide-background.ytmusic-app-layout { border-right: 0 !important; } ytmusic-nav-bar, ytmusic-app-layout[is-bauhaus-sidenav-enabled] .ytmusic-nav-bar { border: none !important; box-shadow: none !important; } /* Center the settings button in the top nav bar (fixes it being rendered at the bottom) */ ytmusic-settings-button.style-scope.ytmusic-nav-bar, ytmusic-nav-bar ytmusic-settings-button.style-scope.ytmusic-nav-bar {position: absolute !important; left: 50% !important; top: 50% !important; transform: translate(-50%, -50%) !important; bottom: auto !important; margin: 0 !important; z-index: 1000 !important;} /* Center the search box in the top nav bar */ ytmusic-search-box, ytmusic-nav-bar ytmusic-search-box, ytmusic-searchbox, ytmusic-nav-bar ytmusic-searchbox {position: absolute !important; left: 50% !important; top: 50% !important; transform: translate(-50%, -50%) !important; margin: 0 !important; max-width: 75% !important; width: auto !important; z-index: 900 !important;} `; const immersiveSearchStyles = ` /* yt-Immersive search behaviour for YouTube Music: expand/center the search when focused */ ytmusic-search-box:has(input:focus), ytmusic-searchbox:has(input:focus), ytmusic-search-box:focus-within, ytmusic-searchbox:focus-within {position: fixed !important; left: 50% !important; top: 12vh !important; transform: translateX(-50%) !important; height: auto !important; max-width: 900px !important; width: min(90vw, 900px) !important; z-index: 1200 !important; display: block !important;} @media only screen and (min-width: 1400px) {ytmusic-search-box:has(input:focus), ytmusic-searchbox:has(input:focus) {top: 10vh !important; max-width: 1000px !important; transform: translateX(-50%) scale(1.05) !important;}} /* Highlight the input and add a soft glow */ ytmusic-search-box:has(input:focus) input, ytmusic-searchbox:has(input:focus) input, ytmusic-search-box:focus-within input, ytmusic-searchbox:focus-within input {background-color: #fffb !important; box-shadow: black 0 0 30px !important;} @media (prefers-color-scheme: dark) {ytmusic-search-box:has(input:focus) input, ytmusic-searchbox:has(input:focus) input {background-color: #000b !important;}} /* Blur/scale the main content when immersive search is active */ ytmusic-app-layout:has(ytmusic-search-box:has(input:focus)) #main-panel, ytmusic-app-layout:has(ytmusic-searchbox:has(input:focus)) #main-panel {filter: blur(18px) !important; transform: scale(1.03) !important;} `; // Ховер эффекты для боковой панели const hoverStyles = ` .ytmusic-guide-renderer {opacity: 0.01 !important; transition: opacity 0.5s ease-in-out !important;} .ytmusic-guide-renderer:hover { opacity: 1 !important;} ytmusic-app[is-bauhaus-sidenav-enabled] #guide-wrapper.ytmusic-app {background-color: transparent !important; border: none !important;} `; // Боковая панель плеера const playerSidebarStyles = ` #side-panel {width: 40em !important; height: 80vh !important; padding: 0 2em !important; right: -30em !important; top: 10vh !important; opacity: 0 !important; position: absolute !important; transition: all 0.3s ease-in-out !important; backdrop-filter: blur(5px) !important; background-color: #0005 !important; border-radius: 1em !important; box-shadow: rgba(0, 0, 0, 0.15) 0px -36px 30px inset, rgba(0, 0, 0, 0.1) 0px -79px 40px inset, rgba(0, 0, 0, 0.06) 0px 2px 1px, rgba(0, 0, 0, 0.09) 0px 4px 2px, rgba(0, 0, 0, 0.09) 0px 8px 4px, rgba(0, 0, 0, 0.09) 0px 16px 8px, rgba(0, 0, 0, 0.09) 0px 32px 16px !important;} #side-panel tp-yt-paper-tabs {transition: height 0.3s ease-in-out !important; height: 0 !important;} #side-panel:hover {right: 0 !important; opacity: 1 !important;} #side-panel:hover tp-yt-paper-tabs {height: 4em !important;} #side-panel:has(ytmusic-tab-renderer[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"]):not(:has(ytmusic-message-renderer:not([style="display: none;"]))) {right: 0 !important; opacity: 1 !important;} #side-panel {min-width: auto !important;} /* Allow JS to control visibility; ensure pointer-events and positioning only. */ #side-panel .ytmusic-top-button { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; } /* When button is placed inside the panel, prefer absolute positioning inside it so it won't be forced to fixed by the global rule. Use high specificity + !important */ #side-panel .ytmusic-top-button {position: absolute !important; bottom: 20px !important; right: 20px !important; z-index: 1200 !important;} `; // Центрированный плеер const centeredPlayerStyles = ` ytmusic-app-layout:not([player-ui-state="FULLSCREEN"]) #main-panel {position: absolute !important; height: 70vh !important; max-width: 70vw !important; aspect-ratio: 1 !important; top: 50vh !important; left: 50vw !important; transform: translate(-50%, -50%) !important;} #player-page {padding: 0 !important; margin: 0 !important; left: 0 !important; top: 0 !important; height: 100% !important; width: 100% !important;} `; // Стилизация плеер бара (центрированная версия) const playerBarStyles = ` ytmusic-player-bar, #player-bar-background {margin: 1vw !important; width: 98vw !important; border-radius: 1em !important; overflow: hidden !important; transition: all 0.5s ease-in-out !important; background-color: #0002 !important; box-shadow: rgba(0, 0, 0, 0.15) 0px -36px 30px inset, rgba(0, 0, 0, 0.1) 0px -79px 40px inset, rgba(0, 0, 0, 0.06) 0px 2px 1px, rgba(0, 0, 0, 0.09) 0px 4px 2px, rgba(0, 0, 0, 0.09) 0px 8px 4px, rgba(0, 0, 0, 0.09) 0px 16px 8px, rgba(0, 0, 0, 0.09) 0px 32px 16px !important;} #layout:not([player-ui-state="PLAYER_PAGE_OPEN"]) #player-bar-background {background-color: #0005 !important;} `; // Центрирование плеер бара const centeredPlayerBarStyles = ` #left-controls {position: absolute !important; left: 49vw !important; bottom: 15px !important; transform: translateX(-50%) !important; width: fit-content !important; order: 1 !important;} .time-info {position: absolute !important; bottom: -10px !important; left: 0 !important; width: 100% !important; text-align: center !important; padding: 0 !important; margin: 0 !important;} .middle-controls {position: absolute !important; left: 1vw !important; bottom: 15px !important; max-width: 30vw !important; order: 0 !important;} `; // Настройки мини-плеера const miniPlayerStyles = ` #main-panel:has(ytmusic-player[player-ui-state="MINIPLAYER"]) {position: fixed !important; width: 100vw !important; height: 100vh !important; top: -100vh !important; left: 0 !important; margin: 0 !important; padding: 0 !important; transform: none !important; max-width: 100vw !important;} ytmusic-player[player-ui-state="MINIPLAYER"] {position: fixed !important; bottom: calc(100vh + 120px) !important; right: 30px !important; width: 350px !important; height: fit-content !important;} #av-id:has(ytmusic-av-toggle) {position: absolute !important; left: 50% !important; transform: translateX(-50%) !important; top: -4em !important; opacity: 0 !important; transition: all 0.3s ease-in-out !important;} #av-id:has(ytmusic-av-toggle):hover {opacity: 1 !important;} #player[player-ui-state="MINIPLAYER"] {display: none !important;} /* Chrome-specific robustness: ensure the AV toggle container is above overlays and can receive hover even if :has() behaves differently. Also provide a non-:has fallback so the element is hoverable regardless of child matching. */ /* Use absolute positioning (keeps internal menu alignment) but promote stacking and rendering to ensure it sits above overlays and receives clicks. */ #av-id {position: absolute !important; left: 50% !important; transform: translateX(-50%) translateZ(0) !important; top: -4em !important; z-index: 10000 !important; pointer-events: auto !important; display: block !important; visibility: visible !important; width: auto !important; height: auto !important; will-change: transform, opacity !important;} #av-id ytmusic-av-toggle {pointer-events: auto !important;} #av-id:hover {opacity: 1 !important;} /* Prevent overlapping overlays from stealing clicks when hovering the toggle. This is a conservative rule; if a specific overlay still steals clicks we can target it explicitly later. */ #av-id:hover, #av-id:active { filter: none !important; } `; // Scroll-to-top styles removed - now handled by enhanced.js universal button /** * Applies all enhanced styles to YouTube Music interface * Only applies styles when on music.youtube.com domain * @function applyStyles * @returns {void} */ function applyStyles() { if (window.location.hostname !== 'music.youtube.com') return; const s = musicSettingsSnapshot || readMusicSettings(); if (!s.enableMusic) return; const styleParts = [enhancedStyles]; if (s.immersiveSearchStyles) styleParts.push(immersiveSearchStyles); if (s.hoverStyles) styleParts.push(hoverStyles); if (s.playerSidebarStyles) styleParts.push(playerSidebarStyles); if (s.centeredPlayerStyles) styleParts.push(centeredPlayerStyles); if (s.playerBarStyles) styleParts.push(playerBarStyles); if (s.centeredPlayerBarStyles) styleParts.push(centeredPlayerBarStyles); if (s.miniPlayerStyles) styleParts.push(miniPlayerStyles); const allStyles = `\n${styleParts.join('\n')}\n`; // Reuse single managed `) ); const getVideoId = url => { try { return new URLSearchParams(new URL(url).search).get('v'); } catch { return null; } }; const queryHTMLElement = selector => { const el = $(selector); return el instanceof HTMLElement ? el : null; }; /** * @typedef {HTMLDivElement & { * getProgressState: () => { current: number, duration: number, number: number }, * pauseVideo: () => void, * seekTo: (seconds: number, allowSeekAhead?: boolean) => void, * isLifaAdPlaying: () => boolean * }} PlayerElement */ /** * @return {{ getProgressState: () => { current: number, duration, number }, pauseVideo: () => void, seekTo: (number) => void, isLifaAdPlaying: () => boolean }} player */ const getPlayer = () => /** @type {PlayerElement | null} */ ($('#movie_player')); const isAdPlaying = () => !!$('.ad-interrupting'); const redirect = (v, list, ytpRandom = null) => { if (location.host === 'm.youtube.com') { // Mobile: use direct navigation const url = `/watch?v=${v}&list=${list}${ytpRandom !== null ? `&ytp-random=${ytpRandom}` : ''}`; window.location.href = url; } else { // Desktop: try YouTube's client-side routing first, with fallback try { const playlistPanel = $('ytd-playlist-panel-renderer #items'); if (playlistPanel) { const redirector = document.createElement('a'); redirector.className = 'yt-simple-endpoint style-scope ytd-playlist-panel-video-renderer'; redirector.setAttribute('hidden', ''); redirector.data = { commandMetadata: { webCommandMetadata: { url: `/watch?v=${v}&list=${list}${ytpRandom !== null ? `&ytp-random=${ytpRandom}` : ''}`, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832, // ??? required though }, }, watchEndpoint: { videoId: v, playlistId: list, }, }; playlistPanel.append(redirector); redirector.click(); } else { // Fallback: use direct navigation if playlist panel not found const url = `/watch?v=${v}&list=${list}${ytpRandom !== null ? `&ytp-random=${ytpRandom}` : ''}`; window.location.href = url; } } catch { // Fallback: use direct navigation on error const url = `/watch?v=${v}&list=${list}${ytpRandom !== null ? `&ytp-random=${ytpRandom}` : ''}`; window.location.href = url; } } }; let id = ''; const apply = (retryCount = 0) => { if (id === '') { // do not apply prematurely, caused by mutation observer console.warn('[Play All] Channel ID not yet determined'); return; } let parent = null; if (location.host === 'm.youtube.com') { parent = queryHTMLElement( 'ytm-feed-filter-chip-bar-renderer .chip-bar-contents, ytm-feed-filter-chip-bar-renderer > div' ); } else { // Use document.querySelector directly to bypass the DOM cache, which can // return a stale null when the chip bar renders after the first apply() call. // Use chip-bar-view-model.ytChipBarViewModelHost as primary (new 2026 UI), // matching the reference script at greasyfork.org/ru/scripts/490557. const desktopParentSelectors = [ 'chip-bar-view-model.ytChipBarViewModelHost', 'ytd-feed-filter-chip-bar-renderer iron-selector#chips', 'ytd-feed-filter-chip-bar-renderer #chips-wrapper', 'yt-chip-cloud-renderer #chips', 'yt-chip-cloud-renderer .yt-chip-cloud-renderer', ]; for (const selector of desktopParentSelectors) { const candidate = document.querySelector(selector); if (candidate instanceof HTMLElement) { parent = candidate; break; } } } // #5: add a custom container for buttons if chip bar not found if (parent === null) { const grid = queryHTMLElement( 'ytd-rich-grid-renderer, ytm-rich-grid-renderer, div.ytChipBarViewModelChipWrapper' ); if (!grid) { // Grid not yet rendered — retry (handles SPA navigation timing) if (retryCount < 12) { setTimeout(() => apply(retryCount + 1), 300); } return; } // Also search inside the grid for chip bar in case it is a child const chipBarInGrid = grid.querySelector( 'chip-bar-view-model.ytChipBarViewModelHost, ytd-feed-filter-chip-bar-renderer iron-selector#chips, ytd-feed-filter-chip-bar-renderer #chips-wrapper, yt-chip-cloud-renderer #chips' ); if (chipBarInGrid instanceof HTMLElement) { parent = chipBarInGrid; } else if (retryCount < 8) { // Chip bar not rendered yet — wait and retry (up to ~2.4s total) setTimeout(() => apply(retryCount + 1), 300); return; } else { // Last resort: insert a wrapper at the top of the grid let existingContainer = grid.querySelector('.ytp-button-container'); if (!existingContainer) { grid.insertAdjacentHTML('afterbegin', '
'); existingContainer = grid.querySelector('.ytp-button-container'); } parent = existingContainer instanceof HTMLElement ? existingContainer : null; } } if (!parent) { console.warn('[Play All] Could not find parent container'); return; } // Prevent duplicate buttons if (parent.querySelector('.ytp-play-all-btn')) { try { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[Play All] Buttons already exist, skipping'); } catch {} return; } // See: available-lists.md const [allPlaylist] = window.location.pathname.endsWith('/videos') ? // Normal videos // list=UU adds shorts into the playlist, list=UULF has videos without shorts ['UULF'] : // Shorts window.location.pathname.endsWith('/shorts') ? ['UUSH'] : // Live streams ['UULV']; const playlistSuffix = id.startsWith('UC') ? id.substring(2) : id; // Insert button directly into the container (chip bar or fallback wrapper) parent.insertAdjacentHTML( 'beforeend', `${getPlayAllLabel()}` ); const navigate = href => { window.location.assign(href); }; if (location.host === 'm.youtube.com') { // Use event delegation for mobile buttons if (!parent.hasAttribute('data-ytp-delegated')) { parent.setAttribute('data-ytp-delegated', 'true'); parent.addEventListener('click', event => { const btn = event.target.closest('.ytp-btn'); if (btn && btn.href) { event.preventDefault(); navigate(btn.href); } }); } } else { // Use event delegation for desktop buttons if (!parent.hasAttribute('data-ytp-delegated')) { parent.setAttribute('data-ytp-delegated', 'true'); parent.addEventListener('click', event => { const btn = event.target.closest('.ytp-play-all-btn'); if (btn && btn.href) { event.preventDefault(); event.stopPropagation(); navigate(btn.href); } }); } } }; let observerFrame = 0; const runObserverWork = () => { observerFrame = 0; if (!featureEnabled) return; removeButton(); apply(); }; const observer = new MutationObserver(() => { if (!featureEnabled) return; if (observerFrame) return; if (typeof requestAnimationFrame === 'function') { observerFrame = requestAnimationFrame(runObserverWork); return; } observerFrame = setTimeout(runObserverWork, 16); }); const addButton = async () => { observer.disconnect(); if (!featureEnabled) return; if ( !( window.location.pathname.endsWith('/videos') || window.location.pathname.endsWith('/shorts') || window.location.pathname.endsWith('/streams') ) ) { return; } // Regenerate button if switched between Latest and Popular. // Observe the grid (attribute changes when chip selection changes) and // also observe chip-bar-view-model directly for the new 2026 UI. const observeTarget = document.querySelector('ytd-rich-grid-renderer') || document.querySelector('chip-bar-view-model.ytChipBarViewModelHost') || $( 'ytm-feed-filter-chip-bar-renderer .iron-selected, ytm-feed-filter-chip-bar-renderer .chip-bar-contents .selected' ); if (observeTarget) { observer.observe(observeTarget, { attributes: true, childList: false, subtree: false, }); } // This check is necessary for the mobile Interval if ($('.ytp-play-all-btn')) { return; } // Try to extract channel ID from canonical link first try { const canonical = $('link[rel="canonical"]'); if (canonical && canonical.href) { const match = canonical.href.match(/\/channel\/(UC[a-zA-Z0-9_-]{22})/); if (match && match[1]) { id = match[1]; apply(); return; } // Also try @handle format const handleMatch = canonical.href.match(/\/@([^\/]+)/); if (handleMatch) { // Try to get channel ID from page data const pageData = $('ytd-browse[page-subtype="channels"]'); if (pageData) { const channelId = pageData.getAttribute('channel-id'); if (channelId && channelId.startsWith('UC')) { id = channelId; apply(); return; } } } } } catch (e) { console.warn('[Play All] Error extracting channel ID from canonical:', e); } // Fallback: fetch HTML and parse try { const html = await (await fetch(location.href)).text(); const canonicalMatch = html.match( / { if (addButtonRetryTimer) clearTimeout(addButtonRetryTimer); addButtonRetryTimer = null; addButtonRetryAttempts = 0; }; const queueDesktopAddButton = (reset = true) => { if (location.host === 'm.youtube.com') { addButton(); return; } if (reset) { stopAddButtonRetries(); } const run = () => { if (!featureEnabled) { stopAddButtonRetries(); return; } if ( !( window.location.pathname.endsWith('/videos') || window.location.pathname.endsWith('/shorts') || window.location.pathname.endsWith('/streams') ) ) { stopAddButtonRetries(); return; } addButton(); if (document.querySelector('.ytp-play-all-btn')) { stopAddButtonRetries(); return; } if (addButtonRetryAttempts >= 14) { stopAddButtonRetries(); return; } addButtonRetryAttempts += 1; addButtonRetryTimer = setTimeout(run, 350); }; run(); }; // Removing the button prevents it from still existing when switching between "Videos", "Shorts", and "Live" // This is necessary due to the mobile Interval requiring a check for an already existing button const removeButton = () => { $$('.ytp-play-all-btn, .ytp-random-badge, .ytp-random-notice').forEach(element => element.remove() ); }; if (location.host === 'm.youtube.com') { // The "yt-navigate-finish" event does not fire on mobile // Detect URL changes via pushState/replaceState override + popstate (lightweight) let lastUrl = location.href; const checkUrlChange = () => { if (location.href !== lastUrl) { lastUrl = location.href; addButton(); } }; // Use centralized pushState/replaceState event from utils.js window.addEventListener('ytp-history-navigate', () => setTimeout(checkUrlChange, 50), { passive: true, }); window.addEventListener('popstate', checkUrlChange, { passive: true }); // Initial call addButton(); } else { window.addEventListener('yt-navigate-start', () => { stopAddButtonRetries(); removeButton(); }); window.addEventListener('yt-navigate-finish', () => setTimeout(() => queueDesktopAddButton(), 120) ); window.addEventListener('pageshow', () => setTimeout(() => queueDesktopAddButton(), 120)); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { queueDesktopAddButton(); } }); // Also attempt to add buttons on initial script run in case the SPA navigation event // already happened before this script was loaded (some browsers/firefox timing). try { setTimeout(() => queueDesktopAddButton(), 300); } catch {} } window.addEventListener('youtube-plus-settings-updated', e => { try { const nextEnabled = e?.detail?.enablePlayAll !== false; if (nextEnabled === featureEnabled) return; setFeatureEnabled(nextEnabled); } catch { setFeatureEnabled(loadFeatureEnabled()); } }); // Random play feature (() => { // Random play is not supported for mobile devices if (location.host === 'm.youtube.com') { return; } const getParams = () => new URLSearchParams(window.location.search); /** @returns {{ params: URLSearchParams, mode: 'random', list: string, storageKey: string } | null} */ const getRandomConfig = () => { const params = getParams(); const modeParam = params.get('ytp-random'); if (!modeParam || modeParam === '0') return null; const list = params.get('list') || ''; if (!list) return null; return { params, mode: 'random', list, storageKey: `ytp-random-${list}` }; }; const getStorage = storageKey => { try { return JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch { return {}; } }; const isWatched = (storageKey, videoId) => getStorage(storageKey)[videoId] || false; const markWatched = (storageKey, videoId) => { localStorage.setItem( storageKey, JSON.stringify({ ...getStorage(storageKey), [videoId]: true }) ); document .querySelectorAll('#wc-endpoint[href*=zsA3X40nz9w]') .forEach(element => element.parentElement.setAttribute('hidden', '')); }; const playNextRandom = (cfg, reload = false) => { const playerInstance = getPlayer(); if (playerInstance && typeof playerInstance.pauseVideo === 'function') { playerInstance.pauseVideo(); } const videos = Object.entries(getStorage(cfg.storageKey)).filter(([_, watched]) => !watched); const params = new URLSearchParams(window.location.search); if (videos.length === 0) { return; } let videoIndex = Math.floor(Math.random() * videos.length); // Safety clamp in case of unexpected edge cases if (videoIndex < 0) videoIndex = 0; if (videoIndex >= videos.length) videoIndex = videos.length - 1; if (reload) { params.set('v', videos[videoIndex][0]); params.set('ytp-random', cfg.mode); params.delete('t'); params.delete('index'); params.delete('ytp-random-initial'); window.location.href = `${window.location.pathname}?${params.toString()}`; } else { // Use the redirect() function for consistent navigation try { redirect(videos[videoIndex][0], params.get('list'), cfg.mode); } catch (error) { console.error( '[Play All] Error using redirect(), falling back to manual redirect:', error ); // Fallback to manual redirect if the redirect() function fails const redirector = document.createElement('a'); redirector.className = 'yt-simple-endpoint style-scope ytd-playlist-panel-video-renderer'; redirector.setAttribute('hidden', ''); redirector.data = { commandMetadata: { webCommandMetadata: { url: `/watch?v=${videos[videoIndex][0]}&list=${params.get('list')}&ytp-random=${cfg.mode}`, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832, }, }, watchEndpoint: { videoId: videos[videoIndex][0], playlistId: params.get('list'), }, }; const listContainer = $('ytd-playlist-panel-renderer #items'); if (listContainer instanceof HTMLElement) { listContainer.append(redirector); } else { document.body.appendChild(redirector); } redirector.click(); } } }; let applyRetryTimeoutId = null; let progressIntervalId = null; stopRandomPlayTimers = () => { if (applyRetryTimeoutId) clearTimeout(applyRetryTimeoutId); applyRetryTimeoutId = null; // progressIntervalId is now a boolean or event listener, not a timer if (progressIntervalId && typeof progressIntervalId !== 'boolean') { clearInterval(progressIntervalId); } progressIntervalId = null; }; const applyRandomPlay = cfg => { if (!featureEnabled) return; if (!window.location.pathname.endsWith('/watch')) return; const playlistContainer = $('#secondary ytd-playlist-panel-renderer'); if (playlistContainer === null) { return; } if (playlistContainer.hasAttribute('ytp-random')) { return; } playlistContainer.setAttribute('ytp-random', 'applied'); const headerContainer = playlistContainer.querySelector('#header'); if (headerContainer && !headerContainer.querySelector('.ytp-random-notice')) { headerContainer.insertAdjacentHTML( 'beforeend', `Play All mode` ); } const storage = getStorage(cfg.storageKey); // Robustly collect playlist anchors - different YT layouts use different selectors const anchorSelectors = [ '#wc-endpoint', 'ytd-playlist-panel-video-renderer a#wc-endpoint', 'ytd-playlist-panel-video-renderer a', 'a#video-title', '#secondary ytd-playlist-panel-renderer a[href*="/watch?"]', ]; const anchors = []; anchorSelectors.forEach(sel => { playlistContainer.querySelectorAll(sel).forEach(a => { if (a instanceof Element && a.tagName === 'A') anchors.push(/** @type {any} */ (a)); }); }); // Deduplicate by href const uniq = []; const seen = new Set(); anchors.forEach(a => { const href = a.href || a.getAttribute('href') || ''; if (!seen.has(href)) { seen.add(href); uniq.push(a); } }); const navigate = href => (window.location.href = href); // Mark videos and prepare links uniq.forEach(element => { let videoId = null; try { videoId = new URL(element.href, window.location.origin).searchParams.get('v'); } catch { videoId = new URLSearchParams(element.search || '').get('v'); } if (!videoId) return; if (!isWatched(cfg.storageKey, videoId)) { storage[videoId] = false; } // Ensure ytp-random param present try { const u = new URL(element.href, window.location.origin); u.searchParams.set('ytp-random', cfg.mode); element.href = u.toString(); } catch {} element.setAttribute('data-ytp-random-link', 'true'); const entryKey = getVideoId(element.href); if (isWatched(cfg.storageKey, entryKey)) { element.parentElement?.setAttribute('hidden', ''); } }); // Use event delegation for video links if (playlistContainer && !playlistContainer.hasAttribute('data-ytp-random-delegated')) { playlistContainer.setAttribute('data-ytp-random-delegated', 'true'); playlistContainer.addEventListener('click', event => { const link = event.target.closest('a[data-ytp-random-link]'); if (link && link.href) { event.preventDefault(); navigate(link.href); } }); } localStorage.setItem(cfg.storageKey, JSON.stringify(storage)); if ( cfg.params.get('ytp-random-initial') === '1' || isWatched(cfg.storageKey, getVideoId(location.href)) ) { playNextRandom(cfg); return; } const header = playlistContainer.querySelector('h3 a'); if (header && header.tagName === 'A') { const anchorHeader = /** @type {HTMLAnchorElement} */ (/** @type {unknown} */ (header)); anchorHeader.insertAdjacentHTML( 'beforeend', ` Play All ×` ); anchorHeader.href = '#'; const badge = anchorHeader.querySelector('.ytp-random-badge'); if (badge) { badge.addEventListener('click', event => { event.preventDefault(); localStorage.removeItem(cfg.storageKey); const params = new URLSearchParams(location.search); params.delete('ytp-random'); window.location.href = `${window.location.pathname}?${params.toString()}`; }); } } document.addEventListener( 'keydown', event => { // SHIFT + N if (event.shiftKey && event.key.toLowerCase() === 'n') { event.stopImmediatePropagation(); event.preventDefault(); const videoId = getVideoId(location.href); markWatched(cfg.storageKey, videoId); // Unfortunately there is no workaround to YouTube redirecting to the next in line without a reload playNextRandom(cfg, true); } }, true ); if (progressIntervalId) return; // Use video timeupdate event instead of setInterval for better performance const videoEl = $('video'); if (!videoEl) return; const handleProgress = () => { const videoId = getVideoId(location.href); const params = new URLSearchParams(location.search); params.set('ytp-random', cfg.mode); window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`); const player = getPlayer(); if (!player || typeof player.getProgressState !== 'function') { return; } const progressState = player.getProgressState(); if ( !progressState || typeof progressState.current !== 'number' || typeof progressState.duration !== 'number' ) { return; } // Do not listen for watch progress when watching advertisements if (!isAdPlaying()) { if (progressState.current / progressState.duration >= 0.9) { if (videoId) markWatched(cfg.storageKey, videoId); } // Autoplay random video if (progressState.current >= progressState.duration - 2) { // make sure vanilla autoplay doesnt take over if (typeof player.pauseVideo === 'function') player.pauseVideo(); if (typeof player.seekTo === 'function') player.seekTo(0); playNextRandom(cfg); } } const nextButton = $('#ytd-player .ytp-next-button.ytp-button:not([ytp-random="applied"])'); if (nextButton instanceof HTMLElement) { // Replace with span to prevent anchor click events const newButton = document.createElement('span'); newButton.className = nextButton.className; newButton.innerHTML = nextButton.innerHTML; nextButton.replaceWith(newButton); newButton.setAttribute('ytp-random', 'applied'); newButton.addEventListener('click', () => { if (videoId) markWatched(cfg.storageKey, videoId); playNextRandom(cfg); }); } }; videoEl.addEventListener('timeupdate', handleProgress, { passive: true }); progressIntervalId = true; // Mark as initialized }; scheduleApplyRandomPlay = (attempt = 0) => { if (!featureEnabled) return; stopRandomPlayTimers(); if (!window.location.pathname.endsWith('/watch')) return; const cfg = getRandomConfig(); if (!cfg) return; // Storage needs to now be { [videoId]: bool } try { const current = localStorage.getItem(cfg.storageKey); if (current && Array.isArray(JSON.parse(current))) { localStorage.removeItem(cfg.storageKey); } } catch { localStorage.removeItem(cfg.storageKey); } applyRandomPlay(cfg); // If the playlist panel isn't ready yet, retry a few times (no always-on polling) if (attempt >= 30) return; applyRetryTimeoutId = setTimeout(() => scheduleApplyRandomPlay(attempt + 1), 250); }; const onNavigate = () => { if (!featureEnabled) { stopRandomPlayTimers(); return; } stopRandomPlayTimers(); scheduleApplyRandomPlay(); }; onNavigate(); window.addEventListener('yt-navigate-finish', () => setTimeout(onNavigate, 200)); })(); })().catch(error => console.error( '%cytp - YouTube Play All\n', 'color: #bf4bcc; font-size: 32px; font-weight: bold', error ) ); // --- MODULE: time.js --- // Time to Read (Resume Playback) (function () { 'use strict'; let featureEnabled = true; let activeCleanup = null; const loadFeatureEnabled = () => { try { const settings = localStorage.getItem('youtube_plus_settings'); if (settings) { const parsed = JSON.parse(settings); return parsed.enableResumeTime !== false; } } catch {} return true; }; const setFeatureEnabled = nextEnabled => { featureEnabled = nextEnabled !== false; if (!featureEnabled) { const existingOverlay = byId(OVERLAY_ID); if (existingOverlay) { try { existingOverlay.remove(); } catch {} } if (typeof activeCleanup === 'function') { try { activeCleanup(); } catch {} activeCleanup = null; } } else { try { initResume(); } catch {} } }; featureEnabled = loadFeatureEnabled(); // DOM helpers const _getDOMCache = () => typeof window !== 'undefined' && window.YouTubeDOMCache; const $ = (sel, ctx) => _getDOMCache()?.querySelector(sel, ctx) || (ctx || document).querySelector(sel); const byId = id => _getDOMCache()?.getElementById(id) || document.getElementById(id); const onDomReady = (() => { let ready = document.readyState !== 'loading'; const queue = []; const run = () => { ready = true; while (queue.length) { const cb = queue.shift(); try { cb(); } catch {} } }; if (!ready) document.addEventListener('DOMContentLoaded', run, { once: true }); return cb => { if (ready) cb(); else queue.push(cb); }; })(); const setupResumeDelegation = (() => { let attached = false; return () => { if (attached) return; attached = true; const delegator = window.YouTubePlusEventDelegation; const handler = (ev, target) => { const action = target?.dataset?.ytpResumeAction; if (!action) return; const wrap = target.closest('.ytp-resume-overlay'); if (!wrap) return; if (action === 'resume') { wrap.dispatchEvent(new CustomEvent('ytp:resume', { bubbles: true })); } else if (action === 'restart') { wrap.dispatchEvent(new CustomEvent('ytp:restart', { bubbles: true })); } }; if (delegator?.on) { delegator.on(document, 'click', '.ytp-resume-btn', handler); delegator.on(document, 'keydown', '.ytp-resume-btn', (ev, target) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); handler(ev, target); } }); } else { document.addEventListener( 'click', ev => { const target = ev.target?.closest?.('.ytp-resume-btn'); if (target) handler(ev, target); }, true ); document.addEventListener( 'keydown', ev => { const target = ev.target?.closest?.('.ytp-resume-btn'); if (!target) return; if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); handler(ev, target); } }, true ); } }; })(); const RESUME_STORAGE_KEY = 'youtube_resume_times_v1'; const OVERLAY_ID = 'yt-resume-overlay'; const AUTO_HIDE_MS = 10000; // hide overlay after 10s // Localization: prefer centralized i18n with local fallback for critical keys const _localFallback = { resumePlayback: { en: 'Resume playback?', ru: 'Продолжить воспроизведение?' }, resume: { en: 'Resume', ru: 'Продолжить' }, startOver: { en: 'Start over', ru: 'Начать сначала' }, }; const t = (key, params = {}) => { if (window.YouTubePlusI18n?.t) return window.YouTubePlusI18n.t(key, params); if (window.YouTubeUtils?.t) return window.YouTubeUtils.t(key, params); // Fallback to local tiny map for this module's critical keys const htmlLang = document.documentElement.lang || 'en'; const lang = htmlLang.startsWith('ru') ? 'ru' : 'en'; const val = _localFallback[key]?.[lang] || _localFallback[key]?.en || key; if (!params || Object.keys(params).length === 0) return val; let result = val; for (const [k, v] of Object.entries(params)) { result = result.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); } return result; }; const readStorage = () => { try { return JSON.parse(localStorage.getItem(RESUME_STORAGE_KEY) || '{}'); } catch { return {}; } }; const writeStorage = obj => { try { localStorage.setItem(RESUME_STORAGE_KEY, JSON.stringify(obj)); } catch {} }; // Get current video id from the page (works on standard watch pages) const getVideoId = () => { try { // First try URL parameters (most reliable) const urlParams = new URLSearchParams(window.location.search); const videoIdFromUrl = urlParams.get('v'); if (videoIdFromUrl) return videoIdFromUrl; // Try canonical link const meta = $('link[rel="canonical"]'); if (meta && meta.href) { const u = new URL(meta.href); const vParam = u.searchParams.get('v'); if (vParam) return vParam; // Try extracting from pathname (for /watch/ or /shorts/ URLs) const pathMatch = u.pathname.match(/\/(watch|shorts)\/([^\/\?]+)/); if (pathMatch && pathMatch[2]) return pathMatch[2]; } // Fallback to ytInitialPlayerResponse if ( window.ytInitialPlayerResponse && window.ytInitialPlayerResponse.videoDetails && window.ytInitialPlayerResponse.videoDetails.videoId ) { return window.ytInitialPlayerResponse.videoDetails.videoId; } // Last resort: try to extract from current URL pathname const pathMatch = window.location.pathname.match(/\/(watch|shorts)\/([^\/\?]+)/); if (pathMatch && pathMatch[2]) return pathMatch[2]; return null; } catch { return null; } }; const createOverlay = (seconds, onResume, onRestart) => { if (byId(OVERLAY_ID)) return null; const wrap = document.createElement('div'); wrap.id = OVERLAY_ID; // Try to insert overlay inside the player so it appears above the progress bar const player = $('#movie_player'); const inPlayer = !!player; // Ensure glassmorphism styles are available for the overlay const resumeOverlayStyles = ` .ytp-resume-overlay{min-width:180px;max-width:36vw;background:rgba(24, 24, 24, 0.3);color:var(--yt-spec-text-primary,#fff);padding:12px 14px;border-radius:12px;backdrop-filter:blur(8px) saturate(150%);-webkit-backdrop-filter:blur(8px) saturate(150%);box-shadow:0 14px 40px rgba(0,0,0,0.48);border:1.25px solid rgba(255,255,255,0.06);font-family:Arial,Helvetica,sans-serif;display:flex;flex-direction:column;align-items:center;text-align:center;animation:ytp-resume-fadein 0.3s ease-out} @keyframes ytp-resume-fadein{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} .ytp-resume-overlay .ytp-resume-title{font-weight:600;margin-bottom:8px;font-size:13px} .ytp-resume-overlay .ytp-resume-actions{display:flex;gap:8px;justify-content:center;margin-top:6px} .ytp-resume-overlay .ytp-resume-btn{padding:6px 12px;border-radius:8px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all 0.2s ease;outline:none} .ytp-resume-overlay .ytp-resume-btn:focus{box-shadow:0 0 0 2px rgba(255,255,255,0.3);outline:2px solid transparent} .ytp-resume-overlay .ytp-resume-btn:hover{transform:translateY(-1px)} .ytp-resume-overlay .ytp-resume-btn:active{transform:translateY(0)} .ytp-resume-overlay .ytp-resume-btn.primary{background:#1e88e5;color:#fff} .ytp-resume-overlay .ytp-resume-btn.primary:hover{background:#1976d2} .ytp-resume-overlay .ytp-resume-btn.ghost{background:rgba(255,255,255,0.06);color:#fff} .ytp-resume-overlay .ytp-resume-btn.ghost:hover{background:rgba(255,255,255,0.12)} `; try { if (window.YouTubeUtils && YouTubeUtils.StyleManager) { YouTubeUtils.StyleManager.add('ytp-resume-overlay-styles', resumeOverlayStyles); } else if (!byId('ytp-resume-overlay-styles')) { const s = document.createElement('style'); s.id = 'ytp-resume-overlay-styles'; s.textContent = resumeOverlayStyles; (document.head || document.documentElement).appendChild(s); } } catch {} if (inPlayer) { try { // Ensure player can be a positioning context const playerStyle = window.getComputedStyle( /** @type {Element} */ (/** @type {unknown} */ (player)) ); if (playerStyle.position === 'static') player.style.position = 'relative'; } catch {} // Position centered inside the player wrap.className = 'ytp-resume-overlay'; // absolute center (use transform to center by both axes) wrap.style.cssText = 'position:absolute;left:50%;bottom:5%;transform:translate(-50%,-50%);z-index:9999;pointer-events:auto;'; player.appendChild(wrap); } else { // Fallback: fixed centered on the page wrap.className = 'ytp-resume-overlay'; wrap.style.cssText = 'position:fixed;left:50%;bottom:5%;transform:translate(-50%,-50%);z-index:1200;pointer-events:auto;'; document.body.appendChild(wrap); } const title = document.createElement('div'); title.className = 'ytp-resume-title'; title.textContent = `${t('resumePlayback')} (${formatTime(seconds)})`; const btnResume = document.createElement('button'); btnResume.className = 'ytp-resume-btn primary'; btnResume.textContent = t('resume'); btnResume.setAttribute('aria-label', `${t('resume')} at ${formatTime(seconds)}`); btnResume.tabIndex = 0; btnResume.dataset.ytpResumeAction = 'resume'; const btnRestart = document.createElement('button'); btnRestart.className = 'ytp-resume-btn ghost'; btnRestart.textContent = t('startOver'); btnRestart.setAttribute('aria-label', t('startOver')); btnRestart.tabIndex = 0; btnRestart.dataset.ytpResumeAction = 'restart'; const handleResume = () => { try { onResume(); } catch (err) { console.error('[YouTube+] Resume error:', err); } try { wrap.remove(); } catch {} }; const handleRestart = () => { try { onRestart(); } catch (err) { console.error('[YouTube+] Restart error:', err); } try { wrap.remove(); } catch {} }; setupResumeDelegation(); wrap.addEventListener('ytp:resume', () => handleResume(), { once: true }); wrap.addEventListener('ytp:restart', () => handleRestart(), { once: true }); // group actions and center them const actions = document.createElement('div'); actions.className = 'ytp-resume-actions'; actions.appendChild(btnResume); actions.appendChild(btnRestart); wrap.appendChild(title); wrap.appendChild(actions); // Set focus to primary button for keyboard accessibility try { requestAnimationFrame(() => { btnResume.focus(); }); } catch {} const to = setTimeout(() => { try { wrap.remove(); } catch {} }, AUTO_HIDE_MS); // Return function to cancel timeout const cancel = () => clearTimeout(to); // Register cleanup: cancel timeout and remove overlay when cleanup runs if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.register(() => { try { cancel(); } catch {} try { wrap.remove(); } catch {} }); } return cancel; }; const formatTime = secs => { const s = Math.floor(secs % 60) .toString() .padStart(2, '0'); const m = Math.floor((secs / 60) % 60).toString(); const h = Math.floor(secs / 3600); return h ? `${h}:${m.padStart(2, '0')}:${s}` : `${m}:${s}`; }; const attachResumeHandlers = videoEl => { if (!featureEnabled) return null; if (!videoEl || videoEl.tagName !== 'VIDEO') { console.warn('[YouTube+] Invalid video element for resume handlers'); return; } // Mark element to prevent duplicate handlers if (videoEl._ytpResumeAttached) return; videoEl._ytpResumeAttached = true; // Get current video ID dynamically each time const getCurrentVideoId = () => getVideoId(); const vid = getCurrentVideoId(); if (!vid) return; const storage = readStorage(); const saved = storage[vid]; // Save current time using `timeupdate` event (throttled) instead of interval let timeUpdateHandler = null; let lastSavedAt = 0; const SAVE_THROTTLE_MS = 800; // minimum ms between writes const startSaving = () => { if (timeUpdateHandler) return; timeUpdateHandler = () => { try { // Get current video ID each time we save const currentVid = getCurrentVideoId(); if (!currentVid) return; const t = Math.floor(videoEl.currentTime || 0); const now = Date.now(); if (t && (!lastSavedAt || now - lastSavedAt > SAVE_THROTTLE_MS)) { const s = readStorage(); s[currentVid] = t; writeStorage(s); lastSavedAt = now; } } catch {} }; videoEl.addEventListener('timeupdate', timeUpdateHandler, { passive: true }); // register cleanup to remove listener if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.register(() => { try { videoEl.removeEventListener('timeupdate', timeUpdateHandler); } catch {} }); } }; const stopSaving = () => { if (!timeUpdateHandler) return; try { videoEl.removeEventListener('timeupdate', timeUpdateHandler); } catch {} timeUpdateHandler = null; lastSavedAt = 0; }; // If saved time exists and is > 5s, show overlay if (saved && saved > 5 && !byId(OVERLAY_ID)) { const cancelTimeout = createOverlay( saved, () => { try { videoEl.currentTime = saved; videoEl.play(); } catch {} }, () => { try { videoEl.currentTime = 0; videoEl.play(); } catch {} } ); // Tag overlay with current video id so future init calls won't immediately remove it try { const overlayEl = byId(OVERLAY_ID); if (overlayEl && vid) overlayEl.dataset.vid = vid; } catch {} // register cleanup for overlay timeout if (window.YouTubeUtils && YouTubeUtils.cleanupManager && cancelTimeout) { YouTubeUtils.cleanupManager.register(cancelTimeout); } } // Start saving when playing const onPlay = () => startSaving(); const onPause = () => stopSaving(); videoEl.addEventListener('play', onPlay, { passive: true }); videoEl.addEventListener('pause', onPause, { passive: true }); // Cleanup listeners when needed const cleanupHandlers = () => { try { videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('pause', onPause); if (timeUpdateHandler) { videoEl.removeEventListener('timeupdate', timeUpdateHandler); } delete videoEl._ytpResumeAttached; } catch (err) { console.error('[YouTube+] Resume cleanup error:', err); } }; if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.register(cleanupHandlers); } // Return cleanup function activeCleanup = cleanupHandlers; return cleanupHandlers; }; // Try to find the primary HTML5 video element on the YouTube watch page const findVideoElement = () => { // Try multiple selectors for better compatibility const selectors = [ 'video.html5-main-video', 'video.video-stream', '#movie_player video', 'video', ]; for (const selector of selectors) { const video = $(selector); if (video && video.tagName === 'VIDEO') { return /** @type {HTMLVideoElement} */ (video); } } return null; }; const initResume = () => { if (!featureEnabled) { const existingOverlay = byId(OVERLAY_ID); if (existingOverlay) { try { existingOverlay.remove(); } catch {} } return; } // Only run on watch pages if (window.location.pathname !== '/watch') { // Remove overlay if we navigate away from watch page const existingOverlay = byId(OVERLAY_ID); if (existingOverlay) { existingOverlay.remove(); } return; } // Remove any existing overlay from previous video — but keep it if it's for the same video id const currentVid = getVideoId(); const existingOverlay = byId(OVERLAY_ID); if (existingOverlay) { try { if (existingOverlay.dataset && existingOverlay.dataset.vid === currentVid) { // overlay matches current video; keep it (prevents immediate disappearance during SPA re-inits) } else { existingOverlay.remove(); } } catch { try { existingOverlay.remove(); } catch {} } } const videoEl = findVideoElement(); if (videoEl) { attachResumeHandlers(videoEl); } else { // Retry after a short delay if video not found yet setTimeout(initResume, 500); } }; // Listen for navigation events used by YouTube SPA const onNavigate = () => setTimeout(initResume, 150); onDomReady(initResume); // YouTube internal navigation event if (window && window.document) { // Prefer custom event registered in other modules if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.registerListener(document, 'yt-navigate-finish', onNavigate, { passive: true, }); } else { document.addEventListener('yt-navigate-finish', onNavigate, { passive: true }); } } window.addEventListener('youtube-plus-settings-updated', e => { try { const nextEnabled = e?.detail?.enableResumeTime !== false; if (nextEnabled === featureEnabled) return; setFeatureEnabled(nextEnabled); } catch { setFeatureEnabled(loadFeatureEnabled()); } }); })(); // --- MODULE: zoom.js --- // --- Zoom UI with wheel, pinch and keyboard support --- (function () { 'use strict'; let featureEnabled = true; const loadFeatureEnabled = () => { try { const settings = localStorage.getItem('youtube_plus_settings'); if (settings) { const parsed = JSON.parse(settings); return parsed.enableZoom !== false; } } catch {} return true; }; const clearZoomUI = () => { try { const ui = byId('ytp-zoom-control'); if (ui) ui.remove(); } catch {} try { const styles = byId('ytp-zoom-styles'); if (styles) styles.remove(); } catch {} try { const video = findVideoElement(); if (video) { video.style.transform = ''; video.style.willChange = ''; video.style.transition = ''; video.style.cursor = ''; } } catch {} }; const setFeatureEnabled = nextEnabled => { featureEnabled = nextEnabled !== false; if (!featureEnabled) { clearZoomUI(); } else { try { initZoom(); } catch {} } }; featureEnabled = loadFeatureEnabled(); // DOM helpers const _getDOMCache = () => typeof window !== 'undefined' && window.YouTubeDOMCache; const $ = (sel, ctx) => _getDOMCache()?.querySelector(sel, ctx) || (ctx || document).querySelector(sel); const byId = id => _getDOMCache()?.getElementById(id) || document.getElementById(id); const ZOOM_PAN_STORAGE_KEY = 'ytp_zoom_pan'; const RESTORE_LOG_KEY = 'ytp_zoom_restore_log'; // stored in sessionStorage for debugging const DEFAULT_ZOOM = 1; const MIN_ZOOM = 0.5; const MAX_ZOOM = 2.5; const ZOOM_STEP = 0.05; // Fullscreen apply timing (ms) and retries — make configurable if needed const FULLSCREEN_APPLY_DELAY = 80; const FULLSCREEN_APPLY_RETRIES = 4; const FULLSCREEN_APPLY_RETRY_DELAY = 120; // Helpers for combined zoom+pan storage function readZoomPan() { try { const raw = localStorage.getItem(ZOOM_PAN_STORAGE_KEY); if (!raw) return { zoom: DEFAULT_ZOOM, panX: 0, panY: 0 }; const obj = JSON.parse(raw); const zoom = Number(obj && obj.zoom) || DEFAULT_ZOOM; const panX = Number(obj && obj.panX) || 0; const panY = Number(obj && obj.panY) || 0; return { zoom, panX, panY }; } catch { return { zoom: DEFAULT_ZOOM, panX: 0, panY: 0 }; } } function saveZoomPan(zoom, panX, panY) { try { const obj = { zoom: Number(zoom) || DEFAULT_ZOOM, panX: Number(panX) || 0, panY: Number(panY) || 0, }; localStorage.setItem(ZOOM_PAN_STORAGE_KEY, JSON.stringify(obj)); } catch {} } function logRestoreEvent(evt) { try { const entry = Object.assign({ time: new Date().toISOString() }, evt); try { const raw = sessionStorage.getItem(RESTORE_LOG_KEY); const arr = raw ? JSON.parse(raw) : []; arr.push(entry); // keep last 200 entries if (arr.length > 200) arr.splice(0, arr.length - 200); sessionStorage.setItem(RESTORE_LOG_KEY, JSON.stringify(arr)); } catch { // fallback: ignore } // Console output for live debugging (only when debug mode is active) if ((typeof window !== 'undefined' && window.YTP_DEBUG) || window.YouTubePlusConfig?.debug) { console.warn('[YouTube+] Zoom restore:', entry); } } catch {} } const findVideoElement = () => { const selectors = ['#movie_player video', 'video.video-stream', 'video']; for (const s of selectors) { const v = $(s); if (v && v.tagName === 'VIDEO') return /** @type {HTMLVideoElement} */ (v); } return null; }; // Transform tracking state (module scope so helpers can access it) let _lastTransformApplied = ''; let _isApplyingTransform = false; const applyZoomToVideo = ( videoEl, zoom, panX = 0, panY = 0, skipTransformTracking = false, skipTransition = false ) => { if (!videoEl) return; const container = videoEl.parentElement || videoEl; try { // Set flag to prevent observer loops if (!skipTransformTracking) { _isApplyingTransform = true; } // Ensure container can display overflow content container.style.overflow = 'visible'; if (!container.style.position || container.style.position === 'static') { container.style.position = 'relative'; } // Set transform origin to center for natural zoom videoEl.style.transformOrigin = 'center center'; // Apply transform with proper precision const transformStr = `translate(${panX.toFixed(2)}px, ${panY.toFixed(2)}px) scale(${zoom.toFixed(3)})`; videoEl.style.transform = transformStr; // Track the transform we just applied if (!skipTransformTracking) { _lastTransformApplied = transformStr; } // Use will-change for GPU acceleration videoEl.style.willChange = zoom !== 1 ? 'transform' : 'auto'; // Smooth transition for better UX (skip during fullscreen transitions to avoid flicker) videoEl.style.transition = skipTransition ? 'none' : 'transform .08s ease-out'; // Reset flag after a short delay if (!skipTransformTracking) { setTimeout(() => { _isApplyingTransform = false; }, 100); } } catch (e) { console.error('[YouTube+] applyZoomToVideo error:', e); _isApplyingTransform = false; } }; function createZoomUI() { const player = $('#movie_player'); if (!player) return null; if (byId('ytp-zoom-control')) { return byId('ytp-zoom-control'); } // styles (minimal) if (!byId('ytp-zoom-styles')) { const s = document.createElement('style'); s.id = 'ytp-zoom-styles'; s.textContent = ` /* Compact control bar matching YouTube control style */ #ytp-zoom-control{position: absolute; left: 12px; bottom: 64px; z-index: 2200; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 24px; background: rgba(0,0,0,0.35); color: #fff; font-size: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.5); backdrop-filter: blur(6px);} #ytp-zoom-control input[type=range]{width: 120px; -webkit-appearance: none; background: transparent; height: 24px;} /* WebKit track */ #ytp-zoom-control input[type=range]::-webkit-slider-runnable-track{height: 4px; background: rgba(255,255,255,0.12); border-radius: 3px;} #ytp-zoom-control input[type=range]::-webkit-slider-thumb{-webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 6px rgba(255,255,255,0.06); margin-top: -4px;} /* Firefox */ #ytp-zoom-control input[type=range]::-moz-range-track{height: 4px; background: rgba(255,255,255,0.12); border-radius: 3px;} #ytp-zoom-control input[type=range]::-moz-range-thumb{width: 12px; height: 12px; border-radius: 50%; background: #fff; border: none;} #ytp-zoom-control .zoom-label{min-width:36px;text-align:center;font-size:11px;padding:0 6px;user-select:none} #ytp-zoom-control::after{content:'Shift + Wheel to zoom';position:absolute;bottom:100%;right:0;padding:4px 8px;background:rgba(0,0,0,0.8);color:#fff;font-size:10px;border-radius:4px;white-space:nowrap;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .2s,transform .2s} #ytp-zoom-control:hover::after{opacity:1;transform:translateY(-4px)} #ytp-zoom-control .zoom-reset{background: rgba(255,255,255,0.06); border: none; color: inherit; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 50%; cursor: pointer; width: 28px; height: 28px;} #ytp-zoom-control .zoom-reset:hover{background: rgba(255,255,255,0.12)} #ytp-zoom-control .zoom-reset svg{display:block;width:14px;height:14px} /* Hidden state to mirror YouTube controls autohide */ #ytp-zoom-control.ytp-hidden{opacity:0;transform:translateY(6px);pointer-events:none} #ytp-zoom-control{transition:opacity .18s ease, transform .18s ease} `; (document.head || document.documentElement).appendChild(s); } const wrap = document.createElement('div'); wrap.id = 'ytp-zoom-control'; const input = document.createElement('input'); input.type = 'range'; input.min = String(MIN_ZOOM); input.max = String(MAX_ZOOM); input.step = String(ZOOM_STEP); const label = document.createElement('div'); label.className = 'zoom-label'; label.setAttribute('role', 'status'); label.setAttribute('aria-live', 'polite'); label.setAttribute('aria-label', 'Current zoom level'); const reset = document.createElement('button'); reset.className = 'zoom-reset'; reset.type = 'button'; reset.setAttribute('aria-label', 'Reset zoom'); reset.title = 'Reset zoom'; reset.innerHTML = ` `; wrap.appendChild(input); wrap.appendChild(label); wrap.appendChild(reset); let video = findVideoElement(); const stored = readZoomPan().zoom; const initZoomVal = Number.isFinite(stored) && !Number.isNaN(stored) ? stored : DEFAULT_ZOOM; const setZoom = z => { const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(z))); input.value = String(clamped); const percentage = Math.round(clamped * 100); label.textContent = `${percentage}%`; label.setAttribute('aria-label', `Current zoom level ${percentage} percent`); if (video) { // clamp pan to new zoom limits clampPan(clamped); // Use RAF for smooth animation requestAnimationFrame(() => { try { applyZoomToVideo(video, clamped, panX, panY); // update cursor depending on zoom try { video.style.cursor = clamped > 1 ? 'grab' : ''; } catch {} } catch (err) { console.error('[YouTube+] Apply zoom error:', err); } }); } try { saveZoomPan(clamped, panX, panY); } catch (err) { console.error('[YouTube+] Save zoom error:', err); } }; input.addEventListener('input', e => setZoom(e.target.value)); reset.addEventListener('click', () => { try { panX = 0; panY = 0; setZoom(DEFAULT_ZOOM); // persist reset pan immediately try { // set via combined storage saveZoomPan(DEFAULT_ZOOM, 0, 0); } catch {} // Provide visual feedback reset.style.transform = 'scale(0.9)'; setTimeout(() => { reset.style.transform = ''; }, 150); } catch (err) { console.error('[YouTube+] Reset zoom error:', err); } }); // Wheel: Shift + wheel to zoom (with throttling for performance) let wheelThrottleTimer = null; // Throttled pan save timer to avoid excessive localStorage writes let panSaveTimer = null; const scheduleSavePan = () => { try { if (panSaveTimer) clearTimeout(panSaveTimer); panSaveTimer = setTimeout(() => { try { const currentZoom = parseFloat(input.value) || readZoomPan().zoom || DEFAULT_ZOOM; saveZoomPan(currentZoom, panX, panY); } catch (err) { console.error('[YouTube+] Save pan error:', err); } panSaveTimer = null; }, 220); } catch (err) { console.error('[YouTube+] Schedule save pan error:', err); } }; const wheelHandler = ev => { try { if (!featureEnabled) return; if (!ev.shiftKey) return; ev.preventDefault(); // Throttle wheel events to prevent excessive zoom changes if (wheelThrottleTimer) return; wheelThrottleTimer = setTimeout(() => { wheelThrottleTimer = null; }, 50); // 50ms throttle // Normalize wheel delta for consistent behavior across browsers const delta = ev.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; const current = readZoomPan().zoom || DEFAULT_ZOOM; const newZoom = current + delta; // Only zoom if within bounds if (newZoom >= MIN_ZOOM && newZoom <= MAX_ZOOM) { setZoom(newZoom); } } catch (err) { console.error('[YouTube+] Wheel zoom error:', err); } }; // Attach wheel handler to player and video (if present) so it works over controls player.addEventListener('wheel', wheelHandler, { passive: false }); if (video) { try { video.addEventListener('wheel', wheelHandler, { passive: false }); } catch (err) { console.error('[YouTube+] Failed to attach wheel handler to video:', err); } } // Keyboard +/- (ignore when typing) const keydownHandler = ev => { try { if (!featureEnabled) return; const active = document.activeElement; if ( active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable) ) { return; } if (ev.key === '+' || ev.key === '=') { ev.preventDefault(); const current = readZoomPan().zoom || DEFAULT_ZOOM; setZoom(Math.min(MAX_ZOOM, current + ZOOM_STEP)); } else if (ev.key === '-') { ev.preventDefault(); const current = readZoomPan().zoom || DEFAULT_ZOOM; setZoom(Math.max(MIN_ZOOM, current - ZOOM_STEP)); } } catch {} }; window.addEventListener('keydown', keydownHandler); // Pinch-to-zoom using Pointer Events // Panning (drag) state let panX = 0; let panY = 0; // Observer to watch for external changes to the video's style (YouTube may override transform) let videoStyleObserver = null; let dragging = false; let dragStartX = 0; let dragStartY = 0; let dragStartPanX = 0; let dragStartPanY = 0; const clampPan = (zoom = readZoomPan().zoom) => { try { if (!video) return; const container = video.parentElement || video; if (!container) return; const containerRect = container.getBoundingClientRect(); if (!containerRect || containerRect.width === 0 || containerRect.height === 0) return; // Get actual video dimensions respecting aspect ratio const baseW = video.videoWidth || video.offsetWidth || containerRect.width; const baseH = video.videoHeight || video.offsetHeight || containerRect.height; // Validate dimensions if (!baseW || !baseH || !Number.isFinite(baseW) || !Number.isFinite(baseH)) return; // Calculate scaled dimensions const scaledW = baseW * zoom; const scaledH = baseH * zoom; // Calculate maximum pan distance (how far content can move) const maxX = Math.max(0, (scaledW - containerRect.width) / 2); const maxY = Math.max(0, (scaledH - containerRect.height) / 2); // Clamp pan values with validation if (Number.isFinite(maxX) && Number.isFinite(panX)) { panX = Math.max(-maxX, Math.min(maxX, panX)); } if (Number.isFinite(maxY) && Number.isFinite(panY)) { panY = Math.max(-maxY, Math.min(maxY, panY)); } } catch (err) { console.error('[YouTube+] Clamp pan error:', err); } }; const pointers = new Map(); let initialPinchDist = null; let pinchStartZoom = null; let prevTouchAction = null; const getDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); const pointerDown = ev => { try { if (!featureEnabled) return; pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); try { ev.target.setPointerCapture(ev.pointerId); } catch {} // Start mouse drag for panning when single mouse pointer and zoomed in. // Skip at default zoom so we don't interfere with YouTube's native // hold-left-mouse-button → 2× speed feature. try { const currentZoom = parseFloat(input.value) || readZoomPan().zoom || DEFAULT_ZOOM; if ( ev.pointerType === 'mouse' && ev.button === 0 && pointers.size <= 1 && video && currentZoom > 1 ) { dragging = true; dragStartX = ev.clientX; dragStartY = ev.clientY; dragStartPanX = panX; dragStartPanY = panY; try { video.style.cursor = 'grabbing'; } catch {} } } catch {} if (pointers.size === 2) { const pts = Array.from(pointers.values()); initialPinchDist = getDistance(pts[0], pts[1]); pinchStartZoom = readZoomPan().zoom; prevTouchAction = player.style.touchAction; try { player.style.touchAction = 'none'; } catch {} } } catch {} }; const pointerMove = ev => { try { if (!featureEnabled) return; // Update pointers map if (pointers.has(ev.pointerId)) { pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); } // If dragging with mouse, pan the video if (dragging && ev.pointerType === 'mouse' && video) { const dx = ev.clientX - dragStartX; const dy = ev.clientY - dragStartY; // Movement should be independent of scale; adjust if desired panX = dragStartPanX + dx; panY = dragStartPanY + dy; // clamp pan to allowed bounds clampPan(); applyZoomToVideo(video, parseFloat(input.value) || DEFAULT_ZOOM, panX, panY); // schedule persisting pan scheduleSavePan(); ev.preventDefault(); return; } // Pinch-to-zoom when two pointers if (pointers.size === 2 && initialPinchDist && pinchStartZoom != null) { const pts = Array.from(pointers.values()); const dist = getDistance(pts[0], pts[1]); if (dist <= 0) return; const ratio = dist / initialPinchDist; const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, pinchStartZoom * ratio)); setZoom(newZoom); ev.preventDefault(); } } catch {} }; const pointerUp = ev => { try { if (!featureEnabled) return; pointers.delete(ev.pointerId); try { ev.target.releasePointerCapture(ev.pointerId); } catch {} // stop dragging try { if (dragging && ev.pointerType === 'mouse') { dragging = false; try { if (video) video.style.cursor = parseFloat(input.value) > 1 ? 'grab' : ''; } catch {} } } catch {} if (pointers.size < 2) { initialPinchDist = null; pinchStartZoom = null; if (prevTouchAction != null) { try { player.style.touchAction = prevTouchAction; } catch {} prevTouchAction = null; } } } catch {} }; player.addEventListener('pointerdown', pointerDown, { passive: true }); player.addEventListener('pointermove', pointerMove, { passive: false }); player.addEventListener('pointerup', pointerUp, { passive: true }); player.addEventListener('pointercancel', pointerUp, { passive: true }); // Touch event fallback for browsers that don't fully support Pointer Events // Enables pinch-to-zoom and one-finger pan on touchscreens let touchDragging = false; let touchDragStartX = 0; let touchDragStartY = 0; let touchDragStartPanX = 0; let touchDragStartPanY = 0; let touchInitialDist = null; let touchPinchStartZoom = null; const getTouchDistance = (t1, t2) => Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); const touchStart = ev => { try { if (!featureEnabled) return; if (!video) return; if (ev.touches.length === 1) { // start pan only if zoomed in const currentZoom = parseFloat(input.value) || readZoomPan().zoom || DEFAULT_ZOOM; if (currentZoom > 1) { touchDragging = true; touchDragStartX = ev.touches[0].clientX; touchDragStartY = ev.touches[0].clientY; touchDragStartPanX = panX; touchDragStartPanY = panY; // prevent page scroll when panning video ev.preventDefault(); } } else if (ev.touches.length === 2) { // pinch start touchInitialDist = getTouchDistance(ev.touches[0], ev.touches[1]); touchPinchStartZoom = parseFloat(input.value) || readZoomPan().zoom || DEFAULT_ZOOM; // prevent default gestures (scroll/zoom) while pinching try { prevTouchAction = player.style.touchAction; player.style.touchAction = 'none'; } catch {} ev.preventDefault(); } } catch (e) { console.error('[YouTube+] touchStart error:', e); } }; const touchMove = ev => { try { if (!featureEnabled) return; if (!video) return; if (ev.touches.length === 1 && touchDragging) { const dx = ev.touches[0].clientX - touchDragStartX; const dy = ev.touches[0].clientY - touchDragStartY; panX = touchDragStartPanX + dx; panY = touchDragStartPanY + dy; clampPan(); applyZoomToVideo(video, parseFloat(input.value) || DEFAULT_ZOOM, panX, panY); scheduleSavePan(); ev.preventDefault(); return; } if (ev.touches.length === 2 && touchInitialDist && touchPinchStartZoom != null) { const dist = getTouchDistance(ev.touches[0], ev.touches[1]); if (dist <= 0) return; const ratio = dist / touchInitialDist; const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, touchPinchStartZoom * ratio)); setZoom(newZoom); ev.preventDefault(); } } catch (e) { console.error('[YouTube+] touchMove error:', e); } }; const touchEnd = ev => { try { if (!featureEnabled) return; if (touchDragging && ev.touches.length === 0) { touchDragging = false; } if (ev.touches.length < 2) { touchInitialDist = null; touchPinchStartZoom = null; if (prevTouchAction != null) { try { player.style.touchAction = prevTouchAction; } catch {} prevTouchAction = null; } } } catch (e) { console.error('[YouTube+] touchEnd error:', e); } }; try { // Use non-passive handlers so we can preventDefault when needed player.addEventListener('touchstart', touchStart, { passive: false }); player.addEventListener('touchmove', touchMove, { passive: false }); player.addEventListener('touchend', touchEnd, { passive: true }); player.addEventListener('touchcancel', touchEnd, { passive: true }); } catch (e) { console.error('[YouTube+] Failed to attach touch handlers:', e); } // Fallback mouse handlers for more reliable dragging on desktop const mouseDownHandler = ev => { try { if (!featureEnabled) return; if (ev.button !== 0 || !video) return; // Only intercept mousedown (and call preventDefault) when actually zoomed in. // At default zoom (1×) we must NOT call preventDefault() because it breaks // YouTube's native hold-left-mouse-button → 2× speed feature. const currentZoom = parseFloat(input.value) || readZoomPan().zoom || DEFAULT_ZOOM; if (currentZoom <= 1) return; dragging = true; dragStartX = ev.clientX; dragStartY = ev.clientY; dragStartPanX = panX; dragStartPanY = panY; try { video.style.cursor = 'grabbing'; } catch {} ev.preventDefault(); } catch {} }; const mouseMoveHandler = ev => { try { if (!featureEnabled) return; if (!dragging || !video) return; const dx = ev.clientX - dragStartX; const dy = ev.clientY - dragStartY; panX = dragStartPanX + dx; panY = dragStartPanY + dy; clampPan(); // Use RAF to avoid excessive repaints if (!video._panRAF) { video._panRAF = requestAnimationFrame(() => { applyZoomToVideo(video, parseFloat(input.value) || DEFAULT_ZOOM, panX, panY); // persist pan after RAF'd update scheduleSavePan(); video._panRAF = null; }); } ev.preventDefault(); } catch (err) { console.error('[YouTube+] Mouse move error:', err); } }; const mouseUpHandler = _ev => { try { if (!featureEnabled) return; if (dragging) { dragging = false; try { if (video) video.style.cursor = parseFloat(input.value) > 1 ? 'grab' : ''; } catch {} } } catch {} }; if (video) { try { video.addEventListener('mousedown', mouseDownHandler); } catch {} try { window.addEventListener('mousemove', mouseMoveHandler); } catch {} try { window.addEventListener('mouseup', mouseUpHandler); } catch {} // Attach style observer to ensure transform isn't clobbered by YouTube try { const attachStyleObserver = () => { try { if (videoStyleObserver) { try { videoStyleObserver.disconnect(); } catch {} videoStyleObserver = null; } if (!video) return; videoStyleObserver = new MutationObserver(muts => { try { // Skip if we're currently applying a transform if (_isApplyingTransform) return; for (const m of muts) { if (m.type === 'attributes' && m.attributeName === 'style') { // If transform has been changed externally, restore expected transform const current = (video && video.style && video.style.transform) || ''; const expectedZoom = readZoomPan().zoom || parseFloat(input.value) || DEFAULT_ZOOM; const expected = `translate(${panX.toFixed(2)}px, ${panY.toFixed(2)}px) scale(${expectedZoom.toFixed(3)})`; // Only restore if transform was actually changed by YouTube (not by us) // and the current zoom is not default if ( expectedZoom !== DEFAULT_ZOOM && current !== expected && current !== _lastTransformApplied ) { // Reapply on next frame to minimize layout thrash requestAnimationFrame(() => { try { applyZoomToVideo(video, expectedZoom, panX, panY); try { logRestoreEvent({ action: 'restore_transform', currentTransform: current, expectedTransform: expected, zoom: expectedZoom, panX, panY, }); } catch {} } catch {} }); } } } } catch {} }); videoStyleObserver.observe(video, { attributes: true, attributeFilter: ['style'] }); } catch {} }; attachStyleObserver(); } catch {} } // If video element is replaced by YouTube (e.g. fullscreen toggle or navigation), rebind handlers const playerObserver = new MutationObserver(() => { try { const newVideo = findVideoElement(); if (newVideo && newVideo !== video) { // Remove listeners from old video try { if (video) { video.removeEventListener('mousedown', mouseDownHandler); video.removeEventListener('wheel', wheelHandler); if (video._panRAF) { cancelAnimationFrame(video._panRAF); video._panRAF = null; } } } catch (err) { console.error('[YouTube+] Error detaching from old video:', err); } // Update reference video = newVideo; // Reattach style observer for the new video element try { if (videoStyleObserver) { try { videoStyleObserver.disconnect(); } catch {} videoStyleObserver = null; } if (video) { videoStyleObserver = new MutationObserver(muts => { try { // Skip if we're currently applying a transform if (_isApplyingTransform) return; for (const m of muts) { if (m.type === 'attributes' && m.attributeName === 'style') { const current = (video && video.style && video.style.transform) || ''; const expectedZoom = readZoomPan().zoom || parseFloat(input.value) || DEFAULT_ZOOM; const expected = `translate(${panX.toFixed(2)}px, ${panY.toFixed(2)}px) scale(${expectedZoom.toFixed(3)})`; // Only restore if transform was actually changed by YouTube (not by us) // and the current zoom is not default if ( expectedZoom !== DEFAULT_ZOOM && current !== expected && current !== _lastTransformApplied ) { requestAnimationFrame(() => { try { applyZoomToVideo(video, expectedZoom, panX, panY); try { logRestoreEvent({ action: 'restore_transform', currentTransform: current, expectedTransform: expected, zoom: expectedZoom, panX, panY, }); } catch {} } catch {} }); } } } } catch {} }); videoStyleObserver.observe(video, { attributes: true, attributeFilter: ['style'] }); } } catch (err) { console.error('[YouTube+] Error attaching style observer to new video:', err); } // Reapply zoom to the new video try { const current = readZoomPan().zoom || DEFAULT_ZOOM; clampPan(current); applyZoomToVideo(video, current, panX, panY); } catch (err) { console.error('[YouTube+] Error applying zoom to new video:', err); } // Attach listeners to new video try { video.addEventListener('mousedown', mouseDownHandler); } catch (err) { console.error('[YouTube+] Error attaching mousedown to new video:', err); } try { video.addEventListener('wheel', wheelHandler, { passive: false }); } catch (err) { console.error('[YouTube+] Error attaching wheel to new video:', err); } } } catch (err) { console.error('[YouTube+] Player observer error:', err); } }); try { playerObserver.observe(player, { childList: true, subtree: true }); } catch (err) { console.error('[YouTube+] Failed to observe player for video changes:', err); } // Reapply zoom on fullscreen change since layout may move elements. // Use a short timeout to allow YouTube to move/replace the video element // when entering/leaving fullscreen, and listen for vendor-prefixed events. const fullscreenHandler = () => { try { const current = readZoomPan().zoom || DEFAULT_ZOOM; // Attempt to find/apply multiple times — YouTube may move/replace the video element setTimeout(() => { try { let attempts = 0; const tryApply = () => { try { const newVideo = findVideoElement(); let swapped = false; if (newVideo && newVideo !== video) { // detach from old video listeners safely try { if (video) video.removeEventListener('wheel', wheelHandler); } catch {} video = newVideo; swapped = true; // Reattach wheel handler if needed try { video.addEventListener('wheel', wheelHandler, { passive: false }); } catch {} } clampPan(current); // Apply zoom without transition during fullscreen to prevent flicker if (video) applyZoomToVideo(video, current, panX, panY, false, true); // If we didn't find/replace video yet, retry a few times if (!swapped && (!video || attempts < FULLSCREEN_APPLY_RETRIES)) { attempts += 1; setTimeout(tryApply, FULLSCREEN_APPLY_RETRY_DELAY); } } catch (e) { console.error('[YouTube+] Fullscreen apply attempt error:', e); } }; tryApply(); } catch (e) { console.error('[YouTube+] Fullscreen inner apply error:', e); } }, FULLSCREEN_APPLY_DELAY); } catch (err) { console.error('[YouTube+] Fullscreen handler error:', err); } }; [ 'fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange', ].forEach(evt => document.addEventListener(evt, fullscreenHandler)); // Apply initial zoom and attach UI // Restore stored pan values (if any) and clamp before applying zoom try { try { const s = readZoomPan(); if (Number.isFinite(s.panX)) panX = s.panX; if (Number.isFinite(s.panY)) panY = s.panY; // Ensure pan is within limits for the initial zoom clampPan(initZoomVal); } catch (err) { console.error('[YouTube+] Restore pan error:', err); } } catch (err) { console.error('[YouTube+] Initial zoom setup error:', err); } // Initialize transform tracking with the initial state try { const initialTransform = `translate(${panX.toFixed(2)}px, ${panY.toFixed(2)}px) scale(${initZoomVal.toFixed(3)})`; _lastTransformApplied = initialTransform; } catch {} setZoom(initZoomVal); // Position the zoom control above YouTube's bottom chrome (progress bar / controls). const updateZoomPosition = () => { try { const chrome = player.querySelector('.ytp-chrome-bottom'); // If chrome exists, place the control just above it; otherwise keep the CSS fallback. if (chrome && chrome.offsetHeight) { const offset = chrome.offsetHeight + 8; // small gap above controls wrap.style.bottom = `${offset}px`; } else { // fallback to original design value wrap.style.bottom = ''; } } catch { // ignore positioning errors } }; // Initial position and reactive updates for fullscreen / resize / chrome changes updateZoomPosition(); // Use a safe ResizeObserver callback that schedules the actual work on the // next animation frame. This reduces the chance of a "ResizeObserver loop // completed with undelivered notifications" error caused by synchronous // layout work inside the observer callback. const ro = new ResizeObserver(_entries => { try { if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { requestAnimationFrame(() => { try { updateZoomPosition(); } catch (e) { try { YouTubeUtils && YouTubeUtils.logError && YouTubeUtils.logError('Enhanced', 'updateZoomPosition failed', e); } catch {} } }); } else { // fallback updateZoomPosition(); } } catch (e) { try { YouTubeUtils && YouTubeUtils.logError && YouTubeUtils.logError('Enhanced', 'ResizeObserver callback error', e); } catch {} } }); // Register observer with cleanup manager so it gets disconnected on unload/cleanup try { if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.registerObserver(ro); } } catch {} try { const chromeEl = player.querySelector('.ytp-chrome-bottom'); if (chromeEl) ro.observe(chromeEl); } catch (e) { try { YouTubeUtils && YouTubeUtils.logError && YouTubeUtils.logError('Enhanced', 'Failed to observe chrome element', e); } catch {} } // Keep a window resize listener for fallback positioning try { window.addEventListener('resize', updateZoomPosition, { passive: true }); if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.registerListener(window, 'resize', updateZoomPosition); } } catch {} // Reposition on fullscreen changes (vendor-prefixed events included) [ 'fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange', ].forEach(evt => { try { document.addEventListener(evt, updateZoomPosition); if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.registerListener(document, evt, updateZoomPosition); } } catch {} }); player.appendChild(wrap); // Sync visibility with YouTube controls (autohide) const chromeBottom = player.querySelector('.ytp-chrome-bottom'); const isControlsHidden = () => { try { // Player class flags if ( player.classList.contains('ytp-autohide') || player.classList.contains('ytp-hide-controls') ) { return true; } // Chrome bottom layer opacity/visibility if (chromeBottom) { const style = window.getComputedStyle(chromeBottom); if ( style && (style.opacity === '0' || style.visibility === 'hidden' || style.display === 'none') ) { return true; } } } catch {} return false; }; const updateHidden = () => { try { if (isControlsHidden()) { wrap.classList.add('ytp-hidden'); } else { wrap.classList.remove('ytp-hidden'); } } catch {} }; // Observe player class changes const visObserver = new MutationObserver(() => updateHidden()); try { visObserver.observe(player, { attributes: true, attributeFilter: ['class', 'style'] }); if (chromeBottom) { visObserver.observe(chromeBottom, { attributes: true, attributeFilter: ['class', 'style'], }); } } catch {} // Temporary show on mousemove over player (like other controls) let showTimer = null; const mouseMoveShow = () => { try { wrap.classList.remove('ytp-hidden'); if (showTimer) clearTimeout(showTimer); showTimer = setTimeout(updateHidden, 2200); } catch {} }; player.addEventListener('mousemove', mouseMoveShow, { passive: true }); // Initial sync updateHidden(); // Cleanup const cleanup = () => { try { // Clear throttle timer if (wheelThrottleTimer) { clearTimeout(wheelThrottleTimer); wheelThrottleTimer = null; } // Clear pan save timer if (panSaveTimer) { clearTimeout(panSaveTimer); panSaveTimer = null; } // Cancel pending RAF if (video && video._panRAF) { cancelAnimationFrame(video._panRAF); video._panRAF = null; } // Remove all event listeners player.removeEventListener('wheel', wheelHandler); player.removeEventListener('pointerdown', pointerDown); player.removeEventListener('pointermove', pointerMove); player.removeEventListener('pointerup', pointerUp); player.removeEventListener('pointercancel', pointerUp); player.removeEventListener('mousemove', mouseMoveShow); window.removeEventListener('keydown', keydownHandler); if (video) { try { video.removeEventListener('mousedown', mouseDownHandler); } catch {} try { video.removeEventListener('wheel', wheelHandler); } catch {} try { window.removeEventListener('mousemove', mouseMoveHandler); } catch {} try { window.removeEventListener('mouseup', mouseUpHandler); } catch {} try { // Reset video styles video.style.cursor = ''; video.style.transform = ''; video.style.willChange = 'auto'; video.style.transition = ''; } catch {} } // Disconnect style observer if (videoStyleObserver) { try { videoStyleObserver.disconnect(); } catch {} videoStyleObserver = null; } // Disconnect observer if (visObserver) { try { visObserver.disconnect(); } catch {} } // Disconnect player mutation observer try { if (playerObserver) playerObserver.disconnect(); } catch {} // Remove fullscreen handler try { document.removeEventListener('fullscreenchange', fullscreenHandler); } catch {} // Clear show timer if (showTimer) { clearTimeout(showTimer); showTimer = null; } // Remove UI element wrap.remove(); } catch (err) { console.error('[YouTube+] Cleanup error:', err); } }; if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.register(cleanup); } return wrap; } // Guard: track whether the yt-navigate-finish listener was already added so that // toggling the zoom feature on/off does not accumulate duplicate listeners. let _navigateListenerAdded = false; // Call this to initialize zoom (e.g. on page load / SPA navigation) function initZoom() { try { if (!featureEnabled) return; const ensure = () => { const player = $('#movie_player'); if (!player) return setTimeout(ensure, 400); createZoomUI(); }; ensure(); if (!_navigateListenerAdded) { _navigateListenerAdded = true; window.addEventListener('yt-navigate-finish', () => setTimeout(() => createZoomUI(), 300)); } } catch { console.error('initZoom error'); } } window.addEventListener('youtube-plus-settings-updated', e => { try { const nextEnabled = e?.detail?.enableZoom !== false; if (nextEnabled === featureEnabled) return; setFeatureEnabled(nextEnabled); } catch { setFeatureEnabled(loadFeatureEnabled()); } }); // Ensure initZoom is used to avoid unused-var lint and to initialize feature try { initZoom(); } catch {} })(); // --- MODULE: voting.js --- /** * Feature Voting System * Supabase-powered voting via REST API */ (function () { 'use strict'; if (typeof window === 'undefined') return; const SUPABASE_URL = 'https://ldpccocxlrdsyejfhrvc.supabase.co'; const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxkcGNjb2N4bHJkc3llamZocnZjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIyMTAyNDYsImV4cCI6MjA4Nzc4NjI0Nn0.QfwrAG4SMJBPLoP-Mcq3hETQXt0ezinoi0CpN57Zn90'; const PREVIEW_FEATURE_TITLE = '__ytp_preview_vote__'; const PREVIEW_FEATURE_DESC = 'Internal row for ytp-plus-voting-preview'; let votingInitialized = false; let voteRequestInFlight = false; function setVoteControlsBusy(container, busy) { if (!container) return; container.querySelectorAll('.ytp-plus-vote-btn, .ytp-plus-vote-bar-btn').forEach(el => { if (busy) { el.setAttribute('aria-disabled', 'true'); el.style.pointerEvents = 'none'; el.style.opacity = '0.7'; } else { el.removeAttribute('aria-disabled'); el.style.pointerEvents = ''; el.style.opacity = ''; } }); } const t = (key, params = {}) => { if (window.YouTubePlusI18n?.t) return window.YouTubePlusI18n.t(key, params); if (window.YouTubeUtils?.t) return window.YouTubeUtils.t(key, params); return key || ''; }; const tf = (key, fallback, params = {}) => { try { const value = t(key, params); if (typeof value === 'string' && value && value !== key) return value; } catch {} return fallback || key || ''; }; function getStatusMeta(status) { const normalized = String(status || '').toLowerCase(); if (normalized === 'completed') { return { className: 'completed', label: tf('statusCompleted', 'Completed'), }; } if (normalized === 'in_progress') { return { className: 'in-progress', label: tf('statusInProgress', 'In progress'), }; } return { className: 'proposed', label: tf('statusProposed', 'Proposed'), }; } // No fallback feature card — when there are no user feature requests, // the list simply shows "No feature requests yet". The preview row in the DB // (__ytp_preview_vote__) is used only for the aggregate vote bar. function getLocalUserId() { let userId = localStorage.getItem('ytp_voting_user_id'); if (!userId) { userId = 'user_' + Math.random().toString(36).substring(2, 15) + Date.now().toString(36); localStorage.setItem('ytp_voting_user_id', userId); } return userId; } function normalizeVoteType(value) { const numeric = Number(value); if (numeric === 1) return 1; if (numeric === -1) return -1; return 0; } async function supabaseFetch(endpoint, options = {}) { const url = `${SUPABASE_URL}/rest/v1/${endpoint}`; const headers = { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}`, 'Content-Type': 'application/json', Prefer: options.prefer || 'return=representation', }; try { const response = await fetch(url, { ...options, headers: { ...headers, ...options.headers }, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.message || `HTTP ${response.status}`); } const data = await response.json().catch(() => null); return { data, error: null }; } catch (error) { return { data: null, error: error.message }; } } async function getFeatures() { const { data, error } = await supabaseFetch( 'ytplus_feature_requests?select=*&order=created_at.desc' ); if (error) { console.error('[Voting] Error fetching features:', error); return []; } return data || []; } async function getAllVotes() { const { data, error } = await supabaseFetch( 'ytplus_feature_votes?select=feature_id,vote_type,ip_address' ); if (error) { console.error('[Voting] Error fetching votes:', error); return {}; } const votes = {}; (data || []).forEach(v => { if (!votes[v.feature_id]) { votes[v.feature_id] = { upvotes: 0, downvotes: 0 }; } const voteType = normalizeVoteType(v.vote_type); if (voteType === 1) votes[v.feature_id].upvotes++; else if (voteType === -1) votes[v.feature_id].downvotes++; }); return votes; } async function getUserVotes() { const userId = getLocalUserId(); const { data, error } = await supabaseFetch( `ytplus_feature_votes?select=feature_id,vote_type&ip_address=eq.${userId}` ); if (error) { console.error('[Voting] Error fetching user votes:', error); return {}; } const userVotes = {}; (data || []).forEach(v => { const voteType = normalizeVoteType(v.vote_type); if (voteType) userVotes[v.feature_id] = voteType; }); return userVotes; } async function vote(featureId, voteType) { const userId = getLocalUserId(); const { data: existing } = await supabaseFetch( `ytplus_feature_votes?feature_id=eq.${featureId}&ip_address=eq.${userId}&select=id` ); if (existing && existing.length > 0) { const existingVote = existing[0]; if (voteType === 0) { await supabaseFetch(`ytplus_feature_votes?id=eq.${existingVote.id}`, { method: 'DELETE' }); return { success: true, action: 'removed' }; } await supabaseFetch(`ytplus_feature_votes?id=eq.${existingVote.id}`, { method: 'PATCH', body: JSON.stringify({ vote_type: voteType }), }); return { success: true, action: 'updated' }; } if (voteType === 0) { return { success: true, action: 'none' }; } const { error } = await supabaseFetch('ytplus_feature_votes', { method: 'POST', body: JSON.stringify({ feature_id: featureId, vote_type: voteType, ip_address: userId, }), }); if (error) { console.error('[Voting] Vote error:', error); return { success: false, error }; } return { success: true, action: 'added' }; } async function submitFeature(title, description) { const userId = getLocalUserId(); const { error } = await supabaseFetch('ytplus_feature_requests', { method: 'POST', body: JSON.stringify({ title, description, author_ip: userId, }), }); if (error) { console.error('[Voting] Submit error:', error); return { success: false, error }; } return { success: true }; } function isPreviewFeature(feature) { return String(feature?.title || '').trim() === PREVIEW_FEATURE_TITLE; } async function ensurePreviewFeature(features) { const fromList = Array.isArray(features) ? features.find(isPreviewFeature) : null; if (fromList) return fromList; const userId = getLocalUserId(); const { data, error } = await supabaseFetch('ytplus_feature_requests', { method: 'POST', body: JSON.stringify({ title: PREVIEW_FEATURE_TITLE, description: PREVIEW_FEATURE_DESC, status: 'proposed', author_ip: userId, }), }); if (error) { console.error('[Voting] Error creating preview row:', error); // Recover if preview row already exists (e.g. conflict/race condition on insert) const encodedTitle = encodeURIComponent(PREVIEW_FEATURE_TITLE); const { data: existingPreview } = await supabaseFetch( `ytplus_feature_requests?select=id,title,description,status&title=eq.${encodedTitle}&limit=1` ); if (Array.isArray(existingPreview) && existingPreview[0]) { return existingPreview[0]; } return null; } if (Array.isArray(data) && data[0]) return data[0]; const refreshed = await getFeatures(); return refreshed.find(isPreviewFeature) || null; } function createVotingUI(container) { container.innerHTML = `

${tf('featureRequests', 'Feature Requests')}

${tf('loading', 'Loading...')}
`; } async function loadFeatures() { const listEl = document.getElementById('ytp-plus-voting-list'); if (!listEl) return; const allFeaturesRaw = await getFeatures(); const previewFeature = await ensurePreviewFeature(allFeaturesRaw); const features = (allFeaturesRaw || []).filter(f => !isPreviewFeature(f)); const allVotes = await getAllVotes(); const userVotes = await getUserVotes(); const renderFeatures = [...features]; if (renderFeatures.length === 0) { listEl.innerHTML = `
${tf('noFeatures', 'No feature requests yet')}
`; // Still update the aggregate vote bar even when there are no user features — // the preview feature in the DB tracks the overall like/dislike count. updateVoteBar(allVotes, userVotes, previewFeature?.id || null); return; } listEl.innerHTML = renderFeatures .map(f => { const votes = allVotes[f.id] || { upvotes: 0, downvotes: 0 }; const userVote = userVotes[f.id] || 0; const totalVotes = votes.upvotes + votes.downvotes; const upPercent = totalVotes > 0 ? Math.round((votes.upvotes / totalVotes) * 100) : 50; const statusMeta = getStatusMeta(f.status); return `
${escapeHtml(f.title)}
${escapeHtml(f.description || '')}
${escapeHtml(statusMeta.label)}
${totalVotes} ${tf('votes', 'votes')}
`; }) .join(''); listEl.querySelectorAll('.ytp-plus-vote-btn').forEach(btn => { btn.addEventListener('click', async () => { if (voteRequestInFlight) return; const featureId = btn.closest('.ytp-plus-voting-item').dataset.featureId; const voteType = parseInt(btn.dataset.vote, 10); const currentUserVote = userVotes[featureId] || 0; let newVoteType = voteType; if (currentUserVote === voteType) { newVoteType = 0; } try { voteRequestInFlight = true; setVoteControlsBusy( listEl.closest('.ytp-plus-settings-section, .ytp-plus-voting') || listEl, true ); const result = await vote(featureId, newVoteType); if (result.success) { await loadFeatures(); } } finally { voteRequestInFlight = false; setVoteControlsBusy( listEl.closest('.ytp-plus-settings-section, .ytp-plus-voting') || listEl, false ); } }); }); // Update aggregate vote bar updateVoteBar(allVotes, userVotes, previewFeature?.id || null); } function escapeHtml(str) { if (!str) return ''; if (window.YouTubeSecurityUtils?.escapeHtml) return window.YouTubeSecurityUtils.escapeHtml(str); const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** Aggregate all feature votes into a single bar above the feature list */ function updateVoteBar(allVotes, userVotes, previewFeatureId) { const fillEl = document.getElementById('ytp-plus-vote-bar-fill'); const countEl = document.getElementById('ytp-plus-vote-bar-count'); const upBtn = document.getElementById('ytp-plus-vote-bar-up'); const downBtn = document.getElementById('ytp-plus-vote-bar-down'); if (!fillEl || !countEl) return; const previewVotes = previewFeatureId ? allVotes[previewFeatureId] || { upvotes: 0, downvotes: 0 } : { upvotes: 0, downvotes: 0 }; const totalUp = previewVotes.upvotes || 0; const totalDown = previewVotes.downvotes || 0; const total = totalUp + totalDown; const pct = total > 0 ? Math.round((totalUp / total) * 100) : 50; fillEl.style.background = `linear-gradient(to right, #4caf50 ${pct}%, #f44336 ${pct}%)`; countEl.textContent = total > 0 ? `${total}` : '0'; const previewUserVote = previewFeatureId ? userVotes[previewFeatureId] || 0 : 0; if (upBtn) upBtn.classList.toggle('active', previewUserVote === 1); if (downBtn) downBtn.classList.toggle('active', previewUserVote === -1); } /** Before/After comparison slider */ function initSlider() { const container = document.querySelector('.ytp-plus-ba-container'); if (!container || container.dataset.sliderInit) return; container.dataset.sliderInit = '1'; const afterEl = container.querySelector('.ytp-plus-ba-after'); const divider = container.querySelector('.ytp-plus-ba-divider'); if (!afterEl || !divider) return; let dragging = false; let resumeTimer = null; let rafId = null; function setPosition(pct, manual = false) { const clamped = Math.max(2, Math.min(98, pct)); afterEl.style.clipPath = `inset(0 0 0 ${clamped}%)`; if (manual) { divider.style.left = `${clamped}%`; } divider.setAttribute('aria-valuenow', String(Math.round(clamped))); } function getPct(clientX) { const rect = container.getBoundingClientRect(); return ((clientX - rect.left) / rect.width) * 100; } function pauseAutoplay() { divider.classList.remove('autoplay'); if (rafId) { cancelAnimationFrame(rafId); rafId = null; } if (resumeTimer) clearTimeout(resumeTimer); resumeTimer = setTimeout(() => { divider.classList.add('autoplay'); startAutoplayRaf(); }, 3000); } function startAutoplayRaf() { if (rafId) return; function loop() { if (!divider.classList.contains('autoplay')) { rafId = null; return; } const rect = container.getBoundingClientRect(); const dRect = divider.getBoundingClientRect(); const pct = ((dRect.left + dRect.width / 2 - rect.left) / rect.width) * 100; setPosition(pct, false); rafId = requestAnimationFrame(loop); } rafId = requestAnimationFrame(loop); } container.addEventListener('mousedown', e => { dragging = true; pauseAutoplay(); setPosition(getPct(e.clientX), true); e.preventDefault(); }); window.addEventListener('mousemove', e => { if (dragging) setPosition(getPct(e.clientX), true); }); window.addEventListener('mouseup', () => { dragging = false; }); container.addEventListener( 'touchstart', e => { dragging = true; pauseAutoplay(); setPosition(getPct(e.touches[0].clientX), true); }, { passive: true } ); window.addEventListener( 'touchmove', e => { if (dragging) setPosition(getPct(e.touches[0].clientX), true); }, { passive: true } ); window.addEventListener('touchend', () => { dragging = false; }); divider.addEventListener('keydown', e => { pauseAutoplay(); const cur = parseFloat(divider.getAttribute('aria-valuenow') || '50'); if (e.key === 'ArrowLeft') { setPosition(cur - 2, true); e.preventDefault(); } if (e.key === 'ArrowRight') { setPosition(cur + 2, true); e.preventDefault(); } }); // initial position 50% setPosition(50, true); // start autoplay after short delay setTimeout(() => { divider.classList.add('autoplay'); startAutoplayRaf(); }, 400); } function initVoting() { if (votingInitialized) return; votingInitialized = true; // Vote bar aggregate buttons document.addEventListener('click', async e => { const barBtn = e.target.closest('.ytp-plus-vote-bar-btn'); if (barBtn) { if (voteRequestInFlight) return; const features = await getFeatures(); const previewFeature = await ensurePreviewFeature(features); if (!previewFeature?.id) return; const userVotes = await getUserVotes(); const voteType = parseInt(barBtn.dataset.vote, 10); const currentUserVote = userVotes[previewFeature.id] || 0; const newVoteType = currentUserVote === voteType ? 0 : voteType; const controlsRoot = barBtn.closest('.ytp-plus-settings-section, .ytp-plus-voting') || document.body; try { voteRequestInFlight = true; setVoteControlsBusy(controlsRoot, true); await vote(previewFeature.id, newVoteType); await loadFeatures(); } finally { voteRequestInFlight = false; setVoteControlsBusy(controlsRoot, false); } } }); document.addEventListener('click', e => { const showAddBtn = e.target.closest('#ytp-plus-show-add-feature'); const cancelBtn = e.target.closest('#ytp-plus-cancel-feature'); const submitBtn = e.target.closest('#ytp-plus-submit-feature'); if (showAddBtn) { const addFormEl = document.getElementById('ytp-plus-voting-add-form'); const showAddEl = document.getElementById('ytp-plus-show-add-feature'); if (addFormEl) addFormEl.style.display = 'block'; if (showAddEl) showAddEl.style.display = 'none'; } if (cancelBtn) { const addFormEl = document.getElementById('ytp-plus-voting-add-form'); const showAddEl = document.getElementById('ytp-plus-show-add-feature'); const titleEl = document.getElementById('ytp-plus-feature-title'); const descEl = document.getElementById('ytp-plus-feature-desc'); if (addFormEl) addFormEl.style.display = 'none'; if (showAddEl) showAddEl.style.display = 'block'; if (titleEl) titleEl.value = ''; if (descEl) descEl.value = ''; } if (submitBtn) { const titleInput = document.getElementById('ytp-plus-feature-title'); const descInput = document.getElementById('ytp-plus-feature-desc'); const title = titleInput?.value?.trim() || ''; const desc = descInput?.value?.trim() || ''; if (!title) return; submitBtn.disabled = true; submitBtn.textContent = tf('loading', 'Loading...'); submitFeature(title, desc).then(result => { submitBtn.disabled = false; submitBtn.textContent = tf('submit', 'Submit'); if (result.success) { const addFormEl = document.getElementById('ytp-plus-voting-add-form'); const showAddEl = document.getElementById('ytp-plus-show-add-feature'); if (addFormEl) addFormEl.style.display = 'none'; if (showAddEl) showAddEl.style.display = 'block'; if (titleInput) titleInput.value = ''; if (descInput) descInput.value = ''; loadFeatures(); } }); } }); } const VotingSystem = { init: initVoting, createUI: createVotingUI, loadFeatures, getFeatures, vote, submitFeature, initSlider, updateVoteBar, }; if (typeof window.YouTubePlus === 'undefined') { window.YouTubePlus = {}; } window.YouTubePlus.Voting = VotingSystem; })();