Greasy Fork is available in English.
标签视图 YouTube、下载及其他功能 ↴
当前为
// ==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/[email protected]/dist/signals-core.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/browser-id3-writer.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/preact.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/hooks/dist/hooks.umd.js // @require https://cdn.jsdelivr.net/npm/@preact/[email protected]/dist/signals.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/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 // ==/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<string, string>} */ 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<string, string>} */ 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<T>} fn - Async function to retry * @param {number} maxRetries - Maximum number of retries * @param {number} baseDelay - Base delay in milliseconds * @returns {Promise<T>} 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<Response>} 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<string | Node>} 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<HTMLElement>} 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 = ` <svg width="24" height="24" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"> <path d="M39.23,26a16.52,16.52,0,0,0,.14-2,16.52,16.52,0,0,0-.14-2l4.33-3.39a1,1,0,0,0,.25-1.31l-4.1-7.11a1,1,0,0,0-1.25-.44l-5.11,2.06a15.68,15.68,0,0,0-3.46-2l-.77-5.43a1,1,0,0,0-1-.86H19.9a1,1,0,0,0-1,.86l-.77,5.43a15.36,15.36,0,0,0-3.46,2L9.54,9.75a1,1,0,0,0-1.25.44L4.19,17.3a1,1,0,0,0,.25,1.31L8.76,22a16.66,16.66,0,0,0-.14,2,16.52,16.52,0,0,0,.14,2L4.44,29.39a1,1,0,0,0-.25,1.31l4.1,7.11a1,1,0,0,0,1.25.44l5.11-2.06a15.68,15.68,0,0,0,3.46,2l.77,5.43a1,1,0,0,0,1,.86h8.2a1,1,0,0,0,1-.86l.77-5.43a15.36,15.36,0,0,0,3.46-2l5.11,2.06a1,1,0,0,0,1.25-.44l4.1-7.11a1,1,0,0,0-.25-1.31ZM24,31.18A7.18,7.18,0,1,1,31.17,24,7.17,7.17,0,0,1,24,31.18Z"/> </svg> `; 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 = `<div class="ytp-plus-settings-panel">${helpers.createSettingsSidebar(t)}${helpers.createMainContent(this.settings, t)}</div>`; // 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 = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;vertical-align:middle;"> <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path> <circle cx="12" cy="13" r="4"></circle> </svg> `; 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 = `<span>${this.speedControl.currentSpeed}×</span>`; 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<void>} */ 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<Function>} tasks - Array of task functions * @param {number} [yieldInterval=50] - Yield after this many ms * @returns {Promise<void>} */ 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<string, {element: Element|null, timestamp: number}>} */ this.cache = new Map(); /** @type {Map<string, NodeList|Element[]>} */ 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<string, WeakMap<Element, any>>} */ this.scopedCaches = new Map(); } /** * Get or create cache for a scope * @param {string} scope - Scope identifier * @returns {WeakMap<Element, any>} */ 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<Element|Element[]|null>} */ 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<Element|null>} */ 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<string, Map<string, Set<Function>>>} */ this.delegatedHandlers = new Map(); /** @type {Map<Element, Map<string, Function>}>} */ 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<string, {fn: Function, priority: number, loaded: boolean}>} */ this.modules = new Map(); /** @type {Set<string>} */ 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<boolean>} 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>} 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 http://greasyfork.icu/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.<string, Element | null>} */ 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 = `<path d="M80 27H12A12 12 90 0 0 0 39v42a12 12 90 0 0 12 12h12v20a2 2 90 0 0 3.4 2L47 93h33a12 12 90 0 0 12-12V39a12 12 90 0 0-12-12zM20 47h26a2 2 90 1 1 0 4H20a2 2 90 1 1 0-4zm52 28H20a2 2 90 1 1 0-4h52a2 2 90 1 1 0 4zm0-12H20a2 2 90 1 1 0-4h52a2 2 90 1 1 0 4zm36-58H40a12 12 90 0 0-12 12v6h52c9 0 16 7 16 16v42h0v4l7 7a2 2 90 0 0 3-1V71h2a12 12 90 0 0 12-12V17a12 12 90 0 0-12-12z"/>`.trim(); const svgVideos = `<path d="M89 10c0-4-3-7-7-7H7c-4 0-7 3-7 7v70c0 4 3 7 7 7h75c4 0 7-3 7-7V10zm-62 2h13v10H27V12zm-9 66H9V68h9v10zm0-56H9V12h9v10zm22 56H27V68h13v10zm-3-25V36c0-2 2-3 4-2l12 8c2 1 2 4 0 5l-12 8c-2 1-4 0-4-2zm25 25H49V68h13v10zm0-56H49V12h13v10zm18 56h-9V68h9v10zm0-56h-9V12h9v10z"/>`.trim(); const svgInfo = `<path d="M30 0C13.3 0 0 13.3 0 30s13.3 30 30 30 30-13.3 30-30S46.7 0 30 0zm6.2 46.6c-1.5.5-2.6 1-3.6 1.3a10.9 10.9 0 0 1-3.3.5c-1.7 0-3.3-.5-4.3-1.4a4.68 4.68 0 0 1-1.6-3.6c0-.4.2-1 .2-1.5a20.9 20.9 90 0 1 .3-2l2-6.8c.1-.7.3-1.3.4-1.9a8.2 8.2 90 0 0 .3-1.6c0-.8-.3-1.4-.7-1.8s-1-.5-2-.5a4.53 4.53 0 0 0-1.6.3c-.5.2-1 .2-1.3.4l.6-2.1c1.2-.5 2.4-1 3.5-1.3s2.3-.6 3.3-.6c1.9 0 3.3.6 4.3 1.3s1.5 2.1 1.5 3.5c0 .3 0 .9-.1 1.6a10.4 10.4 90 0 1-.4 2.2l-1.9 6.7c-.2.5-.2 1.1-.4 1.8s-.2 1.3-.2 1.6c0 .9.2 1.6.6 1.9s1.1.5 2.1.5a6.1 6.1 90 0 0 1.5-.3 9 9 90 0 0 1.4-.4l-.6 2.2zm-3.8-35.2a1 1 0 010 8.6 1 1 0 010-8.6z"/>`.trim(); const svgPlayList = `<path d="M0 3h12v2H0zm0 4h12v2H0zm0 4h8v2H0zm16 0V7h-2v4h-4v2h4v4h2v-4h4v-2z"/>`.trim(); const svgElm = (w, h, vw, vh, p, m) => `<svg${m ? ` class=${m}` : ''} width="${w}" height="${h}" viewBox="0 0 ${vw} ${vh}" preserveAspectRatio="xMidYMid meet">${p}</svg>`; const hiddenTabsByUserCSS = 0; function getTabsHTML() { const sTabBtnVideos = `${svgElm(16, 16, 90, 90, svgVideos)}<span>${getWord('videos')}</span>`; const sTabBtnInfo = `${svgElm(16, 16, 60, 60, svgInfo)}<span>${getWord('info')}</span>`; const sTabBtnPlayList = `${svgElm(16, 16, 20, 20, svgPlayList)}<span>${getWord('playlist')}</span>`; const str1 = ` <paper-ripple class="style-scope yt-icon-button"> <div id="background" class="style-scope paper-ripple" style="opacity:0;"></div> <div id="waves" class="style-scope paper-ripple"></div> </paper-ripple> `; const str_fbtns = ` <div class="font-size-right"> <div class="font-size-btn font-size-plus" tyt-di="8rdLQ"> <svg width="12" height="12" viewbox="0 0 50 50" preserveAspectRatio="xMidYMid meet" stroke="currentColor" stroke-width="6" stroke-linecap="round" vector-effect="non-scaling-size"> <path d="M12 25H38M25 12V38"/> </svg> </div><div class="font-size-btn font-size-minus" tyt-di="8rdLQ"> <svg width="12" height="12" viewbox="0 0 50 50" preserveAspectRatio="xMidYMid meet" stroke="currentColor" stroke-width="6" stroke-linecap="round" vector-effect="non-scaling-size"> <path d="M12 25h26"/> </svg> </div> </div> `.replace(/[\r\n]+/g, ''); const str_tabs = [ `<a id="tab-btn1" tyt-di="q9Kjc" tyt-tab-content="#tab-info" class="tab-btn${(hiddenTabsByUserCSS & 1) === 1 ? ' tab-btn-hidden' : ''}">${sTabBtnInfo}${str1}${str_fbtns}</a>`, `<a id="tab-btn3" tyt-di="q9Kjc" tyt-tab-content="#tab-comments" class="tab-btn${(hiddenTabsByUserCSS & 2) === 2 ? ' tab-btn-hidden' : ''}">${svgElm(16, 16, 120, 120, svgComments)}<span id="tyt-cm-count"></span>${str1}${str_fbtns}</a>`, `<a id="tab-btn4" tyt-di="q9Kjc" tyt-tab-content="#tab-videos" class="tab-btn${(hiddenTabsByUserCSS & 4) === 4 ? ' tab-btn-hidden' : ''}">${sTabBtnVideos}${str1}${str_fbtns}</a>`, `<a id="tab-btn5" tyt-di="q9Kjc" tyt-tab-content="#tab-list" class="tab-btn tab-btn-hidden">${sTabBtnPlayList}${str1}${str_fbtns}</a>`, ].join(''); const addHTML = ` <div id="right-tabs"> <tabview-view-pos-thead></tabview-view-pos-thead> <header> <div id="material-tabs"> ${str_tabs} </div> </header> <div class="tab-content"> <div id="tab-info" class="tab-content-cld tab-content-hidden" tyt-hidden userscript-scrollbar-render></div> <div id="tab-comments" class="tab-content-cld tab-content-hidden" tyt-hidden userscript-scrollbar-render></div> <div id="tab-videos" class="tab-content-cld tab-content-hidden" tyt-hidden userscript-scrollbar-render></div> <div id="tab-list" class="tab-content-cld tab-content-hidden" tyt-hidden userscript-scrollbar-render></div> </div> </div> `; 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.<string, number>} */ 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 http://greasyfork.icu/en/scripts/456108 } } else { link.classList.add('active'); if (content.hasAttribute000('tyt-hidden')) { content.removeAttribute000('tyt-hidden'); // for http://greasyfork.icu/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<Object>} 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<Object>} 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<string, string>} */ const translationCache = new Map(); /** * Language change listeners * @type {Set<Function>} */ 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<boolean>} 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<boolean>} 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 ` <div class="ytp-plus-settings-sidebar"> <div class="ytp-plus-settings-sidebar-header"> <h2 class="ytp-plus-settings-title">${t('settingsTitle')}</h2> </div> <div class="ytp-plus-settings-nav"> ${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())} </div> </div> `; } /** * 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 ` <div class="ytp-plus-settings-nav-item${activeClass}" data-section="${section}"> ${icon} ${label} </div> `; } /** * SVG icon creators */ function createBasicIcon() { return ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/> <circle cx="9" cy="9" r="2"/> <path d="m21 15-3.086-3.086a2 2 0 0 0-1.414-.586H13l-2-2v3h6l3 3"/> </svg> `; } function createAdvancedIcon() { return ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="3"/> <path d="m12 1 0 6m0 6 0 6"/> <path d="m17.5 6.5-4.5 4.5m0 0-4.5 4.5m9-9L12 12l5.5 5.5"/> </svg> `; } function createExperimentalIcon() { return ` <svg width="64px" height="64px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M18.019 4V15.0386L6.27437 39.3014C5.48686 40.9283 6.16731 42.8855 7.79421 43.673C8.23876 43.8882 8.72624 44 9.22013 44H38.7874C40.5949 44 42.0602 42.5347 42.0602 40.7273C42.0602 40.2348 41.949 39.7488 41.7351 39.3052L30.0282 15.0386V4H18.019Z" stroke="currentColor" stroke-width="4" stroke-linejoin="round"></path> <path d="M10.9604 29.9998C13.1241 31.3401 15.2893 32.0103 17.4559 32.0103C19.6226 32.0103 21.7908 31.3401 23.9605 29.9998C26.1088 28.6735 28.2664 28.0103 30.433 28.0103C32.5997 28.0103 34.7755 28.6735 36.9604 29.9998" stroke="currentColor" stroke-width="4" stroke-linecap="round"></path> </svg> `; } function createReportIcon() { return ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> <polyline points="14 2 14 8 20 8"></polyline> <line x1="12" y1="18" x2="12" y2="12"></line> <line x1="12" y1="9" x2="12.01" y2="9"></line> </svg> `; } function createAboutIcon() { return ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="10"/> <path d="m9 12 2 2 4-4"/> </svg> `; } function createVotingIcon() { return ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M7 10v12"/> <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2h0a3.13 3.13 0 0 1 3 3.88Z"/> </svg> `; } /** * 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 ` <div class="ytp-plus-settings-item"> <div> <label class="ytp-plus-settings-item-label" for="${inputId}">${label}</label> <div class="ytp-plus-settings-item-description">${description}</div> </div> <input type="checkbox" id="${inputId}" class="ytp-plus-settings-checkbox" data-setting="${setting}" ${checked ? 'checked' : ''}> </div> `; } /** * 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 ` <div class="download-site-option"> <div class="download-site-header"> <label for="${inputId}" class="download-site-label"> <div class="download-site-name">${name}</div> <div class="download-site-desc">${description}</div> </label> <input type="checkbox" id="${inputId}" class="ytp-plus-settings-checkbox" data-setting="downloadSite_${key}" ${checked ? 'checked' : ''}> </div> ${hasControls ? `<div class="download-site-controls" style="display:${checked ? 'block' : 'none'};">${controls}</div>` : ''} </div> `; } /** * 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 ` <input type="text" placeholder="${t('siteName')}" value="${name}" data-site="externalDownloader" data-field="name" class="download-site-input"> <input type="text" placeholder="${t('urlTemplate')}" value="${url}" data-site="externalDownloader" data-field="url" class="download-site-input small"> <div class="download-site-cta"> <button class="glass-button" id="download-externalDownloader-save">${t('saveButton')}</button> <button class="glass-button danger" id="download-externalDownloader-reset">${t('resetButton')}</button> </div> `; } /** * Creates YTDL controls * @returns {string} Controls HTML */ function createYTDLControls() { return ` <div class="download-site-cta one-btn"> <button class="glass-button" id="open-ytdl-github"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15,3 21,3 21,9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> GitHub </button> </div> `; } /** * 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 ` <div class="download-submenu" data-submenu="download" style="display:${display};"> <div class="glass-card download-submenu-container"> ${sites.map(site => createDownloadSiteOption(site, t)).join('')} </div> </div> `; } /** * 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 ` <div class="style-submenu" data-submenu="style" style="display:${display};"> <div class="glass-card style-submenu-container"> ${rows.map(r => createSettingsItem(r.label, r.desc, r.key, r.value)).join('')} </div> </div> `; } /** * 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 ` <div class="speed-submenu" data-submenu="speed" style="display:${display};"> <div class="glass-card speed-submenu-container"> <div class="ytp-plus-settings-item speed-hotkeys-row"> <div class="speed-hotkeys-info"> <div class="ytp-plus-settings-item-label">${tr(t, 'speedHotkeysTitle', 'Keyboard hotkeys')}</div> <div class="ytp-plus-settings-item-description">${tr( t, 'speedHotkeysDesc', 'Use single-letter shortcuts to decrease/increase/reset playback speed' )}</div> <div class="speed-hotkeys-fields"> <label class="speed-hotkey-field"> <input type="text" class="speed-hotkey-input" data-speed-hotkey="decrease" value="${decrease}" maxlength="1" autocomplete="off" spellcheck="false" > <span>${tr(t, 'decreaseSpeedHotkey', 'Decrease')}</span> </label> <label class="speed-hotkey-field"> <input type="text" class="speed-hotkey-input" data-speed-hotkey="increase" value="${increase}" maxlength="1" autocomplete="off" spellcheck="false" > <span>${tr(t, 'increaseSpeedHotkey', 'Increase')}</span> </label> <label class="speed-hotkey-field"> <input type="text" class="speed-hotkey-input" data-speed-hotkey="reset" value="${reset}" maxlength="1" autocomplete="off" spellcheck="false" > <span>${tr(t, 'resetButton', 'Reset')}</span> </label> </div> </div> </div> </div> </div> `; } /** * 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 ` <div class="loop-submenu" data-submenu="loop" style="display:${display};margin:0 0 4px 0;"> <div class="ytp-plus-settings-item loop-hotkeys-row" style="margin-bottom:0;"> <div class="loop-hotkeys-info"> <div class="ytp-plus-settings-item-label">${tr(t, 'loopSegmentTitle', 'Loop A → B')}</div> <div class="ytp-plus-settings-item-description">${tr( t, 'loopSegmentDesc', 'Repeat a custom segment of the video (A → B)' )}</div> <div class="loop-hotkeys-fields" style="margin-top:12px;"> <label class="loop-hotkey-field"> <input type="text" class="loop-hotkey-input" data-loop-hotkey="setPointA" value="${setPointA}" maxlength="1" autocomplete="off" spellcheck="false" > <span>${tr(t, 'setPointAHotkey', 'Set Point A')}</span> </label> <label class="loop-hotkey-field"> <input type="text" class="loop-hotkey-input" data-loop-hotkey="setPointB" value="${setPointB}" maxlength="1" autocomplete="off" spellcheck="false" > <span>${tr(t, 'setPointBHotkey', 'Set Point B')}</span> </label> <label class="loop-hotkey-field"> <input type="text" class="loop-hotkey-input" data-loop-hotkey="resetPoints" value="${resetPoints}" maxlength="1" autocomplete="off" spellcheck="false" > <span>${tr(t, 'resetButton', 'Reset')}</span> </label> </div> </div> </div> </div> `; } /** * 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 ` <div class="ytp-plus-settings-section" data-section="basic"> <div class="ytp-plus-settings-item ytp-plus-settings-item--with-submenu"> <div> <label class="ytp-plus-settings-item-label" for="ytp-plus-setting-enableZenStyles">${tr( t, 'zenStylesTitle', 'Zen styles' )}</label> <div class="ytp-plus-settings-item-description">${tr( t, 'zenStylesDesc', 'Optional UI tweaks and cosmetic improvements' )}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="style" aria-label="Toggle styles submenu" aria-expanded="${styleEnabled ? 'true' : 'false'}" ${styleEnabled ? '' : 'disabled'} style="display:${styleEnabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" id="ytp-plus-setting-enableZenStyles" class="ytp-plus-settings-checkbox" data-setting="enableZenStyles" ${ styleEnabled ? 'checked' : '' }> </div> </div> ${createStyleSubmenu(settings, t)} <div class="ytp-plus-settings-item ytp-plus-settings-item--with-submenu"> <div> <label class="ytp-plus-settings-item-label" for="ytp-plus-setting-enableSpeedControl">${t( 'speedControl' )}</label> <div class="ytp-plus-settings-item-description">${t('speedControlDesc')}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="speed" aria-label="Toggle speed submenu" aria-expanded="${speedEnabled ? 'true' : 'false'}" ${speedEnabled ? '' : 'disabled'} style="display:${speedEnabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" id="ytp-plus-setting-enableSpeedControl" class="ytp-plus-settings-checkbox" data-setting="enableSpeedControl" ${ speedEnabled ? 'checked' : '' }> </div> </div> ${createSpeedControlSubmenu(settings, t)} ${createSettingsItem(t('screenshotButton'), t('screenshotButtonDesc'), 'enableScreenshot', settings.enableScreenshot)} <div class="ytp-plus-settings-item ytp-plus-settings-item--with-submenu"> <div> <label class="ytp-plus-settings-item-label" for="ytp-plus-setting-enableDownload">${t( 'downloadButton' )}</label> <div class="ytp-plus-settings-item-description">${t('downloadButtonDesc')}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="download" aria-label="Toggle download submenu" aria-expanded="${downloadEnabled ? 'true' : 'false'}" ${downloadEnabled ? '' : 'disabled'} style="display:${downloadEnabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" id="ytp-plus-setting-enableDownload" class="ytp-plus-settings-checkbox" data-setting="enableDownload" ${ settings.enableDownload ? 'checked' : '' }> </div> </div> ${createDownloadSubmenu(settings, t)} </div> `; } /** * Creates the about section with logo * @returns {string} About section HTML */ function createAboutSection() { return ` <div class="ytp-plus-settings-section hidden" data-section="about"> <svg class="app-icon" width="90" height="90" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1"> <path d="m23.24,4.62c-0.85,0.45 -2.19,2.12 -4.12,5.13c-1.54,2.41 -2.71,4.49 -3.81,6.8c-0.55,1.14 -1.05,2.2 -1.13,2.35c-0.08,0.16 -0.78,0.7 -1.66,1.28c-1.38,0.91 -1.8,1.29 -1.4,1.28c0.08,0 0.67,-0.35 1.31,-0.77c0.64,-0.42 1.19,-0.76 1.2,-0.74c0.02,0.02 -0.1,0.31 -0.25,0.66c-1.03,2.25 -1.84,5.05 -1.84,6.37c0.01,1.89 0.84,2.67 2.86,2.67c1.08,0 1.94,-0.31 3.66,-1.29c1.84,-1.06 3.03,-1.93 4.18,-3.09c1.69,-1.7 2.91,-3.4 3.28,-4.59c0.59,-1.9 -0.1,-3.08 -2.02,-3.44c-0.87,-0.16 -2.85,-0.14 -3.75,0.06c-1.78,0.38 -2.74,0.76 -2.5,1c0.03,0.03 0.5,-0.1 1.05,-0.28c1.49,-0.48 2.34,-0.59 3.88,-0.53c1.64,0.07 2.09,0.19 2.69,0.75l0.46,0.43l0,0.87c0,0.74 -0.05,0.98 -0.35,1.6c-0.69,1.45 -2.69,3.81 -4.37,5.14c-0.93,0.74 -2.88,1.94 -4.07,2.5c-1.64,0.77 -3.56,0.72 -4.21,-0.11c-0.39,-0.5 -0.5,-1.02 -0.44,-2.11c0.05,-0.85 0.16,-1.32 0.67,-2.86c0.34,-1.01 0.86,-2.38 1.15,-3.04c0.52,-1.18 0.55,-1.22 1.6,-2.14c4.19,-3.65 8.42,-9.4 9.02,-12.26c0.2,-0.94 0.13,-1.46 -0.21,-1.7c-0.31,-0.22 -0.38,-0.21 -0.89,0.06m0.19,0.26c-0.92,0.41 -3.15,3.44 -5.59,7.6c-1.05,1.79 -3.12,5.85 -3.02,5.95c0.07,0.07 1.63,-1.33 2.58,-2.34c1.57,-1.65 3.73,-4.39 4.88,-6.17c1.31,-2.03 2.06,-4.11 1.77,-4.89c-0.13,-0.34 -0.16,-0.35 -0.62,-0.15m11.69,13.32c-0.3,0.6 -1.19,2.54 -1.98,4.32c-1.6,3.62 -1.67,3.71 -2.99,4.34c-1.13,0.54 -2.31,0.85 -3.54,0.92c-0.99,0.06 -1.08,0.04 -1.38,-0.19c-0.28,-0.22 -0.31,-0.31 -0.26,-0.7c0.03,-0.25 0.64,-1.63 1.35,-3.08c1.16,-2.36 2.52,-5.61 2.52,-6.01c0,-0.49 -0.36,0.19 -1.17,2.22c-0.51,1.26 -1.37,3.16 -1.93,4.24c-0.55,1.08 -1.04,2.17 -1.09,2.43c-0.1,0.59 0.07,1.03 0.49,1.28c0.78,0.46 3.3,0.06 5.13,-0.81l0.93,-0.45l-0.66,1.25c-0.7,1.33 -3.36,6.07 -4.31,7.67c-2.02,3.41 -3.96,5.32 -6.33,6.21c-2.57,0.96 -4.92,0.74 -6.14,-0.58c-0.81,-0.88 -0.82,-1.71 -0.04,-3.22c1.22,-2.36 6.52,-6.15 10.48,-7.49c0.52,-0.18 0.95,-0.39 0.95,-0.46c0,-0.21 -0.19,-0.18 -1.24,0.2c-1.19,0.43 -3.12,1.37 -4.34,2.11c-2.61,1.59 -5.44,4.09 -6.13,5.43c-1.15,2.2 -0.73,3.61 1.4,4.6c0.59,0.28 0.75,0.3 2.04,0.3c1.67,0 2.42,-0.18 3.88,-0.89c1.87,-0.92 3.17,-2.13 4.72,-4.41c0.98,-1.44 4.66,-7.88 5.91,-10.33c0.25,-0.49 0.68,-1.19 0.96,-1.56c0.28,-0.37 0.76,-1.15 1.06,-1.73c0.82,-1.59 2.58,-6.10 2.58,-6.6c0,-0.06 -0.07,-0.1 -0.17,-0.1c-0.10,0 -0.39,0.44 -0.71,1.09m-1.34,3.7c-0.93,2.08 -1.09,2.48 -0.87,2.2c0.19,-0.24 1.66,-3.65 1.6,-3.71c-0.02,-0.02 -0.35,0.66 -0.73,1.51" fill="none" fill-rule="evenodd" stroke="currentColor" /> </svg> <h1>YouTube +</h1><br><br> </div> `; } /** * 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 ` <div class="ytp-plus-settings-section hidden" data-section="advanced"> <div class="ytp-plus-settings-group"> <div class="ytp-plus-settings-item ytp-plus-settings-item--with-submenu"> <div> <label class="ytp-plus-settings-item-label">${tr(t, 'enhancedFeaturesTitle', 'Enhanced Features')}</label> <div class="ytp-plus-settings-item-description">${tr(t, 'enhancedFeaturesDesc', 'Additional productivity features and UI enhancements')}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="enhanced" aria-label="Toggle enhanced features submenu" aria-expanded="${enhancedEnabled ? 'true' : 'false'}" ${enhancedEnabled ? '' : 'disabled'} style="display:${enhancedEnabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" class="ytp-plus-settings-checkbox" data-setting="enableEnhanced" ${ enhancedEnabled ? 'checked' : '' }> </div> </div> <div class="enhanced-submenu" data-submenu="enhanced" style="display:${ enhancedEnabled ? 'block' : 'none' };margin-left:12px;margin-bottom:12px;"> <div class="glass-card" style="display:flex;flex-direction:column;gap:8px;"> <div class="endscreen-settings-slot"></div> ${createSettingsItem( tr(t, 'enablePlayAllLabel', 'Play All Button'), tr(t, 'enablePlayAllDesc', 'Add Play All button to playlists and channel pages'), 'enablePlayAll', enhancedSettings.enablePlayAll )} ${createSettingsItem( tr(t, 'enableResumeTimeLabel', 'Resume Playback'), tr(t, 'enableResumeTimeDesc', 'Remember video position and offer to resume'), 'enableResumeTime', enhancedSettings.enableResumeTime )} ${createSettingsItem( tr(t, 'enableZoomLabel', 'Video Zoom'), tr(t, 'enableZoomDesc', 'Enable zoom and pan controls for video player'), 'enableZoom', enhancedSettings.enableZoom )} ${createSettingsItem( tr(t, 'thumbnailPreview', 'Thumbnail Preview'), tr( t, 'thumbnailPreviewDesc', 'Add a button to thumbnails/avatars/banners to open the original image' ), 'enableThumbnail', enhancedSettings.enableThumbnail )} ${createSettingsItem( tr(t, 'enablePlaylistSearchLabel', 'Playlist Search'), tr(t, 'enablePlaylistSearchDesc', 'Add search functionality to playlist panels'), 'enablePlaylistSearch', enhancedSettings.enablePlaylistSearch )} ${createSettingsItem( tr(t, 'scrollToTopButtonLabel', 'Scroll to Top'), tr(t, 'scrollToTopButtonDesc', 'Show scroll-to-top button on pages'), 'enableScrollToTopButton', enhancedSettings.enableScrollToTopButton )} <div class="ytp-plus-settings-item ytp-plus-settings-item--with-submenu" style="margin-top:4px;"> <div> <label class="ytp-plus-settings-item-label">${tr(t, 'enableLoopLabel', 'Loop')}</label> <div class="ytp-plus-settings-item-description">${tr(t, 'enableLoopDesc', 'Enable looping of videos and custom segments (A → B)')}</div> </div> <div class="ytp-plus-settings-item-actions"> <input type="checkbox" class="ytp-plus-settings-checkbox" data-setting="enableLoop" ${ settings.enableLoop ? 'checked' : '' }> </div> </div> ${createLoopSubmenu(settings, t)} </div> </div> <div class="ytp-plus-settings-item ytp-plus-settings-item--with-submenu"> <div> <label class="ytp-plus-settings-item-label">${t('youtubeMusicTitle')}</label> <div class="ytp-plus-settings-item-description">${t('youtubeMusicDesc')}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="music" aria-label="Toggle YouTube Music submenu" aria-expanded="${musicEnabled ? 'true' : 'false'}" ${musicEnabled ? '' : 'disabled'} style="display:${musicEnabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" class="ytp-plus-settings-checkbox" data-setting="enableMusic" ${ musicSettings.enableMusic ? 'checked' : '' }> </div> </div> <div class="music-submenu" data-submenu="music" style="display:${ musicEnabled ? 'block' : 'none' };margin-left:12px;margin-bottom:12px;"> <div class="glass-card" style="display:flex;flex-direction:column;gap:8px;"> ${createSettingsItem( t('immersiveSearchLabel'), t('immersiveSearchDesc'), 'immersiveSearchStyles', musicSettings.immersiveSearchStyles )} ${createSettingsItem( t('sidebarHoverLabel'), t('sidebarHoverDesc'), 'hoverStyles', musicSettings.hoverStyles )} ${createSettingsItem( t('playerSidebarStylesLabel'), t('playerSidebarStylesDesc'), 'playerSidebarStyles', musicSettings.playerSidebarStyles )} ${createSettingsItem( t('centeredPlayerLabel'), t('centeredPlayerDesc'), 'centeredPlayerStyles', musicSettings.centeredPlayerStyles )} ${createSettingsItem( t('playerBarStylesLabel'), t('playerBarStylesDesc'), 'playerBarStyles', musicSettings.playerBarStyles )} ${createSettingsItem( t('centeredPlayerBarStylesLabel'), t('centeredPlayerBarStylesDesc'), 'centeredPlayerBarStyles', musicSettings.centeredPlayerBarStyles )} ${createSettingsItem( t('miniPlayerStylesLabel'), t('miniPlayerStylesDesc'), 'miniPlayerStyles', musicSettings.miniPlayerStyles )} </div> </div> </div> </div> `; } /** * 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 ` <div class="ytp-plus-settings-section hidden" data-section="experimental"></div> `; } /** * Creates the voting section * @param {Object} _settings - Settings object * @param {Function} t - Translation function * @returns {string} Voting section HTML */ function createVotingSection(_settings, t) { return ` <div class="ytp-plus-settings-section hidden" data-section="voting"> <div class="ytp-plus-settings-voting-header"> <h3>${tr(t, 'votingTitle', 'Feature Requests')}</h3> <p class="ytp-plus-settings-voting-desc">${tr(t, 'votingDesc', 'Vote for features you want to see in YouTube+')}</p> </div> <div class="ytp-plus-voting-preview"> <div class="ytp-plus-ba-container"> <div class="ytp-plus-ba-before"> <img src="https://i.imgur.com/FVW4tdH.jpeg" alt="Before" draggable="false" /> <span class="ytp-plus-ba-label ytp-plus-ba-label-before">Before</span> </div> <div class="ytp-plus-ba-after"> <img src="https://i.imgur.com/ljq1KeL.jpeg" alt="After" draggable="false" /> <span class="ytp-plus-ba-label ytp-plus-ba-label-after">After</span> </div> <div class="ytp-plus-ba-divider" role="separator" tabindex="0" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50"></div> </div> <div class="ytp-plus-vote-bar-section" id="ytp-plus-vote-bar-section"> <div class="ytp-plus-vote-bar-buttons"> <div class="ytp-plus-vote-bar-track" id="ytp-plus-vote-bar-fill"></div> <button class="ytp-plus-vote-bar-btn" id="ytp-plus-vote-bar-up" type="button" aria-label="${tr(t, 'like', 'Like')}" data-vote="1"> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg> </button> <button class="ytp-plus-vote-bar-btn" id="ytp-plus-vote-bar-down" type="button" aria-label="${tr(t, 'dislike', 'Dislike')}" data-vote="-1"> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg> </button> </div> <div class="ytp-plus-vote-bar-count" id="ytp-plus-vote-bar-count">0</div> </div> </div> <div id="ytp-plus-voting-container"></div> </div> `; } /** * 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 ` <div class="ytp-plus-settings-main"> <div class="ytp-plus-settings-sidebar-close"> <button class="ytp-plus-settings-close" aria-label="${t('closeButton')}"> <svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> <div class="ytp-plus-settings-content"> ${createBasicSettingsSection(settings, t)} ${createAdvancedSettingsSection(settings, t)} ${createExperimentalSettingsSection(settings, t)} ${createVotingSection(settings, t)} <div class="ytp-plus-settings-section hidden" data-section="report"></div> ${createAboutSection()} </div> <div class="ytp-plus-footer"> <button class="ytp-plus-button ytp-plus-button-primary" id="ytp-plus-save-settings">${t('saveChanges')}</button> </div> </div> `; } // 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<string, any>} 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<string, any>} */ 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<Object>} 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<Object>} 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<Blob>} 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<Blob>} 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<Object>} Subtitle data */ /** * Fetch player data from YouTube API * @param {string} videoId - Video ID * @returns {Promise<Object>} 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<Object|null>} 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 = /<text\s+start="([^"]+)"\s+dur="([^"]+)"[^>]*>([\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(/<!\[CDATA\[(.*?)\]\]>/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<void>} */ 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<void>} 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<Object|undefined>} 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 = ` <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;vertical-align:middle;"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <polyline points="7 10 12 15 17 10"></polyline> <line x1="12" y1="15" x2="12" y2="3"></line> </svg> `; 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 <div> 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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>'; 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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>'; // 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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>'; 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 = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>'; 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 <style> isn't left behind const el = document.getElementById(STYLE_ELEMENT_ID); if (el) el.remove(); if (nonCriticalTimer) { if (typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') { try { window.cancelIdleCallback(nonCriticalTimer); } catch {} } else { clearTimeout(nonCriticalTimer); } } if (typeof requestIdleCallback === 'function') { nonCriticalTimer = requestIdleCallback(() => applyNonCriticalStyles(nonCriticalCss), { timeout: 5000, }); } else { nonCriticalTimer = setTimeout(() => applyNonCriticalStyles(nonCriticalCss), 200); } return; } } catch {} let el = document.getElementById(STYLE_ELEMENT_ID); if (!el) { el = document.createElement('style'); el.id = STYLE_ELEMENT_ID; (document.head || document.documentElement).appendChild(el); } el.textContent = criticalCss || ''; if (nonCriticalTimer) { if (typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') { try { window.cancelIdleCallback(nonCriticalTimer); } catch {} } else { clearTimeout(nonCriticalTimer); } } if (typeof requestIdleCallback === 'function') { nonCriticalTimer = requestIdleCallback(() => applyNonCriticalStyles(nonCriticalCss), { timeout: 5000, }); } else { nonCriticalTimer = setTimeout(() => applyNonCriticalStyles(nonCriticalCss), 200); } }; const applyFromStorage = () => applyStyles(loadSettings()); // Initial apply applyFromStorage(); // Live updates window.addEventListener('youtube-plus-settings-updated', e => { try { applyStyles(e?.detail || loadSettings()); } catch { applyFromStorage(); } }); } catch (err) { console.error('zen-youtube-features injection failed', err); } })(); // Theater overlay runtime fixes // 1) Auto-expand live chat in theater overlay (avoid "Show chat" placeholder) // 2) Preload comments content so Zen comments panel is not empty (function () { 'use strict'; 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 PRELOADED_ATTR = 'data-ytp-zen-comments-preloaded'; const isWatchPage = () => location.pathname === '/watch'; const readSettings = () => { try { const raw = localStorage.getItem(SETTINGS_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } }; const isTheaterEnhancementEnabled = () => { const settings = readSettings(); if (!settings) return true; if (settings.enableZenStyles === false) return false; if (settings.zenStyles && settings.zenStyles.theaterEnhancements === false) return false; return true; }; const clickElement = element => { if (!element) return; try { element.dispatchEvent( new window.MouseEvent('click', { bubbles: true, cancelable: true, view: window }) ); } catch { try { element.click(); } catch {} } }; const preloadCommentsInBackground = flexy => { const commentsTab = document.querySelector('#tab-comments'); const commentsBtn = document.querySelector('#material-tabs a[tyt-tab-content="#tab-comments"]'); if (!commentsTab || !commentsBtn || commentsTab.getAttribute(PRELOADED_ATTR) === '1') return; // Disable tiny pre-load mode CSS from main.js for theater overlay comments. if (flexy && !flexy.hasAttribute('keep-comments-scroller')) { flexy.setAttribute('keep-comments-scroller', ''); } const activeBtn = document.querySelector('#material-tabs a[tyt-tab-content].active'); clickElement(commentsBtn); requestAnimationFrame(() => { commentsTab.setAttribute(PRELOADED_ATTR, '1'); if (activeBtn && activeBtn !== commentsBtn && activeBtn.isConnected) { clickElement(activeBtn); } }); }; // Re-enabled: now safe because ytBtnCancelTheater() in main.js is guarded by // isZenTheaterOverlayActive() — it won't exit theater when zen overlay CSS is active. // Without this JS step, the iframe inside #chat doesn't load when [collapsed]. let expandAttempts = 0; const MAX_EXPAND_ATTEMPTS = 3; const expandLiveChat = () => { const chat = document.querySelector('ytd-live-chat-frame#chat'); if (!chat) return; // Step 1: Uncollapse the chat element if it has [collapsed] attribute if (chat.hasAttribute('collapsed')) { if (expandAttempts >= MAX_EXPAND_ATTEMPTS) return; expandAttempts++; // Method 1: Polymer internal API (same approach as main.js ytBtnExpandChat) let expanded = false; try { const cnt = chat.polymerController || (typeof chat.__data !== 'undefined' ? chat : null) || (chat.inst ? chat.inst : null); if (cnt && typeof cnt.setCollapsedState === 'function') { cnt.setCollapsedState({ setLiveChatCollapsedStateAction: { collapsed: false }, }); expanded = cnt.collapsed === false; } if (!expanded && cnt && typeof cnt.collapsed === 'boolean') { cnt.collapsed = false; if (cnt.isHiddenByUser === true) cnt.isHiddenByUser = false; expanded = cnt.collapsed === false; } } catch {} // Method 2: click the "Show chat" button as fallback if (!expanded) { const showBtn = chat.querySelector( '#show-hide-button div.yt-spec-touch-feedback-shape, ' + '#show-hide-button ytd-toggle-button-renderer, ' + '#show-hide-button button' ); if (showBtn) clickElement(showBtn); } } // Step 2: Ensure the iframe has its src loaded. // YouTube's Polymer binding may not fire when we uncollapse programmatically, // leaving the iframe empty. Manually set src from the element's URL property. const iframe = chat.querySelector('iframe#chatframe'); if (iframe && !iframe.src && chat.url) { iframe.src = chat.url; } }; const runOverlayFixes = () => { if (!isWatchPage()) return; if (!isTheaterEnhancementEnabled()) return; const flexy = document.querySelector('ytd-watch-flexy'); if (!flexy || flexy.hasAttribute('fullscreen')) return; const isTheaterLike = flexy.hasAttribute('theater') || flexy.hasAttribute('full-bleed-player') || flexy.hasAttribute('theater-requested_'); if (!isTheaterLike) return; expandLiveChat(); preloadCommentsInBackground(flexy); }; let debounceTimer = 0; const scheduleRun = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(runOverlayFixes, 150); }; // --- Targeted observers (NOT document.body subtree — that fires hundreds of times during page parse) --- const setupOverlayObservers = () => { // Observer 1: watch ytd-watch-flexy for theater / fullscreen attribute changes const flexyObserver = new MutationObserver(scheduleRun); let observedFlexy = null; const attachFlexyObserver = () => { const flexy = document.querySelector('ytd-watch-flexy'); if (flexy && flexy !== observedFlexy) { if (observedFlexy) flexyObserver.disconnect(); flexyObserver.observe(flexy, { attributes: true, attributeFilter: ['theater', 'full-bleed-player', 'theater-requested_', 'fullscreen'], }); observedFlexy = flexy; } }; // Observer 2: watch #chat for [collapsed] changes // (CSS handles display; observer just schedules overlay fixes like comment preloading) const chatObserver = new MutationObserver(scheduleRun); let observedChat = null; const attachChatObserver = () => { const chat = document.querySelector('ytd-live-chat-frame#chat'); if (chat && chat !== observedChat) { if (observedChat) chatObserver.disconnect(); chatObserver.observe(chat, { attributes: true, attributeFilter: ['collapsed'], }); observedChat = chat; } }; attachFlexyObserver(); attachChatObserver(); // Re-attach after SPA navigation (new flexy/chat elements are created) window.addEventListener( 'yt-navigate-finish', () => { expandAttempts = 0; // reset on navigation so chat can expand on new page setTimeout(() => { attachFlexyObserver(); attachChatObserver(); scheduleRun(); }, 180); }, { passive: true } ); try { if (window.YouTubeUtils?.cleanupManager?.registerObserver) { window.YouTubeUtils.cleanupManager.registerObserver(flexyObserver); window.YouTubeUtils.cleanupManager.registerObserver(chatObserver); } } catch {} }; // Defer observer setup to after DOMContentLoaded so it does NOT fire during page parse // (observing document.documentElement at document-start fires hundreds of times and hurts LCP) if (document.readyState === 'loading') { document.addEventListener( 'DOMContentLoaded', () => { scheduleRun(); setupOverlayObservers(); }, { once: true } ); } else { scheduleRun(); setupOverlayObservers(); } })(); // Comment Translation Button // Restores the "Translate to ..." button that YouTube removed from comments (function () { 'use strict'; 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 TRANSLATE_BTN_CLASS = 'ytp-comment-translate-btn'; const TRANSLATED_ATTR = 'data-ytp-translated'; const ORIGINAL_ATTR = 'data-ytp-original-text'; /** * Map YouTube+/YouTube locale codes → Google Translate BCP-47 codes. * Google Translate uses mostly ISO 639-1 with some regional variants. */ const LANG_MAP = { // YouTube+ internal codes cn: 'zh-CN', tw: 'zh-TW', kr: 'ko', jp: 'ja', ng: 'en', du: 'nl', be: 'be', bg: 'bg', kk: 'kk', ky: 'ky', uz: 'uz', uk: 'uk', // YouTube locale codes → ISO 639-1 'zh-hans': 'zh-CN', 'zh-hant': 'zh-TW', 'zh-cn': 'zh-CN', 'zh-tw': 'zh-TW', 'zh-hk': 'zh-TW', iw: 'he', // YouTube uses 'iw' for Hebrew jv: 'jw', // Javanese 'sr-latn': 'sr', 'pt-br': 'pt', 'pt-pt': 'pt', // Pass-through for standard ISO 639-1 codes ar: 'ar', az: 'az', cs: 'cs', da: 'da', de: 'de', el: 'el', en: 'en', es: 'es', fi: 'fi', fr: 'fr', hi: 'hi', hr: 'hr', hu: 'hu', id: 'id', it: 'it', lt: 'lt', lv: 'lv', ms: 'ms', nl: 'nl', no: 'no', pl: 'pl', ro: 'ro', ru: 'ru', sk: 'sk', sl: 'sl', sq: 'sq', sv: 'sv', th: 'th', tr: 'tr', vi: 'vi', }; /** Normalise any locale/YouTube+ code to a Google-Translate-compatible code */ const toGoogleLang = code => { if (!code) return 'en'; const lower = code.toLowerCase(); if (LANG_MAP[lower]) return LANG_MAP[lower]; // Strip region suffix for unknown codes (e.g. 'es-419' → 'es') const base = lower.split('-')[0]; return LANG_MAP[base] || base || 'en'; }; /** Detect user's preferred language (returns Google-Translate-compatible code) */ const getUserLanguage = () => { try { // 1. YouTube+ i18n internal code (e.g. 'cn', 'kr', 'jp') if (window.YouTubePlusI18n?.getLanguage) { return toGoogleLang(window.YouTubePlusI18n.getLanguage()); } // 2. <html lang="..."> attribute set by YouTube const htmlLang = document.documentElement.lang; if (htmlLang) return toGoogleLang(htmlLang); } catch {} // 3. Browser navigator.language return toGoogleLang(navigator.language) || 'en'; }; /** Translate text using Google Translate (free endpoint) */ const translateText = async (text, targetLang) => { try { const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${encodeURIComponent(targetLang)}&dt=t&q=${encodeURIComponent(text)}`; const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (Array.isArray(data) && Array.isArray(data[0])) { return data[0].map(s => (s && s[0]) || '').join(''); } } catch (e) { console.warn('[YouTube+] Translation failed:', e); } return null; }; /** Get the translate button label (uses i18n) */ const getTranslateLabel = () => t('translateComment') || 'Translate'; /** Get the show-original button label (uses i18n) */ const getShowOriginalLabel = () => t('showOriginal') || 'Show original'; /** Inject CSS for translate button */ const injectStyles = (() => { let injected = false; return () => { if (injected) return; injected = true; const css = ` .${TRANSLATE_BTN_CLASS}{ display:inline-flex;align-items:center;gap:4px; background:none;border:none;cursor:pointer; color:var(--yt-spec-text-secondary,#aaa); font-size:1.2rem;line-height:1.8rem;font-weight:400; padding:4px 0;margin-top:4px; font-family:'Roboto','Arial',sans-serif; transition:color .2s; } .${TRANSLATE_BTN_CLASS}:hover{color:var(--yt-spec-text-primary,#fff);} .${TRANSLATE_BTN_CLASS}[disabled]{opacity:.5;cursor:wait;} .${TRANSLATE_BTN_CLASS} svg{flex-shrink:0;} `; try { if (window.YouTubeUtils?.StyleManager?.add) { window.YouTubeUtils.StyleManager.add('ytp-comment-translate-styles', css); return; } } catch {} const style = document.createElement('style'); style.id = 'ytp-comment-translate-styles'; style.textContent = css; (document.head || document.documentElement).appendChild(style); }; })(); const translateIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v2h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>`; /** Add translate button to a comment element */ const addTranslateButton = commentEl => { if (commentEl.querySelector(`.${TRANSLATE_BTN_CLASS}`)) return; // Find the text content element const contentEl = commentEl.querySelector( '#content-text.ytd-comment-view-model, ' + '#content-text.ytd-comment-renderer, ' + 'yt-attributed-string#content-text, ' + 'yt-formatted-string#content-text, ' + '#content-text' ); if (!contentEl) return; const text = (contentEl.textContent || '').trim(); if (!text || text.length < 2) return; // Don't add if comment is already in user's language (basic heuristic) const userLang = getUserLanguage(); const btn = document.createElement('button'); btn.className = TRANSLATE_BTN_CLASS; btn.type = 'button'; btn.innerHTML = `${translateIcon} ${getTranslateLabel()}`; btn.setAttribute('aria-label', getTranslateLabel()); btn.addEventListener('click', async e => { e.preventDefault(); e.stopPropagation(); if (contentEl.hasAttribute(TRANSLATED_ATTR)) { // Toggle back to original const original = contentEl.getAttribute(ORIGINAL_ATTR); if (original) { contentEl.textContent = original; contentEl.removeAttribute(TRANSLATED_ATTR); btn.innerHTML = `${translateIcon} ${getTranslateLabel()}`; } return; } btn.disabled = true; btn.innerHTML = `${translateIcon} ...`; const originalText = contentEl.textContent || ''; const translated = await translateText(originalText, userLang); if (translated && translated !== originalText) { contentEl.setAttribute(ORIGINAL_ATTR, originalText); contentEl.setAttribute(TRANSLATED_ATTR, 'true'); contentEl.textContent = translated; btn.innerHTML = `${translateIcon} ${getShowOriginalLabel()}`; } else { btn.innerHTML = `${translateIcon} ${getTranslateLabel()}`; } btn.disabled = false; }); // Insert after the text content const actionBar = commentEl.querySelector( '#action-buttons, ytd-comment-action-buttons-renderer, #toolbar' ); if (actionBar) { actionBar.parentElement.insertBefore(btn, actionBar); } else { contentEl.after(btn); } }; /** Process all visible comments */ const processComments = () => { const commentSelectors = [ 'ytd-comment-view-model', 'ytd-comment-renderer', 'ytd-comment-thread-renderer', ]; for (const sel of commentSelectors) { document.querySelectorAll(sel).forEach(addTranslateButton); } }; /** Debounced processing */ let processTimeout = null; const scheduleProcess = () => { if (processTimeout) clearTimeout(processTimeout); processTimeout = setTimeout(processComments, 300); }; /** Initialize */ const init = () => { injectStyles(); processComments(); // Observe for new comments const commentsContainer = document.querySelector('#comments, #tab-comments, #content'); const target = commentsContainer || document.body; const observer = new MutationObserver(mutations => { let hasNewComments = false; for (const m of mutations) { for (const node of m.addedNodes) { if (!(node instanceof Element)) continue; if ( node.matches?.( 'ytd-comment-view-model, ytd-comment-renderer, ytd-comment-thread-renderer' ) || node.querySelector?.('ytd-comment-view-model, ytd-comment-renderer, #content-text') ) { hasNewComments = true; break; } } if (hasNewComments) break; } if (hasNewComments) scheduleProcess(); }); observer.observe(target, { childList: true, subtree: true }); try { if (window.YouTubeUtils?.cleanupManager) { window.YouTubeUtils.cleanupManager.registerObserver(observer); } } catch {} }; // Lazy init on watch pages const scheduleInit = () => { const isVideoPage = location.pathname === '/watch' || location.pathname.startsWith('/shorts/'); if (!isVideoPage) return; if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => init(), { timeout: 3000 }); } else { setTimeout(init, 1500); } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', scheduleInit, { once: true }); } else { scheduleInit(); } window.addEventListener('yt-navigate-finish', scheduleInit, { passive: true }); })(); // --- MODULE: adblocker.js --- // Ad Blocker (function () { 'use strict'; // 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); }; /** * Translation helper - uses centralized i18n system * @param {string} key - Translation key * @param {Object} params - Interpolation parameters * @returns {string} Translated string */ 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 { // Fallback to key if central i18n unavailable } return key; } /** * Ad blocking functionality for YouTube * @namespace AdBlocker */ const AdBlocker = { /** * Configuration settings * @type {Object} */ config: { skipInterval: 1000, // Combined ad-check interval (skip + remove + dismiss). removeInterval: 3000, enableLogging: false, maxRetries: 2, enabled: true, storageKey: 'youtube_adblocker_settings', }, /** * Current state tracking * @type {Object} */ state: { isYouTubeShorts: false, isYouTubeMusic: location.hostname === 'music.youtube.com', lastSkipAttempt: 0, retryCount: 0, initialized: false, }, /** * Cached DOM queries for performance * @type {Object} */ cache: { moviePlayer: null, ytdPlayer: null, lastCacheTime: 0, cacheTimeout: 10000, // Increased cache timeout for better performance }, /** * Optimized CSS selectors for ad elements * @type {Object} */ selectors: { // Only hide minor ad UI elements that YouTube doesn't monitor ads: '.ytp-ad-timed-pie-countdown-container,.ytp-ad-survey-questions,.ytp-ad-overlay-container,.ytp-ad-progress,.ytp-ad-progress-list', // These are removed via DOM manipulation only (not CSS) to avoid detection elements: '#masthead-ad,ytd-merch-shelf-renderer,.yt-mealbar-promo-renderer,ytmusic-mealbar-promo-renderer,ytmusic-statement-banner-renderer,.ytp-featured-product,ytd-in-feed-ad-layout-renderer,ytd-banner-promo-renderer,ytd-statement-banner-renderer,ytd-brand-video-singleton-renderer,ytd-brand-video-shelf-renderer,ytd-promoted-sparkles-web-renderer,ytd-display-ad-renderer,ytd-promoted-video-renderer,.ytd-mealbar-promo-renderer', video: 'video.html5-main-video', // Match both ad-slot renderers inside reels and standalone ad-slot-renderer nodes removal: 'ytd-reel-video-renderer .ytd-ad-slot-renderer, ytd-ad-slot-renderer, #player-ads, ytd-in-feed-ad-layout-renderer, ytd-display-ad-renderer, ytd-promoted-sparkles-web-renderer, ytd-promoted-video-renderer, ad-slot-renderer, ytd-player-legacy-desktop-watch-ads-renderer', }, // Known item wrapper selectors that should be removed when they only contain ads wrappers: [ 'ytd-rich-item-renderer', 'ytd-grid-video-renderer', 'ytd-compact-video-renderer', 'ytd-rich-grid-media', 'ytd-rich-shelf-renderer', 'ytd-rich-grid-row', 'ytd-video-renderer', 'ytd-playlist-renderer', 'ytd-reel-video-renderer', ], /** * Settings management with localStorage persistence * @type {Object} */ settings: { /** * Load settings from localStorage with validation * @returns {void} */ load() { try { const saved = localStorage.getItem(AdBlocker.config.storageKey); if (!saved) return; const parsed = JSON.parse(saved); if (typeof parsed !== 'object' || parsed === null) { console.warn('[AdBlocker] Invalid settings format'); return; } // Validate and apply settings if (typeof parsed.enabled === 'boolean') { AdBlocker.config.enabled = parsed.enabled; } else { AdBlocker.config.enabled = true; // Default to enabled } if (typeof parsed.enableLogging === 'boolean') { AdBlocker.config.enableLogging = parsed.enableLogging; } else { AdBlocker.config.enableLogging = false; // Default to disabled } } catch (error) { console.error('[AdBlocker] Error loading settings:', error); // Set safe defaults on error AdBlocker.config.enabled = true; AdBlocker.config.enableLogging = false; } }, /** * Save settings to localStorage with error handling * @returns {void} */ save() { try { const settingsToSave = { enabled: AdBlocker.config.enabled, enableLogging: AdBlocker.config.enableLogging, }; localStorage.setItem(AdBlocker.config.storageKey, JSON.stringify(settingsToSave)); } catch (error) { console.error('[AdBlocker] Error saving settings:', error); } }, }, /** * Get cached player elements * @returns {Object} Object containing player element and controller */ getPlayer() { const now = Date.now(); if (now - AdBlocker.cache.lastCacheTime > AdBlocker.cache.cacheTimeout) { AdBlocker.cache.moviePlayer = $('#movie_player'); AdBlocker.cache.ytdPlayer = $('#ytd-player'); AdBlocker.cache.lastCacheTime = now; } const playerEl = AdBlocker.cache.ytdPlayer; return { element: AdBlocker.cache.moviePlayer, player: playerEl?.getPlayer?.() || playerEl, }; }, /** * Skip current ad by clicking skip button or speeding through * Uses a stealthier approach to avoid YouTube ad blocker detection * @returns {void} */ skipAd() { if (!AdBlocker.config.enabled) return; const now = Date.now(); if (now - AdBlocker.state.lastSkipAttempt < 300) return; AdBlocker.state.lastSkipAttempt = now; if (location.pathname.startsWith('/shorts/')) return; // Check for ad-showing class on player const moviePlayer = $('#movie_player'); const isAdShowing = moviePlayer && (moviePlayer.classList.contains('ad-showing') || moviePlayer.classList.contains('ad-interrupting')); if (!isAdShowing) { AdBlocker.state.retryCount = 0; return; } try { // Strategy 1: Click skip button if available (most natural user action) const skipSelectors = [ '.ytp-ad-skip-button', '.ytp-ad-skip-button-modern', '.ytp-skip-ad-button', '.videoAdUiSkipButton', 'button.ytp-ad-skip-button-modern', '.ytp-ad-skip-button-slot button', '.ytp-ad-skip-button-container button', '.ytp-ad-skip-button-modern .ytp-ad-skip-button-container', // 2025+ new skip button selectors '.ytp-skip-ad-button__text', 'button[class*="skip"]', '.ytp-ad-skip-button-modern button', 'ytd-button-renderer.ytp-ad-skip-button-renderer button', ]; for (const sel of skipSelectors) { const skipButton = document.querySelector(sel); if (skipButton) { // offsetParent is null for position:fixed elements (YouTube skip buttons) // so use getBoundingClientRect width/height as the visibility check instead const rect = skipButton.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { skipButton.click(); AdBlocker.state.retryCount = 0; return; } } } // Strategy 2: Speed through ad (mute + seek to end) const video = $(AdBlocker.selectors.video); if (video) { video.muted = true; // Attempt to seek to end of ad if duration is available if (video.duration && isFinite(video.duration) && video.duration > 0) { try { video.currentTime = Math.max(video.duration - 0.1, 0); } catch (e) { console.warn('[YouTube+] Ad seek error:', e); } } } // Strategy 3: Close overlay ads const overlaySelectors = [ '.ytp-ad-overlay-close-button', '.ytp-ad-overlay-close-container button', '.ytp-ad-overlay-close-button button', // 2025+ overlay close '.ytp-ad-overlay-ad-info-button-container', 'button[id="dismiss-button"]', ]; for (const sel of overlaySelectors) { const overlayClose = $(sel); if (overlayClose) { overlayClose.click(); break; } } AdBlocker.state.retryCount = 0; } catch { if (AdBlocker.state.retryCount < AdBlocker.config.maxRetries) { AdBlocker.state.retryCount++; setTimeout(AdBlocker.skipAd, 800); } } }, /** * Dismiss YouTube's ad blocker warning popup if detected * @returns {void} */ dismissAdBlockerWarning() { if (!AdBlocker.config.enabled) return; try { // Strategy 1: Handle the enforcement message overlay const enforcement = document.querySelector('ytd-enforcement-message-view-model'); if (enforcement) { // Find any dismiss/close/allow button const btns = enforcement.querySelectorAll( 'button, tp-yt-paper-button, a.yt-spec-button-shape-next--outline' ); for (const btn of btns) { const btnText = (btn.textContent || '').toLowerCase().trim(); // Click "Allow YouTube ads", "Dismiss", or the X button if ( btnText.includes('allow') || btnText.includes('dismiss') || btnText.includes('разрешить') || btn.getAttribute('aria-label')?.includes('close') ) { btn.click(); return; } } // If no matching button, try removing the overlay enforcement.remove(); return; } // Strategy 2: Handle paper dialog popups const dialogs = document.querySelectorAll( 'tp-yt-paper-dialog, ytd-popup-container tp-yt-paper-dialog, yt-dialog-container' ); for (const dialog of dialogs) { const text = (dialog.textContent || '').toLowerCase(); const isAdBlockWarning = text.includes('ad blocker') || text.includes('ad blockers') || text.includes('блокировщик') || text.includes('will be blocked') || text.includes('будет заблокирован') || (text.includes('allow') && text.includes('ads')) || (text.includes('blocker') && text.includes('video')); if (!isAdBlockWarning) continue; // Try dismiss/allow buttons const dismissBtns = dialog.querySelectorAll( '#dismiss-button button, .dismiss-button, button[id*="dismiss"], ' + 'tp-yt-paper-button, yt-button-renderer button, a[href]' ); for (const btn of dismissBtns) { const btnText = (btn.textContent || '').toLowerCase(); if ( btnText.includes('dismiss') || btnText.includes('allow') || btnText.includes('not using') || btnText.includes('report') ) { btn.click(); return; } } // Last resort: remove dialog dialog.style.display = 'none'; dialog.remove(); return; } // Strategy 3: Handle overlay/backdrop that blocks interaction const overlays = document.querySelectorAll( 'tp-yt-iron-overlay-backdrop, .yt-dialog-overlay' ); for (const overlay of overlays) { if (overlay.style.display !== 'none' && overlay.offsetParent !== null) { overlay.style.display = 'none'; } } } catch { // Silently ignore } }, // Minimal CSS injection - only hide minor UI elements that YouTube doesn't monitor addCss() { if ($('#yt-ab-styles') || !AdBlocker.config.enabled) return; // Only use ads selectors (countdown, survey) in CSS // element selectors (masthead-ad, merch, etc.) are removed via DOM to avoid detection const styles = `${AdBlocker.selectors.ads}{display:none!important;}`; YouTubeUtils.StyleManager.add('yt-ab-styles', styles); }, removeCss() { YouTubeUtils.StyleManager.remove('yt-ab-styles'); }, // Batched element removal removeElements() { if (!AdBlocker.config.enabled || AdBlocker.state.isYouTubeMusic) return; // Use requestIdleCallback for non-blocking removal const remove = () => { // Remove known ad elements directly (these were previously in CSS) try { const adElements = document.querySelectorAll(AdBlocker.selectors.elements); adElements.forEach(el => { try { el.remove(); } catch {} }); } catch {} // Remove ad-slot renderers const elements = document.querySelectorAll(AdBlocker.selectors.removal); elements.forEach(el => { try { // Prefer removing a known item wrapper (thumbnail card, reel item, etc.) for (const w of AdBlocker.wrappers) { const wrap = el.closest(w); if (wrap) { wrap.remove(); return; } } // If ad is inside a reel item specifically, remove the reel container const reel = el.closest('ytd-reel-video-renderer'); if (reel) { reel.remove(); return; } // If standalone ad-slot-renderer or other ad container, remove the nearest reasonable container const container = el.closest('ytd-ad-slot-renderer') || el.closest('.ad-container') || el; if (container && container.remove) container.remove(); } catch (e) { if (AdBlocker.config.enableLogging) console.warn('[AdBlocker] removeElements error', e); } }); }; if (window.requestIdleCallback) { requestIdleCallback(remove, { timeout: 100 }); } else { setTimeout(remove, 0); } }, // Optimized settings UI addSettingsUI() { const section = $('.ytp-plus-settings-section[data-section="basic"]'); if (!section || section.querySelector('.ab-settings')) return; try { const item = document.createElement('div'); item.className = 'ytp-plus-settings-item ab-settings'; item.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${t('adBlocker')}</label> <div class="ytp-plus-settings-item-description">${t('adBlockerDescription')}</div> </div> <input type="checkbox" class="ytp-plus-settings-checkbox" ${AdBlocker.config.enabled ? 'checked' : ''}> `; section.appendChild(item); item.querySelector('input').addEventListener('change', e => { const target = /** @type {EventTarget & HTMLInputElement} */ (e.target); AdBlocker.config.enabled = target.checked; AdBlocker.settings.save(); AdBlocker.config.enabled ? AdBlocker.addCss() : AdBlocker.removeCss(); }); } catch (error) { YouTubeUtils.logError('AdBlocker', 'Failed to add settings UI', error); } }, // Streamlined initialization init() { if (AdBlocker.state.initialized) return; AdBlocker.state.initialized = true; AdBlocker.settings.load(); if (AdBlocker.config.enabled) { AdBlocker.addCss(); AdBlocker.removeElements(); } // Start optimized intervals with cleanup registration // Use single combined interval instead of 3 separate ones const combinedAdCheck = () => { if (AdBlocker.config.enabled) { AdBlocker.skipAd(); AdBlocker.removeElements(); AdBlocker.dismissAdBlockerWarning(); } }; // Single interval for all ad-related checks (faster interval for responsive skip) const adInterval = setInterval(combinedAdCheck, AdBlocker.config.skipInterval); YouTubeUtils.cleanupManager.registerInterval(adInterval); // Also monitor video play events for immediate ad detection try { const handleVideoPlay = () => { if (AdBlocker.config.enabled) { setTimeout(AdBlocker.skipAd, 50); setTimeout(AdBlocker.skipAd, 200); setTimeout(AdBlocker.skipAd, 500); } }; document.addEventListener('playing', handleVideoPlay, { capture: true, passive: true }); } catch (e) { console.warn('[YouTube+] Ad play listener error:', e); } // Navigation handling const handleNavigation = () => { AdBlocker.state.isYouTubeShorts = location.pathname.startsWith('/shorts/'); AdBlocker.cache.lastCacheTime = 0; // Reset cache }; // Use centralized pushState/replaceState event from utils.js window.addEventListener('ytp-history-navigate', () => setTimeout(handleNavigation, 50)); // Settings modal integration — use event instead of MutationObserver document.addEventListener('youtube-plus-settings-modal-opened', () => { setTimeout(AdBlocker.addSettingsUI, 50); }); // Observe DOM for dynamically inserted ad slots and remove them // Use targeted observation rather than full subtree to reduce detection risk try { const adSlotObserver = new MutationObserver(mutations => { let shouldRemove = false; for (const m of mutations) { for (const node of m.addedNodes) { if (!(node instanceof Element)) continue; try { if ( node.matches && node.matches('ytd-ad-slot-renderer, ytd-merch-shelf-renderer') ) { shouldRemove = true; break; } if (node.querySelector && node.querySelector('ytd-ad-slot-renderer')) { shouldRemove = true; break; } } catch (e) { if (AdBlocker.config.enableLogging) { console.warn('[AdBlocker] adSlotObserver node check', e); } } } if (shouldRemove) break; } if (shouldRemove) { AdBlocker.removeElements(); } }); // Observe only specific content containers, not the entire body const observeContentContainers = () => { const containers = [ document.querySelector('#content'), document.querySelector('#page-manager'), document.querySelector('ytd-browse'), document.querySelector('ytd-search'), ].filter(Boolean); if (containers.length === 0) { // Fallback to body with reduced scope adSlotObserver.observe(document.body, { childList: true, subtree: true }); } else { containers.forEach(container => { adSlotObserver.observe(container, { childList: true, subtree: true }); }); } }; if (document.body) { observeContentContainers(); } else { document.addEventListener('DOMContentLoaded', observeContentContainers); } // Register for cleanup YouTubeUtils.cleanupManager.registerObserver(adSlotObserver); } catch (e) { if (AdBlocker.config.enableLogging) { console.warn('[AdBlocker] Failed to create adSlotObserver', e); } } const clickHandler = e => { const target = /** @type {EventTarget & HTMLElement} */ (e.target); if (target.dataset?.section === 'basic') { setTimeout(AdBlocker.addSettingsUI, 25); } }; YouTubeUtils.cleanupManager.registerListener(document, 'click', clickHandler, { passive: true, capture: true, }); // Initial skip attempt if (AdBlocker.config.enabled) { setTimeout(AdBlocker.skipAd, 200); // Also check for ad blocker warning popup setTimeout(AdBlocker.dismissAdBlockerWarning, 500); } }, }; // Initialize if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', AdBlocker.init, { once: true }); } else { AdBlocker.init(); } })(); // --- MODULE: pip.js --- // YouTube Picture-in-Picture settings (function () { 'use strict'; /** * Translation helper - uses centralized i18n system * @param {string} key - Translation key * @param {Object} params - Interpolation parameters * @returns {string} Translated string */ 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 { // Fallback to key if central i18n unavailable } return key; } /** * PiP settings configuration * @type {Object} * @property {boolean} enabled - Whether PiP is enabled * @property {Object} shortcut - Keyboard shortcut configuration * @property {string} storageKey - LocalStorage key for persistence */ const pipSettings = { enabled: true, shortcut: { key: 'P', shiftKey: true, altKey: false, ctrlKey: false }, storageKey: 'youtube_pip_settings', }; const PIP_SESSION_KEY = 'youtube_plus_pip_session'; /** * Get video element with validation * @returns {HTMLVideoElement|null} Video element or null if not found */ const getVideoElement = () => { try { const candidate = (typeof YouTubeUtils?.querySelector === 'function' && YouTubeUtils.querySelector('video')) || document.querySelector('video'); if (candidate && candidate.tagName && candidate.tagName.toLowerCase() === 'video') { return /** @type {HTMLVideoElement} */ (candidate); } return null; } catch (error) { console.error('[PiP] Error getting video element:', error); return null; } }; /** * Wait for video metadata to load with timeout * @param {HTMLVideoElement} video - Video element * @returns {Promise<void>} Resolves when metadata is loaded */ const waitForMetadata = video => { if (!video) { return Promise.reject(new Error('[PiP] Invalid video element')); } if (video.readyState >= 1 && !video.seeking) { return Promise.resolve(); } return new Promise((resolve, reject) => { let settled = false; const cleanup = () => { video.removeEventListener('loadedmetadata', onLoaded); video.removeEventListener('error', onError); if (timeoutId) { clearTimeout(timeoutId); } }; const onLoaded = () => { if (settled) return; settled = true; cleanup(); resolve(); }; const onError = () => { if (settled) return; settled = true; cleanup(); reject(new Error('[PiP] Video metadata failed to load')); }; let timeoutId = setTimeout(() => { if (settled) return; settled = true; cleanup(); reject(new Error('[PiP] Timed out waiting for video metadata')); }, 3000); const registeredTimeout = YouTubeUtils?.cleanupManager?.registerTimeout?.(timeoutId); if (registeredTimeout) { timeoutId = registeredTimeout; } video.addEventListener('loadedmetadata', onLoaded, { once: true }); video.addEventListener('error', onError, { once: true }); }); }; const setSessionActive = isActive => { try { if (isActive) { sessionStorage.setItem(PIP_SESSION_KEY, 'true'); } else { sessionStorage.removeItem(PIP_SESSION_KEY); } } catch {} }; const wasSessionActive = () => { try { return sessionStorage.getItem(PIP_SESSION_KEY) === 'true'; } catch { return false; } }; /** * Load settings from localStorage with validation * @returns {void} */ const loadSettings = () => { try { const saved = localStorage.getItem(pipSettings.storageKey); if (!saved) return; const parsed = JSON.parse(saved); if (typeof parsed !== 'object' || parsed === null) { console.warn('[PiP] Invalid settings format'); return; } // Validate and merge settings if (typeof parsed.enabled === 'boolean') { pipSettings.enabled = parsed.enabled; } // Validate shortcut object if (parsed.shortcut && typeof parsed.shortcut === 'object') { if (typeof parsed.shortcut.key === 'string' && parsed.shortcut.key.length > 0) { pipSettings.shortcut.key = parsed.shortcut.key; } if (typeof parsed.shortcut.shiftKey === 'boolean') { pipSettings.shortcut.shiftKey = parsed.shortcut.shiftKey; } if (typeof parsed.shortcut.altKey === 'boolean') { pipSettings.shortcut.altKey = parsed.shortcut.altKey; } if (typeof parsed.shortcut.ctrlKey === 'boolean') { pipSettings.shortcut.ctrlKey = parsed.shortcut.ctrlKey; } } } catch (e) { console.error('[PiP] Error loading settings:', e); } }; /** * Save settings to localStorage with error handling * @returns {void} */ const saveSettings = () => { try { const settingsToSave = { enabled: pipSettings.enabled, shortcut: pipSettings.shortcut, }; localStorage.setItem(pipSettings.storageKey, JSON.stringify(settingsToSave)); } catch (e) { console.error('[PiP] Error saving settings:', e); } }; /** * Get current PiP element as HTMLVideoElement when available * @returns {HTMLVideoElement|null} */ const getCurrentPiPElement = () => { const current = document.pictureInPictureElement; if (current && typeof current === 'object' && 'tagName' in current) { const tag = /** @type {{ tagName?: string }} */ (current).tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'video') { return /** @type {HTMLVideoElement} */ (/** @type {unknown} */ (current)); } } return null; }; /** * Toggle Picture-in-Picture mode * @param {HTMLVideoElement} video - The video element * @returns {Promise<void>} */ const togglePictureInPicture = async video => { if (!pipSettings.enabled || !video) return; try { const currentPiP = getCurrentPiPElement(); if (currentPiP && currentPiP !== video) { await document.exitPictureInPicture(); setSessionActive(false); } if (getCurrentPiPElement() === video) { await document.exitPictureInPicture(); setSessionActive(false); return; } if (video.disablePictureInPicture) { throw new Error('Picture-in-Picture is disabled by the video element'); } await waitForMetadata(video); await video.requestPictureInPicture(); setSessionActive(true); } catch (error) { console.error('[YouTube+][PiP] Failed to toggle Picture-in-Picture:', error); } }; /** * Add PiP settings UI to advanced settings modal * @returns {void} */ const addPipSettingsToModal = () => { const advancedSection = YouTubeUtils.querySelector( '.ytp-plus-settings-section[data-section="advanced"]' ); if (!advancedSection || YouTubeUtils.querySelector('.pip-settings-item')) return; const getSubmenuExpanded = () => { try { const raw = localStorage.getItem('ytp-plus-submenu-states'); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed && typeof parsed.pip === 'boolean') return parsed.pip; } catch {} return null; }; const storedExpanded = getSubmenuExpanded(); const initialExpanded = typeof storedExpanded === 'boolean' ? storedExpanded : true; // Add styles if they don't exist if (!document.getElementById('pip-styles')) { const styles = ` .pip-shortcut-editor { display: flex; align-items: center; gap: 8px; } .pip-shortcut-editor select, #pip-key {background: rgba(34, 34, 34, var(--yt-header-bg-opacity)); color: var(--yt-spec-text-primary); border: 1px solid var(--yt-spec-10-percent-layer); border-radius: var(--yt-radius-sm); padding: 4px;} `; YouTubeUtils.StyleManager.add('pip-styles', styles); } // Enable/disable toggle const enableItem = document.createElement('div'); enableItem.className = 'ytp-plus-settings-item pip-settings-item ytp-plus-settings-item--with-submenu'; enableItem.innerHTML = ` <div> <label class="ytp-plus-settings-item-label" for="pip-enable-checkbox">${t( 'pipTitle' )}</label> <div class="ytp-plus-settings-item-description">${t('pipDescription')}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="pip" aria-label="Toggle PiP submenu" aria-expanded="${initialExpanded ? 'true' : 'false'}" ${pipSettings.enabled ? '' : 'disabled'} style="display:${pipSettings.enabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" class="ytp-plus-settings-checkbox" data-setting="enablePiP" id="pip-enable-checkbox" ${pipSettings.enabled ? 'checked' : ''}> </div> `; advancedSection.appendChild(enableItem); // Shortcut settings const submenuWrap = document.createElement('div'); submenuWrap.className = 'pip-submenu'; submenuWrap.dataset.submenu = 'pip'; submenuWrap.style.display = pipSettings.enabled && initialExpanded ? 'block' : 'none'; submenuWrap.style.marginLeft = '12px'; submenuWrap.style.marginBottom = '12px'; const submenuCard = document.createElement('div'); submenuCard.className = 'glass-card'; submenuCard.style.display = 'flex'; submenuCard.style.flexDirection = 'column'; submenuCard.style.gap = '8px'; const shortcutItem = document.createElement('div'); shortcutItem.className = 'ytp-plus-settings-item pip-shortcut-item'; shortcutItem.style.display = 'flex'; const { ctrlKey, altKey, shiftKey } = pipSettings.shortcut; const modifierValue = ctrlKey && altKey && shiftKey ? 'ctrl+alt+shift' : ctrlKey && altKey ? 'ctrl+alt' : ctrlKey && shiftKey ? 'ctrl+shift' : altKey && shiftKey ? 'alt+shift' : ctrlKey ? 'ctrl' : altKey ? 'alt' : shiftKey ? 'shift' : 'none'; shortcutItem.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${t('pipShortcutTitle')}</label> <div class="ytp-plus-settings-item-description">${t('pipShortcutDescription')}</div> </div> <div class="pip-shortcut-editor"> <!-- hidden native select kept for compatibility --> <select id="pip-modifier-combo" style="display:none;"> ${[ 'none', 'ctrl', 'alt', 'shift', 'ctrl+alt', 'ctrl+shift', 'alt+shift', 'ctrl+alt+shift', ] .map( v => `<option value="${v}" ${v === modifierValue ? 'selected' : ''}>${ v === 'none' ? t('none') : v .replace(/\+/g, '+') .split('+') .map(k => t(k.toLowerCase())) .join('+') .split('+') .map(k => k.charAt(0).toUpperCase() + k.slice(1)) .join('+') }</option>` ) .join('')} </select> <div class="glass-dropdown" id="pip-modifier-dropdown" tabindex="0" role="listbox" aria-expanded="false"> <button class="glass-dropdown__toggle" type="button" aria-haspopup="listbox"> <span class="glass-dropdown__label">${ modifierValue === 'none' ? t('none') : modifierValue .replace(/\+/g, '+') .split('+') .map(k => t(k.toLowerCase())) .map(k => k.charAt(0).toUpperCase() + k.slice(1)) .join('+') }</span> <svg class="glass-dropdown__chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> </button> <ul class="glass-dropdown__list" role="presentation"> ${[ 'none', 'ctrl', 'alt', 'shift', 'ctrl+alt', 'ctrl+shift', 'alt+shift', 'ctrl+alt+shift', ] .map(v => { const label = v === 'none' ? t('none') : v .replace(/\+/g, '+') .split('+') .map(k => t(k.toLowerCase())) .map(k => k.charAt(0).toUpperCase() + k.slice(1)) .join('+'); const sel = v === modifierValue ? ' aria-selected="true"' : ''; return `<li class="glass-dropdown__item" data-value="${v}" role="option"${sel}>${label}</li>`; }) .join('')} </ul> </div> <span>+</span> <input type="text" id="pip-key" value="${pipSettings.shortcut.key}" maxlength="1" style="width: 30px; text-align: center;"> </div> `; submenuCard.appendChild(shortcutItem); submenuWrap.appendChild(submenuCard); advancedSection.appendChild(submenuWrap); // Initialize glass dropdown interactions for PiP selector const initPipDropdown = () => { const hidden = document.getElementById('pip-modifier-combo'); const dropdown = document.getElementById('pip-modifier-dropdown'); if (!hidden || !dropdown) return; const toggle = dropdown.querySelector('.glass-dropdown__toggle'); const list = dropdown.querySelector('.glass-dropdown__list'); const label = dropdown.querySelector('.glass-dropdown__label'); let items = Array.from(list.querySelectorAll('.glass-dropdown__item')); let idx = items.findIndex(it => it.getAttribute('aria-selected') === 'true'); if (idx < 0) idx = 0; const openList = () => { dropdown.setAttribute('aria-expanded', 'true'); list.style.display = 'block'; items = Array.from(list.querySelectorAll('.glass-dropdown__item')); }; const closeList = () => { dropdown.setAttribute('aria-expanded', 'false'); list.style.display = 'none'; }; toggle.addEventListener('click', () => { const expanded = dropdown.getAttribute('aria-expanded') === 'true'; if (expanded) closeList(); else openList(); }); document.addEventListener('click', e => { if (!dropdown.contains(e.target)) closeList(); }); // Arrow-key navigation and selection dropdown.addEventListener('keydown', e => { const expanded = dropdown.getAttribute('aria-expanded') === 'true'; if (e.key === 'ArrowDown') { e.preventDefault(); if (!expanded) openList(); idx = Math.min(idx + 1, items.length - 1); items.forEach(it => it.removeAttribute('aria-selected')); items[idx].setAttribute('aria-selected', 'true'); items[idx].scrollIntoView({ block: 'nearest' }); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (!expanded) openList(); idx = Math.max(idx - 1, 0); items.forEach(it => it.removeAttribute('aria-selected')); items[idx].setAttribute('aria-selected', 'true'); items[idx].scrollIntoView({ block: 'nearest' }); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!expanded) { openList(); return; } const it = items[idx]; if (it) { hidden.value = it.dataset.value; hidden.dispatchEvent(new Event('change', { bubbles: true })); label.textContent = it.textContent; closeList(); } } else if (e.key === 'Escape') { closeList(); } }); list.addEventListener('click', e => { const it = e.target.closest('.glass-dropdown__item'); if (!it) return; const val = it.dataset.value; hidden.value = val; list .querySelectorAll('.glass-dropdown__item') .forEach(li => li.removeAttribute('aria-selected')); it.setAttribute('aria-selected', 'true'); label.textContent = it.textContent; hidden.dispatchEvent(new Event('change', { bubbles: true })); closeList(); }); }; setTimeout(initPipDropdown, 0); // Event listeners document.getElementById('pip-enable-checkbox').addEventListener('change', e => { const target = /** @type {EventTarget & HTMLInputElement} */ (e.target); pipSettings.enabled = target.checked; const submenuToggle = enableItem.querySelector( '.ytp-plus-submenu-toggle[data-submenu="pip"]' ); if (submenuToggle instanceof HTMLElement) { if (pipSettings.enabled) { const stored = getSubmenuExpanded(); const nextExpanded = typeof stored === 'boolean' ? stored : true; submenuToggle.removeAttribute('disabled'); submenuToggle.style.display = 'inline-flex'; submenuToggle.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false'); submenuWrap.style.display = nextExpanded ? 'block' : 'none'; } else { submenuToggle.setAttribute('disabled', ''); submenuToggle.style.display = 'none'; submenuWrap.style.display = 'none'; } } saveSettings(); }); document.getElementById('pip-modifier-combo').addEventListener('change', e => { const target = /** @type {EventTarget & HTMLSelectElement} */ (e.target); const value = target.value; pipSettings.shortcut.ctrlKey = value.includes('ctrl'); pipSettings.shortcut.altKey = value.includes('alt'); pipSettings.shortcut.shiftKey = value.includes('shift'); saveSettings(); }); document.getElementById('pip-key').addEventListener('input', e => { const target = /** @type {EventTarget & HTMLInputElement} */ (e.target); if (target.value) { pipSettings.shortcut.key = target.value.toUpperCase(); saveSettings(); } }); document.getElementById('pip-key').addEventListener('keydown', e => e.stopPropagation()); }; // Initialize loadSettings(); // Event listeners document.addEventListener('keydown', e => { if (!pipSettings.enabled) return; const { shiftKey, altKey, ctrlKey, key } = pipSettings.shortcut; if ( e.shiftKey === shiftKey && e.altKey === altKey && e.ctrlKey === ctrlKey && e.key.toUpperCase() === key ) { const video = getVideoElement(); if (video) { void togglePictureInPicture(video); } e.preventDefault(); } }); window.addEventListener('storage', e => { if (e.key === pipSettings.storageKey) { loadSettings(); } }); window.addEventListener('load', () => { if (!pipSettings.enabled || !wasSessionActive() || document.pictureInPictureElement) { return; } const resumePiP = () => { const video = getVideoElement(); if (!video) return; togglePictureInPicture(video).catch(() => { // If resume fails we reset the session flag to avoid loops setSessionActive(false); }); }; const ensureCleanup = handler => { if (!handler) return; try { document.removeEventListener('pointerdown', handler, true); } catch {} }; const cleanupListeners = () => { ensureCleanup(pointerListener); ensureCleanup(keyListener); }; const pointerListener = () => { cleanupListeners(); resumePiP(); }; const keyListener = () => { cleanupListeners(); resumePiP(); }; document.addEventListener('pointerdown', pointerListener, { once: true, capture: true }); document.addEventListener('keydown', keyListener, { once: true, capture: true }); }); // Settings modal integration — use event instead of MutationObserver document.addEventListener('youtube-plus-settings-modal-opened', () => { setTimeout(addPipSettingsToModal, 100); }); document.addEventListener('leavepictureinpicture', () => { setSessionActive(false); }); const clickHandler = e => { const target = /** @type {EventTarget & HTMLElement} */ (e.target); if (target.classList && target.classList.contains('ytp-plus-settings-nav-item')) { if (target.dataset?.section === 'advanced') { setTimeout(addPipSettingsToModal, 50); } } }; YouTubeUtils.cleanupManager.registerListener(document, 'click', clickHandler, true); })(); // --- MODULE: timecode.js --- // YouTube Timecode Panel (function () { 'use strict'; // DOM Cache Helper - reduces repeated queries const getCache = () => 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) => getCache()?.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) => getCache()?.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 => getCache()?.getElementById(id) || document.getElementById(id); if (window.location.hostname !== 'www.youtube.com' || window.frameElement) { return; } // Prevent multiple initializations if (window._timecodeModuleInitialized) return; window._timecodeModuleInitialized = true; /** * Translation helper - uses centralized i18n system * @param {string} key - Translation key * @param {Object} params - Interpolation parameters * @returns {string} Translated string */ 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 return key || ''; }; // Configuration const config = { enabled: true, autoDetect: true, shortcut: { key: 'T', shiftKey: true, altKey: false, ctrlKey: false }, storageKey: 'youtube_timecode_settings', autoSave: true, autoTrackPlayback: true, panelPosition: null, export: true, }; // State management const state = { timecodes: new Map(), dom: {}, isReloading: false, activeIndex: null, trackingId: 0, dragging: false, editingIndex: null, resizeListenerKey: null, }; let initStarted = false; const isRelevantRoute = () => { try { return location.pathname === '/watch'; } catch { return false; } }; const scheduleInitRetry = () => { const timeoutId = setTimeout(init, 250); YouTubeUtils.cleanupManager?.registerTimeout?.(timeoutId); }; // Utilities /** * Load settings from localStorage with error handling * @returns {void} */ const loadSettings = () => { try { const saved = localStorage.getItem(config.storageKey); if (!saved) return; const parsed = JSON.parse(saved); if (typeof parsed !== 'object' || parsed === null) { console.warn('[Timecode] Invalid settings format'); return; } // Validate and merge settings if (typeof parsed.enabled === 'boolean') { config.enabled = parsed.enabled; } if (typeof parsed.autoDetect === 'boolean') { config.autoDetect = parsed.autoDetect; } if (typeof parsed.autoSave === 'boolean') { config.autoSave = parsed.autoSave; } if (typeof parsed.autoTrackPlayback === 'boolean') { config.autoTrackPlayback = parsed.autoTrackPlayback; } if (typeof parsed.export === 'boolean') { config.export = parsed.export; } // Validate shortcut object if (parsed.shortcut && typeof parsed.shortcut === 'object') { if (typeof parsed.shortcut.key === 'string') { config.shortcut.key = parsed.shortcut.key; } if (typeof parsed.shortcut.shiftKey === 'boolean') { config.shortcut.shiftKey = parsed.shortcut.shiftKey; } if (typeof parsed.shortcut.altKey === 'boolean') { config.shortcut.altKey = parsed.shortcut.altKey; } if (typeof parsed.shortcut.ctrlKey === 'boolean') { config.shortcut.ctrlKey = parsed.shortcut.ctrlKey; } } // Validate panel position if (parsed.panelPosition && typeof parsed.panelPosition === 'object') { const { left, top } = parsed.panelPosition; if ( typeof left === 'number' && typeof top === 'number' && !isNaN(left) && !isNaN(top) && left >= 0 && top >= 0 ) { config.panelPosition = { left, top }; } } } catch (error) { console.error('[Timecode] Error loading settings:', error); } }; /** * Save settings to localStorage with error handling * @returns {void} */ const saveSettings = () => { try { const settingsToSave = { enabled: config.enabled, autoDetect: config.autoDetect, shortcut: config.shortcut, autoSave: config.autoSave, autoTrackPlayback: config.autoTrackPlayback, panelPosition: config.panelPosition, export: config.export, }; localStorage.setItem(config.storageKey, JSON.stringify(settingsToSave)); } catch (error) { console.error('[Timecode] Error saving settings:', error); } }; /** * Clamp panel position within viewport bounds * @param {HTMLElement} panel - Panel element * @param {number} left - Desired left position * @param {number} top - Desired top position * @returns {{left: number, top: number}} Clamped position */ const clampPanelPosition = (panel, left, top) => { try { if (!panel || !(panel instanceof HTMLElement)) { console.warn('[Timecode] Invalid panel element'); return { left: 0, top: 0 }; } // Validate input coordinates if (typeof left !== 'number' || typeof top !== 'number' || isNaN(left) || isNaN(top)) { console.warn('[Timecode] Invalid position coordinates'); return { left: 0, top: 0 }; } const rect = panel.getBoundingClientRect(); const width = rect.width || panel.offsetWidth || 0; const height = rect.height || panel.offsetHeight || 0; const maxLeft = Math.max(0, window.innerWidth - width); const maxTop = Math.max(0, window.innerHeight - height); return { left: Math.min(Math.max(0, left), maxLeft), top: Math.min(Math.max(0, top), maxTop), }; } catch (error) { console.error('[Timecode] Error clamping panel position:', error); return { left: 0, top: 0 }; } }; /** * Save panel position to settings * @param {number} left - Left position * @param {number} top - Top position * @returns {void} */ /** * Save panel position to configuration and localStorage * @param {number} left - X coordinate in pixels * @param {number} top - Y coordinate in pixels * @returns {void} */ const savePanelPosition = (left, top) => { try { if (typeof left !== 'number' || typeof top !== 'number' || isNaN(left) || isNaN(top)) { console.warn('[Timecode] Invalid position coordinates for saving'); return; } config.panelPosition = { left, top }; saveSettings(); } catch (error) { console.error('[Timecode] Error saving panel position:', error); } }; /** * Apply saved panel position to a panel element * @param {HTMLElement} panel - The panel element to position * @returns {void} */ const applySavedPanelPosition = panel => { if (!panel || !config.panelPosition) return; requestAnimationFrame(() => { const { left, top } = clampPanelPosition( panel, config.panelPosition.left, config.panelPosition.top ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; }); }; /** * Display a notification message to the user * @param {string} message - The message to display * @param {number} [duration=2000] - Duration in milliseconds * @param {string} [type='info'] - Notification type (info, success, warning, error) * @returns {void} */ const showNotification = (message, duration = 2000, type = 'info') => { YouTubeUtils.NotificationManager.show(message, { duration, type }); }; /** * Format seconds into HH:MM:SS or MM:SS time string * @param {number} seconds - Number of seconds to format * @returns {string} Formatted time string */ const formatTime = seconds => { if (isNaN(seconds)) return '00:00'; seconds = Math.round(seconds); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return h > 0 ? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` : `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; /** * Remove duplicate text patterns from a string * @param {string} text - Text to deduplicate * @returns {string} Deduplicated text */ const removeDuplicateText = text => { if (!text || text.length < 10) return text; let cleaned = text.trim(); // Remove trailing ellipsis and truncation markers cleaned = cleaned.replace(/\s*\.{2,}$/, '').replace(/\s*…$/, ''); const words = cleaned.split(/\s+/); if (words.length < 4) return cleaned; // Too short to have meaningful duplicates // Try exact half split first const half = Math.floor(words.length / 2); if (half >= 2) { const firstHalf = words.slice(0, half).join(' '); const secondHalf = words.slice(half, half * 2).join(' '); if (firstHalf === secondHalf) { return firstHalf; } } // Try sliding window approach for partial duplicates // Search for the longest repeating pattern const minPatternLength = Math.max(2, Math.floor(words.length / 4)); const maxPatternLength = Math.floor(words.length / 2); for (let len = maxPatternLength; len >= minPatternLength; len--) { const pattern = words.slice(0, len).join(' '); const patternWords = words.slice(0, len); // Check if this pattern appears again anywhere in the text for (let offset = 1; offset <= words.length - len; offset++) { let matchCount = 0; let partialWordMatch = false; const testWords = words.slice(offset, Math.min(offset + len, words.length)); for (let i = 0; i < patternWords.length; i++) { const patternWord = patternWords[i]; const testWord = testWords[i]; if (!testWord) break; // Exact match if (patternWord === testWord) { matchCount++; } // Partial match (for truncated words like "сте..." vs "стекла") else if (testWord.length >= 3 && patternWord.startsWith(testWord)) { matchCount += 0.8; // Partial credit partialWordMatch = true; } else if (patternWord.length >= 3 && testWord.startsWith(patternWord)) { matchCount += 0.8; // Partial credit partialWordMatch = true; } } // If 70%+ of the pattern matches (allowing for partial words), it's a duplicate const similarity = matchCount / patternWords.length; const effectiveMatches = Math.floor(matchCount); if ( similarity >= 0.7 && (effectiveMatches >= 2 || (matchCount >= 1.5 && partialWordMatch)) ) { return pattern; } } } return cleaned; }; /** * Parse time string to seconds with validation * @param {string} timeStr - Time string (MM:SS or HH:MM:SS) * @returns {number|null} Seconds or null if invalid */ const parseTime = timeStr => { try { if (!timeStr || typeof timeStr !== 'string') return null; const str = timeStr.trim(); if (str.length === 0 || str.length > 12) return null; // Sanity check // Handle HH:MM:SS format let match = str.match(/^(\d+):(\d{1,2}):(\d{2})$/); if (match) { const [, h, m, s] = match.map(Number); // Validate ranges if (isNaN(h) || isNaN(m) || isNaN(s)) return null; if (m >= 60 || s >= 60 || h < 0 || m < 0 || s < 0) return null; const total = h * 3600 + m * 60 + s; // Sanity check: max 24 hours return total <= 86400 ? total : null; } // Handle MM:SS format match = str.match(/^(\d{1,2}):(\d{2})$/); if (match) { const [, m, s] = match.map(Number); // Validate ranges if (isNaN(m) || isNaN(s)) return null; if (m >= 60 || s >= 60 || m < 0 || s < 0) return null; return m * 60 + s; } return null; } catch (error) { console.error('[Timecode] Error parsing time:', error); return null; } }; /** * Extract timecodes from text with validation * @param {string} text - Text containing timecodes * @returns {Array<{time: number, label: string, originalText: string}>} Extracted timecodes */ const extractTimecodes = text => { try { if (!text || typeof text !== 'string') return []; // Security: limit text length to prevent DoS if (text.length > 50000) { console.warn('[Timecode] Text too long, truncating'); text = text.substring(0, 50000); } const timecodes = []; const seen = new Set(); const patterns = [ /(\d{1,2}:\d{2}(?::\d{2})?)\s*[-–—]\s*(.+?)$/gm, /^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+?)$/gm, /(\d{1,2}:\d{2}(?::\d{2})?)\s*[-–—:]\s*([^\n\r]{1,100}?)(?=\s*\d{1,2}:\d{2}|\s*$)/g, /(\d{1,2}:\d{2}(?::\d{2})?)\s*[–—-]\s*([^\n]+)/gm, /^(\d{1,2}:\d{2}(?::\d{2})?)\s*(.+)$/gm, ]; for (const pattern of patterns) { let match; let iterations = 0; const maxIterations = 1000; // Prevent infinite loops while ((match = pattern.exec(text)) !== null && iterations++ < maxIterations) { const time = parseTime(match[1]); if (time !== null && !seen.has(time)) { seen.add(time); // Sanitize label text - only use match[2] if it exists and is not empty let label = (match[2] || '') .trim() .replace(/^\d+[\.\)]\s*/, '') .replace(/\s+/g, ' ') // Normalize whitespace .substring(0, 100); // Limit label length // Debug logging const originalLabel = label; // Remove potentially dangerous characters label = label.replace(/[<>\"']/g, ''); // Remove duplicate text in label label = removeDuplicateText(label); if (originalLabel !== label && label.length > 0) { console.warn('[Timecode] Description deduplicated:', originalLabel, '->', label); } // Only add if we have actual content (time is always added, label can be empty) timecodes.push({ time, label: label || '', originalText: match[1] }); } } if (iterations >= maxIterations) { console.warn('[Timecode] Maximum iterations reached during extraction'); } } return timecodes.sort((a, b) => a.time - b.time); } catch (error) { console.error('[Timecode] Error extracting timecodes:', error); return []; } }; const DESCRIPTION_SELECTORS = [ '#description-inline-expander yt-attributed-string', '#description-inline-expander yt-formatted-string', '#description-inline-expander ytd-text-inline-expander', '#description-inline-expander .yt-core-attributed-string', '#description ytd-text-inline-expander', '#description ytd-expandable-video-description-body-renderer', '#description.ytd-watch-metadata yt-formatted-string', '#description.ytd-watch-metadata #description-inline-expander', '#tab-info ytd-expandable-video-description-body-renderer yt-formatted-string', '#tab-info ytd-expandable-video-description-body-renderer yt-attributed-string', '#structured-description ytd-text-inline-expander', '#structured-description yt-formatted-string', 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-macro-markers-description-chapters"] yt-formatted-string', 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-macro-markers-description-chapters"] yt-attributed-string', 'ytd-watch-metadata #description', 'ytd-watch-metadata #description-inline-expander', '#description', ]; const DESCRIPTION_SELECTOR_COMBINED = DESCRIPTION_SELECTORS.join(','); const DESCRIPTION_EXPANDERS = [ '#description-inline-expander yt-button-shape button', '#description-inline-expander tp-yt-paper-button#expand', '#description-inline-expander tp-yt-paper-button[aria-label]', 'ytd-watch-metadata #description-inline-expander yt-button-shape button', 'ytd-text-inline-expander[collapsed] yt-button-shape button', 'ytd-text-inline-expander[collapsed] tp-yt-paper-button#expand', 'ytd-expandable-video-description-body-renderer #expand', 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-macro-markers-description-chapters"] #expand', ]; /** * Sleep/delay utility using Promises * @param {number} [ms=250] - Milliseconds to wait * @returns {Promise<void>} */ const sleep = (ms = 250) => new Promise(resolve => setTimeout(resolve, ms)); /** * Collect and concatenate text from video description using multiple selectors * @returns {string} Concatenated description text */ const collectDescriptionText = () => { const snippets = []; DESCRIPTION_SELECTORS.forEach(selector => { $$(selector).forEach(node => { const text = node?.textContent?.trim(); if (text) { snippets.push(text); } }); }); return snippets.join('\n'); }; // Collect visible comments text (top-level few comments) to search for timecodes const COMMENT_SELECTORS = [ 'ytd-comment-thread-renderer #content-text', 'ytd-comment-renderer #content-text', 'ytd-comment-thread-renderer yt-formatted-string#content-text', 'ytd-comment-renderer yt-formatted-string#content-text', '#comments ytd-comment-thread-renderer #content-text', ]; /** * Collect text from visible comments to search for timecodes * @param {number} [maxComments=30] - Maximum number of comments to collect * @returns {string} Concatenated comments text */ const collectCommentsText = (maxComments = 30) => { try { const snippets = []; for (const sel of COMMENT_SELECTORS) { $$(sel).forEach(node => { if (snippets.length >= maxComments) return; const text = node?.textContent?.trim(); if (text) snippets.push(text); }); if (snippets.length >= maxComments) break; } return snippets.join('\n'); } catch (error) { YouTubeUtils.logError('TimecodePanel', 'collectCommentsText failed', error); return ''; } }; const expandDescriptionIfNeeded = async () => { for (const selector of DESCRIPTION_EXPANDERS) { const button = $(selector); if (!button) continue; const ariaExpanded = button.getAttribute('aria-expanded'); if (ariaExpanded === 'true') return false; const ariaLabel = button.getAttribute('aria-label')?.toLowerCase(); if (ariaLabel && ariaLabel.includes('less')) return false; if (button.offsetParent !== null) { try { /** @type {HTMLElement} */ (button).click(); await sleep(400); return true; } catch (error) { console.warn('[Timecode] Failed to click expand button:', error); } } } const inlineExpander = $('ytd-text-inline-expander[collapsed]'); if (inlineExpander) { try { inlineExpander.removeAttribute('collapsed'); } catch (error) { YouTubeUtils.logError('TimecodePanel', 'Failed to expand description', error); } await sleep(300); return true; } return false; }; const ensureDescriptionReady = async () => { const initialText = collectDescriptionText(); if (initialText) return; const maxAttempts = 3; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { await YouTubeUtils.waitForElement(DESCRIPTION_SELECTOR_COMBINED, 1500); } catch { // Continue trying } await sleep(200); const expanded = await expandDescriptionIfNeeded(); await sleep(expanded ? 500 : 200); const text = collectDescriptionText(); if (text && text.length > initialText.length) { return; } } }; const getCurrentVideoId = () => new URLSearchParams(window.location.search).get('v'); // Detection const detectTimecodes = async (options = {}) => { const { force = false } = options; if (!config.enabled) return []; if (!force && !config.autoDetect) return []; const videoId = getCurrentVideoId(); if (!videoId) return []; const cacheKey = `detect_${videoId}`; if (!force && state.timecodes.has(cacheKey)) { const cached = state.timecodes.get(cacheKey); if (Array.isArray(cached) && cached.length) { return cached; } state.timecodes.delete(cacheKey); } await ensureDescriptionReady(); const uniqueMap = new Map(); const descriptionText = collectDescriptionText(); if (descriptionText) { const extracted = extractTimecodes(descriptionText); extracted.forEach(tc => { if (tc.time >= 0) { uniqueMap.set(tc.time.toString(), tc); } }); } // Get native chapters const chapters = getYouTubeChapters(); chapters.forEach(chapter => { if (chapter.time >= 0) { const key = chapter.time.toString(); const existing = uniqueMap.get(key); // Prefer chapter label if existing label is empty or duplicate if (existing && chapter.label && chapter.label.length > existing.label.length) { uniqueMap.set(key, { ...existing, label: chapter.label, isChapter: true }); } else if (!existing) { uniqueMap.set(key, chapter); } else { // Mark existing as chapter uniqueMap.set(key, { ...existing, isChapter: true }); } } }); // If no timecodes from description/chapters, try scanning visible comments if (uniqueMap.size === 0) { try { const commentsText = collectCommentsText(); if (commentsText) { const extractedComments = extractTimecodes(commentsText); extractedComments.forEach(tc => { if (tc.time >= 0) uniqueMap.set(tc.time.toString(), tc); }); } } catch (error) { YouTubeUtils.logError('TimecodePanel', 'Comment scanning failed', error); } } const result = Array.from(uniqueMap.values()).sort((a, b) => a.time - b.time); const hadExistingItems = state.dom.list?.childElementCount > 0; if (result.length > 0) { updateTimecodePanel(result); state.timecodes.set(cacheKey, result); if (config.autoSave) saveTimecodesToStorage(result); } else { if (force || !hadExistingItems) { updateTimecodePanel([]); } if (force) { state.timecodes.delete(cacheKey); } } return result; }; /** * Reload timecodes by re-detecting them from the current video * @param {HTMLElement|null} [buttonOverride=null] - Optional reload button element * @returns {Promise<void>} */ const reloadTimecodes = async (buttonOverride = null) => { const button = buttonOverride || state.dom.reloadButton || byId('timecode-reload'); if (state.isReloading || !config.enabled) return; state.isReloading = true; if (button) { button.disabled = true; button.classList.add('loading'); } try { const result = await detectTimecodes({ force: true }); if (Array.isArray(result) && result.length) { showNotification(t('foundTimecodes').replace('{count}', result.length)); } else { updateTimecodePanel([]); showNotification(t('noTimecodesFound')); } } catch (error) { YouTubeUtils.logError('TimecodePanel', 'Reload failed', error); showNotification(t('reloadError')); } finally { if (button) { button.disabled = false; button.classList.remove('loading'); } state.isReloading = false; } }; /** * Extract chapter markers from YouTube's native chapter system * @returns {Array<{time: number, label: string, isChapter: boolean}>} Array of chapter objects */ const getYouTubeChapters = () => { // Расширенный поиск глав/эпизодов const selectors = [ 'ytd-macro-markers-list-item-renderer', 'ytd-chapter-renderer', 'ytd-engagement-panel-section-list-renderer[target-id*="description-chapters"] ytd-macro-markers-list-item-renderer', 'ytd-engagement-panel-section-list-renderer[target-id*="description-chapters"] #details', '#structured-description ytd-horizontal-card-list-renderer ytd-macro-markers-list-item-renderer', ]; const items = $$(selectors.join(', ')); const chapters = new Map(); items.forEach(item => { // Попробуем разные способы извлечения времени и заголовка const timeSelectors = ['.time-info', '.timestamp', '#time', 'span[id*="time"]']; const titleSelectors = ['.marker-title', '.chapter-title', '#details', 'h4', '.title']; let timeText = null; for (const sel of timeSelectors) { const el = item.querySelector(sel); if (el?.textContent) { timeText = el.textContent; break; } } let titleText = null; for (const sel of titleSelectors) { const el = item.querySelector(sel); if (el?.textContent) { titleText = el.textContent; break; } } if (timeText) { const time = parseTime(timeText.trim()); if (time !== null) { // Очищаем заголовок от лишних пробелов и переносов строк let cleanTitle = titleText?.trim().replace(/\s+/g, ' ') || ''; // Debug logging if (cleanTitle && cleanTitle.length > 0) { console.warn('[Timecode Debug] Raw chapter title:', cleanTitle); } // Remove time prefix if present in label cleanTitle = cleanTitle.replace(/^\d{1,2}:\d{2}(?::\d{2})?\s*[-–—:]?\s*/, ''); // Remove duplicate text (some YouTube chapters repeat the title) const deduplicated = removeDuplicateText(cleanTitle); if (cleanTitle !== deduplicated) { console.warn('[Timecode] Removed duplicate:', cleanTitle, '->', deduplicated); } cleanTitle = deduplicated; chapters.set(time.toString(), { time, label: cleanTitle, isChapter: true, }); } } }); const result = Array.from(chapters.values()).sort((a, b) => a.time - b.time); return result; }; // Settings panel const addTimecodePanelSettings = () => { const advancedSection = YouTubeUtils.querySelector ? YouTubeUtils.querySelector('.ytp-plus-settings-section[data-section="advanced"]') : $('.ytp-plus-settings-section[data-section="advanced"]'); if ( !advancedSection || (YouTubeUtils.querySelector ? YouTubeUtils.querySelector('.timecode-settings-item') : $('.timecode-settings-item')) ) { return; } const getSubmenuExpanded = () => { try { const raw = localStorage.getItem('ytp-plus-submenu-states'); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed && typeof parsed.timecode === 'boolean') return parsed.timecode; } catch {} return null; }; const storedExpanded = getSubmenuExpanded(); const initialExpanded = typeof storedExpanded === 'boolean' ? storedExpanded : true; const { ctrlKey, altKey, shiftKey } = config.shortcut; const modifierValue = [ ctrlKey && altKey && shiftKey && 'ctrl+alt+shift', ctrlKey && altKey && 'ctrl+alt', ctrlKey && shiftKey && 'ctrl+shift', altKey && shiftKey && 'alt+shift', ctrlKey && 'ctrl', altKey && 'alt', shiftKey && 'shift', ].find(Boolean) || 'none'; const enableDiv = document.createElement('div'); enableDiv.className = 'ytp-plus-settings-item timecode-settings-item ytp-plus-settings-item--with-submenu'; enableDiv.innerHTML = ` <div> <label class="ytp-plus-settings-item-label" for="timecode-enable-checkbox">${t( 'enableTimecode' )}</label> <div class="ytp-plus-settings-item-description">${t('enableDescription')}</div> </div> <div class="ytp-plus-settings-item-actions"> <button type="button" class="ytp-plus-submenu-toggle" data-submenu="timecode" aria-label="Toggle timecode submenu" aria-expanded="${initialExpanded ? 'true' : 'false'}" ${config.enabled ? '' : 'disabled'} style="display:${config.enabled ? 'inline-flex' : 'none'};" > <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <input type="checkbox" id="timecode-enable-checkbox" class="ytp-plus-settings-checkbox" data-setting="enabled" ${ config.enabled ? 'checked' : '' }> </div> `; const submenuWrap = document.createElement('div'); submenuWrap.className = 'timecode-submenu'; submenuWrap.dataset.submenu = 'timecode'; submenuWrap.style.display = config.enabled && initialExpanded ? 'block' : 'none'; submenuWrap.style.marginLeft = '12px'; submenuWrap.style.marginBottom = '12px'; const submenuCard = document.createElement('div'); submenuCard.className = 'glass-card'; submenuCard.style.display = 'flex'; submenuCard.style.flexDirection = 'column'; submenuCard.style.gap = '8px'; const shortcutDiv = document.createElement('div'); shortcutDiv.className = 'ytp-plus-settings-item timecode-settings-item timecode-shortcut-item'; shortcutDiv.style.display = 'flex'; shortcutDiv.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${t('keyboardShortcut')}</label> <div class="ytp-plus-settings-item-description">${t('shortcutDescription')}</div> </div> <div style="display: flex; align-items: center; gap: 8px;"> <!-- Hidden native select kept for programmatic compatibility --> <select id="timecode-modifier-combo" style="display:none;"> ${[ 'none', 'ctrl', 'alt', 'shift', 'ctrl+alt', 'ctrl+shift', 'alt+shift', 'ctrl+alt+shift', ] .map( v => `<option value="${v}" ${v === modifierValue ? 'selected' : ''}>${ v === 'none' ? 'None' : v .split('+') .map(k => k.charAt(0).toUpperCase() + k.slice(1)) .join('+') }</option>` ) .join('')} </select> <div class="glass-dropdown" id="timecode-modifier-dropdown" tabindex="0" role="listbox" aria-expanded="false"> <button class="glass-dropdown__toggle" type="button" aria-haspopup="listbox"> <span class="glass-dropdown__label">${ modifierValue === 'none' ? 'None' : modifierValue .split('+') .map(k => k.charAt(0).toUpperCase() + k.slice(1)) .join('+') }</span> <svg class="glass-dropdown__chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> </button> <ul class="glass-dropdown__list" role="presentation"> ${[ 'none', 'ctrl', 'alt', 'shift', 'ctrl+alt', 'ctrl+shift', 'alt+shift', 'ctrl+alt+shift', ] .map(v => { const label = v === 'none' ? 'None' : v .split('+') .map(k => k.charAt(0).toUpperCase() + k.slice(1)) .join('+'); const sel = v === modifierValue ? ' aria-selected="true"' : ''; return `<li class="glass-dropdown__item" data-value="${v}" role="option"${sel}>${label}</li>`; }) .join('')} </ul> </div> <span style="color:inherit;opacity:0.8;">+</span> <input type="text" id="timecode-key" value="${config.shortcut.key}" maxlength="1" style="width: 30px; text-align: center; background: rgba(34, 34, 34, var(--yt-header-bg-opacity)); color: white; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 4px;"> </div> `; submenuCard.appendChild(shortcutDiv); submenuWrap.appendChild(submenuCard); advancedSection.append(enableDiv, submenuWrap); // Initialize custom glass dropdown interactions const initGlassDropdown = () => { const hiddenSelect = byId('timecode-modifier-combo'); const dropdown = byId('timecode-modifier-dropdown'); if (!hiddenSelect || !dropdown) return; const toggle = $('.glass-dropdown__toggle', dropdown); const list = $('.glass-dropdown__list', dropdown); const label = $('.glass-dropdown__label', dropdown); let items = Array.from($$('.glass-dropdown__item', list)); let idx = items.findIndex(it => it.getAttribute('aria-selected') === 'true'); if (idx < 0) idx = 0; const closeList = () => { dropdown.setAttribute('aria-expanded', 'false'); list.style.display = 'none'; }; const openList = () => { dropdown.setAttribute('aria-expanded', 'true'); list.style.display = 'block'; items = Array.from($$('.glass-dropdown__item', list)); }; // Set initial state closeList(); toggle.addEventListener('click', () => { const expanded = dropdown.getAttribute('aria-expanded') === 'true'; if (expanded) closeList(); else openList(); }); // Click outside to close document.addEventListener('click', e => { if (!dropdown.contains(e.target)) closeList(); }); // Item selection list.addEventListener('click', e => { const it = e.target.closest('.glass-dropdown__item'); if (!it) return; const val = it.dataset.value; hiddenSelect.value = val; // update aria-selected list .querySelectorAll('.glass-dropdown__item') .forEach(li => li.removeAttribute('aria-selected')); it.setAttribute('aria-selected', 'true'); idx = items.indexOf(it); label.textContent = it.textContent; // trigger change to reuse existing save logic hiddenSelect.dispatchEvent(new Event('change', { bubbles: true })); closeList(); }); // keyboard support with arrow navigation dropdown.addEventListener('keydown', e => { const expanded = dropdown.getAttribute('aria-expanded') === 'true'; if (e.key === 'ArrowDown') { e.preventDefault(); if (!expanded) openList(); idx = Math.min(idx + 1, items.length - 1); items.forEach(it => it.removeAttribute('aria-selected')); items[idx].setAttribute('aria-selected', 'true'); items[idx].scrollIntoView({ block: 'nearest' }); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (!expanded) openList(); idx = Math.max(idx - 1, 0); items.forEach(it => it.removeAttribute('aria-selected')); items[idx].setAttribute('aria-selected', 'true'); items[idx].scrollIntoView({ block: 'nearest' }); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!expanded) { openList(); return; } const it = items[idx]; if (it) { hiddenSelect.value = it.dataset.value; hiddenSelect.dispatchEvent(new Event('change', { bubbles: true })); label.textContent = it.textContent; closeList(); } } else if (e.key === 'Escape') { closeList(); } }); }; // Defer init to ensure elements are in DOM setTimeout(initGlassDropdown, 0); // Event listeners advancedSection.addEventListener('change', e => { const target = /** @type {EventTarget & HTMLElement} */ (e.target); if (target.matches && target.matches('.ytp-plus-settings-checkbox[data-setting="enabled"]')) { config.enabled = /** @type {HTMLInputElement} */ (target).checked; const submenuToggle = enableDiv.querySelector( '.ytp-plus-submenu-toggle[data-submenu="timecode"]' ); if (submenuToggle instanceof HTMLElement) { if (config.enabled) { const stored = getSubmenuExpanded(); const nextExpanded = typeof stored === 'boolean' ? stored : true; submenuToggle.removeAttribute('disabled'); submenuToggle.style.display = 'inline-flex'; submenuToggle.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false'); submenuWrap.style.display = nextExpanded ? 'block' : 'none'; } else { submenuToggle.setAttribute('disabled', ''); submenuToggle.style.display = 'none'; submenuWrap.style.display = 'none'; } } toggleTimecodePanel(config.enabled); saveSettings(); } }); byId('timecode-modifier-combo')?.addEventListener('change', e => { const target = /** @type {EventTarget & HTMLSelectElement} */ (e.target); const value = target.value; config.shortcut.ctrlKey = value.includes('ctrl'); config.shortcut.altKey = value.includes('alt'); config.shortcut.shiftKey = value.includes('shift'); saveSettings(); }); byId('timecode-key')?.addEventListener('input', e => { const target = /** @type {EventTarget & HTMLInputElement} */ (e.target); if (target.value) { config.shortcut.key = target.value.toUpperCase(); saveSettings(); } }); }; const ensureTimecodePanelSettings = (attempt = 0) => { const advancedVisible = $('.ytp-plus-settings-section[data-section="advanced"]:not(.hidden)'); if (!advancedVisible) { if (attempt < 20) setTimeout(() => ensureTimecodePanelSettings(attempt + 1), 80); return; } addTimecodePanelSettings(); if (!$('.timecode-settings-item') && attempt < 20) { setTimeout(() => ensureTimecodePanelSettings(attempt + 1), 80); } }; // CSS const insertTimecodeStyles = () => { if (byId('timecode-panel-styles')) return; const styles = ` :root{--tc-panel-bg:rgba(255,255,255,0.06);--tc-panel-border:rgba(255,255,255,0.12);--tc-panel-color:#fff} html[dark],body[dark]{--tc-panel-bg:rgba(34,34,34,0.75);--tc-panel-border:rgba(255,255,255,0.12);--tc-panel-color:#fff} html:not([dark]){--tc-panel-bg:rgba(255,255,255,0.95);--tc-panel-border:rgba(0,0,0,0.08);--tc-panel-color:#222} #timecode-panel{position:fixed;right:20px;top:80px;background:var(--tc-panel-bg);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.45);width:320px;max-height:70vh;z-index:10000;color:var(--tc-panel-color);backdrop-filter:blur(14px) saturate(140%);-webkit-backdrop-filter:blur(14px) saturate(140%);border:1.5px solid var(--tc-panel-border);transition:transform .28s cubic-bezier(.4,0,.2,1),opacity .28s;overflow:hidden;display:flex;flex-direction:column} #timecode-panel.hidden{transform:translateX(300px);opacity:0;pointer-events:none} #timecode-panel.auto-tracking{box-shadow:0 12px 48px rgba(255,0,0,0.12);border-color:rgba(255,0,0,0.25)} #timecode-header{display:flex;justify-content:space-between;align-items:center;padding:14px;border-bottom:1px solid rgba(255,255,255,0.04);background:linear-gradient(180deg, rgba(255,255,255,0.02), transparent);cursor:move} #timecode-title{font-weight:600;margin:0;font-size:15px;user-select:none;display:flex;align-items:center;gap:8px} #timecode-tracking-indicator{width:8px;height:8px;background:red;border-radius:50%;opacity:0;transition:opacity .3s} #timecode-panel.auto-tracking #timecode-tracking-indicator{opacity:1} #timecode-current-time{font-family:monospace;font-size:12px;padding:2px 6px;background:rgba(255,0,0,.3);border-radius:3px;margin-left:auto} #timecode-header-controls{display:flex;align-items:center;gap:6px} #timecode-reload,#timecode-close{background:transparent;border:none;color:inherit;cursor:pointer;width:28px;height:28px;padding:0;display:flex;align-items:center;justify-content:center;border-radius:6px;transition:background .18s,color .18s} #timecode-reload:hover,#timecode-close:hover{background:rgba(255,255,255,0.04)} #timecode-reload.loading{animation:timecode-spin .8s linear infinite} #timecode-list{overflow-y:auto;padding:8px 0;max-height:calc(70vh - 80px);scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.3) transparent} #timecode-list::-webkit-scrollbar{width:6px} #timecode-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.3);border-radius:3px} .timecode-item{padding:10px 14px;display:flex;align-items:center;cursor:pointer;transition:background-color .16s,transform .12s;border-left:3px solid transparent;position:relative;border-radius:8px;margin:6px 10px} .timecode-item:hover{background:rgba(255,255,255,0.04);transform:translateY(-2px)} .timecode-item:hover .timecode-actions{opacity:1} .timecode-item.active{background:linear-gradient(90deg, rgba(255,68,68,0.12), rgba(255,68,68,0.04));border-left-color:#ff6666;box-shadow:inset 0 0 0 1px rgba(255,68,68,0.03)} .timecode-item.active.pulse{animation:pulse .8s ease-out} .timecode-item.editing{background:linear-gradient(90deg, rgba(255,170,0,0.08), rgba(255,170,0,0.03));border-left-color:#ffaa00} .timecode-item.editing .timecode-actions{opacity:1} @keyframes pulse{0%{transform:scale(1)}50%{transform:scale(1.02)}100%{transform:scale(1)}} @keyframes timecode-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} .timecode-time{font-family:monospace;margin-right:10px;color:rgba(255,255,255,.8);font-size:13px;min-width:45px;flex-shrink:0} .timecode-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;flex:1;margin-left:4px} .timecode-item:not(:has(.timecode-label)) .timecode-time{flex:1;text-align:left} .timecode-item.has-chapter .timecode-time{color:#ff4444} .timecode-progress{width:0;height:2px;background:#ff4444;position:absolute;bottom:0;left:0;transition:width .3s;opacity:.8} .timecode-actions{position:absolute;right:8px;top:50%;transform:translateY(-50%);display:flex;gap:4px;opacity:0;transition:opacity .2s;background:rgba(0,0,0,.8);border-radius:4px;padding:2px} .timecode-action{background:none;border:none;color:rgba(255,255,255,.8);cursor:pointer;padding:4px;font-size:12px;border-radius:2px;transition:color .2s,background-color .2s} .timecode-action:hover{color:#fff;background:rgba(255,255,255,.2)} .timecode-action.edit:hover{color:#ffaa00} .timecode-action.delete:hover{color:#ff4444} #timecode-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;text-align:center;color:rgba(255,255,255,.7);font-size:13px} #timecode-form{padding:12px;border-top:1px solid rgba(255,255,255,.04);display:none} #timecode-form.visible{display:block} #timecode-form input{width:100%;margin-bottom:8px;padding:8px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);border-radius:4px;color:#fff;font-size:13px} #timecode-form input::placeholder{color:rgba(255,255,255,.6)} #timecode-form-buttons{display:flex;gap:8px;justify-content:flex-end} #timecode-form-buttons button{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;font-size:12px;transition:background-color .2s} #timecode-form-cancel{background:rgba(255,255,255,.2);color:#fff} #timecode-form-cancel:hover{background:rgba(255,255,255,.3)} #timecode-form-save{background:#ff4444;color:#fff} #timecode-form-save:hover{background:#ff6666} #timecode-actions{padding:10px;border-top:1px solid rgba(255,255,255,.04);display:flex;gap:8px;background:linear-gradient(180deg,transparent,rgba(0,0,0,0.03))} #timecode-actions button{padding:8px 12px;border:none;border-radius:8px;cursor:pointer;font-size:13px;transition:background .18s;color:inherit;background:rgba(255,255,255,0.02)} #timecode-actions button:hover{background:rgba(255,255,255,0.04)} #timecode-track-toggle.active{background:linear-gradient(90deg,#ff6b6b,#ff4444);color:#fff} `; YouTubeUtils.StyleManager.add('timecode-panel-styles', styles); }; // Panel creation const createTimecodePanel = () => { if (state.dom.panel) return state.dom.panel; // Remove any existing panels (for redundancy) $$('#timecode-panel').forEach(p => p.remove()); const panel = document.createElement('div'); panel.id = 'timecode-panel'; panel.className = config.enabled ? '' : 'hidden'; if (config.autoTrackPlayback) panel.classList.add('auto-tracking'); panel.innerHTML = ` <div id="timecode-header"> <h3 id="timecode-title"> <div id="timecode-tracking-indicator"></div> ${t('timecodes')} <span id="timecode-current-time"></span> </h3> <div id="timecode-header-controls"> <button id="timecode-reload" title="${t('reload')}" aria-label="${t('reload')}">⟳</button> <button id="timecode-close" title="${t('close')}" aria-label="${t('close')}"> <svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> </div> <div id="timecode-list"></div> <div id="timecode-empty"> <div>${t('noTimecodesFound')}</div> <div style="margin-top:5px;font-size:12px">${t('clickToAdd')}</div> </div> <div id="timecode-form"> <input type="text" id="timecode-form-time" placeholder="${t('timePlaceholder')}"> <input type="text" id="timecode-form-label" placeholder="${t('labelPlaceholder')}"> <div id="timecode-form-buttons"> <button type="button" id="timecode-form-cancel">${t('cancel')}</button> <button type="button" id="timecode-form-save" class="save">${t('save')}</button> </div> </div> <div id="timecode-actions"> <button id="timecode-add-btn">${t('add')}</button> <button id="timecode-export-btn" ${config.export ? '' : 'style="display:none"'}>${t('export')}</button> <button id="timecode-track-toggle" class="${config.autoTrackPlayback ? 'active' : ''}">${config.autoTrackPlayback ? t('tracking') : t('track')}</button> </div> `; // Cache DOM elements state.dom = { panel, list: panel.querySelector('#timecode-list'), empty: panel.querySelector('#timecode-empty'), form: panel.querySelector('#timecode-form'), timeInput: panel.querySelector('#timecode-form-time'), labelInput: panel.querySelector('#timecode-form-label'), currentTime: panel.querySelector('#timecode-current-time'), trackToggle: panel.querySelector('#timecode-track-toggle'), reloadButton: panel.querySelector('#timecode-reload'), }; // Event delegation panel.addEventListener('click', handlePanelClick); makeDraggable(panel); document.body.appendChild(panel); applySavedPanelPosition(panel); return panel; }; // Event handling const handlePanelClick = e => { const { target } = e; const item = target.closest('.timecode-item'); // Use closest so clicks on child SVG/path elements are detected const reloadButton = target.closest ? target.closest('#timecode-reload') : target.id === 'timecode-reload' ? target : null; if (reloadButton) { e.preventDefault(); reloadTimecodes(reloadButton); return; } const closeButton = target.closest ? target.closest('#timecode-close') : target.id === 'timecode-close' ? target : null; if (closeButton) { toggleTimecodePanel(false); } else if (target.id === 'timecode-add-btn') { const video = YouTubeUtils.querySelector ? YouTubeUtils.querySelector('video') : $('video'); if (video) showTimecodeForm(video.currentTime); } else if (target.id === 'timecode-track-toggle') { config.autoTrackPlayback = !config.autoTrackPlayback; target.textContent = config.autoTrackPlayback ? t('tracking') : t('track'); target.classList.toggle('active', config.autoTrackPlayback); state.dom.panel.classList.toggle('auto-tracking', config.autoTrackPlayback); saveSettings(); if (config.autoTrackPlayback) startTracking(); } else if (target.id === 'timecode-export-btn') { exportTimecodes(); } else if (target.id === 'timecode-form-cancel') { hideTimecodeForm(); } else if (target.id === 'timecode-form-save') { saveTimecodeForm(); } else if (target.classList.contains('timecode-action')) { e.stopPropagation(); const action = target.dataset.action; const index = parseInt(target.closest('.timecode-item').dataset.index, 10); if (action === 'edit') { editTimecode(index); } else if (action === 'delete') { deleteTimecode(index); } } else if (item && !target.closest('.timecode-actions')) { const time = parseFloat(item.dataset.time); const video = $('video'); if (video && !isNaN(time)) { /** @type {HTMLVideoElement} */ (video).currentTime = time; if (video.paused) video.play(); updateActiveItem(item); } } }; // Edit timecode const editTimecode = index => { const timecodes = getCurrentTimecodes(); if (index < 0 || index >= timecodes.length) return; const timecode = timecodes[index]; state.editingIndex = index; // Update item appearance const item = state.dom.list.querySelector(`.timecode-item[data-index="${index}"]`); if (item) { item.classList.add('editing'); // Hide other editing items state.dom.list.querySelectorAll('.timecode-item.editing').forEach(el => { if (el !== item) el.classList.remove('editing'); }); } showTimecodeForm(timecode.time, timecode.label); }; // Delete timecode const deleteTimecode = index => { const timecodes = getCurrentTimecodes(); if (index < 0 || index >= timecodes.length) return; const timecode = timecodes[index]; // Don't allow deletion of native YouTube chapters if (timecode.isChapter && !timecode.isUserAdded) { showNotification(t('cannotDeleteChapter')); return; } // Confirm deletion if (!confirm(t('confirmDelete').replace('{label}', timecode.label))) return; timecodes.splice(index, 1); updateTimecodePanel(timecodes); saveTimecodesToStorage(timecodes); showNotification(t('timecodeDeleted')); }; // Form handling const showTimecodeForm = (currentTime, existingLabel = '') => { const { form, timeInput, labelInput } = state.dom; form.classList.add('visible'); timeInput.value = formatTime(currentTime); labelInput.value = existingLabel; requestAnimationFrame(() => labelInput.focus()); }; const hideTimecodeForm = () => { state.dom.form.classList.remove('visible'); state.editingIndex = null; // Remove editing class from all items state.dom.list?.querySelectorAll('.timecode-item.editing').forEach(el => { el.classList.remove('editing'); }); }; const saveTimecodeForm = () => { const { timeInput, labelInput } = state.dom; const timeValue = timeInput.value.trim(); const labelValue = labelInput.value.trim(); const time = parseTime(timeValue); if (time === null) { showNotification(t('invalidTimeFormat')); return; } const timecodes = getCurrentTimecodes(); const newTimecode = { time, label: labelValue || '', isUserAdded: true, isChapter: false, }; if (state.editingIndex !== null) { // Editing existing timecode const oldTimecode = timecodes[state.editingIndex]; if (oldTimecode.isChapter && !oldTimecode.isUserAdded) { showNotification(t('cannotEditChapter')); hideTimecodeForm(); return; } timecodes[state.editingIndex] = { ...oldTimecode, ...newTimecode }; showNotification(t('timecodeUpdated')); } else { // Adding new timecode timecodes.push(newTimecode); showNotification(t('timecodeAdded')); } const sorted = timecodes.sort((a, b) => a.time - b.time); updateTimecodePanel(sorted); saveTimecodesToStorage(sorted); hideTimecodeForm(); }; // Export const exportTimecodes = () => { const timecodes = getCurrentTimecodes(); if (!timecodes.length) { showNotification(t('noTimecodesToExport')); return; } const exportBtn = state.dom.panel?.querySelector('#timecode-export-btn'); if (exportBtn) { exportBtn.textContent = t('copied'); exportBtn.style.backgroundColor = 'rgba(0,220,0,0.8)'; setTimeout(() => { exportBtn.textContent = t('export'); exportBtn.style.backgroundColor = ''; }, 2000); } const videoTitle = document.title.replace(/\s-\sYouTube$/, ''); let content = `${videoTitle}\n\nTimecodes:\n`; timecodes.forEach(tc => { const label = tc.label?.trim(); content += label ? `${formatTime(tc.time)} - ${label}\n` : `${formatTime(tc.time)}\n`; }); if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(content).then(() => { showNotification(t('timecodesCopied')); }); } }; // Panel updates const updateTimecodePanel = timecodes => { const { list, empty } = state.dom; if (!list || !empty) return; const isEmpty = !timecodes.length; empty.style.display = isEmpty ? 'flex' : 'none'; list.style.display = isEmpty ? 'none' : 'block'; if (isEmpty) { list.innerHTML = ''; return; } list.innerHTML = timecodes .map((tc, i) => { const timeStr = formatTime(tc.time); // Only use label if it exists and is different from time let rawLabel = tc.label?.trim() || ''; // Remove time prefix from label if it starts with the same time const timePattern = /^\d{1,2}:\d{2}(?::\d{2})?\s*[-–—:]?\s*/; rawLabel = rawLabel.replace(timePattern, ''); // Remove duplicate text in label (final safety check) const beforeDedup = rawLabel; rawLabel = removeDuplicateText(rawLabel); if (beforeDedup !== rawLabel && rawLabel.length > 0) { console.warn('[Timecode] Display deduplicated:', beforeDedup, '->', rawLabel); } // Normalize time comparisons (remove leading zeros for comparison) const normalizedTime = timeStr.replace(/^0+:/, ''); const normalizedLabel = rawLabel.replace(/^0+:/, ''); const hasCustomLabel = rawLabel && rawLabel !== timeStr && normalizedLabel !== normalizedTime && rawLabel !== tc.originalText && rawLabel.length > 0; const displayLabel = hasCustomLabel ? rawLabel : ''; const safeLabel = displayLabel.replace( /[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] ); const isEditable = !tc.isChapter || tc.isUserAdded; return ` <div class="timecode-item ${tc.isChapter ? 'has-chapter' : ''}" data-time="${tc.time}" data-index="${i}"> <div class="timecode-time">${timeStr}</div> ${safeLabel ? `<div class="timecode-label" title="${safeLabel}">${safeLabel}</div>` : ''} <div class="timecode-progress"></div> ${ isEditable ? ` <div class="timecode-actions"> <button class="timecode-action edit" data-action="edit" title="${t('edit')}">✎</button> <button class="timecode-action delete" data-action="delete" title="${t('delete')}">✕</button> </div> ` : '' } </div> `; }) .join(''); }; const updateActiveItem = activeItem => { const items = state.dom.list?.querySelectorAll('.timecode-item'); if (!items) return; items.forEach(item => item.classList.remove('active', 'pulse')); if (activeItem) { activeItem.classList.add('active', 'pulse'); setTimeout(() => activeItem.classList.remove('pulse'), 800); } }; // Tracking const startTracking = () => { if (state.trackingId) return; const track = () => { try { const video = $('video'); const { panel, currentTime, list } = state.dom; // Stop tracking if essential elements are missing or panel is hidden if (!video || !panel || panel.classList.contains('hidden') || !config.autoTrackPlayback) { if (state.trackingId) { cancelAnimationFrame(state.trackingId); state.trackingId = 0; } return; } // Update current time display if (currentTime && !isNaN(video.currentTime)) { currentTime.textContent = formatTime(video.currentTime); } // Update active item const items = list?.querySelectorAll('.timecode-item'); if (items?.length) { let activeIndex = -1; let nextIndex = -1; for (let i = 0; i < items.length; i++) { const timeData = items[i].dataset.time; if (!timeData) continue; const time = parseFloat(timeData); if (isNaN(time)) continue; if (video.currentTime >= time) { activeIndex = i; } else if (nextIndex === -1) { nextIndex = i; } } // Update active state if (state.activeIndex !== activeIndex) { // Remove previous active state if (state.activeIndex !== null && state.activeIndex >= 0 && items[state.activeIndex]) { items[state.activeIndex].classList.remove('active'); } // Set new active state if (activeIndex >= 0 && items[activeIndex]) { items[activeIndex].classList.add('active'); try { items[activeIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { // Fallback for browsers that don't support smooth scrolling items[activeIndex].scrollIntoView(false); } } state.activeIndex = activeIndex; } // Update progress bar if (activeIndex >= 0 && nextIndex >= 0 && items[activeIndex]) { const currentTimeData = items[activeIndex].dataset.time; const nextTimeData = items[nextIndex].dataset.time; if (currentTimeData && nextTimeData) { const current = parseFloat(currentTimeData); const next = parseFloat(nextTimeData); if (!isNaN(current) && !isNaN(next) && next > current) { const progress = ((video.currentTime - current) / (next - current)) * 100; const progressEl = items[activeIndex].querySelector('.timecode-progress'); if (progressEl) { const clampedProgress = Math.min(100, Math.max(0, progress)); progressEl.style.width = `${clampedProgress}%`; } } } } } // Continue tracking if enabled if (config.autoTrackPlayback) { state.trackingId = requestAnimationFrame(track); } } catch (error) { console.warn('Timecode tracking error:', error); // Stop tracking on error to prevent infinite error loops if (state.trackingId) { cancelAnimationFrame(state.trackingId); state.trackingId = 0; } } }; state.trackingId = requestAnimationFrame(track); }; // Stop tracking function const stopTracking = () => { if (state.trackingId) { cancelAnimationFrame(state.trackingId); state.trackingId = 0; } }; // Drag functionality const makeDraggable = panel => { const header = panel.querySelector('#timecode-header'); if (!header) return; let startX, startY, startLeft, startTop; const mouseDownHandler = e => { if (e.button !== 0) return; state.dragging = true; startX = e.clientX; startY = e.clientY; const rect = panel.getBoundingClientRect(); if (!panel.style.left) { panel.style.left = `${rect.left}px`; } if (!panel.style.top) { panel.style.top = `${rect.top}px`; } panel.style.right = 'auto'; startLeft = parseFloat(panel.style.left) || rect.left; startTop = parseFloat(panel.style.top) || rect.top; const handleMove = event => { if (!state.dragging) return; const deltaX = event.clientX - startX; const deltaY = event.clientY - startY; const { left, top } = clampPanelPosition(panel, startLeft + deltaX, startTop + deltaY); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; }; const handleUp = () => { if (!state.dragging) return; state.dragging = false; document.removeEventListener('mousemove', handleMove); document.removeEventListener('mouseup', handleUp); const rectAfter = panel.getBoundingClientRect(); const { left, top } = clampPanelPosition(panel, rectAfter.left, rectAfter.top); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = 'auto'; savePanelPosition(left, top); }; document.addEventListener('mousemove', handleMove); document.addEventListener('mouseup', handleUp); }; YouTubeUtils.cleanupManager.registerListener(header, 'mousedown', mouseDownHandler); }; // Storage const saveTimecodesToStorage = timecodes => { const videoId = new URLSearchParams(window.location.search).get('v'); if (!videoId) return; try { const minimal = timecodes.map(tc => ({ t: tc.time, l: tc.label?.trim() || '', c: tc.isChapter || false, u: tc.isUserAdded || false, })); localStorage.setItem(`yt_tc_${videoId}`, JSON.stringify(minimal)); } catch {} }; const loadTimecodesFromStorage = () => { const videoId = new URLSearchParams(window.location.search).get('v'); if (!videoId) return null; try { const data = localStorage.getItem(`yt_tc_${videoId}`); return data ? JSON.parse(data) .map(tc => ({ time: tc.t, label: tc.l, isChapter: tc.c, isUserAdded: tc.u || false, })) .sort((a, b) => a.time - b.time) : null; } catch { return null; } }; const getCurrentTimecodes = () => { const items = state.dom.list?.querySelectorAll('.timecode-item'); if (!items) return []; return Array.from(items) .map(item => { const time = parseFloat(item.dataset.time); const labelEl = item.querySelector('.timecode-label'); // Only use label if element exists and has actual text content const label = labelEl?.textContent?.trim() || ''; return { time, label: label, // Keep original label (can be empty) isChapter: item.classList.contains('has-chapter'), isUserAdded: !item.classList.contains('has-chapter') || false, }; }) .sort((a, b) => a.time - b.time); }; // Toggle panel const toggleTimecodePanel = show => { // Close any existing panels first (cleanup) $$('#timecode-panel').forEach(panel => { if (panel !== state.dom.panel) panel.remove(); }); const panel = state.dom.panel || createTimecodePanel(); if (show === undefined) show = panel.classList.contains('hidden'); panel.classList.toggle('hidden', !show); if (show) { applySavedPanelPosition(panel); const saved = loadTimecodesFromStorage(); if (saved?.length) { updateTimecodePanel(saved); } else if (config.autoDetect) { detectTimecodes().catch(err => console.error('[Timecode] Detection failed:', err)); } if (config.autoTrackPlayback) startTracking(); } else if (state.trackingId) { cancelAnimationFrame(state.trackingId); state.trackingId = 0; } }; // Navigation handling const setupNavigation = () => { let currentVideoId = new URLSearchParams(window.location.search).get('v'); const handleNavigationChange = () => { const newVideoId = new URLSearchParams(window.location.search).get('v'); if (newVideoId === currentVideoId || window.location.pathname !== '/watch') return; currentVideoId = newVideoId; state.activeIndex = null; state.editingIndex = null; state.timecodes.clear(); if (config.enabled && state.dom.panel && !state.dom.panel.classList.contains('hidden')) { const saved = loadTimecodesFromStorage(); if (saved?.length) { updateTimecodePanel(saved); } else if (config.autoDetect) { setTimeout( () => detectTimecodes().catch(err => console.error('[Timecode] Detection failed:', err)), 500 ); } if (config.autoTrackPlayback) startTracking(); } }; document.addEventListener('yt-navigate-finish', handleNavigationChange); }; // Keyboard shortcuts const setupKeyboard = () => { document.addEventListener('keydown', e => { if (!config.enabled) return; const target = /** @type {EventTarget & HTMLElement} */ (e.target); if (target.matches && target.matches('input, textarea, [contenteditable]')) return; const { key, shiftKey, altKey, ctrlKey } = config.shortcut; if ( e.key.toUpperCase() === key && e.shiftKey === shiftKey && e.altKey === altKey && e.ctrlKey === ctrlKey ) { e.preventDefault(); toggleTimecodePanel(); } }); }; // Cleanup on unload const cleanup = () => { stopTracking(); if (state.dom.panel) { state.dom.panel.remove(); state.dom.panel = null; } }; // Initialize const init = () => { if (initStarted) return; if (!isRelevantRoute()) return; const appRoot = (typeof YouTubeUtils?.querySelector === 'function' && YouTubeUtils.querySelector('ytd-app')) || $('ytd-app'); if (!appRoot) { scheduleInitRetry(); return; } initStarted = true; loadSettings(); insertTimecodeStyles(); setupKeyboard(); setupNavigation(); // Settings modal observer let modalObserver = null; let modalObserverTimeout = null; const attachModalObserver = modalEl => { if (!modalEl || !(modalEl instanceof Element)) return; if (modalObserver) { try { modalObserver.disconnect(); } catch {} modalObserver = null; } modalObserver = new MutationObserver(() => { // Debounce modal observer to reduce unnecessary checks if (modalObserverTimeout) return; modalObserverTimeout = setTimeout(() => { modalObserverTimeout = null; if ( $('.ytp-plus-settings-section[data-section="advanced"]:not(.hidden)') && !$('.timecode-settings-item') ) { setTimeout(() => ensureTimecodePanelSettings(), 50); } }, 30); }); YouTubeUtils.cleanupManager.registerObserver(modalObserver); modalObserver.observe(modalEl, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'], }); }; // Settings modal integration — use event instead of body MutationObserver document.addEventListener('youtube-plus-settings-modal-opened', () => { const modal = document.querySelector('.ytp-plus-settings-modal'); if (modal) { attachModalObserver(modal); setTimeout(() => ensureTimecodePanelSettings(), 100); } }); const clickHandler = e => { const target = /** @type {HTMLElement} */ (e.target); const navItem = target?.closest?.('.ytp-plus-settings-nav-item'); if (navItem?.dataset?.section === 'advanced') { setTimeout(() => ensureTimecodePanelSettings(), 50); } }; YouTubeUtils.cleanupManager.registerListener(document, 'click', clickHandler, true); if (config.enabled && !state.resizeListenerKey) { const onResize = YouTubeUtils.throttle(() => { if (!state.dom.panel) return; const rect = state.dom.panel.getBoundingClientRect(); const { left, top } = clampPanelPosition(state.dom.panel, rect.left, rect.top); state.dom.panel.style.left = `${left}px`; state.dom.panel.style.top = `${top}px`; state.dom.panel.style.right = 'auto'; savePanelPosition(left, top); }, 200); state.resizeListenerKey = YouTubeUtils.cleanupManager.registerListener( window, 'resize', onResize ); } }; const handleNavigate = () => { if (!isRelevantRoute()) { if (initStarted) cleanup(); return; } init(); }; // Start on document ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', handleNavigate, { once: true }); } else { handleNavigate(); } if (window.YouTubeUtils?.cleanupManager?.registerListener) { YouTubeUtils.cleanupManager.registerListener(document, 'yt-navigate-finish', handleNavigate, { passive: true, }); } else { document.addEventListener('yt-navigate-finish', handleNavigate, { passive: true }); } // Cleanup on beforeunload window.addEventListener('beforeunload', cleanup); })(); // --- MODULE: playlist-search.js --- // Playlist Search (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.enablePlaylistSearch !== false; } } catch {} return true; }; const setFeatureEnabled = nextEnabled => { featureEnabled = nextEnabled !== false; if (!featureEnabled) { cleanup(); } else { ensureInit(); handleNavigation(); } }; featureEnabled = loadFeatureEnabled(); // Prevent multiple initializations if (window._playlistSearchInitialized) return; window._playlistSearchInitialized = true; // 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); }; /** * Translation helper - uses centralized i18n system * @param {string} key - Translation key * @param {Object} params - Interpolation parameters * @returns {string} Translated string */ const t = (key, params = {}) => { if (window.YouTubePlusI18n?.t) return window.YouTubePlusI18n.t(key, params); if (window.YouTubeUtils?.t) return window.YouTubeUtils.t(key, params); // Embedded English fallback (prevents showing raw keys during early init) try { const embeddedEn = window.YouTubePlusEmbeddedTranslations?.en; if (embeddedEn && embeddedEn[key]) { let text = embeddedEn[key]; if (params && Object.keys(params).length > 0) { Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); }); } return text; } } catch {} // Fallback for initialization phase return key || ''; }; // This module targets playlist content on both /watch and /playlist pages. const shouldRunOnThisPage = () => { return ( window.location.hostname.endsWith('youtube.com') && window.location.hostname !== 'music.youtube.com' && (window.location.pathname === '/watch' || window.location.pathname === '/playlist') ); }; const isWatchPage = () => window.location.pathname === '/watch'; const isPlaylistPage = () => window.location.pathname === '/playlist'; const isRelevantRoute = () => { if (!shouldRunOnThisPage()) return false; try { const params = new URLSearchParams(window.location.search); return params.has('list'); } catch { return false; } }; const onDomReady = cb => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', cb, { once: true }); } else { cb(); } }; // Use shared debounce/throttle from YouTubeUtils const debounce = (func, wait) => { if (window.YouTubeUtils?.debounce) return window.YouTubeUtils.debounce(func, wait); let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; }; const throttle = (func, limit) => { if (window.YouTubeUtils?.throttle) return window.YouTubeUtils.throttle(func, limit); let inThrottle; return (...args) => { if (!inThrottle) { func(...args); inThrottle = true; setTimeout(() => (inThrottle = false), limit); } }; }; // Previously limited to specific lists (LL/WL). Now support any playlist id. const config = { enabled: true, storageKey: 'youtube_playlist_search_settings', searchDebounceMs: 150, // Optimized debounce for better responsiveness observerThrottleMs: 300, // Reduced throttle for faster updates maxPlaylistItems: 10000, // Increased limit for large playlists maxQueryLength: 300, // Increased for more flexible search deleteDelay: 250, // Delay between sequential delete actions }; const state = { searchInput: null, searchResults: null, originalItems: [], currentPlaylistId: null, mutationObserver: null, rafId: null, itemsCache: new Map(), // Cache for faster lookups itemsContainer: null, itemSelector: null, itemTagName: null, playlistPanel: null, isPlaylistPage: false, // Deletion state isDeleting: false, deleteMode: false, selectedItems: new Set(), }; const inputDebouncers = new WeakMap(); const setupInputDelegation = (() => { let attached = false; return () => { if (attached) return; attached = true; const handleFocus = input => { input.style.borderColor = 'var(--yt-spec-call-to-action)'; }; const handleBlur = input => { input.style.borderColor = 'var(--yt-spec-10-percent-layer)'; }; const handleInput = input => { let debounced = inputDebouncers.get(input); if (!debounced) { debounced = debounce(value => { if (value.length > config.maxQueryLength) { const truncated = value.substring(0, config.maxQueryLength); input.value = truncated; filterPlaylistItems(truncated); return; } filterPlaylistItems(value); }, config.searchDebounceMs); inputDebouncers.set(input, debounced); } debounced(input.value || ''); }; const delegator = window.YouTubePlusEventDelegation; if (delegator?.on) { delegator.on(document, 'focusin', '.ytplus-playlist-search-input', (ev, target) => { void ev; if (target) handleFocus(target); }); delegator.on(document, 'focusout', '.ytplus-playlist-search-input', (ev, target) => { void ev; if (target) handleBlur(target); }); delegator.on(document, 'input', '.ytplus-playlist-search-input', (ev, target) => { void ev; if (target) handleInput(target); }); } else { document.addEventListener( 'focusin', ev => { const target = ev.target?.closest?.('.ytplus-playlist-search-input'); if (target) handleFocus(target); }, true ); document.addEventListener( 'focusout', ev => { const target = ev.target?.closest?.('.ytplus-playlist-search-input'); if (target) handleBlur(target); }, true ); document.addEventListener( 'input', ev => { const target = ev.target?.closest?.('.ytplus-playlist-search-input'); if (target) handleInput(target); }, true ); } }; })(); // Load settings from localStorage const loadSettings = () => { try { const globalSettings = localStorage.getItem('youtube_plus_settings'); if (globalSettings) { const parsedGlobal = JSON.parse(globalSettings); if (typeof parsedGlobal.enablePlaylistSearch === 'boolean') { config.enabled = parsedGlobal.enablePlaylistSearch; } } const saved = localStorage.getItem(config.storageKey); if (saved) { const parsed = JSON.parse(saved); // Use safeMerge to prevent prototype pollution if (window.YouTubeUtils && window.YouTubeUtils.safeMerge) { window.YouTubeUtils.safeMerge(config, parsed); } else { // Fallback: only copy known safe keys if (typeof parsed.enabled === 'boolean') config.enabled = parsed.enabled; } } } catch (error) { console.warn('[Playlist Search] Failed to load settings:', error); } }; // (saveSettings removed - settings are static for this module) /** * Get current playlist id with validation * @returns {string|null} Valid playlist ID or null */ const getCurrentPlaylistId = () => { try { const urlParams = new URLSearchParams(window.location.search); const listId = urlParams.get('list'); // Validate playlist ID format (alphanumeric, dashes, underscores) if (listId && /^[a-zA-Z0-9_-]+$/.test(listId)) { return listId; } return null; } catch (error) { console.warn('[Playlist Search] Failed to get playlist ID:', error); return null; } }; /** * Try to obtain a display name for the current playlist from DOM * @param {Element|HTMLElement} playlistPanel - Playlist panel element * @param {string} listId - Playlist ID * @returns {string} Playlist display name */ const getPlaylistDisplayName = (playlistPanel, listId) => { try { // Common places for title: .title, h3 a, #header-title, #title const sel = [ 'ytd-playlist-header-renderer #title', 'ytd-playlist-header-renderer .title', '.title', 'h3 a', '#header-title', '#title', '.playlist-title', 'h1.title', ]; for (const s of sel) { const el = playlistPanel.querySelector(s) || qs(s); if (el && el.textContent && el.textContent.trim()) { // Sanitize and limit length const title = el.textContent.trim(); return title.length > 100 ? title.substring(0, 100) + '...' : title; } } // Fallback to meta or channel-specific metadata const meta = qs('meta[name="title"]') || qs('meta[property="og:title"]'); if (meta && meta.content) { const title = meta.content.trim(); return title.length > 100 ? title.substring(0, 100) + '...' : title; } } catch (error) { console.warn('[Playlist Search] Failed to get display name:', error); } // Default to sanitized id if nothing else if (listId && typeof listId === 'string') { return listId.substring(0, 50); // Limit length } return 'playlist'; }; const getPlaylistContext = () => { if (isPlaylistPage()) { const panel = qs('ytd-playlist-video-list-renderer'); if (!panel) return null; const itemsContainer = panel.querySelector('#contents') || panel.querySelector('ytd-playlist-video-list-renderer #contents'); return { panel, itemsContainer, itemSelector: 'ytd-playlist-video-renderer', itemTagName: 'YTD-PLAYLIST-VIDEO-RENDERER', isPlaylistPage: true, }; } if (isWatchPage()) { const panel = qs('ytd-playlist-panel-renderer'); if (!panel) return null; const itemsContainer = panel.querySelector('#items') || panel.querySelector('.playlist-items.style-scope.ytd-playlist-panel-renderer') || panel.querySelector('.playlist-items'); return { panel, itemsContainer, itemSelector: 'ytd-playlist-panel-video-renderer', itemTagName: 'YTD-PLAYLIST-PANEL-VIDEO-RENDERER', isPlaylistPage: false, }; } return null; }; // Add search UI to playlist panel const addSearchUI = () => { if (!config.enabled) return; if (!shouldRunOnThisPage()) return; const playlistId = getCurrentPlaylistId(); if (!playlistId) return; const context = getPlaylistContext(); if (!context) return; const { panel: playlistPanel, itemsContainer, itemSelector, itemTagName } = context; // Don't add search UI twice if (playlistPanel.querySelector('.ytplus-playlist-search')) return; state.currentPlaylistId = playlistId; state.itemsContainer = itemsContainer || null; state.itemSelector = itemSelector; state.itemTagName = itemTagName; state.playlistPanel = playlistPanel; state.isPlaylistPage = context.isPlaylistPage; // Create search container const searchContainer = document.createElement('div'); searchContainer.className = 'ytplus-playlist-search'; searchContainer.style.cssText = ` padding: 8px 16px; background: transparent; border-bottom: 1px solid var(--yt-spec-10-percent-layer); z-index: 50; width: 94%; `; // Make search (and delete bar inside it) sticky within the playlist area. // We try to use `position: sticky` when possible; if the DOM structure // prevents sticky from working, fall back to `position: fixed` anchored // to the playlist panel so the UI remains visible while scrolling. const ensureSticky = () => { try { // If we're on the /watch page, keep the previous simple sticky style // to avoid changing the look/positioning inside the right-hand panel. if (!state.isPlaylistPage) { searchContainer.style.position = 'sticky'; searchContainer.style.top = '0'; searchContainer.style.zIndex = '1'; searchContainer.style.background = 'transparent'; return; } const panel = state.playlistPanel || getPlaylistContext()?.panel; // Prefer small top offset on watch page (inside right panel), larger // offset on playlist page to account for header/thumbnail column. const topOffset = state.isPlaylistPage ? 84 : 8; // Try to find a scrollable ancestor for sticky positioning let scrollAncestor = panel; while (scrollAncestor && scrollAncestor !== document.body) { const style = window.getComputedStyle(scrollAncestor); const overflowY = style.overflowY; if ( (overflowY === 'auto' || overflowY === 'scroll') && scrollAncestor.scrollHeight > scrollAncestor.clientHeight ) { break; } scrollAncestor = scrollAncestor.parentElement; } if (scrollAncestor && scrollAncestor !== document.body) { // If a scrollable ancestor exists, use sticky searchContainer.style.position = 'sticky'; searchContainer.style.top = `${topOffset}px`; searchContainer.style.background = 'var(--yt-spec-badge-chip-background)'; searchContainer.style.backdropFilter = 'blur(6px)'; searchContainer.style.boxShadow = 'var(--yt-shadow)'; } else if (panel) { // Fallback: position fixed near the playlist panel so it remains visible const rect = panel.getBoundingClientRect(); searchContainer.style.position = 'fixed'; searchContainer.style.top = `${topOffset}px`; // Place horizontally aligned with the panel searchContainer.style.left = `${rect.left}px`; searchContainer.style.width = `${rect.width}px`; searchContainer.style.background = 'var(--yt-spec-badge-chip-background)'; searchContainer.style.backdropFilter = 'blur(6px)'; searchContainer.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)'; searchContainer.style.zIndex = '9999'; // Recompute on resize/scroll to keep alignment const recompute = debounce(() => { const r = panel.getBoundingClientRect(); searchContainer.style.left = `${r.left}px`; searchContainer.style.width = `${r.width}px`; }, 120); window.addEventListener('resize', recompute, { passive: true }); // If panel scrolls inside the page, adjust on scroll window.addEventListener('scroll', recompute, { passive: true }); } else { // Last fallback: simple sticky at top searchContainer.style.position = 'sticky'; searchContainer.style.top = `${topOffset}px`; searchContainer.style.background = 'var(--yt-spec-badge-chip-background)'; } } catch { // Ignore errors and leave default styles } }; // Ensure sticky after insertion as DOM layout may change setTimeout(ensureSticky, 100); const searchInput = document.createElement('input'); searchInput.type = 'text'; const playlistName = getPlaylistDisplayName(playlistPanel, playlistId); const placeholderKey = state.isPlaylistPage ? 'searchPlaceholderPlaylistPage' : 'searchPlaceholder'; searchInput.placeholder = t(placeholderKey, { playlist: playlistName }); searchInput.className = 'ytplus-playlist-search-input'; searchInput.style.cssText = ` width: 93%; padding: 8px 16px; border: 1px solid var(--yt-spec-10-percent-layer); border-radius: 20px; background: var(--yt-spec-badge-chip-background); color: var(--yt-spec-text-primary); font-size: 14px; font-family: 'Roboto', Arial, sans-serif; outline: none; transition: border-color 0.2s; `; setupInputDelegation(); searchContainer.appendChild(searchInput); state.searchInput = searchInput; // Try to insert the search UI into the playlist items container so it appears // inline with the list of videos. Prefer inserting before the first // ytd-playlist-panel-video-renderer if present. // Use more specific selector first for better performance if (itemsContainer) { /** @type {Element|null} */ const firstVideo = itemsContainer.querySelector(itemSelector); if (firstVideo && firstVideo.parentElement === itemsContainer) { itemsContainer.insertBefore(searchContainer, /** @type {Node} */ (firstVideo)); } else { // Append to items container if no video element found itemsContainer.appendChild(searchContainer); } } else { // Fallback: prepend to the panel root to ensure visibility if (playlistPanel.firstChild) { playlistPanel.insertBefore(searchContainer, playlistPanel.firstChild); } else { playlistPanel.appendChild(searchContainer); } } // Store original items collectOriginalItems(); // Add delete UI (toggle button + action bar) addDeleteUI(searchContainer); // Setup MutationObserver to watch for new playlist items setupPlaylistObserver(); }; // Setup MutationObserver for dynamic playlist updates const setupPlaylistObserver = () => { // Disconnect existing observer if any if (state.mutationObserver) { state.mutationObserver.disconnect(); } const playlistPanel = state.playlistPanel || getPlaylistContext()?.panel; if (!playlistPanel || !state.itemTagName) return; let lastUpdateCount = state.originalItems.length; let updateScheduled = false; const itemTagName = state.itemTagName; const itemSelector = state.itemSelector; const itemsRoot = state.itemsContainer || playlistPanel; // Throttled handler for mutations with better batching const handleMutations = throttle(mutations => { // Skip if update already scheduled if (updateScheduled) return; // Fast check: only process if playlist items were actually added/removed const hasRelevantChange = mutations.some(mutation => { if (mutation.type !== 'childList') return false; if (mutation.addedNodes.length === 0 && mutation.removedNodes.length === 0) return false; // Check if added/removed nodes contain playlist items for (let i = 0; i < mutation.addedNodes.length; i++) { const node = mutation.addedNodes[i]; if (node.nodeType === 1) { const element = /** @type {Element} */ (node); if (element.tagName === itemTagName) return true; } } for (let i = 0; i < mutation.removedNodes.length; i++) { const node = mutation.removedNodes[i]; if (node.nodeType === 1) { const element = /** @type {Element} */ (node); if (element.tagName === itemTagName) return true; } } return false; }); if (!hasRelevantChange) return; updateScheduled = true; requestAnimationFrame(() => { const currentCount = lastUpdateCount; const newItems = itemsRoot ? itemsRoot.querySelectorAll(itemSelector) : /** @type {NodeListOf<Element>} */ ([]); // Only recollect if item count changed if (newItems.length !== currentCount) { lastUpdateCount = newItems.length; collectOriginalItems(); // Re-apply current search filter if any if (state.searchInput && state.searchInput.value) { filterPlaylistItems(state.searchInput.value); } } updateScheduled = false; }); }, config.observerThrottleMs); state.mutationObserver = new MutationObserver(handleMutations); // Observe only the items container, not entire subtree const targetElement = itemsRoot || playlistPanel; state.mutationObserver.observe(targetElement, { childList: true, subtree: itemsRoot ? false : true, // Only observe subtree if we couldn't find items container }); }; /** * Collect all playlist items for filtering with limit and improved caching */ const collectOriginalItems = () => { const itemsRoot = state.itemsContainer || state.playlistPanel; if (!itemsRoot || !state.itemSelector) return; const items = itemsRoot.querySelectorAll(state.itemSelector); // Limit number of items to prevent performance issues if (items.length > config.maxPlaylistItems) { console.warn( `[Playlist Search] Playlist has ${items.length} items, limiting to ${config.maxPlaylistItems}` ); } // Don't clear cache - keep existing cached items to avoid reprocessing // Only remove items that are no longer in the DOM const currentVideoIds = new Set(); const itemsArray = Array.from(items).slice(0, config.maxPlaylistItems); state.originalItems = itemsArray.map((item, index) => { const videoId = item.getAttribute('video-id') || `item-${index}`; currentVideoIds.add(videoId); // Check if this item is already cached and element is still the same if (state.itemsCache.has(videoId)) { const cached = state.itemsCache.get(videoId); if (cached.element === item) { return cached; } } // Optimize: use textContent directly without extra trim/toLowerCase calls const titleEl = item.querySelector('#video-title') || item.querySelector('a#video-title'); const bylineEl = item.querySelector('#byline') || item.querySelector('#channel-name') || item.querySelector('ytd-channel-name a'); const title = titleEl?.textContent || ''; const channel = bylineEl?.textContent || ''; const itemData = { element: item, videoId, // Store original text and lowercased version separately for better performance titleOriginal: title, channelOriginal: channel, title: title.trim().toLowerCase(), channel: channel.trim().toLowerCase(), }; // Cache the item data state.itemsCache.set(videoId, itemData); return itemData; }); // Clean up cache - remove items no longer in DOM for (const [videoId] of state.itemsCache) { if (!currentVideoIds.has(videoId)) { state.itemsCache.delete(videoId); } } }; /** * Filter playlist items based on search query with validation * @param {string} query - Search query */ const filterPlaylistItems = query => { // Cancel any pending RAF if (state.rafId) { cancelAnimationFrame(state.rafId); } // Validate and sanitize query if (query && typeof query !== 'string') { console.warn('[Playlist Search] Invalid query type'); return; } // Limit query length to prevent performance issues if (query && query.length > config.maxQueryLength) { query = query.substring(0, config.maxQueryLength); } if (!query || query.trim() === '') { // Show all items using RAF for smooth update state.rafId = requestAnimationFrame(() => { state.originalItems.forEach(item => { item.element.style.display = ''; }); state.rafId = null; }); return; } const searchTerm = query.toLowerCase().trim(); let visibleCount = 0; // Batch DOM updates using RAF state.rafId = requestAnimationFrame(() => { // Use document fragment approach - collect changes first const updates = []; state.originalItems.forEach(item => { const matches = item.title.includes(searchTerm) || item.channel.includes(searchTerm); if (matches) { if (item.element.style.display === 'none') { updates.push({ element: item.element, display: '' }); } visibleCount++; } else { if (item.element.style.display !== 'none') { updates.push({ element: item.element, display: 'none' }); } } }); // Apply all updates in one batch to minimize reflows updates.forEach(update => { update.element.style.display = update.display; }); // Update results count indicator if needed updateResultsCount(visibleCount, state.originalItems.length); state.rafId = null; }); }; // Update results count (optional visual feedback) const updateResultsCount = (visible, total) => { // Could add a results counter here if desired window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug(`[Playlist Search] Showing ${visible} of ${total} videos`); }; // ── Video Deletion Feature (similar to comment.js pattern) ── /** * Log error with error boundary integration * @param {string} context - Error context * @param {Error|string|unknown} error - Error object or message */ const logError = (context, error) => { const errorObj = error instanceof Error ? error : new Error(String(error)); if (window.YouTubeErrorBoundary) { window.YouTubeErrorBoundary.logError(errorObj, { context }); } else { console.error(`[YouTube+][PlaylistSearch] ${context}:`, error); } }; /** * Wraps function with error boundary protection * @template {Function} T * @param {T} fn - Function to wrap * @param {string} context - Error context for debugging * @returns {T} Wrapped function */ // Use shared withErrorBoundary from YouTubeErrorBoundary const withErrorBoundary = (fn, context) => { if (window.YouTubeErrorBoundary?.withErrorBoundary) { return /** @type {any} */ ( window.YouTubeErrorBoundary.withErrorBoundary(fn, 'PlaylistSearch') ); } return /** @type {any} */ ( (...args) => { try { return fn(...args); } catch (e) { logError(context, e); return null; } } ); }; /** * Toggle delete mode — shows/hides checkboxes on playlist items */ const toggleDeleteMode = withErrorBoundary(() => { state.deleteMode = !state.deleteMode; state.selectedItems.clear(); const container = state.playlistPanel || getPlaylistContext()?.panel; if (!container) return; const toggleBtn = container.querySelector('.ytplus-playlist-delete-toggle'); const deleteBar = container.querySelector('.ytplus-playlist-delete-bar'); if (state.deleteMode) { if (toggleBtn) { toggleBtn.classList.add('active'); toggleBtn.setAttribute('aria-pressed', 'true'); toggleBtn.title = t('playlistDeleteModeExit'); } if (deleteBar) deleteBar.style.display = ''; addCheckboxesToItems(); } else { if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.setAttribute('aria-pressed', 'false'); toggleBtn.title = t('playlistDeleteMode'); } if (deleteBar) deleteBar.style.display = 'none'; removeCheckboxesFromItems(); } updateDeleteBarState(); }, 'toggleDeleteMode'); /** * Add selection checkboxes to each playlist video item */ const addCheckboxesToItems = withErrorBoundary(() => { const itemsRoot = state.itemsContainer || state.playlistPanel; if (!itemsRoot || !state.itemSelector) return; const items = itemsRoot.querySelectorAll(state.itemSelector); items.forEach((item, idx) => { if (item.querySelector('.ytplus-playlist-item-checkbox')) return; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; // Use shared settings checkbox styling for consistent look checkbox.className = 'ytplus-playlist-item-checkbox ytp-plus-settings-checkbox'; checkbox.setAttribute('aria-label', t('playlistSelectVideo')); checkbox.dataset.index = String(idx); checkbox.style.cssText = ` position: absolute; top: 8px; left: 8px; z-index: 2; cursor: pointer; `; checkbox.addEventListener('change', () => { const videoId = item.getAttribute('video-id') || `item-${idx}`; if (checkbox.checked) { state.selectedItems.add(videoId); } else { state.selectedItems.delete(videoId); } updateDeleteBarState(); }); checkbox.addEventListener('click', e => e.stopPropagation()); // Ensure the parent has relative positioning for the checkbox item.style.position = 'relative'; item.insertBefore(checkbox, item.firstChild); }); }, 'addCheckboxesToItems'); /** * Remove all checkboxes from playlist items */ const removeCheckboxesFromItems = withErrorBoundary(() => { const itemsRoot = state.itemsContainer || state.playlistPanel; if (!itemsRoot) return; itemsRoot.querySelectorAll('.ytplus-playlist-item-checkbox').forEach(cb => cb.remove()); state.selectedItems.clear(); }, 'removeCheckboxesFromItems'); /** * Update delete action bar button state */ const updateDeleteBarState = withErrorBoundary(() => { const container = state.playlistPanel || getPlaylistContext()?.panel; if (!container) return; const deleteBtn = container.querySelector('.ytplus-playlist-delete-selected'); const countSpan = container.querySelector('.ytplus-playlist-selected-count'); if (deleteBtn) { deleteBtn.disabled = state.selectedItems.size === 0; deleteBtn.style.opacity = state.selectedItems.size > 0 ? '1' : '0.5'; } if (countSpan) { countSpan.textContent = t('playlistSelectedCount', { count: state.selectedItems.size }); } }, 'updateDeleteBarState'); /** * Select all visible playlist items */ const selectAllItems = withErrorBoundary(() => { const itemsRoot = state.itemsContainer || state.playlistPanel; if (!itemsRoot) return; itemsRoot.querySelectorAll('.ytplus-playlist-item-checkbox').forEach(cb => { const item = cb.closest(state.itemSelector); if (item && item.style.display !== 'none') { cb.checked = true; const videoId = item.getAttribute('video-id') || `item-${cb.dataset.index}`; state.selectedItems.add(videoId); } }); updateDeleteBarState(); }, 'selectAllItems'); /** * Clear all checkbox selections */ const clearAllItems = withErrorBoundary(() => { const itemsRoot = state.itemsContainer || state.playlistPanel; if (!itemsRoot) return; itemsRoot.querySelectorAll('.ytplus-playlist-item-checkbox').forEach(cb => { cb.checked = false; }); state.selectedItems.clear(); updateDeleteBarState(); }, 'clearAllItems'); /** * Find and click the native "Remove from playlist" menu option for a given item. * YouTube provides a three-dot menu on each playlist item. We simulate a click on * the menu button, wait for the popup, then click the remove option. * @param {Element} item - playlist video renderer element * @returns {Promise<boolean>} Whether the item was successfully removed */ const removeItemViaMenu = item => { return new Promise(resolve => { try { // Find the three-dot menu button (⋮) const menuBtn = item.querySelector('button#button[aria-label]') || item.querySelector('yt-icon-button#button') || item.querySelector('ytd-menu-renderer button') || item.querySelector('[aria-haspopup="menu"]') || item.querySelector('button.yt-icon-button'); if (!menuBtn) { console.warn('[Playlist Search] Could not find menu button for item'); resolve(false); return; } // Click the menu button to open popup menuBtn.click(); // Wait for the popup menu to appear setTimeout(() => { try { // Look for the "Remove from playlist" option in the popup const menuItems = document.querySelectorAll( 'tp-yt-paper-listbox ytd-menu-service-item-renderer, ' + 'ytd-menu-popup-renderer ytd-menu-service-item-renderer, ' + 'tp-yt-iron-dropdown ytd-menu-service-item-renderer' ); let removeOption = null; for (const mi of menuItems) { const text = (mi.textContent || '').toLowerCase(); // Match various translations of "Remove from playlist" if ( text.includes('remove') || text.includes('удалить') || text.includes('supprimer') || text.includes('entfernen') || text.includes('eliminar') || text.includes('rimuovi') || text.includes('kaldır') || text.includes('削除') || text.includes('삭제') || text.includes('移除') || text.includes('oʻchirish') || text.includes('жою') || text.includes('өчүрүү') || text.includes('выдаліць') || text.includes('премахване') || text.includes('xóa') ) { removeOption = mi; break; } } if (removeOption) { removeOption.click(); // Close any remaining popup setTimeout(() => { document.body.click(); resolve(true); }, 100); } else { // Close the menu if we can't find the option document.body.click(); console.warn('[Playlist Search] Could not find "Remove" option in menu'); resolve(false); } } catch (err) { document.body.click(); logError('removeItemViaMenu:findOption', err); resolve(false); } }, 350); } catch (err) { logError('removeItemViaMenu', err); resolve(false); } }); }; /** * Delete selected videos from the playlist sequentially */ const deleteSelectedItems = withErrorBoundary(async () => { if (state.isDeleting || state.selectedItems.size === 0) return; const count = state.selectedItems.size; const confirmed = confirm(t('playlistDeleteConfirm', { count })); if (!confirmed) return; state.isDeleting = true; const itemsRoot = state.itemsContainer || state.playlistPanel; if (!itemsRoot || !state.itemSelector) { state.isDeleting = false; return; } const allItems = Array.from(itemsRoot.querySelectorAll(state.itemSelector)); const toDelete = allItems.filter((item, idx) => { const videoId = item.getAttribute('video-id') || `item-${idx}`; return state.selectedItems.has(videoId); }); let successCount = 0; let failCount = 0; for (const item of toDelete) { const result = await removeItemViaMenu(item); if (result) { successCount++; } else { failCount++; } // Delay between actions to let YouTube process await new Promise(r => setTimeout(r, config.deleteDelay)); } state.isDeleting = false; state.selectedItems.clear(); // Re-collect items after deletion setTimeout(() => { collectOriginalItems(); if (state.deleteMode) { addCheckboxesToItems(); } updateDeleteBarState(); }, 500); // Notify user const msg = failCount > 0 ? t('playlistDeletePartial', { success: successCount, fail: failCount }) : t('playlistDeleteSuccess', { count: successCount }); window.YouTubeUtils?.logger?.debug?.(`[Playlist Search] ${msg}`); }, 'deleteSelectedItems'); /** * Add delete mode toggle button and action bar to the search UI * @param {HTMLElement} searchContainer - The .ytplus-playlist-search container */ const addDeleteUI = searchContainer => { if (!searchContainer || searchContainer.querySelector('.ytplus-playlist-delete-toggle')) return; // Add styles for delete UI (once) addDeleteStyles(); // Toggle button (trash icon) next to the search input const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'ytplus-playlist-delete-toggle'; toggleBtn.setAttribute('aria-pressed', 'false'); toggleBtn.title = t('playlistDeleteMode'); toggleBtn.innerHTML = ` <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="3 6 5 6 21 6"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <line x1="10" y1="11" x2="10" y2="17"/> <line x1="14" y1="11" x2="14" y2="17"/> </svg> `; toggleBtn.style.cssText = ` background: transparent; border: 1px solid var(--yt-spec-10-percent-layer); border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--yt-spec-text-secondary); transition: all 0.2s; vertical-align: middle; margin-left: 6px; flex-shrink: 0; `; toggleBtn.addEventListener('click', toggleDeleteMode); // Wrap search input and toggle in a flex container const inputWrapper = document.createElement('div'); inputWrapper.style.cssText = 'display:flex;align-items:center;gap:6px;'; const searchInput = searchContainer.querySelector('.ytplus-playlist-search-input'); if (searchInput) { searchInput.style.width = ''; // Reset fixed width searchInput.style.flex = '1'; searchInput.parentNode.insertBefore(inputWrapper, searchInput); inputWrapper.appendChild(searchInput); inputWrapper.appendChild(toggleBtn); } // Action bar (hidden initially) const deleteBar = document.createElement('div'); deleteBar.className = 'ytplus-playlist-delete-bar'; deleteBar.style.cssText = ` display: none; padding: 6px 0 0; gap: 8px; align-items: center; flex-wrap: wrap; `; deleteBar.style.display = 'none'; const countSpan = document.createElement('span'); countSpan.className = 'ytplus-playlist-selected-count'; countSpan.style.cssText = ` font-size: 12px; color: var(--yt-spec-text-secondary); margin-right: auto; `; countSpan.textContent = t('playlistSelectedCount', { count: 0 }); const createBtn = (label, cls, onClick) => { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = label; btn.className = cls; btn.style.cssText = ` padding: 5px 12px; border-radius: 16px; border: 1px solid var(--yt-spec-10-percent-layer); cursor: pointer; font-size: 12px; font-weight: 500; background: var(--yt-spec-badge-chip-background); color: var(--yt-spec-text-primary); transition: all 0.2s; `; btn.addEventListener('click', onClick); return btn; }; const selectAllBtn = createBtn(t('selectAll'), 'ytplus-playlist-select-all', selectAllItems); const clearAllBtn = createBtn(t('clearAll'), 'ytplus-playlist-clear-all', clearAllItems); const deleteBtn = createBtn( t('deleteSelected'), 'ytplus-playlist-delete-selected', deleteSelectedItems ); deleteBtn.disabled = true; deleteBtn.style.opacity = '0.5'; deleteBtn.style.background = 'rgba(255,99,71,.12)'; deleteBtn.style.borderColor = 'rgba(255,99,71,.25)'; deleteBtn.style.color = '#ff5c5c'; deleteBar.append(countSpan, selectAllBtn, clearAllBtn, deleteBtn); searchContainer.appendChild(deleteBar); }; /** * Add CSS styles for the delete UI components */ const addDeleteStyles = () => { if (document.getElementById('ytplus-playlist-delete-styles')) return; const css = ` .ytplus-playlist-delete-toggle.active { color: #ff5c5c !important; border-color: rgba(255,99,71,.4) !important; background: rgba(255,99,71,.1) !important; } .ytplus-playlist-delete-toggle:hover { color: var(--yt-spec-text-primary); border-color: var(--yt-spec-text-secondary); } .ytplus-playlist-delete-bar { display: flex; } .ytplus-playlist-delete-selected:not(:disabled):hover { background: rgba(255,99,71,.22) !important; } .ytplus-playlist-select-all:hover, .ytplus-playlist-clear-all:hover { background: var(--yt-spec-10-percent-layer) !important; } .ytplus-playlist-item-checkbox { opacity: 0.85; transition: opacity 0.15s; } .ytplus-playlist-item-checkbox:hover { opacity: 1; } /* Use the shared settings checkbox styling for playlist item checkboxes */ .ytplus-playlist-item-checkbox.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]) .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox{border-color:rgba(0,0,0,.25);color:#222;} .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox:focus-visible{outline:2px solid var(--yt-accent);outline-offset:2px;} .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox:hover{background:var(--yt-hover-bg);transform:scale(1.1);} .ytplus-playlist-item-checkbox.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;} .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox::after{content:"";width:0;height:2px;background:var(--yt-text-primary);position:absolute;transform:rotate(305deg);top:11px;left:7px;transition:width 100ms ease,opacity 50ms;transform-origin:0% 0%;opacity:0;} .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox:checked{transform:rotate(0deg) scale(1.15);} .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox:checked::before{width:9px;opacity:1;background:#fff;transition:width 150ms ease 100ms,opacity 150ms ease 100ms;} .ytplus-playlist-item-checkbox.ytp-plus-settings-checkbox:checked::after{width:16px;opacity:1;background:#fff;transition:width 150ms ease 250ms,opacity 150ms ease 250ms;} `; try { if (window.YouTubeUtils?.StyleManager) { window.YouTubeUtils.StyleManager.add('ytplus-playlist-delete-styles', css); return; } } catch {} const style = document.createElement('style'); style.id = 'ytplus-playlist-delete-styles'; style.textContent = css; (document.head || document.documentElement).appendChild(style); }; // Clean up search UI const cleanup = () => { // Exit delete mode if active if (state.deleteMode) { removeCheckboxesFromItems(); state.deleteMode = false; } state.isDeleting = false; state.selectedItems.clear(); const searchUI = qs('.ytplus-playlist-search'); if (searchUI) { searchUI.remove(); } // Disconnect mutation observer if (state.mutationObserver) { state.mutationObserver.disconnect(); state.mutationObserver = null; } // Cancel any pending RAF if (state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; } // Clear cache state.itemsCache.clear(); state.searchInput = null; state.originalItems = []; state.currentPlaylistId = null; state.itemsContainer = null; state.itemSelector = null; state.itemTagName = null; state.playlistPanel = null; state.isPlaylistPage = false; }; // Handle navigation changes with debouncing const handleNavigation = debounce(() => { if (!featureEnabled) { cleanup(); return; } if (!shouldRunOnThisPage()) { cleanup(); return; } // Check if we're still on a playlist page const newPlaylistId = getCurrentPlaylistId(); // If playlist hasn't changed and UI exists, no action needed if (newPlaylistId === state.currentPlaylistId && qs('.ytplus-playlist-search')) { return; } cleanup(); // Only add UI if we're on a playlist page if (newPlaylistId) { setTimeout(addSearchUI, 300); } }, 250); let initialized = false; const ensureInit = () => { if (initialized || !featureEnabled || !isRelevantRoute()) return; initialized = true; const run = () => { loadSettings(); if (!featureEnabled || config.enabled === false) return; addSearchUI(); }; if (typeof requestIdleCallback === 'function') { requestIdleCallback(run, { timeout: 1500 }); } else { setTimeout(run, 0); } }; const handleNavigate = () => { if (!isRelevantRoute()) { cleanup(); return; } ensureInit(); handleNavigation(); }; onDomReady(ensureInit); if (window.YouTubeUtils?.cleanupManager?.registerListener) { YouTubeUtils.cleanupManager.registerListener(document, 'yt-navigate-finish', handleNavigate, { passive: true, }); YouTubeUtils.cleanupManager.registerListener(window, 'beforeunload', cleanup, { passive: true, }); } else { document.addEventListener('yt-navigate-finish', handleNavigate); window.addEventListener('beforeunload', cleanup); } window.addEventListener('youtube-plus-settings-updated', e => { try { const nextEnabled = e?.detail?.enablePlaylistSearch !== false; if (nextEnabled === featureEnabled) return; setFeatureEnabled(nextEnabled); } catch { setFeatureEnabled(loadFeatureEnabled()); } }); })(); // --- MODULE: thumbnail.js --- (function () { 'use strict'; // 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); }; const qsAll = selector => { if (window.YouTubeDOMCache && typeof window.YouTubeDOMCache.getAll === 'function') { return window.YouTubeDOMCache.getAll(selector); } return document.querySelectorAll(selector); }; // Use centralized i18n from YouTubePlusI18n or YouTubeUtils 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; }; const SETTINGS_KEY = 'youtube_plus_settings'; const DEFAULT_ENABLE_THUMBNAIL = true; function loadEnableThumbnail() { try { const raw = localStorage.getItem(SETTINGS_KEY); if (!raw) return DEFAULT_ENABLE_THUMBNAIL; const parsed = JSON.parse(raw); return parsed?.enableThumbnail !== false; } catch { return DEFAULT_ENABLE_THUMBNAIL; } } let thumbnailFeatureEnabled = loadEnableThumbnail(); const isEnabled = () => thumbnailFeatureEnabled; let started = false; let startScheduled = false; /** @type {MutationObserver|null} */ let mutationObserver = null; /** @type {null | (() => void)} */ let urlChangeCleanup = null; let thumbnailStylesInjected = false; /** * Extract video ID from thumbnail source with validation * @param {string} thumbnailSrc - Thumbnail source URL * @returns {string|null} Video ID or null if invalid */ function extractVideoId(thumbnailSrc) { try { if (!thumbnailSrc || typeof thumbnailSrc !== 'string') return null; const match = thumbnailSrc.match(/\/vi\/([^\/]+)\//); const videoId = match ? match[1] : null; // Validate video ID format (11 characters, alphanumeric + - and _) if (videoId && !/^[a-zA-Z0-9_-]{11}$/.test(videoId)) { console.warn('[YouTube+][Thumbnail]', 'Invalid video ID format:', videoId); return null; } return videoId; } catch (error) { console.error('[YouTube+][Thumbnail]', 'Error extracting video ID:', error); return null; } } /** * Extract shorts ID from URL with validation * @param {string} href - URL to extract shorts ID from * @returns {string|null} Shorts ID or null if invalid */ function extractShortsId(href) { try { if (!href || typeof href !== 'string') return null; const match = href.match(/\/shorts\/([^\/\?]+)/); const shortsId = match ? match[1] : null; // Validate shorts ID format (11 characters, alphanumeric + - and _) if (shortsId && !/^[a-zA-Z0-9_-]{11}$/.test(shortsId)) { console.warn('[YouTube+][Thumbnail]', 'Invalid shorts ID format:', shortsId); return null; } return shortsId; } catch (error) { console.error('[YouTube+][Thumbnail]', 'Error extracting shorts ID:', error); return null; } } /** * Check if image exists with timeout and error handling * @param {string} url - Image URL to check * @returns {Promise<boolean>} True if image exists and is accessible */ /** * Validate URL string format * @param {string} url - URL to validate * @returns {boolean} True if valid */ function isValidUrlString(url) { if (!url || typeof url !== 'string') { console.warn('[YouTube+][Thumbnail]', 'Invalid URL provided'); return false; } return true; } /** * Validate URL protocol (HTTPS only) * @param {URL} parsedUrl - Parsed URL object * @returns {boolean} True if valid */ function hasValidProtocol(parsedUrl) { if (parsedUrl.protocol !== 'https:') { console.warn('[YouTube+][Thumbnail]', 'Only HTTPS URLs are allowed'); return false; } return true; } /** * Validate URL domain (YouTube only) * @param {URL} parsedUrl - Parsed URL object * @returns {boolean} True if valid */ function hasValidDomain(parsedUrl) { const { hostname } = parsedUrl; if (!hostname.endsWith('ytimg.com') && !hostname.endsWith('youtube.com')) { console.warn('[YouTube+][Thumbnail]', 'Only YouTube image domains are allowed'); return false; } return true; } /** * Parse and validate URL * @param {string} url - URL to parse * @returns {URL|null} Parsed URL or null if invalid */ function parseAndValidateUrl(url) { try { const parsedUrl = new URL(url); if (!hasValidProtocol(parsedUrl)) return null; if (!hasValidDomain(parsedUrl)) return null; return parsedUrl; } catch (error) { console.error('[YouTube+][Thumbnail]', 'Invalid URL:', error); return null; } } /** * Check image via HEAD request * @param {string} url - Image URL * @returns {Promise<boolean>} True if image exists */ async function checkViaHeadRequest(url) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const response = await fetch(url, { method: 'HEAD', signal: controller.signal, }).catch(() => null); clearTimeout(timeoutId); return response ? response.ok : true; } catch { clearTimeout(timeoutId); return null; } } /** * Cleanup image element * @param {HTMLImageElement} img - Image element */ function cleanupImageElement(img) { if (img.parentNode) { document.body.removeChild(img); } } /** * Check image via image load test * @param {string} url - Image URL * @returns {Promise<boolean>} True if image loads */ function checkViaImageLoad(url) { return new Promise(resolve => { const img = document.createElement('img'); img.style.display = 'none'; const timeout = setTimeout(() => { cleanupImageElement(img); resolve(false); }, 3000); img.onload = () => { clearTimeout(timeout); cleanupImageElement(img); resolve(true); }; img.onerror = () => { clearTimeout(timeout); cleanupImageElement(img); resolve(false); }; document.body.appendChild(img); img.src = url; }); } /** * Check if image exists at URL * @param {string} url - Image URL to check * @returns {Promise<boolean>} True if image exists */ async function checkImageExists(url) { try { if (!isValidUrlString(url)) return false; const parsedUrl = parseAndValidateUrl(url); if (!parsedUrl) return false; // Try HEAD request first const headResult = await checkViaHeadRequest(url); if (headResult !== null) return headResult; // Fallback to image load test return await checkViaImageLoad(url); } catch (error) { console.error('[YouTube+][Thumbnail]', 'Error checking image:', error); return false; } } function createSpinner() { const spinner = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); spinner.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); spinner.setAttribute('width', '16'); spinner.setAttribute('height', '16'); spinner.setAttribute('viewBox', '0 0 24 24'); spinner.setAttribute('fill', 'none'); spinner.setAttribute('stroke', 'white'); spinner.setAttribute('stroke-width', '2'); spinner.setAttribute('stroke-linecap', 'round'); spinner.setAttribute('stroke-linejoin', 'round'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M21 12a9 9 0 1 1-6.219-8.56'); spinner.appendChild(path); spinner.style.animation = 'spin 1s linear infinite'; if (!qs('#spinner-keyframes')) { const style = document.createElement('style'); style.id = 'spinner-keyframes'; style.textContent = ` @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `; (document.head || document.documentElement).appendChild(style); } return spinner; } /** * Open thumbnail in modal with error handling * @param {string} videoId - YouTube video ID * @param {boolean} isShorts - Whether this is a Shorts video * @param {HTMLElement} overlayElement - Overlay element containing the button * @returns {Promise<void>} */ /** * Validate video ID format * @param {string} videoId - Video ID to validate * @returns {boolean} True if valid */ function isValidVideoId(videoId) { return videoId && typeof videoId === 'string' && /^[a-zA-Z0-9_-]{11}$/.test(videoId); } /** * Validate overlay element * @param {HTMLElement} overlayElement - Overlay element to validate * @returns {boolean} True if valid */ function isValidOverlayElement(overlayElement) { return overlayElement && overlayElement instanceof HTMLElement; } /** * Get thumbnail URLs for shorts * @param {string} videoId - Video ID * @returns {{primary: string, fallback: string}} Thumbnail URLs */ function getShortsThumbnailUrls(videoId) { return { primary: `https://i.ytimg.com/vi/${videoId}/oardefault.jpg`, fallback: `https://i.ytimg.com/vi/${videoId}/oar2.jpg`, }; } /** * Get thumbnail URLs for regular videos * @param {string} videoId - Video ID * @returns {{primary: string, fallback: string}} Thumbnail URLs */ function getVideoThumbnailUrls(videoId) { return { primary: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, fallback: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`, }; } /** * Load and show best available thumbnail * @param {string} videoId - Video ID * @param {boolean} isShorts - Whether this is a shorts video */ async function loadAndShowThumbnail(videoId, isShorts) { const urls = isShorts ? getShortsThumbnailUrls(videoId) : getVideoThumbnailUrls(videoId); const isPrimaryAvailable = await checkImageExists(urls.primary); showImageModal(isPrimaryAvailable ? urls.primary : urls.fallback); } /** * Replace SVG with spinner * @param {HTMLElement} overlayElement - Overlay element * @param {SVGElement} originalSvg - Original SVG element * @returns {HTMLElement} Spinner element */ function replaceWithSpinner(overlayElement, originalSvg) { const spinner = createSpinner(); overlayElement.replaceChild(spinner, originalSvg); return spinner; } /** * Restore original SVG after loading * @param {HTMLElement} overlayElement - Overlay element * @param {HTMLElement} spinner - Spinner element * @param {SVGElement} originalSvg - Original SVG element */ function restoreOriginalSvg(overlayElement, spinner, originalSvg) { try { if (spinner && spinner.parentNode) { overlayElement.replaceChild(originalSvg, spinner); } } catch (restoreError) { console.error('[YouTube+][Thumbnail]', 'Error restoring original SVG:', restoreError); if (spinner && spinner.parentNode) { spinner.parentNode.removeChild(spinner); } } } /** * Open thumbnail in modal viewer * @param {string} videoId - Video ID * @param {boolean} isShorts - Whether this is a shorts video * @param {HTMLElement} overlayElement - Overlay element */ async function openThumbnail(videoId, isShorts, overlayElement) { try { if (!isValidVideoId(videoId)) { console.error('[YouTube+][Thumbnail]', 'Invalid video ID:', videoId); return; } if (!isValidOverlayElement(overlayElement)) { console.error('[YouTube+][Thumbnail]', 'Invalid overlay element'); return; } const originalSvg = overlayElement.querySelector('svg'); if (!originalSvg) { console.warn('[YouTube+][Thumbnail]', 'No SVG found in overlay element'); return; } const spinner = replaceWithSpinner(overlayElement, originalSvg); try { await loadAndShowThumbnail(videoId, isShorts); } finally { restoreOriginalSvg(overlayElement, spinner, originalSvg); } } catch (error) { console.error('[YouTube+][Thumbnail]', 'Error opening thumbnail:', error); } } function ensureThumbnailStyles() { if (thumbnailStylesInjected) return; try { const css = ` :root { --thumbnail-btn-bg-light: rgba(255, 255, 255, 0.85); --thumbnail-btn-bg-dark: rgba(0, 0, 0, 0.7); --thumbnail-btn-hover-bg-light: rgba(255, 255, 255, 1); --thumbnail-btn-hover-bg-dark: rgba(0, 0, 0, 0.9); --thumbnail-btn-color-light: #222; --thumbnail-btn-color-dark: #fff; --thumbnail-modal-bg-light: rgba(255, 255, 255, 0.95); --thumbnail-modal-bg-dark: rgba(34, 34, 34, 0.85); --thumbnail-modal-title-light: #222; --thumbnail-modal-title-dark: #fff; --thumbnail-modal-btn-bg-light: rgba(0, 0, 0, 0.08); --thumbnail-modal-btn-bg-dark: rgba(255, 255, 255, 0.08); --thumbnail-modal-btn-hover-bg-light: rgba(0, 0, 0, 0.18); --thumbnail-modal-btn-hover-bg-dark: rgba(255, 255, 255, 0.18); --thumbnail-modal-btn-color-light: #222; --thumbnail-modal-btn-color-dark: #fff; --thumbnail-modal-btn-hover-color-light: #ff4444; --thumbnail-modal-btn-hover-color-dark: #ff4444; --thumbnail-glass-blur: blur(18px) saturate(180%); --thumbnail-glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); --thumbnail-glass-border: rgba(255, 255, 255, 0.2); } html[dark], body[dark] { --thumbnail-btn-bg: var(--thumbnail-btn-bg-dark); --thumbnail-btn-hover-bg: var(--thumbnail-btn-hover-bg-dark); --thumbnail-btn-color: var(--thumbnail-btn-color-dark); --thumbnail-modal-bg: var(--thumbnail-modal-bg-dark); --thumbnail-modal-title: var(--thumbnail-modal-title-dark); --thumbnail-modal-btn-bg: var(--thumbnail-modal-btn-bg-dark); --thumbnail-modal-btn-hover-bg: var(--thumbnail-modal-btn-hover-bg-dark); --thumbnail-modal-btn-color: var(--thumbnail-modal-btn-color-dark); --thumbnail-modal-btn-hover-color: var(--thumbnail-modal-btn-hover-color-dark); } html:not([dark]) { --thumbnail-btn-bg: var(--thumbnail-btn-bg-light); --thumbnail-btn-bg: var(--thumbnail-btn-bg-light); --thumbnail-btn-hover-bg: var(--thumbnail-btn-hover-bg-light); --thumbnail-btn-color: var(--thumbnail-btn-color-light); --thumbnail-modal-bg: var(--thumbnail-modal-bg-light); --thumbnail-modal-title: var(--thumbnail-modal-title-light); --thumbnail-modal-btn-bg: var(--thumbnail-modal-btn-bg-light); --thumbnail-modal-btn-hover-bg: var(--thumbnail-modal-btn-hover-bg-light); --thumbnail-modal-btn-color: var(--thumbnail-modal-btn-color-light); --thumbnail-modal-btn-hover-color: var(--thumbnail-modal-btn-hover-color-light); } .thumbnail-overlay-container { position: absolute; bottom: 8px; left: 8px; z-index: 9999; opacity: 0; transition: opacity 0.2s ease; } .thumbnail-overlay-button { width: 28px; height: 28px; background: var(--thumbnail-btn-bg); border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--thumbnail-btn-color); position: relative; box-shadow: var(--thumbnail-glass-shadow); backdrop-filter: var(--thumbnail-glass-blur); -webkit-backdrop-filter: var(--thumbnail-glass-blur); border: 1px solid var(--thumbnail-glass-border); } .thumbnail-overlay-button:hover { background: var(--thumbnail-btn-hover-bg); } .thumbnail-dropdown { position: absolute; bottom: 100%; left: 0; background: var(--thumbnail-btn-hover-bg); border-radius: 8px; padding: 4px; margin-bottom: 4px; display: none; flex-direction: column; min-width: 140px; box-shadow: var(--thumbnail-glass-shadow); z-index: 10000; backdrop-filter: var(--thumbnail-glass-blur); -webkit-backdrop-filter: var(--thumbnail-glass-blur); border: 1px solid var(--thumbnail-glass-border); } .thumbnail-dropdown.show { display: flex !important; } .thumbnail-dropdown-item { background: none; border: none; color: var(--thumbnail-btn-color); padding: 8px 12px; cursor: pointer; border-radius: 4px; font-size: 12px; text-align: left; white-space: nowrap; transition: background-color 0.2s ease; } .thumbnail-dropdown-item:hover { background: rgba(255,255,255,0.06); } .thumbnailPreview-button { position: absolute; bottom: 10px; left: 5px; background-color: var(--thumbnail-btn-bg); color: var(--thumbnail-btn-color); border: none; border-radius: 6px; padding: 3px; font-size: 18px; cursor: pointer; z-index: 2000; opacity: 0; transition: opacity 0.3s; display: flex; align-items: center; justify-content: center; box-shadow: var(--thumbnail-glass-shadow); backdrop-filter: var(--thumbnail-glass-blur); -webkit-backdrop-filter: var(--thumbnail-glass-blur); border: 1px solid var(--thumbnail-glass-border); } .thumbnailPreview-container { position: relative; } .thumbnailPreview-container:hover .thumbnailPreview-button { opacity: 1; } .thumbnail-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.55); z-index: 100000; display: flex; align-items: center; justify-content: center; animation: fadeInModal 0.22s cubic-bezier(.4,0,.2,1); backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } .thumbnail-modal-content { background: var(--thumbnail-modal-bg); border-radius: 20px; box-shadow: 0 12px 40px rgba(0,0,0,0.45); max-width: 78vw; max-height: 90vh; overflow: auto; position: relative; display: flex; flex-direction: column; align-items: center; animation: scaleInModal 0.22s cubic-bezier(.4,0,.2,1); border: 1.5px solid var(--thumbnail-glass-border); backdrop-filter: blur(14px) saturate(150%); -webkit-backdrop-filter: blur(14px) saturate(150%);} /* Wrapper to place content and action buttons side-by-side */ .thumbnail-modal-wrapper { display: flex; align-items: flex-start; gap: 12px; } .thumbnail-modal-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; } .thumbnail-modal-action-btn { width: 40px; height: 40px; border-radius: 50%; background: var(--thumbnail-modal-btn-bg); border: 1px solid rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,0.2); transition: transform 0.12s ease, background 0.12s ease; color: var(--thumbnail-modal-btn-color); } .thumbnail-modal-action-btn:hover { transform: translateY(-2px); } .thumbnail-modal-close { } .thumbnail-modal-open { } .thumbnail-modal-img { max-width: 72vw; max-height: 70vh; box-shadow: var(--thumbnail-glass-shadow); background: #222; border: 1px solid var(--thumbnail-glass-border); } .thumbnail-modal-options { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; } .thumbnail-modal-option-btn { background: var(--thumbnail-modal-btn-bg); color: var(--thumbnail-modal-btn-color); border: none; border-radius: 8px; padding: 8px 18px; font-size: 14px; cursor: pointer; transition: background 0.2s; margin-bottom: 6px; box-shadow: var(--thumbnail-glass-shadow); backdrop-filter: var(--thumbnail-glass-blur); -webkit-backdrop-filter: var(--thumbnail-glass-blur); border: 1px solid var(--thumbnail-glass-border); } .thumbnail-modal-option-btn:hover { background: var(--thumbnail-modal-btn-hover-bg); color: var(--thumbnail-modal-btn-hover-color); } .thumbnail-modal-title { font-size: 18px; font-weight: 600; color: var(--thumbnail-modal-title); margin-bottom: 10px; text-align: center; text-shadow: 0 2px 8px rgba(0,0,0,0.15); } @keyframes fadeInModal { from { opacity: 0; } to { opacity: 1; } } @keyframes scaleInModal { from { transform: scale(0.95); } to { transform: scale(1); } } `; if ( window.YouTubeUtils && YouTubeUtils.StyleManager && typeof YouTubeUtils.StyleManager.add === 'function' ) { YouTubeUtils.StyleManager.add('thumbnail-viewer-styles', css); } else { const s = document.createElement('style'); s.id = 'ytplus-thumbnail-styles'; s.textContent = css; (document.head || document.documentElement).appendChild(s); } thumbnailStylesInjected = true; } catch { // fallback: inject minimal styles if (!document.getElementById('ytplus-thumbnail-styles')) { const s = document.createElement('style'); s.id = 'ytplus-thumbnail-styles'; s.textContent = '.thumbnail-modal-img{max-width:72vw;max-height:70vh;}'; (document.head || document.documentElement).appendChild(s); } thumbnailStylesInjected = true; } } function removeThumbnailStyles() { try { if (window.YouTubeUtils?.StyleManager?.remove) { window.YouTubeUtils.StyleManager.remove('thumbnail-viewer-styles'); } } catch {} const el = document.getElementById('ytplus-thumbnail-styles'); if (el) { try { el.remove(); } catch {} } thumbnailStylesInjected = false; } /** * Validate modal URL security * @param {string} url - URL to validate * @returns {boolean} True if valid */ function validateModalUrl(url) { if (!url || typeof url !== 'string') { console.error('[YouTube+][Thumbnail]', 'Invalid URL provided to modal'); return false; } try { const parsedUrl = new URL(url); if (parsedUrl.protocol !== 'https:') { console.error('[YouTube+][Thumbnail]', 'Only HTTPS URLs are allowed'); return false; } const allowedDomains = ['ytimg.com', 'youtube.com', 'ggpht.com', 'googleusercontent.com']; if (!allowedDomains.some(d => parsedUrl.hostname.endsWith(d))) { console.error('[YouTube+][Thumbnail]', 'Image domain not allowed:', parsedUrl.hostname); return false; } return true; } catch (urlError) { console.error('[YouTube+][Thumbnail]', 'Invalid URL format:', urlError); return false; } } /** * Create modal image element * @param {string} url - Image URL * @returns {HTMLImageElement} Image element */ function createModalImage(url) { const img = document.createElement('img'); img.className = 'thumbnail-modal-img'; img.src = url; img.alt = t('thumbnailPreview'); img.title = ''; img.style.cursor = 'pointer'; img.addEventListener('click', () => window.open(img.src, '_blank')); return img; } /** * Create close button for modal * @param {HTMLElement} overlay - Overlay element to remove on click * @returns {HTMLButtonElement} Close button */ function createCloseButton(overlay) { const closeBtn = document.createElement('button'); closeBtn.className = 'thumbnail-modal-close thumbnail-modal-action-btn'; closeBtn.innerHTML = `\n <svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>\n </svg>\n `; closeBtn.title = t('close'); closeBtn.setAttribute('aria-label', t('close')); closeBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); overlay.remove(); }); return closeBtn; } /** * Create open in new tab button for modal * @param {HTMLImageElement} img - Image element * @returns {HTMLButtonElement} New tab button */ function createNewTabButton(img) { const newTabBtn = document.createElement('button'); newTabBtn.className = 'thumbnail-modal-open thumbnail-modal-action-btn'; newTabBtn.innerHTML = `\n <svg fill="currentColor" viewBox="0 0 24 24" width="18" height="18" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">\n <g id="SVGRepo_bgCarrier" stroke-width="0"></g>\n <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>\n <g id="SVGRepo_iconCarrier"><path d="M14.293,9.707a1,1,0,0,1,0-1.414L18.586,4H16a1,1,0,0,1,0-2h5a1,1,0,0,1,1,1V8a1,1,0,0,1-2,0V5.414L15.707,9.707a1,1,0,0,1-1.414,0ZM3,22H8a1,1,0,0,0,0-2H5.414l4.293-4.293a1,1,0,0,0-1.414-1.414L4,18.586V16a1,1,0,0,0-2,0v5A1,1,0,0,0,3,22Z"></path></g>\n </svg>\n `; newTabBtn.title = t('clickToOpen'); newTabBtn.setAttribute('aria-label', t('clickToOpen')); newTabBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); window.open(img.src, '_blank'); }); return newTabBtn; } /** * Download image as blob * @param {string} imgSrc - Image source URL * @returns {Promise<void>} */ async function downloadImageAsBlob(imgSrc) { const response = await fetch(imgSrc); if (!response.ok) throw new Error('Network response was not ok'); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; try { const urlObj = new URL(imgSrc); const segments = urlObj.pathname.split('/'); a.download = segments[segments.length - 1] || 'thumbnail.jpg'; } catch { a.download = 'thumbnail.jpg'; } document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); } /** * Create download button for modal * @param {HTMLImageElement} img - Image element * @returns {HTMLButtonElement} Download button */ function createDownloadButton(img) { const downloadBtn = document.createElement('button'); downloadBtn.className = 'thumbnail-modal-download thumbnail-modal-action-btn'; downloadBtn.innerHTML = `\n <svg viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>\n <polyline points="7 10 12 15 17 10"/>\n <line x1="12" y1="15" x2="12" y2="3"/>\n </svg>\n `; downloadBtn.title = t('download'); downloadBtn.setAttribute('aria-label', t('download')); downloadBtn.addEventListener('click', async e => { e.preventDefault(); e.stopPropagation(); try { await downloadImageAsBlob(img.src); } catch { window.open(img.src, '_blank'); } }); return downloadBtn; } /** * Setup modal keyboard handlers * @param {HTMLElement} overlay - Overlay element */ function setupModalKeyboard(overlay) { function escHandler(e) { if (e.key === 'Escape') { overlay.remove(); window.removeEventListener('keydown', escHandler, true); } } window.addEventListener('keydown', escHandler, true); } /** * Setup modal image error handler * @param {HTMLImageElement} img - Image element * @param {HTMLElement} content - Content container */ function setupImageErrorHandler(img, content) { img.addEventListener('error', () => { const err = document.createElement('div'); err.textContent = t('thumbnailLoadFailed'); err.style.color = 'white'; content.appendChild(err); }); } /** * Show image in modal with error handling and security * @param {string} url - Image URL to display * @returns {void} */ function showImageModal(url) { try { if (!isEnabled()) return; if (!validateModalUrl(url)) return; // Remove existing modals qsAll('.thumbnail-modal-overlay').forEach(m => m.remove()); const overlay = document.createElement('div'); overlay.className = 'thumbnail-modal-overlay'; const content = document.createElement('div'); content.className = 'thumbnail-modal-content'; const img = createModalImage(url); const optionsDiv = document.createElement('div'); optionsDiv.className = 'thumbnail-modal-options'; const closeBtn = createCloseButton(overlay); const newTabBtn = createNewTabButton(img); const downloadBtn = createDownloadButton(img); content.appendChild(img); content.appendChild(optionsDiv); const wrapper = document.createElement('div'); wrapper.className = 'thumbnail-modal-wrapper'; const actionsDiv = document.createElement('div'); actionsDiv.className = 'thumbnail-modal-actions'; actionsDiv.appendChild(closeBtn); actionsDiv.appendChild(newTabBtn); actionsDiv.appendChild(downloadBtn); wrapper.appendChild(content); wrapper.appendChild(actionsDiv); overlay.appendChild(wrapper); overlay.addEventListener('click', ({ target }) => { if (target === overlay) overlay.remove(); }); setupModalKeyboard(overlay); setupImageErrorHandler(img, content); document.body.appendChild(overlay); } catch (error) { console.error('[YouTube+][Thumbnail]', 'Error showing modal:', error); } } let thumbnailPreviewCurrentVideoId = ''; let thumbnailPreviewClosed = false; let thumbnailInsertionAttempts = 0; const MAX_ATTEMPTS = 10; const RETRY_DELAY = 500; function isWatchPage() { const url = new URL(window.location.href); return url.pathname === '/watch' && url.searchParams.has('v'); } /** * Get current video ID from URL * @returns {string|null} Video ID or null */ function getCurrentVideoId() { return new URLSearchParams(window.location.search).get('v'); } /** * Remove old thumbnail overlay */ function removeOldOverlay() { const oldOverlay = qs('#thumbnailPreview-player-overlay'); if (oldOverlay) { oldOverlay.remove(); } } /** * Check if thumbnail update should be skipped * @param {string|null} newVideoId - New video ID * @returns {boolean} True if should skip */ function shouldSkipThumbnailUpdate(newVideoId) { return !newVideoId || newVideoId === thumbnailPreviewCurrentVideoId || thumbnailPreviewClosed; } /** * Find player element with retry logic * @returns {HTMLElement|null} Player element or null */ function findPlayerElement() { return qs('#movie_player') || qs('ytd-player'); } /** * Create thumbnail overlay for player * @param {string} videoId - Video ID * @param {HTMLElement} player - Player element * @returns {HTMLElement} Created overlay element */ function createPlayerThumbnailOverlay(videoId, player) { const overlay = /** @type {any} */ (createThumbnailOverlay(videoId, player)); overlay.id = 'thumbnailPreview-player-overlay'; overlay.dataset.videoId = videoId; overlay.style.cssText = ` position: absolute; top: 10%; right: 8px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: 6px; cursor: pointer; z-index: 1001; transition: all 0.15s ease; opacity: 0; `; return overlay; } /** * Attempt to insert thumbnail overlay */ function attemptInsertion() { const player = findPlayerElement(); if (!player) { thumbnailInsertionAttempts++; if (thumbnailInsertionAttempts < MAX_ATTEMPTS) { setTimeout(attemptInsertion, RETRY_DELAY); } else { thumbnailInsertionAttempts = 0; } return; } const overlayId = 'thumbnailPreview-player-overlay'; let overlay = player.querySelector(`#${overlayId}`); if (!overlay) { overlay = createPlayerThumbnailOverlay(thumbnailPreviewCurrentVideoId, player); // Add hover and focus behaviour so overlay becomes fully visible when interacted with overlay.tabIndex = 0; // make focusable for keyboard users overlay.onmouseenter = () => { try { overlay.style.opacity = '0.5'; } catch {} }; overlay.onmouseleave = () => { try { overlay.style.opacity = '0'; } catch {} }; overlay.onfocus = () => { try { overlay.style.opacity = '0.5'; } catch {} }; overlay.onblur = () => { try { overlay.style.opacity = '0'; } catch {} }; // allow Enter/Space to open the thumbnail overlay.addEventListener('keydown', e => { // cast to KeyboardEvent for lint/type safety const ke = /** @type {KeyboardEvent} */ (e); if (ke && (ke.key === 'Enter' || ke.key === ' ')) { ke.preventDefault(); overlay.click(); } }); // ensure the player is positioned to allow absolute child const playerAny = /** @type {any} */ (player); if (/** @type {any} */ (getComputedStyle(playerAny)).position === 'static') { playerAny.style.position = 'relative'; } playerAny.appendChild(overlay); return; } // overlay already exists — verify it matches current video ID, otherwise remove and recreate if (overlay.dataset.videoId !== thumbnailPreviewCurrentVideoId) { overlay.remove(); // Recursively call to create new overlay attemptInsertion(); } thumbnailInsertionAttempts = 0; } /** * Add or update thumbnail image on watch page */ function addOrUpdateThumbnailImage() { if (!isEnabled()) return; if (!isWatchPage()) return; const newVideoId = getCurrentVideoId(); if (newVideoId !== thumbnailPreviewCurrentVideoId) { thumbnailPreviewClosed = false; removeOldOverlay(); } if (shouldSkipThumbnailUpdate(newVideoId)) { return; } thumbnailPreviewCurrentVideoId = newVideoId; attemptInsertion(); } function createThumbnailOverlay(videoId, container) { const overlay = document.createElement('div'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'white'); svg.setAttribute('stroke-width', '2'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.style.transition = 'stroke 0.2s ease'; const mainRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); mainRect.setAttribute('width', '18'); mainRect.setAttribute('height', '18'); mainRect.setAttribute('x', '3'); mainRect.setAttribute('y', '3'); mainRect.setAttribute('rx', '2'); mainRect.setAttribute('ry', '2'); svg.appendChild(mainRect); const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '9'); circle.setAttribute('cy', '9'); circle.setAttribute('r', '2'); svg.appendChild(circle); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'm21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21'); svg.appendChild(path); overlay.appendChild(svg); overlay.style.cssText = ` position: absolute; bottom: 8px; left: 8px; background: rgba(0, 0, 0, 0.3); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; z-index: 1000; opacity: 0; transition: all 0.2s ease; `; overlay.onmouseenter = () => { overlay.style.background = 'rgba(0, 0, 0, 0.7)'; }; overlay.onmouseleave = () => { overlay.style.background = 'rgba(0, 0, 0, 0.3)'; }; overlay.onclick = async e => { e.preventDefault(); e.stopPropagation(); const isShorts = container.closest('ytm-shorts-lockup-view-model') || container.closest('.shortsLockupViewModelHost') || container.closest('[class*="shortsLockupViewModelHost"]') || container.querySelector('a[href*="/shorts/"]'); await openThumbnail(videoId, !!isShorts, overlay); }; return overlay; } /** * Find thumbnail container from image * @param {HTMLImageElement} img - Image element * @returns {HTMLElement|null} Thumbnail container */ function findThumbnailContainerFromImage(img) { return img.closest('yt-thumbnail-view-model') || img.parentElement; } /** * Find thumbnail container for shorts * @param {HTMLImageElement} shortsImg - Shorts image * @returns {HTMLElement|null} Thumbnail container */ function findShortsThumbnailContainer(shortsImg) { if (!shortsImg) return null; return ( shortsImg.closest('.ytCoreImageHost') || shortsImg.closest('[class*="ThumbnailContainer"]') || shortsImg.closest('[class*="ImageHost"]') || shortsImg.parentElement ); } /** * Extract video ID and container from regular video * @param {HTMLElement} container - Container element * @returns {{videoId: string|null, thumbnailContainer: HTMLElement|null}} Result */ function extractVideoInfo(container) { const img = container.querySelector('img[src*="ytimg.com"]'); if (!img?.src) return { videoId: null, thumbnailContainer: null }; const videoId = extractVideoId(img.src); const thumbnailContainer = findThumbnailContainerFromImage(img); return { videoId, thumbnailContainer }; } /** * Extract shorts ID and container * @param {HTMLElement} container - Container element * @returns {{videoId: string|null, thumbnailContainer: HTMLElement|null}} Result */ function extractShortsInfo(container) { const link = container.querySelector('a[href*="/shorts/"]'); if (!link?.href) return { videoId: null, thumbnailContainer: null }; const videoId = extractShortsId(link.href); const shortsImg = container.querySelector('img[src*="ytimg.com"]'); const thumbnailContainer = findShortsThumbnailContainer(shortsImg); return { videoId, thumbnailContainer }; } /** * Ensure container has relative positioning * @param {HTMLElement} thumbnailContainer - Thumbnail container * @returns {void} */ function ensureRelativePosition(thumbnailContainer) { if (getComputedStyle(thumbnailContainer).position === 'static') { thumbnailContainer.style.position = 'relative'; } } /** * Setup hover effects for overlay * @param {HTMLElement} thumbnailContainer - Thumbnail container * @param {HTMLElement} overlay - Overlay element * @returns {void} */ function setupOverlayHoverEffects(thumbnailContainer, overlay) { thumbnailContainer.onmouseenter = () => { overlay.style.opacity = '1'; }; thumbnailContainer.onmouseleave = () => { overlay.style.opacity = '0'; }; } /** * Add thumbnail overlay to container * @param {HTMLElement} container - Container element * @returns {void} */ function addThumbnailOverlay(container) { if (!isEnabled()) return; if (container.querySelector('.thumb-overlay')) return; // Try regular video first let { videoId, thumbnailContainer } = extractVideoInfo(container); // If no video found, try shorts if (!videoId) { ({ videoId, thumbnailContainer } = extractShortsInfo(container)); } if (!videoId || !thumbnailContainer) return; ensureRelativePosition(thumbnailContainer); const overlay = createThumbnailOverlay(videoId, container); overlay.className = 'thumb-overlay'; thumbnailContainer.appendChild(overlay); setupOverlayHoverEffects(thumbnailContainer, overlay); } function createAvatarOverlay() { const overlay = document.createElement('div'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'white'); svg.setAttribute('stroke-width', '2'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.style.transition = 'stroke 0.2s ease'; const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '12'); circle.setAttribute('cy', '8'); circle.setAttribute('r', '5'); svg.appendChild(circle); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M20 21a8 8 0 0 0-16 0'); svg.appendChild(path); overlay.appendChild(svg); overlay.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.7); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%; cursor: pointer; z-index: 1000; opacity: 0; transition: all 0.2s ease; `; overlay.onmouseenter = () => { overlay.style.background = 'rgba(0, 0, 0, 0.9)'; }; overlay.onmouseleave = () => { overlay.style.background = 'rgba(0, 0, 0, 0.7)'; }; return overlay; } function addAvatarOverlay(img) { if (!isEnabled()) return; const container = img.parentElement; if (!container) return; // Don't add avatar overlay on avatar buttons or when inside a button. // This avoids adding the overlay on elements like `avatar-btn` which are // already interactive controls and may conflict with their behavior. if ( img.closest('.avatar-btn, #avatar-btn') || container.closest('.avatar-btn, #avatar-btn') || img.closest('button') || container.closest('button') || // Skip when inside the thumbnail modal wrapper (don't add overlays inside modals) img.closest('.thumbnail-modal-wrapper') || container.closest('.thumbnail-modal-wrapper') ) { return; } // Don't add avatar overlay inside Shorts lockups/containers — these are // special UI elements where avatar overlays are undesirable. if ( img.closest('ytm-shorts-lockup-view-model') || container.closest('ytm-shorts-lockup-view-model') || img.closest('.shortsLockupViewModelHost') || container.closest('.shortsLockupViewModelHost') || img.closest('[class*="shortsLockupViewModelHost"]') || container.closest('[class*="shortsLockupViewModelHost"]') || img.closest('[class*="shorts"]') || container.closest('[class*="shorts"]') ) { return; } if (container.querySelector('.avatar-overlay')) return; if (getComputedStyle(container).position === 'static') { container.style.position = 'relative'; } const overlay = createAvatarOverlay(); overlay.className = 'avatar-overlay'; overlay.onclick = e => { e.preventDefault(); e.stopPropagation(); const highResUrl = img.src.replace(/=s\d+-c-k-c0x00ffffff-no-rj.*/, '=s0'); showImageModal(highResUrl); }; container.appendChild(overlay); container.onmouseenter = () => { overlay.style.opacity = '1'; }; container.onmouseleave = () => { overlay.style.opacity = '0'; }; } function createBannerOverlay() { const overlay = document.createElement('div'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'white'); svg.setAttribute('stroke-width', '2'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.style.transition = 'stroke 0.2s ease'; const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', '3'); rect.setAttribute('y', '3'); rect.setAttribute('width', '18'); rect.setAttribute('height', '18'); rect.setAttribute('rx', '2'); rect.setAttribute('ry', '2'); svg.appendChild(rect); const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', '9'); circle.setAttribute('cy', '9'); circle.setAttribute('r', '2'); svg.appendChild(circle); const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); polyline.setAttribute('points', '21,15 16,10 5,21'); svg.appendChild(polyline); overlay.appendChild(svg); overlay.style.cssText = ` position: absolute; bottom: 8px; left: 8px; background: rgba(0, 0, 0, 0.7); width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; z-index: 1000; opacity: 0; transition: all 0.2s ease; `; overlay.onmouseenter = () => { overlay.style.background = 'rgba(0, 0, 0, 0.9)'; }; overlay.onmouseleave = () => { overlay.style.background = 'rgba(0, 0, 0, 0.7)'; }; return overlay; } function addBannerOverlay(img) { if (!isEnabled()) return; const container = img.parentElement; if (container.querySelector('.banner-overlay')) return; if (getComputedStyle(container).position === 'static') { container.style.position = 'relative'; } const overlay = createBannerOverlay(); overlay.className = 'banner-overlay'; overlay.onclick = e => { e.preventDefault(); e.stopPropagation(); const highResUrl = img.src.replace(/=w\d+-.*/, '=s0'); showImageModal(highResUrl); }; container.appendChild(overlay); container.onmouseenter = () => { overlay.style.opacity = '1'; }; container.onmouseleave = () => { overlay.style.opacity = '0'; }; } function processAvatars() { const avatarSelectors = [ 'yt-avatar-shape img', '#avatar img', 'ytd-channel-avatar-editor img', '.ytd-video-owner-renderer img[src*="yt"]', 'img[src*="yt3.ggpht.com"]', // Добавляем прямой селектор для аватаров 'img[src*="yt4.ggpht.com"]', ]; avatarSelectors.forEach(selector => { qsAll(selector).forEach(img => { if (!img.src) return; if (!img.src.includes('yt')) return; if (img.closest('.avatar-overlay')) return; // Проверяем, что это действительно аватар (квадратное изображение) const isAvatar = img.naturalWidth > 0 && img.naturalWidth === img.naturalHeight; if (isAvatar || img.src.includes('ggpht.com')) { addAvatarOverlay(img); } }); }); } function processBanners() { const bannerSelectors = [ 'yt-image-banner-view-model img', 'ytd-c4-tabbed-header-renderer img[src*="yt"]', '#channel-header img[src*="banner"]', 'img[src*="banner"]', // Более общий селектор для баннеров ]; bannerSelectors.forEach(selector => { qsAll(selector).forEach(img => { if (!img.src) return; if (img.closest('.banner-overlay')) return; const isBanner = (img.src.includes('banner') || img.src.includes('yt')) && img.naturalWidth > img.naturalHeight * 2; // Баннеры обычно широкие if (isBanner || img.src.includes('banner')) { addBannerOverlay(img); } }); }); } function processThumbnails() { // Cache NodeLists to avoid repeated DOM lookups and reduce GC churn const n1 = qsAll('yt-thumbnail-view-model'); for (let i = 0; i < n1.length; i++) addThumbnailOverlay(n1[i]); const n2 = qsAll('.ytd-thumbnail'); for (let i = 0; i < n2.length; i++) addThumbnailOverlay(n2[i]); const n3 = qsAll('ytm-shorts-lockup-view-model'); for (let i = 0; i < n3.length; i++) addThumbnailOverlay(n3[i]); const n4 = qsAll('.shortsLockupViewModelHost'); for (let i = 0; i < n4.length; i++) addThumbnailOverlay(n4[i]); const n5 = qsAll('[class*="shortsLockupViewModelHost"]'); for (let i = 0; i < n5.length; i++) addThumbnailOverlay(n5[i]); } function processAll() { if (!isEnabled()) return; processThumbnails(); processAvatars(); processBanners(); addOrUpdateThumbnailImage(); } // Throttle/debounce processing to avoid expensive full-page rescans on every DOM mutation. let processAllTimerId = null; let lastProcessAllTime = 0; const MIN_PROCESS_ALL_INTERVAL = 350; function scheduleProcessAll(minDelay = 0) { if (processAllTimerId) return; const now = Date.now(); const dueIn = Math.max( minDelay, Math.max(0, MIN_PROCESS_ALL_INTERVAL - (now - lastProcessAllTime)) ); processAllTimerId = setTimeout(() => { processAllTimerId = null; lastProcessAllTime = Date.now(); try { if (!isEnabled()) return; processAll(); } catch (e) { console.error('[YouTube+][Thumbnail]', 'processAll failed:', e); } }, dueIn); } function setupMutationObserver() { if (mutationObserver) return; mutationObserver = new MutationObserver(() => { scheduleProcessAll(120); }); // Scope to #content or #page-manager instead of full body for performance const startObserving = () => { if (!mutationObserver) return; const target = document.querySelector('#content') || document.querySelector('#page-manager') || document.body; // Optimize observer: avoid subtree on body, only childList needed for route changes mutationObserver.observe(target, { childList: true, subtree: target !== document.body, }); }; if (document.body) { startObserving(); } else { document.addEventListener('DOMContentLoaded', startObserving); } } function teardownMutationObserver() { if (!mutationObserver) return; try { mutationObserver.disconnect(); } catch {} mutationObserver = null; } function setupUrlChangeDetection() { let currentUrl = location.href; const onNavChange = () => { setTimeout(() => { if (!isEnabled()) return; if (location.href !== currentUrl) { currentUrl = location.href; scheduleProcessAll(250); } }, 100); }; const ytNavigateHandler = () => { if (!isEnabled()) return; if (location.href !== currentUrl) { currentUrl = location.href; } scheduleProcessAll(120); }; // Use centralized pushState/replaceState event from utils.js window.addEventListener('ytp-history-navigate', onNavChange); window.addEventListener('popstate', onNavChange); window.addEventListener('yt-navigate-finish', ytNavigateHandler); return () => { try { window.removeEventListener('ytp-history-navigate', onNavChange); window.removeEventListener('popstate', onNavChange); window.removeEventListener('yt-navigate-finish', ytNavigateHandler); } catch {} }; } function removeInjectedUi() { try { qsAll('.thumbnail-modal-overlay').forEach(m => m.remove()); } catch {} try { qsAll('.thumb-overlay, .avatar-overlay, .banner-overlay').forEach(el => el.remove()); } catch {} try { const playerOverlay = qs('#thumbnailPreview-player-overlay'); if (playerOverlay) playerOverlay.remove(); } catch {} } function stop() { if (!started) return; started = false; try { if (processAllTimerId) { clearTimeout(processAllTimerId); processAllTimerId = null; } } catch {} teardownMutationObserver(); if (urlChangeCleanup) { try { urlChangeCleanup(); } catch {} urlChangeCleanup = null; } removeInjectedUi(); removeThumbnailStyles(); } function start() { if (started) return; if (!isEnabled()) return; started = true; ensureThumbnailStyles(); if (!urlChangeCleanup) { urlChangeCleanup = setupUrlChangeDetection(); } setupMutationObserver(); // Defer heavy work off the critical path. if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => scheduleProcessAll(0), { timeout: 2000 }); } else { scheduleProcessAll(400); } // A couple of spaced retries for late-loaded nodes. setTimeout(() => scheduleProcessAll(0), 900); setTimeout(() => scheduleProcessAll(0), 1800); } function startMaybe() { if (started || startScheduled) return; if (!isEnabled()) return; startScheduled = true; const run = () => { startScheduled = false; start(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(run, 100), { once: true, }); } else { setTimeout(run, 100); } } function setEnabled(nextEnabled) { thumbnailFeatureEnabled = nextEnabled !== false; if (thumbnailFeatureEnabled) startMaybe(); else stop(); } // Initial state startMaybe(); // Live updates window.addEventListener('youtube-plus-settings-updated', e => { try { const enabledFromEvent = e?.detail?.enableThumbnail; setEnabled(enabledFromEvent !== false); } catch { setEnabled(loadEnableThumbnail()); } }); })(); // --- MODULE: shorts.js --- // Shorts Keyboard controls (function () { 'use strict'; // Use centralized i18n from YouTubePlusI18n or YouTubeUtils 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; }; // Configuration - Using lazy getters for translations to avoid early loading const config = { enabled: true, get shortcuts() { return { seekBackward: { key: 'ArrowLeft', get description() { return t('seekBackward'); }, }, seekForward: { key: 'ArrowRight', get description() { return t('seekForward'); }, }, volumeUp: { key: '+', get description() { return t('volumeUp'); }, }, volumeDown: { key: '-', get description() { return t('volumeDown'); }, }, mute: { key: 'm', get description() { return t('muteUnmute'); }, }, toggleCaptions: { key: 'c', get description() { return t('toggleCaptions'); }, }, showHelp: { key: '?', get description() { return t('showHideHelp'); }, editable: false, }, }; }, storageKey: 'youtube_shorts_keyboard_settings', }; // State management const state = { helpVisible: false, lastAction: null, actionTimeout: null, editingShortcut: null, cachedVideo: null, lastVideoCheck: 0, initialized: false, routeObserver: null, }; /** * Get the currently active video element in YouTube Shorts with caching * Optimizes performance by caching results for 100ms * @returns {HTMLVideoElement|null} The active video element or null if not found */ const getCurrentVideo = (() => { const selectors = ['ytd-reel-video-renderer[is-active] video', '#shorts-player video', 'video']; return () => { const now = Date.now(); if (state.cachedVideo?.isConnected && now - state.lastVideoCheck < 100) { return state.cachedVideo; } for (const selector of selectors) { const video = YouTubeUtils.querySelector(selector); if (video) { state.cachedVideo = video; state.lastVideoCheck = now; return video; } } state.cachedVideo = null; return null; }; })(); // Optimized utilities const utils = { /** * Check if current page is a YouTube Shorts page * @returns {boolean} True if on Shorts page */ isInShortsPage: () => location.pathname.startsWith('/shorts/'), /** * Check if an input element currently has focus * @returns {boolean} True if input/textarea/contenteditable is focused */ isInputFocused: () => { const el = document.activeElement; return el?.matches?.('input, textarea, [contenteditable="true"]') || el?.isContentEditable; }, /** * Load settings from localStorage with validation * @returns {void} */ loadSettings: () => { try { const saved = localStorage.getItem(config.storageKey); if (!saved) return; const parsed = JSON.parse(saved); if (typeof parsed !== 'object' || parsed === null) { console.warn('[YouTube+][Shorts]', 'Invalid settings format'); return; } // Validate enabled flag if (typeof parsed.enabled === 'boolean') { config.enabled = parsed.enabled; } // Validate shortcuts object if (parsed.shortcuts && typeof parsed.shortcuts === 'object') { const defaultShortcuts = utils.getDefaultShortcuts(); for (const [action, shortcut] of Object.entries(parsed.shortcuts)) { // Only restore valid shortcut actions if (!defaultShortcuts[action]) continue; if (!shortcut || typeof shortcut !== 'object') continue; const { key: sKey, editable: sEditable } = /** @type {{ key?: string, editable?: boolean }} */ (shortcut); if (typeof sKey === 'string' && sKey.length > 0 && sKey.length <= 20) { config.shortcuts[action] = { key: sKey, description: defaultShortcuts[action].description, editable: sEditable !== false, }; } } } } catch (error) { console.error('[YouTube+][Shorts]', 'Error loading settings:', error); } }, /** * Save settings to localStorage with error handling * @returns {void} */ saveSettings: () => { try { const settingsToSave = { enabled: config.enabled, shortcuts: config.shortcuts, }; localStorage.setItem(config.storageKey, JSON.stringify(settingsToSave)); } catch (error) { console.error('[YouTube+][Shorts]', 'Error saving settings:', error); } }, /** * Get default keyboard shortcuts configuration * @returns {Object} Object containing default shortcut definitions */ getDefaultShortcuts: () => ({ seekBackward: { key: 'ArrowLeft', get description() { return t('seekBackward'); }, }, seekForward: { key: 'ArrowRight', get description() { return t('seekForward'); }, }, volumeUp: { key: '+', get description() { return t('volumeUp'); }, }, volumeDown: { key: '-', get description() { return t('volumeDown'); }, }, mute: { key: 'm', get description() { return t('muteUnmute'); }, }, toggleCaptions: { key: 'c', get description() { return t('toggleCaptions'); }, }, showHelp: { key: '?', get description() { return t('showHideHelp'); }, editable: false, }, }), }; /** * Feedback system for displaying temporary notifications in Shorts * Uses glassmorphism design for visual feedback */ const feedback = (() => { let element = null; /** * Create or retrieve the feedback element * @returns {HTMLElement} The feedback container element */ const create = () => { if (element) return element; element = document.createElement('div'); element.id = 'shorts-keyboard-feedback'; element.style.cssText = ` position:fixed;top:50%;left:50%;transform:translate(-50%,-50%); background:var(--shorts-feedback-bg,rgba(255,255,255,.1)); backdrop-filter:blur(16px) saturate(150%); border:1px solid var(--shorts-feedback-border,rgba(255,255,255,.15)); border-radius:20px; color:var(--shorts-feedback-color,#fff); padding:18px 32px;font-size:20px;font-weight:700; z-index:10000;opacity:0;visibility:hidden;pointer-events:none; transition:all .3s cubic-bezier(.4,0,.2,1);text-align:center; box-shadow:0 8px 32px rgba(0,0,0,.4); background: rgba(155, 155, 155, 0.15); border: 1px solid rgba(255,255,255,0.2); box-shadow: 0 8px 32px 0 rgba(31,38,135,0.37); backdrop-filter: blur(12px) saturate(180%); -webkit-backdrop-filter: blur(12px) saturate(180%); `; document.body.appendChild(element); return element; }; return { /** * Display a feedback message to the user * @param {string} text - Message text to display * @returns {void} */ show: text => { state.lastAction = text; clearTimeout(state.actionTimeout); const el = create(); el.textContent = text; requestAnimationFrame(() => { el.style.opacity = '1'; el.style.visibility = 'visible'; el.style.transform = 'translate(-50%, -50%) scale(1.05)'; }); state.actionTimeout = setTimeout(() => { el.style.opacity = '0'; el.style.visibility = 'hidden'; el.style.transform = 'translate(-50%, -50%) scale(0.95)'; }, 1500); }, }; })(); // Optimized actions const actions = { /** * Seek backward 5 seconds in the video * @returns {void} */ seekBackward: () => { const video = getCurrentVideo(); if (video) { video.currentTime = Math.max(0, video.currentTime - 5); feedback.show('-5s'); } }, /** * Seek forward 5 seconds in the video * @returns {void} */ seekForward: () => { const video = getCurrentVideo(); if (video) { video.currentTime = Math.min(video.duration || Infinity, video.currentTime + 5); feedback.show('+5s'); } }, /** * Toggle captions/subtitles on or off * Attempts to click UI button first, then falls back to programmatic toggle * @returns {void} */ toggleCaptions: () => { // Try to click a captions/subtitles button first try { const container = document.querySelector( 'ytd-shorts-player-controls, ytd-reel-video-renderer, #shorts-player' ) || document; const buttons = container.querySelectorAll('button[aria-label]'); for (const b of buttons) { const aria = (b.getAttribute('aria-label') || '').toLowerCase(); if ( aria.includes('subtit') || aria.includes('caption') || aria.includes('субтит') || aria.includes('субтитр') || aria.includes('cc') ) { if (b.offsetParent !== null) { b.click(); // It's hard to know exact state, so try to use textTracks below for feedback break; } } } } catch { // Continue to fallback } const video = getCurrentVideo(); if (video && video.textTracks && video.textTracks.length) { const tracks = Array.from(video.textTracks).filter( tr => tr.kind === 'subtitles' || tr.kind === 'captions' || !tr.kind ); if (tracks.length) { const anyShowing = tracks.some(tr => tr.mode === 'showing'); tracks.forEach(tr => { tr.mode = anyShowing ? 'hidden' : 'showing'; }); feedback.show(anyShowing ? t('captionsOff') : t('captionsOn')); return; } } // If we couldn't toggle via textTracks or button, inform user feedback.show(t('captionsUnavailable')); }, /** * Increase video volume by 10% * @returns {void} */ volumeUp: () => { const video = getCurrentVideo(); if (video) { video.volume = Math.min(1, video.volume + 0.1); feedback.show(`${Math.round(video.volume * 100)}%`); } }, /** * Decrease video volume by 10% * @returns {void} */ volumeDown: () => { const video = getCurrentVideo(); if (video) { video.volume = Math.max(0, video.volume - 0.1); feedback.show(`${Math.round(video.volume * 100)}%`); } }, /** * Toggle video mute state * Attempts to click UI mute button first, then falls back to programmatic toggle * @returns {void} */ mute: () => { const video = getCurrentVideo(); // Try to click a visible mute/volume button so the player UI updates its icon try { const container = document.querySelector( 'ytd-shorts-player-controls, ytd-reel-video-renderer, #shorts-player' ) || document; const buttons = container.querySelectorAll('button[aria-label]'); for (const b of buttons) { const aria = (b.getAttribute('aria-label') || '').toLowerCase(); if ( aria.includes('mute') || aria.includes('unmute') || aria.includes('sound') || aria.includes('volume') || aria.includes('звук') || aria.includes('громк') ) { if (b.offsetParent !== null) { b.click(); // Give the player a moment to update state, then show feedback based on video.muted setTimeout(() => { const v = getCurrentVideo(); if (v) feedback.show(v.muted ? '🔇' : '🔊'); }, 60); return; } } } } catch { // ignore and fallback } // Fallback: toggle programmatically if (video) { video.muted = !video.muted; feedback.show(video.muted ? '🔇' : '🔊'); } }, /** * Show or hide the keyboard shortcuts help panel * @returns {void} */ showHelp: () => helpPanel.toggle(), }; /** * Help panel system for displaying keyboard shortcuts reference * Provides interactive UI for viewing and editing shortcuts */ const helpPanel = (() => { let panel = null; /** * Create or retrieve the help panel element * @returns {HTMLElement} The help panel container element */ const create = () => { if (panel) return panel; panel = document.createElement('div'); panel.id = 'shorts-keyboard-help'; panel.className = 'glass-panel shorts-help-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.tabIndex = -1; const render = () => { panel.innerHTML = ` <div class="help-header"> <h3>${t('keyboardShortcuts')}</h3> <button class="ytp-plus-settings-close help-close" type="button" aria-label="${t('closeButton')}"> <svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> <div class="help-content"> ${Object.entries(config.shortcuts) .map( ([action, shortcut]) => `<div class="help-item"> <kbd data-action="${action}" ${shortcut.editable === false ? 'class="non-editable"' : ''}>${shortcut.key === ' ' ? 'Space' : shortcut.key}</kbd> <span>${shortcut.description}</span> </div>` ) .join('')} </div> <div class="help-footer"> <button class="ytp-plus-button ytp-plus-button-primary reset-all-shortcuts">${t('resetAll')}</button> </div> `; panel.querySelector('.help-close').onclick = () => helpPanel.hide(); panel.querySelector('.reset-all-shortcuts').onclick = () => { if (confirm(t('resetAllConfirm'))) { config.shortcuts = utils.getDefaultShortcuts(); utils.saveSettings(); feedback.show(t('shortcutsReset')); render(); } }; panel.querySelectorAll('kbd[data-action]:not(.non-editable)').forEach(kbd => { kbd.onclick = () => editShortcut(kbd.dataset.action, config.shortcuts[kbd.dataset.action].key); }); }; render(); document.body.appendChild(panel); return panel; }; return { /** * Display the help panel * @returns {void} */ show: () => { const p = create(); p.classList.add('visible'); state.helpVisible = true; p.focus(); }, /** * Hide the help panel * @returns {void} */ hide: () => { if (panel) { panel.classList.remove('visible'); state.helpVisible = false; } }, /** * Toggle help panel visibility * @returns {void} */ toggle: () => (state.helpVisible ? helpPanel.hide() : helpPanel.show()), /** * Refresh the help panel by removing and recreating it * @returns {void} */ refresh: () => { if (panel) { panel.remove(); panel = null; } }, }; })(); /** * Open dialog to edit a keyboard shortcut * @param {string} actionKey - The action identifier to edit * @param {string} currentKey - The current key binding * @returns {void} */ const editShortcut = (actionKey, currentKey) => { const dialog = document.createElement('div'); dialog.className = 'glass-modal shortcut-edit-dialog'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.innerHTML = ` <div class="glass-panel shortcut-edit-content"> <h4>${t('editShortcut')}: ${config.shortcuts[actionKey].description}</h4> <p>${t('pressAnyKey')}</p> <div class="current-shortcut">${t('current')}: <kbd>${currentKey === ' ' ? 'Space' : currentKey}</kbd></div> <button class="ytp-plus-button ytp-plus-button-primary shortcut-cancel" type="button">${t('cancel')}</button> </div> `; document.body.appendChild(dialog); state.editingShortcut = actionKey; const handleKey = e => { e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') return cleanup(); const conflict = Object.keys(config.shortcuts).find( key => key !== actionKey && config.shortcuts[key].key === e.key ); if (conflict) { feedback.show(t('keyAlreadyUsed', { key: e.key })); return; } config.shortcuts[actionKey].key = e.key; utils.saveSettings(); feedback.show(t('shortcutUpdated')); helpPanel.refresh(); cleanup(); }; const cleanup = () => { document.removeEventListener('keydown', handleKey, true); dialog.remove(); state.editingShortcut = null; }; dialog.querySelector('.shortcut-cancel').onclick = cleanup; // Use parameter destructuring to satisfy prefer-destructuring rule dialog.onclick = ({ target }) => { // target is expected to be an Element here if (target === dialog) cleanup(); }; document.addEventListener('keydown', handleKey, true); }; /** * Add glassmorphism styles for Shorts keyboard controls * Uses CSS custom properties for theme support * @returns {void} */ const addStyles = () => { if (document.getElementById('shorts-keyboard-styles')) return; const styles = ` :root{--shorts-feedback-bg:rgba(255,255,255,.15);--shorts-feedback-border:rgba(255,255,255,.2);--shorts-feedback-color:#fff;--shorts-help-bg:rgba(255,255,255,.15);--shorts-help-border:rgba(255,255,255,.2);--shorts-help-color:#fff;} html[dark],body[dark]{--shorts-feedback-bg:rgba(34,34,34,.7);--shorts-feedback-border:rgba(255,255,255,.15);--shorts-feedback-color:#fff;--shorts-help-bg:rgba(34,34,34,.7);--shorts-help-border:rgba(255,255,255,.1);--shorts-help-color:#fff;} html:not([dark]){--shorts-feedback-bg:rgba(255,255,255,.95);--shorts-feedback-border:rgba(0,0,0,.08);--shorts-feedback-color:#222;--shorts-help-bg:rgba(255,255,255,.98);--shorts-help-border:rgba(0,0,0,.08);--shorts-help-color:#222;} .shorts-help-panel{position:fixed;top:50%;left:25%;transform:translate(-50%,-50%) scale(.9);z-index:10001;opacity:0;visibility:hidden;transition:all .3s ease;width:340px;max-width:95vw;max-height:80vh;overflow:hidden;outline:none;color:var(--shorts-help-color,#fff);} .shorts-help-panel.visible{opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1);} .help-header{display:flex;justify-content:space-between;align-items:center;padding:24px 24px 12px;border-bottom:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.05);} html:not([dark]) .help-header{background:rgba(0,0,0,.04);border-bottom:1px solid rgba(0,0,0,.08);} .help-header h3{margin:0;font-size:20px;font-weight:700;} .help-close{display:flex;align-items:center;justify-content:center;padding:4px;} .help-content{padding:18px 24px;max-height:400px;overflow-y:auto;} .help-item{display:flex;align-items:center;margin-bottom:14px;gap:18px;} .help-item kbd{background:rgba(255,255,255,.15);color:inherit;padding:7px 14px;border-radius:8px;font-family:monospace;font-size:15px;font-weight:700;min-width:60px;text-align:center;border:1.5px solid rgba(255,255,255,.2);cursor:pointer;transition:all .2s;position:relative;} html:not([dark]) .help-item kbd{background:rgba(0,0,0,.06);color:#222;border:1.5px solid rgba(0,0,0,.08);} .help-item kbd:hover{background:rgba(255,255,255,.22);transform:scale(1.07);} .help-item kbd:after{content:"✎";position:absolute;top:-7px;right:-7px;font-size:11px;opacity:0;transition:opacity .2s;} .help-item kbd:hover:after{opacity:.7;} .help-item kbd.non-editable{cursor:default;opacity:.7;} .help-item kbd.non-editable:hover{background:rgba(255,255,255,.15);transform:none;} .help-item kbd.non-editable:after{display:none;} .help-item span{font-size:15px;color:rgba(255,255,255,.92);} html:not([dark]) .help-item span{color:#222;} .help-footer{padding:16px 24px 20px;border-top:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.05);text-align:center;} html:not([dark]) .help-footer{background:rgba(0,0,0,.04);border-top:1px solid rgba(0,0,0,.08);} .reset-all-shortcuts{display:inline-flex;align-items:center;justify-content:center;gap:var(--yt-space-sm);} .shortcut-edit-dialog{z-index:10002;} .shortcut-edit-content{padding:28px 32px;min-width:320px;text-align:center;display:flex;flex-direction:column;gap:var(--yt-space-md);color:inherit;} html:not([dark]) .shortcut-edit-content{color:#222;} .shortcut-edit-content h4{margin:0 0 14px;font-size:17px;font-weight:700;} .shortcut-edit-content p{margin:0 0 18px;font-size:15px;color:rgba(255,255,255,.85);} html:not([dark]) .shortcut-edit-content p{color:#222;} .current-shortcut{margin:18px 0;font-size:15px;} .current-shortcut kbd{background:rgba(255,255,255,.15);padding:5px 12px;border-radius:6px;font-family:monospace;border:1.5px solid rgba(255,255,255,.2);} html:not([dark]) .current-shortcut kbd{background:rgba(0,0,0,.06);color:#222;border:1.5px solid rgba(0,0,0,.08);} .shortcut-cancel{display:inline-flex;align-items:center;justify-content:center;gap:var(--yt-space-sm);} @media(max-width:480px){.shorts-help-panel{width:98vw;max-height:85vh}.help-header{padding:16px 10px 8px 10px}.help-content{padding:12px 10px}.help-item{gap:10px}.help-item kbd{min-width:44px;font-size:13px;padding:5px 7px}.shortcut-edit-content{margin:20px;min-width:auto}} #shorts-keyboard-feedback{background:var(--shorts-feedback-bg,rgba(255,255,255,.15));color:var(--shorts-feedback-color,#fff);border:1.5px solid var(--shorts-feedback-border,rgba(255,255,255,.2));border-radius:20px;box-shadow:0 8px 32px 0 rgba(31,38,135,.37);backdrop-filter:blur(12px) saturate(180%);-webkit-backdrop-filter:blur(12px) saturate(180%);} html:not([dark]) #shorts-keyboard-feedback{background:var(--shorts-feedback-bg,rgba(255,255,255,.95));color:var(--shorts-feedback-color,#222);border:1.5px solid var(--shorts-feedback-border,rgba(0,0,0,.08));} `; YouTubeUtils.StyleManager.add('shorts-keyboard-styles', styles); }; /** * Main keyboard event handler for Shorts controls * Routes keypress events to appropriate actions * @param {KeyboardEvent} e - The keyboard event * @returns {void} */ const handleKeydown = e => { if ( !config.enabled || !utils.isInShortsPage() || utils.isInputFocused() || state.editingShortcut ) { return; } let { key } = e; if (e.code === 'NumpadAdd') key = '+'; else if (e.code === 'NumpadSubtract') key = '-'; const action = Object.keys(config.shortcuts).find(k => config.shortcuts[k].key === key); if (action && actions[action]) { e.preventDefault(); e.stopPropagation(); actions[action](); } }; /** * Check if current route is /shorts page * @returns {boolean} True if on /shorts/* route */ const isOnShortsPage = () => location.pathname.startsWith('/shorts/'); /** * Cleanup when leaving shorts page */ const cleanup = () => { if (!state.initialized) return; // Remove help panel if visible if (state.helpVisible) { helpPanel.hide(); } // Clear any pending timeouts if (state.actionTimeout) { clearTimeout(state.actionTimeout); state.actionTimeout = null; } // Clear cached video state.cachedVideo = null; state.initialized = false; }; /** * Initialize the Shorts keyboard controls module * Sets up event listeners and styles * @returns {void} */ const init = () => { // Strict route guard if (!isOnShortsPage()) return; if (state.initialized) return; state.initialized = true; utils.loadSettings(); addStyles(); YouTubeUtils.cleanupManager.registerListener(document, 'keydown', handleKeydown, true); // Prefer destructuring the event parameter const clickHandler = ({ target }) => { if (state.helpVisible && target?.closest && !target.closest('#shorts-keyboard-help')) { helpPanel.hide(); } }; YouTubeUtils.cleanupManager.registerListener(document, 'click', clickHandler); document.addEventListener('keydown', e => { if (e.key === 'Escape' && state.helpVisible) { e.preventDefault(); helpPanel.hide(); } }); }; // Route observer to cleanup when leaving /shorts const observeRoute = () => { let lastPath = location.pathname; let isCurrentlyOnShorts = isOnShortsPage(); state.routeObserver = new MutationObserver(() => { const currentPath = location.pathname; // Quick path check first before expensive isOnShortsPage() if (currentPath === lastPath) return; lastPath = currentPath; const nowOnShorts = isOnShortsPage(); if (nowOnShorts !== isCurrentlyOnShorts) { isCurrentlyOnShorts = nowOnShorts; if (!nowOnShorts && state.initialized) { // Left shorts page cleanup(); } else if (nowOnShorts && !state.initialized) { // Entered shorts page init(); } } }); if (document.body) { state.routeObserver.observe(document.body, { childList: true, subtree: false, }); } }; // Initialize if on shorts page if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { init(); observeRoute(); }); } else { init(); observeRoute(); } // Show help tip on first visit if (isOnShortsPage() && !localStorage.getItem('shorts_keyboard_help_shown')) { setTimeout(() => { if (isOnShortsPage()) { feedback.show('Press ? for shortcuts'); localStorage.setItem('shorts_keyboard_help_shown', 'true'); } }, 2000); } })(); // --- MODULE: stats.js --- // Stats button and menu (function () { 'use strict'; // DOM Cache Helper - reduces repeated queries const getCache = () => 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) => getCache()?.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) => getCache()?.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 => getCache()?.getElementById(id) || document.getElementById(id); // Do not run this module inside YouTube Studio (studio.youtube.com) const isStudioPage = () => { try { const host = location.hostname || ''; const href = location.href || ''; return ( host.includes('studio.youtube.com') || host.includes('studio.') || href.includes('studio.youtube.com') ); } catch { return false; } }; if (isStudioPage()) return; let statsInitialized = false; const isStatsRelevant = () => { try { const path = location.pathname || ''; if (path === '/watch' || path.startsWith('/shorts')) return true; return isChannelPage(location.href); } catch { return false; } }; const runWhenReady = cb => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', cb, { once: true }); } else { cb(); } }; // Use centralized i18n from YouTubePlusI18n or YouTubeUtils 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; }; // Glassmorphism styles for stats button and menu (shorts-keyboard-feedback look) const styles = ` .videoStats{width:36px;height:36px;border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;margin-left:8px;margin-right:8px;background:transparent;backdrop-filter:blur(10px) saturate(160%);-webkit-backdrop-filter:blur(10px) saturate(160%);border:none;transition:transform .18s ease,background .18s} html[dark] .videoStats{background:transparent;border:none}html:not([dark]) .videoStats{background:transparent;border:none}.videoStats:hover{transform:translateY(-2px)}.videoStats svg{width:18px;height:18px;fill:var(--yt-spec-text-primary,#030303)}html[dark] .videoStats svg{fill:#fff}html:not([dark]) .videoStats svg{fill:#222} .shortsStats{display:flex;align-items:center;justify-content:center;margin-top:16px;margin-bottom:16px;width:48px;height:48px;border-radius:50%;cursor:pointer;background:rgba(255,255,255,0.12);box-shadow:0 12px 30px rgba(0,0,0,0.32);backdrop-filter:blur(10px) saturate(160%);-webkit-backdrop-filter:blur(10px) saturate(160%);border:1.25px solid rgba(255,255,255,0.12);transition:transform .22s ease}html[dark] .shortsStats{background:rgba(24,24,24,0.68);border:1.25px solid rgba(255,255,255,0.08)}html:not([dark]) .shortsStats{background:rgba(255,255,255,0.12);border:1.25px solid rgba(0,0,0,0.06)} .shortsStats:hover{transform:translateY(-3px)}.shortsStats svg{width:24px;height:24px;fill:#222}html[dark] .shortsStats svg{fill:#fff}html:not([dark]) .shortsStats svg{fill:#222} .stats-menu-container{position:relative;display:inline-block}.stats-horizontal-menu{position:absolute;display:flex;left:100%;top:0;height:100%;visibility:hidden;opacity:0;transition:visibility 0s,opacity 0.2s linear;z-index:100}.stats-menu-container:hover .stats-horizontal-menu{visibility:visible;opacity:1}.stats-menu-button{margin-left:8px;white-space:nowrap} /* Modal overlay and container with glassmorphism */ .stats-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:linear-gradient(rgba(0,0,0,0.45),rgba(0,0,0,0.55));z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeInModal .18s;backdrop-filter:blur(20px) saturate(170%);-webkit-backdrop-filter:blur(20px) saturate(170%)} .stats-modal-container{max-width:1100px;max-height:calc(100vh - 32px);display:flex;flex-direction:column} .stats-modal-content{background:rgba(24,24,24,0.92);border-radius:20px;box-shadow:0 18px 40px rgba(0,0,0,0.45);overflow:hidden;display:flex;flex-direction:column;animation:scaleInModal .18s;border:1.5px solid rgba(255,255,255,0.08);backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%)} /* Fix custom element display for Chrome */ button-view-model{display:inline-flex;align-items:center;justify-content:center;} button-view-model.yt-spec-button-view-model{vertical-align:top;} html[dark] .stats-modal-content{background:rgba(24, 24, 24, 0.25)} html:not([dark]) .stats-modal-content{background:rgba(255,255,255,0.95);color:#222;border:1.25px solid rgba(0,0,0,0.06)} .stats-modal-close{background:transparent;border:none;color:#fff;font-size:36px;line-height:1;width:36px;height:36px;cursor:pointer;transition:transform .15s ease,color .15s;display:flex;align-items:center;justify-content:center;border-radius:8px;padding:0} .stats-modal-close:hover{color:#ff6b6b;transform:scale(1.1)} html:not([dark]) .stats-modal-close{color:#666} html:not([dark]) .stats-modal-close:hover{color:#ff6b6b} /* Modal body */ .stats-modal-body{padding:16px;overflow:visible;flex:1;display:flex;flex-direction:column} /* Thumbnail preview */ .stats-thumb-title-centered{font-size:16px;font-weight:600;color:#fff;margin:0 0 15px 0;text-align:center} html:not([dark]) .stats-thumb-title-centered{color:#111} .stats-thumb-row{display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap} .stats-thumb-img{width:36vw;max-width:420px;height:auto;object-fit:cover;border-radius:8px;flex-shrink:0;border:1px solid rgba(255,255,255,0.06);max-height:44vh} html:not([dark]) .stats-thumb-img{border:1px solid rgba(0,0,0,0.06)} /* ensure the grid takes remaining horizontal space */ .stats-thumb-row .stats-grid{flex:1;min-width:0} .stats-side-column{flex:1;min-width:280px;display:flex;flex-direction:column} .stats-thumb-left{display:flex;flex-direction:column;align-items:center;gap:8px} .stats-thumb-left .stats-thumb-sub{font-size:13px;color:rgba(255,255,255,0.65)} html:not([dark]) .stats-thumb-left .stats-thumb-sub{color:rgba(0,0,0,0.6)} /* extras row under thumbnail: inline, single line */ .stats-thumb-extras{display:flex;flex-direction:row;gap:10px;align-items:center;margin-top:8px} .stats-thumb-extras .stats-card{padding:8px 10px} .stats-thumb-meta{display:flex;flex-direction:column;justify-content:center} .stats-thumb-sub{font-size:13px;color:rgba(255,255,255,0.65)} html:not([dark]) .stats-thumb-sub{color:rgba(0,0,0,0.6)} /* Loading state */ .stats-loader{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;color:#fff} html:not([dark]) .stats-loader{color:#666} .stats-spinner{width:60px;height:60px;animation:spin 1s linear infinite;margin-bottom:16px} .stats-spinner circle{stroke-dasharray:80;stroke-dashoffset:60;animation:dash 1.5s ease-in-out infinite} /* Error state */ .stats-error{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;color:#ff6b6b;text-align:center} .stats-error-icon{width:60px;height:60px;margin-bottom:16px;stroke:#ff6b6b} /* Stats grid */ .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px} /* Stats card */ .stats-card{background:rgba(255,255,255,0.05);border-radius:12px;padding:12px;display:flex;align-items:center;gap:12px;border:1px solid rgba(255,255,255,0.08);transition:transform .18s ease,box-shadow .18s ease} html:not([dark]) .stats-card{background:rgba(0,0,0,0.03);border:1px solid rgba(0,0,0,0.1)} .stats-card:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,0.3)} /* Stats icon */ .stats-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0} .stats-icon svg{width:24px;height:24px} .stats-icon-views{background:rgba(59,130,246,0.15);color:#3b82f6} .stats-icon-likes{background:rgba(34,197,94,0.15);color:#22c55e} .stats-icon-dislikes{background:rgba(239,68,68,0.15);color:#ef4444} .stats-icon-comments{background:rgba(168,85,247,0.15);color:#a855f7} .stats-icon-viewers{background:rgba(234,179,8,0.15);color:#eab308} .stats-icon-subscribers{background:rgba(236,72,153,0.15);color:#ec4899} .stats-icon-videos{background:rgba(14,165,233,0.15);color:#0ea5e9} /* Pair likes/dislikes into a single grid cell */ .stats-card-pair{display:flex;gap:8px;align-items:stretch} .stats-card-pair .stats-card{flex:1;margin:0} @media(max-width:480px){.stats-card-pair{flex-direction:column}} /* Stats info */ .stats-info{flex:1;min-width:0} .stats-label{font-size:13px;color:rgba(255,255,255,0.72);margin-bottom:4px;font-weight:500} html:not([dark]) .stats-label{color:rgba(0,0,0,0.6)} .stats-value{font-size:20px;font-weight:700;color:#fff;line-height:1.2;margin-bottom:2px} html:not([dark]) .stats-value{color:#111} .stats-exact{font-size:13px;color:rgba(255,255,255,0.5);font-weight:400} html:not([dark]) .stats-exact{color:rgba(0,0,0,0.5)} /* Animations */ @keyframes fadeInModal{from{opacity:0}to{opacity:1}} @keyframes scaleInModal{from{transform:scale(0.95);opacity:0}to{transform:scale(1);opacity:1}} @keyframes spin{to{transform:rotate(360deg)}} @keyframes dash{0%{stroke-dashoffset:80}50%{stroke-dashoffset:10}100%{stroke-dashoffset:80}} /* Responsive */ @media(max-width:768px){.stats-modal-container{width:95vw}.stats-grid{grid-template-columns:1fr}.stats-card{padding:16px}.stats-side-column{min-width:0;width:100%}} /* Centered large author handle (preferred) */ .stats-author-big{display:block;text-align:center;margin-top:13px;padding-inline:8px} .stats-author-name-big{display:block;color:rgba(255,255,255,0.9);font-weight:600;font-size:16px} .stats-author-handle-big{display:inline-block;color:#ffffff;font-weight:700;font-size:20px;text-decoration:none;padding:6px 10px;border-radius:6px} .stats-author-handle-big:hover{color:#e6f0ff;text-decoration:underline} html:not([dark]) .stats-author-name-big{color:rgba(0,0,0,0.8)} html:not([dark]) .stats-author-handle-big{color:#0b61d6} html:not([dark]) .stats-author-handle-big:hover{color:#0647a6} `; // Settings state const SETTINGS_KEY = 'youtube_stats_button_enabled'; let statsButtonEnabled = localStorage.getItem(SETTINGS_KEY) !== 'false'; let previousUrl = location.href; let isChecking = false; let experimentalNavListenerKey = null; let channelFeatures = { hasStreams: false, hasShorts: false, }; /** * Rate limiter for API calls * @type {Object} */ const rateLimiter = { requests: new Map(), maxRequests: 10, timeWindow: 60000, // 1 minute /** * Check if request is allowed * @param {string} key - Request identifier * @returns {boolean} Whether request is allowed */ canRequest: key => { const now = Date.now(); const requests = rateLimiter.requests.get(key) || []; // Remove old requests outside time window const recentRequests = requests.filter(time => now - time < rateLimiter.timeWindow); if (recentRequests.length >= rateLimiter.maxRequests) { console.warn( `[YouTube+][Stats] Rate limit exceeded for ${key}. Max ${rateLimiter.maxRequests} requests per minute.` ); return false; } recentRequests.push(now); rateLimiter.requests.set(key, recentRequests); return true; }, /** * Clear rate limiter state */ clear: () => { rateLimiter.requests.clear(); }, }; function addStyles() { if (!byId('youtube-enhancer-styles')) { YouTubeUtils.StyleManager.add('youtube-enhancer-styles', styles); } } /** * Get current video URL with validation * @returns {string|null} Valid YouTube video URL or null */ /** * Validate if a string is a valid YouTube video ID * @param {string} id - Video ID to validate * @returns {boolean} True if valid */ function isValidVideoId(id) { return id && /^[a-zA-Z0-9_-]{11}$/.test(id); } /** * Extract video ID from URL parameters * @returns {string|null} Video ID or null */ function getVideoIdFromParams() { const urlParams = new URLSearchParams(window.location.search); const videoId = urlParams.get('v'); return isValidVideoId(videoId) ? `https://www.youtube.com/watch?v=${videoId}` : null; } /** * Extract video ID from shorts URL * @param {string} url - Current URL * @returns {string|null} Video ID or null */ function getVideoIdFromShorts(url) { const shortsMatch = url.match(/\/shorts\/([^?]+)/); if (shortsMatch && isValidVideoId(shortsMatch[1])) { return `https://www.youtube.com/shorts/${shortsMatch[1]}`; } return null; } function getCurrentVideoUrl() { try { const url = window.location.href; // Validate URL is from YouTube domain if (!url.includes('youtube.com')) { return null; } // Try to get video ID from query params first const fromParams = getVideoIdFromParams(); if (fromParams) return fromParams; // Try to get from shorts URL return getVideoIdFromShorts(url); } catch (error) { YouTubeUtils?.logError?.('Stats', 'Failed to get video URL', error); return null; } } /** * Get channel identifier with validation * @returns {string} Channel identifier */ function getChannelIdentifier() { try { const url = window.location.href; let identifier = ''; if (url.includes('/channel/')) { identifier = url.split('/channel/')[1].split('/')[0]; } else if (url.includes('/@')) { identifier = url.split('/@')[1].split('/')[0]; } // Validate identifier (alphanumeric, dashes, underscores) if (identifier && /^[a-zA-Z0-9_-]+$/.test(identifier)) { return identifier; } return ''; } catch (error) { YouTubeUtils?.logError?.('Stats', 'Failed to get channel identifier', error); return ''; } } /** * Validate YouTube URL * @param {string} url - URL to validate * @returns {boolean} True if valid YouTube URL */ function validateYouTubeUrl(url) { if (!url || typeof url !== 'string') { return false; } try { const parsedUrl = new URL(url); if (parsedUrl.hostname !== 'www.youtube.com' && parsedUrl.hostname !== 'youtube.com') { console.warn('[YouTube+][Stats] Invalid domain for channel check'); return false; } return true; } catch (error) { YouTubeUtils?.logError?.('Stats', 'Invalid URL for channel check', error); return false; } } /** * Fetch channel page HTML with timeout * @param {string} url - URL to fetch * @returns {Promise<string|null>} HTML content or null on error */ async function fetchChannelHtml(url) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout try { const response = await fetch(url, { credentials: 'same-origin', signal: controller.signal, headers: { Accept: 'text/html', }, }); clearTimeout(timeoutId); if (!response.ok) { console.warn(`[YouTube+][Stats] HTTP ${response.status} when checking channel tabs`); return null; } const html = await response.text(); // Limit response size to prevent memory issues if (html.length > 5000000) { // 5MB limit console.warn('[YouTube+][Stats] Response too large, skipping parse'); return null; } return html; } catch (error) { if (error.name === 'AbortError') { console.warn('[YouTube+][Stats] Channel check timed out'); } throw error; } } /** * Extract YouTube initial data from HTML * @param {string} html - HTML content * @returns {Object|null} Parsed YouTube data or null */ function extractYouTubeData(html) { const match = html.match(/var ytInitialData = (.+?);<\/script>/); if (!match || !match[1]) { return null; } try { return JSON.parse(match[1]); } catch (parseError) { YouTubeUtils?.logError?.('Stats', 'Failed to parse ytInitialData', parseError); return null; } } /** * Analyze channel tabs for streams and shorts * @param {Object} data - YouTube initial data * @returns {{hasStreams: boolean, hasShorts: boolean}} Channel features */ /** * Extract tab URL from tab renderer * @param {Object} tab - Tab object * @returns {string|null} Tab URL or null */ function getTabUrl(tab) { return tab?.tabRenderer?.endpoint?.commandMetadata?.webCommandMetadata?.url || null; } /** * Check if tab matches a URL pattern * @param {string} url - Tab URL * @param {RegExp} pattern - Pattern to match * @returns {boolean} True if matches */ function tabMatches(url, pattern) { return typeof url === 'string' && pattern.test(url); } /** * Analyze channel tabs for presence of streams and shorts * @param {Object} data - Channel data * @returns {{hasStreams: boolean, hasShorts: boolean}} Tab analysis result */ /** * Check if tab is a streams tab * @param {string} tabUrl - Tab URL * @returns {boolean} True if streams tab */ function isStreamsTab(tabUrl) { return tabMatches(tabUrl, /\/streams$/); } /** * Check if tab is a shorts tab * @param {string} tabUrl - Tab URL * @returns {boolean} True if shorts tab */ function isShortsTab(tabUrl) { return tabMatches(tabUrl, /\/shorts$/); } /** * Check if both streams and shorts are found * @param {boolean} hasStreams - Has streams flag * @param {boolean} hasShorts - Has shorts flag * @returns {boolean} True if both found */ function hasBothContentTypes(hasStreams, hasShorts) { return hasStreams && hasShorts; } /** * Update content type flags based on tab URL * @param {string} tabUrl - Tab URL * @param {Object} flags - Flags object with hasStreams and hasShorts */ function updateContentTypeFlags(tabUrl, flags) { if (!flags.hasStreams && isStreamsTab(tabUrl)) { flags.hasStreams = true; } if (!flags.hasShorts && isShortsTab(tabUrl)) { flags.hasShorts = true; } } /** * Analyze channel tabs to determine available content types * @param {Object} data - Channel data * @returns {{hasStreams: boolean, hasShorts: boolean}} Analysis result */ function analyzeChannelTabs(data) { const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || []; const flags = { hasStreams: false, hasShorts: false }; for (const tab of tabs) { const tabUrl = getTabUrl(tab); if (!tabUrl) continue; updateContentTypeFlags(tabUrl, flags); // Early exit if both found if (hasBothContentTypes(flags.hasStreams, flags.hasShorts)) break; } return flags; } /** * Refresh stats menu after checking tabs */ function refreshStatsMenu() { const existingMenu = $('.stats-menu-container'); if (existingMenu) { existingMenu.remove(); createStatsMenu(); } } /** * Check channel tabs with rate limiting and enhanced security * @param {string} url - Channel URL to check * @returns {Promise<void>} */ async function checkChannelTabs(url) { if (isChecking) return; // Validate URL if (!validateYouTubeUrl(url)) { return; } // Rate limiting if (!rateLimiter.canRequest('checkChannelTabs')) { return; } isChecking = true; try { const html = await fetchChannelHtml(url); if (!html) { isChecking = false; return; } const data = extractYouTubeData(html); if (!data) { isChecking = false; return; } channelFeatures = analyzeChannelTabs(data); refreshStatsMenu(); } catch (error) { YouTubeUtils?.logError?.('Stats', 'Failed to check channel tabs', error); } finally { isChecking = false; } } /** * Check if URL is a channel page * @param {string} url - URL to check * @returns {boolean} Whether URL is a channel page */ function isChannelPage(url) { try { return ( url && typeof url === 'string' && url.includes('youtube.com/') && (url.includes('/channel/') || url.includes('/@')) && !url.includes('/video/') && !url.includes('/watch') ); } catch { return false; } } /** * Check for URL changes with debouncing */ const checkUrlChange = YouTubeUtils?.debounce?.(() => { try { const currentUrl = location.href; if (currentUrl !== previousUrl) { previousUrl = currentUrl; if (isChannelPage(currentUrl)) { setTimeout(() => checkChannelTabs(currentUrl), 500); } } } catch (error) { YouTubeUtils?.logError?.('Stats', 'URL change check failed', error); } }, 300) || function () { try { const currentUrl = location.href; if (currentUrl !== previousUrl) { previousUrl = currentUrl; if (isChannelPage(currentUrl)) { setTimeout(() => checkChannelTabs(currentUrl), 500); } } } catch (error) { console.error('[YouTube+][Stats] URL change check failed:', error); } }; function createStatsIcon() { const icon = document.createElement('div'); // single universal icon for all pages icon.className = 'videoStats'; const SVG_NS = window.YouTubePlusConstants?.SVG_NS || 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('viewBox', '0 0 512 512'); const path = document.createElementNS(SVG_NS, 'path'); path.setAttribute( 'd', 'M500 89c13.8-11 16-31.2 5-45s-31.2-16-45-5L319.4 151.5 211.2 70.4c-11.7-8.8-27.8-8.5-39.2 .6L12 199c-13.8 11-16 31.2-5 45s31.2 16 45 5L192.6 136.5l108.2 81.1c11.7 8.8 27.8 8.5 39.2-.6L500 89zM160 256l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32zM32 352l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32zm288-64c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-128c0-17.7-14.3-32-32-32zm96-32l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32z' ); svg.appendChild(path); icon.appendChild(svg); icon.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const videoUrl = getCurrentVideoUrl(); if (videoUrl) { const urlParams = new URLSearchParams(new URL(videoUrl).search); const videoId = urlParams.get('v') || videoUrl.match(/\/shorts\/([^?]+)/)?.[1]; if (videoId) { openStatsModal('video', videoId); } } }); return icon; } function insertUniversalIcon() { if (!statsButtonEnabled) return; // Try to insert into masthead area (requested: "style-scope ytd-masthead"). // Prefer element matching 'ytd-masthead.style-scope' if present, otherwise fallback to 'ytd-masthead'. let masthead = $('ytd-masthead.style-scope'); if (!masthead) masthead = $('ytd-masthead'); if (!masthead || $('.videoStats')) return; const statsIcon = createStatsIcon(); // Preferred target: element with id="end" and class containing 'style-scope' inside masthead let endElem = $('#end.style-scope.ytd-masthead', masthead); if (!endElem) endElem = $('#end', masthead); if (endElem) { // Insert as first child of #end so it appears at the beginning endElem.insertBefore(statsIcon, endElem.firstChild); } else { // Fallback: append to masthead masthead.appendChild(statsIcon); } } function createButton(text, svgPath, viewBox, className, onClick) { const buttonViewModel = document.createElement('button-view-model'); buttonViewModel.className = `yt-spec-button-view-model ${className}-view-model`; const button = document.createElement('button'); button.className = `yt-spec-button-shape-next yt-spec-button-shape-next--outline yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--enable-backdrop-filter-experiment ${className}-button`; button.setAttribute('aria-disabled', 'false'); button.setAttribute('aria-label', text); button.style.display = 'flex'; button.style.alignItems = 'center'; button.style.justifyContent = 'center'; button.style.gap = '8px'; button.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); onClick(); }); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', viewBox); svg.style.width = '20px'; svg.style.height = '20px'; svg.style.fill = 'currentColor'; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', svgPath); svg.appendChild(path); const buttonText = document.createElement('div'); buttonText.className = `yt-spec-button-shape-next__button-text-content ${className}-text`; buttonText.textContent = text; buttonText.style.display = 'flex'; buttonText.style.alignItems = 'center'; const touchFeedback = document.createElement('yt-touch-feedback-shape'); touchFeedback.style.borderRadius = 'inherit'; const touchFeedbackDiv = document.createElement('div'); touchFeedbackDiv.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response'; touchFeedbackDiv.setAttribute('aria-hidden', 'true'); const strokeDiv = document.createElement('div'); strokeDiv.className = 'yt-spec-touch-feedback-shape__stroke'; const fillDiv = document.createElement('div'); fillDiv.className = 'yt-spec-touch-feedback-shape__fill'; touchFeedbackDiv.appendChild(strokeDiv); touchFeedbackDiv.appendChild(fillDiv); touchFeedback.appendChild(touchFeedbackDiv); button.appendChild(svg); button.appendChild(buttonText); button.appendChild(touchFeedback); buttonViewModel.appendChild(button); return buttonViewModel; } /** * InnerTube API configuration */ const INNERTUBE_API_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'; const INNERTUBE_CLIENT_VERSION = '2.20201209.01.00'; /** * Fetch video stats from InnerTube API (more complete data) * @param {string} videoId - Video ID * @returns {Promise<Object|null>} Video stats with views, likes, country, monetization */ /** * Create InnerTube API request body * @param {string} videoId - Video ID * @returns {Object} Request body */ function createInnerTubeRequestBody(videoId) { return { context: { client: { clientName: 'WEB', clientVersion: INNERTUBE_CLIENT_VERSION, hl: 'en', gl: 'US', }, }, videoId, }; } /** * Create InnerTube API fetch options * @param {string} videoId - Video ID * @returns {Object} Fetch options */ function createInnerTubeFetchOptions(videoId) { return { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-YouTube-Client-Name': '1', 'X-YouTube-Client-Version': INNERTUBE_CLIENT_VERSION, }, body: JSON.stringify(createInnerTubeRequestBody(videoId)), }; } /** * Extract best thumbnail URL from details * @param {Object} details - Video details * @returns {string|null} Thumbnail URL */ function extractThumbnailUrl(details) { const thumbnails = details.thumbnail?.thumbnails; return thumbnails?.[thumbnails.length - 1]?.url || null; } /** * Parse video stats from InnerTube response * @param {Object} data - InnerTube response data * @returns {Object} Parsed video stats */ function parseVideoStatsFromResponse(data) { const details = data.videoDetails || {}; const microformat = data.microformat?.playerMicroformatRenderer || {}; // Extract channel handle from ownerProfileUrl (e.g. "/@handle" → "@handle") const ownerProfileUrl = microformat.ownerProfileUrl || microformat.ownerUrls?.[0] || ''; const handleMatch = ownerProfileUrl.match(/\/@([\w.-]+)/); const authorHandle = handleMatch ? `@${handleMatch[1]}` : null; return { videoId: details.videoId, title: details.title, author: details.author || null, authorHandle: authorHandle, views: details.viewCount ? parseInt(details.viewCount, 10) : null, likes: null, // Will be fetched separately thumbnail: extractThumbnailUrl(details), duration: details.lengthSeconds, country: null, // Fetched separately from channel browse (availableCountries is geo-restriction, not creator country) monetized: microformat.isFamilySafe !== undefined, channelId: details.channelId, }; } /** * Fetch the channel creator's country from InnerTube browse API * @param {string} channelId - YouTube channel ID (UCxxxx) * @returns {Promise<string|null>} 2-letter country code or null */ async function fetchChannelCountryFromInnerTube(channelId) { if (!channelId) return null; try { const url = `https://www.youtube.com/youtubei/v1/browse?key=${INNERTUBE_API_KEY}&prettyPrint=false`; const body = { browseId: channelId, context: { client: { clientName: 'WEB', clientVersion: INNERTUBE_CLIENT_VERSION, hl: 'en', gl: 'US', }, }, }; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-YouTube-Client-Name': '1', 'X-YouTube-Client-Version': INNERTUBE_CLIENT_VERSION, }, body: JSON.stringify(body), }); if (!response.ok) return null; const data = await response.json(); // Try multiple header paths — YouTube returns c4TabbedHeaderRenderer for // older-UI channels and pageHeaderRenderer for newer-UI channels. // Also check frameworkUpdates mutations for new-UI country metadata. const country = data?.header?.c4TabbedHeaderRenderer?.country || data?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows?.[0]?.metadataParts?.find?.( p => p?.text?.content?.length === 2 )?.text?.content || (() => { const mutations = data?.frameworkUpdates?.entityBatchUpdate?.mutations || []; for (const m of mutations) { const c = m?.payload?.channelHeaderMetadataEntityViewModel?.country || m?.payload?.channelBasicInfoEntityViewModel?.country; if (c) return c; } return null; })(); return country || null; } catch { return null; } } /** * Fallback: fetch channel country from TubeInsights backend API * @param {string} channelId - YouTube channel ID (UCxxxx) * @returns {Promise<string|null>} 2-letter country code or null */ async function fetchChannelCountryFromTubeInsights(channelId) { if (!channelId) return null; try { const response = await fetch(`https://tubeinsights.exyezed.cc/api/channels/${channelId}`); if (!response.ok) return null; const data = await response.json(); const country = data?.items?.[0]?.snippet?.country; return typeof country === 'string' && country.trim() ? country.trim().toUpperCase() : null; } catch { return null; } } /** * Fetch video stats from InnerTube API * @param {string} videoId - Video ID * @returns {Promise<Object|null>} Video stats or null */ async function fetchVideoStatsInnerTube(videoId) { if (!videoId) return null; try { const url = `https://www.youtube.com/youtubei/v1/player?key=${INNERTUBE_API_KEY}&prettyPrint=false`; const response = await fetch(url, createInnerTubeFetchOptions(videoId)); if (!response.ok) { console.warn(`[YouTube+][Stats] InnerTube API failed:`, response.status); return null; } const data = await response.json(); const stats = parseVideoStatsFromResponse(data); // Fetch the real creator country from channel browse API if (stats.channelId) { stats.country = await fetchChannelCountryFromInnerTube(stats.channelId); if (!stats.country) { stats.country = await fetchChannelCountryFromTubeInsights(stats.channelId); } } return stats; } catch (error) { console.error('[YouTube+][Stats] InnerTube fetch error:', error); return null; } } /** * Fetch dislikes from Return YouTube Dislike API * @param {string} videoId - Video ID * @returns {Promise<Object|null>} Likes and dislikes data */ async function fetchDislikesData(videoId) { if (!videoId) return null; try { const response = await fetch(`https://returnyoutubedislikeapi.com/votes?videoId=${videoId}`); if (!response.ok) return null; const data = await response.json(); return { likes: data.likes || null, dislikes: data.dislikes || null, rating: data.rating || null, }; } catch (error) { console.error('[YouTube+][Stats] Failed to fetch dislikes:', error); return null; } } /** * Fetch video or channel stats from API (combines InnerTube + RYD) * @param {string} type - 'video' or 'channel' * @param {string} id - Video ID or Channel ID * @returns {Promise<Object|null>} Stats data or null on error */ async function fetchStats(type, id) { if (!id) return { ok: false, status: 0, data: null }; try { if (type === 'video') { // Use InnerTube API for video data const videoData = await fetchVideoStatsInnerTube(id); if (!videoData) { return { ok: false, status: 404, data: null }; } // Fetch likes/dislikes from RYD API const dislikeData = await fetchDislikesData(id); if (dislikeData) { videoData.likes = dislikeData.likes; videoData.dislikes = dislikeData.dislikes; videoData.rating = dislikeData.rating; } return { ok: true, status: 200, data: videoData }; } // For channels, use existing API const endpoint = `https://api.livecounts.io/youtube-live-subscriber-counter/stats/${id}`; const response = await fetch(endpoint, { method: 'GET', headers: { Accept: 'application/json', }, }); if (!response.ok) { console.warn(`[YouTube+][Stats] Failed to fetch ${type} stats:`, response.status); return { ok: false, status: response.status, data: null, url: endpoint }; } const data = await response.json(); return { ok: true, status: response.status, data, url: endpoint }; } catch (error) { YouTubeUtils?.logError?.('Stats', `Failed to fetch ${type} stats`, error); return { ok: false, status: 0, data: null }; } } /** * Attempt to read basic video stats from the current page DOM as a fallback * Returns an object with views, likes, comments, subscribers when available */ /** * Get video stats from current page DOM * Refactored to use helper functions and reduce complexity * @returns {Object|null} Stats object or null */ function getPageVideoStats() { try { // Use centralized helpers from YouTubeStatsHelpers when available. // If not present (some runtime environments), provide a lightweight // DOM-based fallback to avoid noisy errors and still surface basic stats. const helpers = window.YouTubeStatsHelpers || {}; const fallbackHelpers = { extractViews() { try { const el = $('yt-view-count-renderer, #count .view-count'); const text = el && el.textContent ? el.textContent.trim() : ''; const match = text.replace(/[^0-9,\.]/g, '').replace(/,/g, ''); return match ? { views: Number(match) || null } : {}; } catch { return {}; } }, extractLikes() { try { const btn = $('ytd-toggle-button-renderer[is-icon-button] yt-formatted-string') || $( '#top-level-buttons-computed ytd-toggle-button-renderer:first-child yt-formatted-string' ); const text = btn && btn.textContent ? btn.textContent.trim() : ''; const match = text.replace(/[^0-9,\.]/g, '').replace(/,/g, ''); return match ? { likes: Number(match) || null } : {}; } catch { return {}; } }, extractDislikes() { // Dislike counts may not be available; return empty return {}; }, extractComments() { try { const el = $( '#count > ytd-comment-thread-renderer, ytd-comments-header-renderer #count' ); const text = el && el.textContent ? el.textContent.trim() : ''; const match = text.replace(/[^0-9,\.]/g, '').replace(/,/g, ''); return match ? { comments: Number(match) || null } : {}; } catch { return {}; } }, extractSubscribers() { try { const el = $('#owner-sub-count, #subscriber-count'); const text = el && el.textContent ? el.textContent.trim() : ''; return text ? { subscribers: text } : {}; } catch { return {}; } }, extractThumbnail() { try { const meta = $('link[rel="image_src"]') || $('meta[property="og:image"]'); const url = meta && (meta.href || meta.content) ? meta.href || meta.content : null; return url ? { thumbnail: url } : {}; } catch { return {}; } }, extractTitle() { try { const el = $('h1.title yt-formatted-string') || $('h1'); const text = el && el.textContent ? el.textContent.trim() : ''; return text ? { title: text } : {}; } catch { return {}; } }, extractAuthor() { try { // Try to get the @handle from the owner link const handleEl = $('ytd-video-owner-renderer #channel-handle') || $('ytd-video-owner-renderer yt-formatted-string.ytd-channel-name a') || $('#owner ytd-channel-name a') || $('ytd-video-owner-renderer #owner-name a'); const handleText = handleEl?.textContent?.trim() || ''; // Some links contain the @handle, others contain the channel name const handle = handleText.startsWith('@') ? handleText : null; const nameEl = $('ytd-video-owner-renderer #channel-name') || $('ytd-video-owner-renderer #owner-name'); const authorName = nameEl?.textContent?.trim() || null; if (handle || authorName) { return { authorHandle: handle, author: authorName }; } return {}; } catch { return {}; } }, }; const use = helpers && helpers.extractViews ? helpers : fallbackHelpers; // Merge all extracted stats (helpers may return partial objects) const result = Object.assign( {}, use.extractViews?.() || {}, use.extractLikes?.() || {}, use.extractDislikes?.() || {}, use.extractComments?.() || {}, use.extractSubscribers?.() || {}, use.extractThumbnail?.() || {}, use.extractTitle?.() || {}, use.extractAuthor?.() || {} ); return Object.keys(result).length > 0 ? result : null; } catch (e) { YouTubeUtils?.logError?.('Stats', 'Failed to read page stats', e); return null; } } /** * Render a small grid from page-derived stats into container * @param {HTMLElement} container * @param {Object} pageStats */ // Helper to create a stats card HTML when value exists function buildPageStatCard(value, labelKey, iconClass, iconSvg) { if (value === undefined || value === null) return ''; return ` <div class="stats-card"> <div class="stats-icon ${iconClass}"> ${iconSvg} </div> <div class="stats-info"> <div class="stats-label">${t(labelKey)}</div> <div class="stats-value">${formatNumber(value)}</div> <div class="stats-exact">${(value || 0).toLocaleString()}</div> </div> </div> `; } // Helper to create a stats-card that shows only a value (no label) // iconOrClass can be either an HTML string (SVG) or a class name like 'stats-icon-views' function buildValueOnlyCard( value, iconOrClass = '', options = { showValue: true, showIcon: true } ) { const { showValue, showIcon } = options; if (!showValue && !showIcon) return ''; // If value is null/undefined and we are to show value, treat as unknown let displayVal = ''; if (showValue) { displayVal = value === undefined || value === null ? t('unknown') : value; } // Determine whether iconOrClass is HTML (contains '<') or a plain class name let iconContent = ''; let extraClass = ''; if (showIcon) { if (iconOrClass && typeof iconOrClass === 'string' && iconOrClass.indexOf('<') >= 0) { // it's HTML (SVG), render inside iconContent = iconOrClass; } else if (iconOrClass && typeof iconOrClass === 'string') { // treat as a class name to apply to the icon wrapper extraClass = ` ${iconOrClass}`; } } return ` <div class="stats-card"> <div class="stats-icon${extraClass}">${iconContent}</div> <div class="stats-info"> ${showValue ? `<div class="stats-value">${displayVal}</div>` : ''} </div> </div> `; } /** * Build stat cards for all metrics * @param {Object} pageStats - Page statistics * @returns {Array<string>} Array of card HTML strings * @private */ function buildStatCards(pageStats) { const cardConfigs = [ { value: pageStats.views, key: 'views', icon: 'stats-icon-views', svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>', }, { value: pageStats.likes, key: 'likes', icon: 'stats-icon-likes', svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg>', }, { value: pageStats.dislikes, key: 'dislikes', icon: 'stats-icon-dislikes', svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path></svg>', }, { value: pageStats.comments, key: 'comments', icon: 'stats-icon-comments', svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>', }, ]; return cardConfigs .map(config => buildPageStatCard(config.value, config.key, config.icon, config.svg)) .filter(card => card); } /** * Get thumbnail URL from various sources * @param {string} id - Video ID * @param {Object} pageStats - Page statistics * @returns {string} Thumbnail URL or empty string * @private */ function getThumbnailUrl(id, pageStats) { if (pageStats && pageStats.thumbnail) { return pageStats.thumbnail; } if (id) { return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; } return ''; } /** * Build extra metadata cards * @param {Object} extras - Extra metadata * @returns {string} HTML for extra cards * @private */ function buildExtraCards(extras) { const monetizationText = extras.monetization || t('unknown'); const countryText = extras.country || t('unknown'); const durationText = extras.duration || t('unknown'); const extraMonCard = buildValueOnlyCard(monetizationText, 'stats-icon-subscribers', { showValue: false, showIcon: true, }); const extraCountryCard = buildValueOnlyCard(countryText, 'stats-icon-views', { showValue: false, showIcon: true, }); const extraDurationCard = buildValueOnlyCard(durationText, 'stats-icon-videos', { showValue: true, showIcon: false, }); return `${extraMonCard}${extraCountryCard}${extraDurationCard}`; } /** * Build complete HTML with thumbnail layout * @param {string} titleHtml - Title HTML * @param {string} thumbUrl - Thumbnail URL * @param {string} gridHtml - Grid HTML * @param {Object} extras - Extra metadata * @returns {string} Complete HTML * @private */ function buildThumbnailLayout(titleHtml, thumbUrl, gridHtml, extras) { const extraCards = buildExtraCards(extras); const leftHtml = `<div class="stats-thumb-left"><img class="stats-thumb-img" src="${thumbUrl}" alt="thumbnail"><div class="stats-thumb-extras">${extraCards}</div></div>`; return `${titleHtml}<div class="stats-thumb-row">${leftHtml}${gridHtml}</div>`; } /** * Render page statistics fallback view * @param {HTMLElement} container - Container element * @param {Object} pageStats - Page statistics * @param {string} id - Video ID */ function renderPageFallback(container, pageStats, id) { // Build stat cards const cards = buildStatCards(pageStats); const gridHtml = `<div class="stats-grid">${cards.join('')}</div>`; // Get title and escape for XSS prevention const title = (pageStats && pageStats.title) || document.title || ''; const escapeHtml = window.YouTubeSecurityUtils?.escapeHtml || (s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }); const safeTitle = escapeHtml(title); const titleHtml = safeTitle ? `<div class="stats-thumb-title-centered">${safeTitle}</div>` : ''; // Get thumbnail and extras const thumbUrl = getThumbnailUrl(id, pageStats); const extras = getVideoExtras(null, pageStats, id); // Render appropriate layout if (thumbUrl) { container.innerHTML = buildThumbnailLayout(titleHtml, thumbUrl, gridHtml, extras); } else { container.innerHTML = `${titleHtml}${gridHtml}`; } } /** * Format number with K/M/B suffixes * @param {number} num - Number to format * @returns {string} Formatted number */ function formatNumber(num) { if (!num || isNaN(num)) return '0'; const absNum = Math.abs(num); if (absNum >= 1e9) { return `${(num / 1e9).toFixed(1)}B`; } if (absNum >= 1e6) { return `${(num / 1e6).toFixed(1)}M`; } if (absNum >= 1e3) { return `${(num / 1e3).toFixed(1)}K`; } return num.toLocaleString(); } /** * Create a stats card HTML fragment * @param {string} labelKey * @param {number|null} value * @param {number|null} exact * @param {string} iconClass * @param {string} iconSvg * @returns {string} */ function makeStatsCard(labelKey, value, exact, iconClass, iconSvg) { const display = value == null ? t('unknown') : formatNumber(value); // Show exact 0 as "0" (0 is falsy), only show dash when null/undefined // Ensure numeric values are properly converted to integers for exact display let exactText = '—'; if (exact !== null && exact !== undefined) { const numExact = Number(exact); exactText = !isNaN(numExact) ? Math.floor(numExact).toLocaleString() : String(exact); } return ` <div class="stats-card"> <div class="stats-icon ${iconClass}"> ${iconSvg} </div> <div class="stats-info"> <div class="stats-label">${t(labelKey)}</div> <div class="stats-value">${display}</div> <div class="stats-exact">${exactText}</div> </div> </div> `; } /** * Normalize and pick preferred video fields * @param {Object} stats * @returns {{views: number|null, likes: number|null, dislikes: number|null, comments: number|null, liveViewer: number|null, title: string, thumbUrl: string, country: string|null, monetized: boolean|null}} */ /** * Extract video fields from stats object * Simplified by using more consistent field access * @param {Object} stats - Stats object * @param {string} id - Video ID * @returns {Object} Extracted fields */ /** * Get first available field from stats object * @param {Object} stats - Stats object * @param {string[]} fields - Field names to check * @returns {*} First available value or null */ function getFirstAvailableField(stats, ...fields) { for (const field of fields) { if (stats?.[field] != null) return stats[field]; } return null; } /** * Get thumbnail URL for video * @param {Object} stats - Stats object * @param {string} id - Video ID * @returns {string} Thumbnail URL */ function getThumbnailUrl(stats, id) { return stats?.thumbnail || (id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : ''); } /** * Extract video fields from stats object * @param {Object} stats - Stats data * @param {string} id - Video ID * @returns {Object} Extracted fields */ function extractVideoFields(stats, id) { return { views: getFirstAvailableField(stats, 'liveViews', 'views', 'viewCount'), likes: getFirstAvailableField(stats, 'liveLikes', 'likes', 'likeCount'), dislikes: getFirstAvailableField(stats, 'dislikes', 'liveDislikes', 'dislikeCount'), comments: getFirstAvailableField(stats, 'liveComments', 'comments', 'commentCount'), liveViewer: getFirstAvailableField(stats, 'liveViewer', 'live_viewers'), title: stats?.title || document.title || '', thumbUrl: getThumbnailUrl(stats, id), country: getFirstAvailableField(stats, 'country'), monetized: stats?.monetized ?? null, duration: getFirstAvailableField(stats, 'duration'), author: getFirstAvailableField(stats, 'author'), authorHandle: getFirstAvailableField(stats, 'authorHandle'), }; } /** * Merge API-provided video stats with page-derived stats * Simplified to use helper function for field extraction * @param {Object|null} apiStats - API stats * @param {Object|null} pageStats - Page stats * @returns {Object} Merged stats */ function mergeVideoStats(apiStats, pageStats) { if (!pageStats) return apiStats || {}; const getValue = (...fields) => { for (const field of fields) { if (apiStats?.[field] != null) return apiStats[field]; } for (const field of fields) { if (pageStats?.[field] != null) return pageStats[field]; } return null; }; return { ...apiStats, views: getValue('views', 'viewCount'), likes: getValue('likes', 'likeCount'), dislikes: getValue('dislikes'), comments: getValue('comments', 'commentCount'), thumbnail: getValue('thumbnail'), title: getValue('title'), liveViewer: getValue('liveViewer'), // Preserve extra metadata when available (duration, country, monetization) duration: getValue('duration'), country: getValue('country'), monetized: getValue('monetized', 'isMonetized', 'monetization'), author: getValue('author'), authorHandle: getValue('authorHandle'), }; } /** * Extract extra metadata (duration, monetization, country) from API or page * @param {Object|null} apiStats - API stats * @param {Object|null} pageStats - Page stats * @returns {{duration: string|null, monetization: string|null, country: string|null}} Metadata */ function getVideoExtras(apiStats, pageStats) { const helpers = window.YouTubeStatsHelpers || {}; // Prefer explicit fields on the stats objects first, then fall back to helper functions const duration = apiStats?.duration ?? pageStats?.duration ?? helpers.getDurationFromSources?.(apiStats, pageStats) ?? null; const country = apiStats?.country ?? pageStats?.country ?? helpers.getCountryFromSources?.(apiStats, pageStats) ?? null; // Monetization can be boolean or descriptive string from helpers let monetization = null; if (apiStats?.monetized != null) { monetization = apiStats.monetized === true ? t('yes') : t('no'); } else if (pageStats?.monetized != null) { monetization = pageStats.monetized === true ? t('yes') : t('no'); } else { monetization = helpers.getMonetizationFromSources?.(apiStats, pageStats, t) ?? null; } return { duration, country, monetization }; } /** * Open stats modal with live data display * @param {string} type - 'video' or 'channel' * @param {string} id - Video ID or Channel ID */ /** * Create close button for stats modal * @param {HTMLElement} overlay - Overlay element to close * @returns {HTMLElement} Close button */ function createStatsModalCloseButton(overlay) { const closeBtn = document.createElement('button'); closeBtn.className = 'thumbnail-modal-close thumbnail-modal-action-btn'; closeBtn.innerHTML = ` <svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> `; closeBtn.title = t('close'); closeBtn.setAttribute('aria-label', t('close')); closeBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); overlay.remove(); }); return closeBtn; } /** * Create loading spinner element * @returns {HTMLElement} Loading spinner */ function createLoadingSpinner() { const loader = document.createElement('div'); loader.className = 'stats-loader'; loader.innerHTML = ` <svg class="stats-spinner" viewBox="0 0 50 50"> <circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="4"></circle> </svg> <p>${t('loadingStats')}</p> `; return loader; } /** * Create stats modal structure * @param {HTMLElement} overlay - Overlay element * @returns {{body: HTMLElement, container: HTMLElement}} Modal elements */ function createStatsModalStructure(overlay) { const container = document.createElement('div'); container.className = 'stats-modal-container'; const content = document.createElement('div'); content.className = 'stats-modal-content'; const body = document.createElement('div'); body.className = 'stats-modal-body'; body.appendChild(createLoadingSpinner()); content.appendChild(body); const wrapper = document.createElement('div'); wrapper.className = 'thumbnail-modal-wrapper'; const actionsDiv = document.createElement('div'); actionsDiv.className = 'thumbnail-modal-actions'; actionsDiv.appendChild(createStatsModalCloseButton(overlay)); wrapper.appendChild(content); wrapper.appendChild(actionsDiv); container.appendChild(wrapper); return { body, container }; } /** * Setup modal event handlers * @param {HTMLElement} overlay - Overlay element * @returns {void} */ function setupModalEventHandlers(overlay) { // Close when clicking outside overlay.addEventListener('click', ({ target }) => { if (target === overlay) overlay.remove(); }); // ESC to close function escHandler(e) { if (e.key === 'Escape') { overlay.remove(); window.removeEventListener('keydown', escHandler, true); } } window.addEventListener('keydown', escHandler, true); } /** * Render error message in modal * @param {HTMLElement} body - Body element * @param {Object} result - Fetch result * @returns {void} */ function renderErrorMessage(body, result) { const statusText = result?.status ? ` (${result.status})` : ''; const endpointHint = result?.url ? `<div style="margin-top:8px;font-size:12px;opacity:0.8;word-break:break-all">${result.url}</div>` : ''; body.innerHTML = ` <div class="stats-error"> <svg class="stats-error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="10"></circle> <line x1="12" y1="8" x2="12" y2="12"></line> <line x1="12" y1="16" x2="12.01" y2="16"></line> </svg> <p>${t('failedToLoadStats')}${statusText}</p> ${endpointHint} </div> `; } /** * Handle failed stats fetch * @param {HTMLElement} body - Body element * @param {Object} result - Fetch result * @param {string} id - Video/channel ID * @returns {void} */ function handleFailedFetch(body, result, id) { const pageStats = getPageVideoStats(); if (pageStats) { renderPageFallback(body, pageStats, id); } else { renderErrorMessage(body, result); } } /** * Display stats based on type * @param {HTMLElement} body - Body element * @param {string} type - Stats type (video/channel) * @param {Object} stats - Stats data * @param {string} id - Video/channel ID * @returns {void} */ function displayStatsBasedOnType(body, type, stats, id) { if (type === 'video') { try { const pageStats = getPageVideoStats(); const merged = mergeVideoStats(stats, pageStats); displayVideoStats(body, merged, id); } catch { displayVideoStats(body, stats, id); } } else { displayChannelStats(body, stats); } } /** * Open stats modal * @param {string} type - Stats type (video/channel) * @param {string} id - Video/channel ID * @returns {Promise<void>} */ async function openStatsModal(type, id) { if (!type || !id) { console.error('[YouTube+][Stats] Invalid parameters for modal'); return; } // Remove existing overlays (cache NodeList to avoid repeated lookups) const existingOverlays = $$('.stats-modal-overlay'); for (let i = 0; i < existingOverlays.length; i++) { try { existingOverlays[i].remove(); } catch { /* ignore individual failures */ } } // Create modal structure const overlay = document.createElement('div'); overlay.className = 'stats-modal-overlay'; const { body, container } = createStatsModalStructure(overlay); overlay.appendChild(container); setupModalEventHandlers(overlay); document.body.appendChild(overlay); // Fetch and display stats const result = await fetchStats(type, id); if (!result?.ok) { handleFailedFetch(body, result, id); return; } displayStatsBasedOnType(body, type, result.data, id); } /** * Display video statistics * @param {HTMLElement} container - Container element * @param {Object} stats - Stats data */ /** * Get stat card definitions for video stats * @param {Object} fields - Extracted video fields * @returns {Array} Card definitions */ function getVideoStatDefinitions(fields) { const { views, likes, dislikes, comments } = fields; return [ { label: 'views', value: views, exact: views, iconClass: 'stats-icon-views', iconSvg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>`, }, { label: 'likes', value: likes, exact: likes, iconClass: 'stats-icon-likes', iconSvg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg>`, }, { label: 'dislikes', value: dislikes, exact: dislikes, iconClass: 'stats-icon-dislikes', iconSvg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path></svg>`, }, { label: 'comments', value: comments, exact: comments, iconClass: 'stats-icon-comments', iconSvg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`, }, ]; } /** * Create live viewer stats card if available * @param {*} liveViewer - Live viewer count * @returns {string} HTML string or empty string */ function createLiveViewerCard(liveViewer) { if (liveViewer === undefined || liveViewer === null) return ''; return makeStatsCard( 'liveViewers', liveViewer, liveViewer, 'stats-icon-viewers', `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>` ); } /** * Create monetization meta card * @param {Object} extras - Video extras * @param {Object} stats - Stats object * @returns {string} HTML string */ function createMonetizationCard(extras, stats) { const monetizationValue = extras.monetization || t('unknown'); const isMonetized = extras.monetization === t('yes') || stats?.monetized === true; const monIcon = isMonetized ? `<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>` : `<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>`; return `<div class="stats-card" style="padding:10px;"><div class="stats-icon stats-icon-subscribers">${monIcon}</div><div class="stats-info"><div class="stats-label" style="font-size:12px;">${t('monetization')}</div><div class="stats-value" style="font-size:16px;">${monetizationValue}</div></div></div>`; } /** * Create country meta card with flag * @param {Object} extras - Video extras * @returns {string} HTML string */ function createCountryCard(extras) { const countryValue = extras.country || t('unknown'); const countryCode = extras.country && extras.country !== t('unknown') ? extras.country.toUpperCase() : ''; const globeIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`; if (countryCode) { const flagUrl = `https://cdn.jsdelivr.net/gh/lipis/[email protected]/flags/4x3/${countryCode.toLowerCase()}.svg`; return `<div class="stats-card" style="padding:10px;"><div class="stats-icon stats-icon-views" data-fallback-icon="globe"><img class="country-flag" src="${flagUrl}" alt="${countryCode}" width="32" height="24" style="border-radius:4px;"/></div><div class="stats-info"><div class="stats-label" style="font-size:12px;">${t('country')}</div><div class="stats-value" style="font-size:16px;">${countryCode}</div></div></div>`; } return `<div class="stats-card" style="padding:10px;"><div class="stats-icon stats-icon-views">${globeIcon}</div><div class="stats-info"><div class="stats-label" style="font-size:12px;">${t('country')}</div><div class="stats-value" style="font-size:16px;">${countryValue}</div></div></div>`; } /** * Create duration meta card * @param {Object} extras - Video extras * @returns {string} HTML string */ /** * Format duration values into human readable strings. * Accepts seconds (number or numeric string), ISO8601 (PT1H2M3S), * or colon-formatted strings (MM:SS or HH:MM:SS). * Returns null when value cannot be parsed. * @param {number|string} value * @returns {string|null} */ function formatDuration(value) { if (value == null) return null; function pad(n) { return String(n).padStart(2, '0'); } function secToHms(sec) { sec = Math.max(0, Math.floor(Number(sec) || 0)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (h > 0) return `${h}:${pad(m)}:${pad(s)}`; return `${m}:${pad(s)}`; } // number -> seconds if (typeof value === 'number' && Number.isFinite(value)) return secToHms(value); // numeric string if (typeof value === 'string') { const s = value.trim(); if (/^\d+$/.test(s)) return secToHms(Number(s)); // ISO 8601 duration PT#H#M#S const iso = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i.exec(s); if (iso) { const h = parseInt(iso[1] || '0', 10); const m = parseInt(iso[2] || '0', 10); const sec = parseInt(iso[3] || '0', 10); const total = h * 3600 + m * 60 + sec; return secToHms(total); } // Already colon formatted like M:SS or H:MM:SS if (/^\d+:\d{1,2}(:\d{1,2})?$/.test(s)) { const parts = s.split(':').map(p => p.replace(/^0+(\d)/, '$1')); // normalize to pad minutes/seconds if (parts.length === 2) { const [mm, ss] = parts; return `${Number(mm)}:${pad(Number(ss))}`; } if (parts.length === 3) { const [hh, mm, ss] = parts; return `${Number(hh)}:${pad(Number(mm))}:${pad(Number(ss))}`; } } // fallback: return as-is (useful when API already provides formatted text) return s || null; } return null; } function createDurationCard(extras) { const raw = extras?.duration ?? null; const formatted = formatDuration(raw); const durationValue = formatted || (raw ? String(raw) : null) || t('unknown'); const durationIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`; return `<div class="stats-card" style="padding:10px;"><div class="stats-icon stats-icon-videos">${durationIcon}</div><div class="stats-info"><div class="stats-label" style="font-size:12px;">${t('duration')}</div><div class="stats-value" style="font-size:16px;">${durationValue}</div></div></div>`; } /** * Build metadata cards HTML * @param {Object} stats - Stats object * @param {Object} extras - Video extras * @returns {string} HTML string */ function buildMetaCardsHtml(stats, extras) { const cards = [ createMonetizationCard(extras, stats), createCountryCard(extras), createDurationCard(extras), ]; return cards.filter(Boolean).join(''); } /** * Display video statistics * @param {HTMLElement} container - Container element * @param {Object} stats - Stats data * @param {string} id - Video ID */ function displayVideoStats(container, stats, id) { const fields = extractVideoFields(stats, id); const { liveViewer, title, thumbUrl } = fields; // Escape title for XSS prevention const escapeHtml = window.YouTubeSecurityUtils?.escapeHtml || (s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }); const safeTitle = escapeHtml(title); const titleHtml = safeTitle ? `<div class="stats-thumb-title-centered">${safeTitle}</div>` : ''; const defs = getVideoStatDefinitions(fields); // Build individual cards but group likes+dislikes into a single grid cell so // they appear side-by-side on one line. const viewsDef = defs.find(d => d.label === 'views'); const likesDef = defs.find(d => d.label === 'likes'); const dislikesDef = defs.find(d => d.label === 'dislikes'); const commentsDef = defs.find(d => d.label === 'comments'); const viewsHtml = viewsDef ? makeStatsCard( viewsDef.label, viewsDef.value, viewsDef.exact, viewsDef.iconClass, viewsDef.iconSvg ) : ''; const likesHtml = likesDef ? makeStatsCard( likesDef.label, likesDef.value, likesDef.exact, likesDef.iconClass, likesDef.iconSvg ) : ''; const dislikesHtml = dislikesDef ? makeStatsCard( dislikesDef.label, dislikesDef.value, dislikesDef.exact, dislikesDef.iconClass, dislikesDef.iconSvg ) : ''; const commentsHtml = commentsDef ? makeStatsCard( commentsDef.label, commentsDef.value, commentsDef.exact, commentsDef.iconClass, commentsDef.iconSvg ) : ''; const pairHtml = likesHtml || dislikesHtml ? `<div class="stats-card-pair">${likesHtml}${dislikesHtml}</div>` : ''; // Build centered large author/handle display (placed below stats grid) const { author, authorHandle } = fields; const safeAuthor = author ? escapeHtml(String(author)) : ''; const safeHandle = authorHandle ? escapeHtml(String(authorHandle)) : ''; const authorBigHtml = safeHandle || safeAuthor ? `<div class="stats-author-big">${ safeHandle ? `<a class="stats-author-handle-big" href="https://www.youtube.com/${encodeURIComponent( authorHandle )}" target="_blank" rel="noopener noreferrer">${safeHandle}</a>` : `<span class="stats-author-name-big">${safeAuthor}</span>` }</div>` : ''; const parts = [viewsHtml, pairHtml, commentsHtml].filter(Boolean); const liveViewerCard = createLiveViewerCard(liveViewer); if (liveViewerCard) parts.push(liveViewerCard); const gridHtml = `<div class="stats-grid">${parts.join('')}</div>`; const sideColumnHtml = `<div class="stats-side-column">${gridHtml}${authorBigHtml}</div>`; if (thumbUrl) { const extras = getVideoExtras(stats, null); const metaCardsHtml = buildMetaCardsHtml(stats, extras); const metaExtrasHtml = metaCardsHtml ? `<div class="stats-thumb-extras" style="display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;">${metaCardsHtml}</div>` : ''; const leftHtml = `<div class="stats-thumb-left"><img class="stats-thumb-img" src="${thumbUrl}" alt="thumbnail">${metaExtrasHtml}</div>`; container.innerHTML = `${titleHtml}<div class="stats-thumb-row">${leftHtml}${sideColumnHtml}</div>`; } else { container.innerHTML = `${titleHtml}${sideColumnHtml}`; } // Set up error handlers for country flag images setupFlagImageErrorHandlers(container); } /** * Setup error handlers for country flag images to prevent XSS * @param {HTMLElement} container - Container element */ function setupFlagImageErrorHandlers(container) { const flagImages = $$('.country-flag', container); const globeIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`; flagImages.forEach(img => { img.addEventListener( 'error', function () { const iconContainer = this.parentElement; if (iconContainer && iconContainer.dataset.fallbackIcon === 'globe') { this.style.display = 'none'; iconContainer.innerHTML = globeIcon; } }, { once: true } ); }); } /** * Display channel statistics * @param {HTMLElement} container - Container element * @param {Object} stats - Stats data */ function displayChannelStats(container, stats) { const { liveSubscriber, liveViews, liveVideos } = stats; container.innerHTML = ` <div class="stats-grid"> <div class="stats-card"> <div class="stats-icon stats-icon-subscribers"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path> <circle cx="9" cy="7" r="4"></circle> <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path> <path d="M16 3.13a4 4 0 0 1 0 7.75"></path> </svg> </div> <div class="stats-info"> <div class="stats-label">${t('subscribers')}</div> <div class="stats-value">${formatNumber(liveSubscriber)}</div> <div class="stats-exact">${(liveSubscriber || 0).toLocaleString()}</div> </div> </div> <div class="stats-card"> <div class="stats-icon stats-icon-views"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> <circle cx="12" cy="12" r="3"></circle> </svg> </div> <div class="stats-info"> <div class="stats-label">${t('totalViews')}</div> <div class="stats-value">${formatNumber(liveViews)}</div> <div class="stats-exact">${(liveViews || 0).toLocaleString()}</div> </div> </div> <div class="stats-card"> <div class="stats-icon stats-icon-videos"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <polygon points="23 7 16 12 23 17 23 7"></polygon> <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect> </svg> </div> <div class="stats-info"> <div class="stats-label">${t('totalVideos')}</div> <div class="stats-value">${formatNumber(liveVideos)}</div> <div class="stats-exact">${(liveVideos || 0).toLocaleString()}</div> </div> </div> </div> `; } function createStatsMenu() { if (!statsButtonEnabled) return undefined; if ($('.stats-menu-container')) { return undefined; } const containerDiv = document.createElement('div'); containerDiv.className = 'yt-flexible-actions-view-model-wiz__action stats-menu-container'; const mainButtonViewModel = document.createElement('button-view-model'); mainButtonViewModel.className = 'yt-spec-button-view-model main-stats-view-model'; const mainButton = document.createElement('button'); mainButton.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--outline yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--enable-backdrop-filter-experiment main-stats-button'; mainButton.setAttribute('aria-disabled', 'false'); mainButton.setAttribute('aria-label', t('stats')); mainButton.style.display = 'flex'; mainButton.style.alignItems = 'center'; mainButton.style.justifyContent = 'center'; mainButton.style.gap = '8px'; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 512 512'); svg.style.width = '20px'; svg.style.height = '20px'; svg.style.fill = 'currentColor'; const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute( 'd', 'M500 89c13.8-11 16-31.2 5-45s-31.2-16-45-5L319.4 151.5 211.2 70.4c-11.7-8.8-27.8-8.5-39.2 .6L12 199c-13.8 11-16 31.2-5 45s31.2 16 45 5L192.6 136.5l108.2 81.1c11.7 8.8 27.8 8.5 39.2-.6L500 89zM160 256l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32zM32 352l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32zm288-64c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32s32-14.3 32-32l0-128c0-17.7-14.3-32-32-32zm96-32l0 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-192c0-17.7-14.3-32-32-32s-32 14.3-32 32z' ); svg.appendChild(path); const buttonText = document.createElement('div'); buttonText.className = 'yt-spec-button-shape-next__button-text-content main-stats-text'; buttonText.textContent = t('stats'); buttonText.style.display = 'flex'; buttonText.style.alignItems = 'center'; const touchFeedback = document.createElement('yt-touch-feedback-shape'); touchFeedback.style.borderRadius = 'inherit'; const touchFeedbackDiv = document.createElement('div'); touchFeedbackDiv.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--touch-response'; touchFeedbackDiv.setAttribute('aria-hidden', 'true'); const strokeDiv = document.createElement('div'); strokeDiv.className = 'yt-spec-touch-feedback-shape__stroke'; const fillDiv = document.createElement('div'); fillDiv.className = 'yt-spec-touch-feedback-shape__fill'; touchFeedbackDiv.appendChild(strokeDiv); touchFeedbackDiv.appendChild(fillDiv); touchFeedback.appendChild(touchFeedbackDiv); mainButton.appendChild(svg); mainButton.appendChild(buttonText); mainButton.appendChild(touchFeedback); mainButtonViewModel.appendChild(mainButton); containerDiv.appendChild(mainButtonViewModel); const horizontalMenu = document.createElement('div'); horizontalMenu.className = 'stats-horizontal-menu'; const channelButtonContainer = document.createElement('div'); channelButtonContainer.className = 'stats-menu-button channel-stats-container'; const channelButton = createButton( t('channel'), 'M64 48c-8.8 0-16 7.2-16 16l0 288c0 8.8 7.2 16 16 16l512 0c8.8 0 16-7.2 16-16l0-288c0-8.8-7.2-16-16-16L64 48zM0 64C0 28.7 28.7 0 64 0L576 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 416c-35.3 0-64-28.7-64-64L0 64zM120 464l400 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-400 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z', '0 0 640 512', 'channel-stats', () => { const channelId = getChannelIdentifier(); if (channelId) { openStatsModal('channel', channelId); } } ); channelButtonContainer.appendChild(channelButton); horizontalMenu.appendChild(channelButtonContainer); if (channelFeatures.hasStreams) { const liveButtonContainer = document.createElement('div'); liveButtonContainer.className = 'stats-menu-button live-stats-container'; const liveButton = createButton( t('live'), 'M99.8 69.4c10.2 8.4 11.6 23.6 3.2 33.8C68.6 144.7 48 197.9 48 256s20.6 111.3 55 152.8c8.4 10.2 7 25.3-3.2 33.8s-25.3 7-33.8-3.2C24.8 389.6 0 325.7 0 256S24.8 122.4 66 72.6c8.4-10.2 23.6-11.6 33.8-3.2zm376.5 0c10.2-8.4 25.3-7 33.8 3.2c41.2 49.8 66 113.8 66 183.4s-24.8 133.6-66 183.4c-8.4 10.2-23.6 11.6-33.8 3.2s-11.6-23.6-3.2-33.8c34.3-41.5 55-94.7 55-152.8s-20.6-111.3-55-152.8c-8.4-10.2-7-25.3 3.2-33.8zM248 256a40 40 0 1 1 80 0 40 40 0 1 1 -80 0zm-61.1-78.5C170 199.2 160 226.4 160 256s10 56.8 26.9 78.5c8.1 10.5 6.3 25.5-4.2 33.7s-25.5 6.3-33.7-4.2c-23.2-29.8-37-67.3-37-108s13.8-78.2 37-108c8.1-10.5 23.2-12.3 33.7-4.2s12.3 23.2 4.2 33.7zM427 148c23.2 29.8 37 67.3 37 108s-13.8 78.2-37 108c-8.1 10.5-23.2 12.3-33.7 4.2s-12.3-23.2-4.2-33.7C406 312.8 416 285.6 416 256s-10-56.8-26.9-78.5c-8.1-10.5-6.3-25.5 4.2-33.7s25.5-6.3 33.7 4.2z', '0 0 576 512', 'live-stats', () => { const channelId = getChannelIdentifier(); if (channelId) { openStatsModal('channel', channelId); } } ); liveButtonContainer.appendChild(liveButton); horizontalMenu.appendChild(liveButtonContainer); } if (channelFeatures.hasShorts) { const shortsButtonContainer = document.createElement('div'); shortsButtonContainer.className = 'stats-menu-button shorts-stats-container'; const shortsButton = createButton( t('shorts'), 'M80 48c-8.8 0-16 7.2-16 16l0 384c0 8.8 7.2 16 16 16l224 0c8.8 0 16-7.2 16-16l0-384c0-8.8-7.2-16-16-16L80 48zM16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zM160 400l64 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-64 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z', '0 0 384 512', 'shorts-stats', () => { const channelId = getChannelIdentifier(); if (channelId) { openStatsModal('channel', channelId); } } ); shortsButtonContainer.appendChild(shortsButton); horizontalMenu.appendChild(shortsButtonContainer); } containerDiv.appendChild(horizontalMenu); const joinButton = $('.yt-flexible-actions-view-model-wiz__action:not(.stats-menu-container)'); if (joinButton) { joinButton.parentNode.appendChild(containerDiv); } else { const buttonContainer = $('#subscribe-button + #buttons'); if (buttonContainer) { buttonContainer.appendChild(containerDiv); } } return containerDiv; } function checkAndAddMenu() { if (!statsButtonEnabled) return; const joinButton = $('.yt-flexible-actions-view-model-wiz__action:not(.stats-menu-container)'); const statsMenu = $('.stats-menu-container'); if (joinButton && !statsMenu) { createStatsMenu(); } } function checkAndInsertIcon() { if (!statsButtonEnabled) return; // Always ensure universal icon is present in the masthead insertUniversalIcon(); } function addSettingsUI() { const section = $('.ytp-plus-settings-section[data-section="experimental"]'); if (!section) return false; const existingItem = $('.stats-button-settings-item', section); if (existingItem) { const label = existingItem.querySelector('.ytp-plus-settings-item-label'); const description = existingItem.querySelector('.ytp-plus-settings-item-description'); if (label) label.textContent = t('statisticsButton'); if (description) description.textContent = t('statisticsButtonDescription'); return true; } const item = document.createElement('div'); item.className = 'ytp-plus-settings-item stats-button-settings-item'; item.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${t('statisticsButton')}</label> <div class="ytp-plus-settings-item-description">${t('statisticsButtonDescription')}</div> </div> <input type="checkbox" class="ytp-plus-settings-checkbox" ${statsButtonEnabled ? 'checked' : ''}> `; section.appendChild(item); item.querySelector('input')?.addEventListener('change', e => { const { target } = e; const input = /** @type {EventTarget & HTMLInputElement} */ (target); statsButtonEnabled = input.checked; localStorage.setItem(SETTINGS_KEY, statsButtonEnabled ? 'true' : 'false'); // Remove all stats buttons and menus $$('.videoStats,.stats-menu-container').forEach(el => el.remove()); if (statsButtonEnabled) { checkAndInsertIcon(); checkAndAddMenu(); } }); return true; } function ensureSettingsUI(attempt = 0) { const attached = addSettingsUI(); if (attached || attempt >= 20) return; setTimeout(() => ensureSettingsUI(attempt + 1), 100); } // Settings modal integration — use event instead of MutationObserver document.addEventListener('youtube-plus-settings-modal-opened', () => { ensureSettingsUI(); }); const handleExperimentalNavClick = e => { const { target } = e; const el = /** @type {EventTarget & HTMLElement} */ (target); const navItem = el?.closest?.('.ytp-plus-settings-nav-item'); if (navItem?.dataset?.section === 'experimental') { ensureSettingsUI(); } }; document.addEventListener('youtube-plus-language-changed', () => { ensureSettingsUI(); }); if (!experimentalNavListenerKey) { experimentalNavListenerKey = YouTubeUtils.cleanupManager.registerListener( document, 'click', handleExperimentalNavClick, true ); } function init() { addStyles(); if (statsButtonEnabled) { checkAndInsertIcon(); checkAndAddMenu(); } // Use centralized pushState/replaceState event from utils.js instead of wrapping independently window.addEventListener('ytp-history-navigate', checkUrlChange); window.addEventListener('popstate', checkUrlChange); if (isChannelPage(location.href)) { checkChannelTabs(location.href); } } const scheduleInit = () => { if (statsInitialized || !isStatsRelevant()) return; statsInitialized = true; const run = () => { try { init(); } catch (e) { statsInitialized = false; throw e; } }; if (typeof requestIdleCallback === 'function') { requestIdleCallback(run, { timeout: 2000 }); } else { setTimeout(run, 0); } }; runWhenReady(scheduleInit); const handleNavigate = () => { scheduleInit(); if (!statsInitialized || !statsButtonEnabled) return; checkAndInsertIcon(); checkAndAddMenu(); if (isChannelPage(location.href)) { checkChannelTabs(location.href); } }; if (window.YouTubeUtils?.cleanupManager?.registerListener) { YouTubeUtils.cleanupManager.registerListener(document, 'yt-navigate-finish', handleNavigate, { passive: true, }); } else { window.addEventListener('yt-navigate-finish', handleNavigate); } const handleAction = event => { scheduleInit(); if (!statsInitialized || !statsButtonEnabled) return; const ev = /** @type {CustomEvent<any>} */ (event); if (ev.detail && ev.detail.actionName === 'yt-reload-continuation-items-command') { checkAndInsertIcon(); checkAndAddMenu(); } }; if (window.YouTubeUtils?.cleanupManager?.registerListener) { YouTubeUtils.cleanupManager.registerListener(document, 'yt-action', handleAction, { passive: true, }); } else { document.addEventListener('yt-action', handleAction); } })(); // count (function () { 'use strict'; // Reuse shared helpers (separate IIFE scope requires local aliases) const getCache = () => typeof window !== 'undefined' && window.YouTubeDOMCache; const $ = (sel, ctx) => getCache()?.querySelector(sel, ctx) || (ctx || document).querySelector(sel); const $$ = (sel, ctx) => getCache()?.querySelectorAll(sel, ctx) || Array.from((ctx || document).querySelectorAll(sel)); const byId = id => getCache()?.getElementById(id) || document.getElementById(id); // Do not run this module inside YouTube Studio (studio.youtube.com) const isStudioPageCount = () => { try { const host = location.hostname || ''; const href = location.href || ''; return ( host.includes('studio.youtube.com') || host.includes('studio.') || href.includes('studio.youtube.com') ); } catch { return false; } }; if (isStudioPageCount()) return; // i18n alias const t = (key, params = {}) => { if (window.YouTubePlusI18n?.t) return window.YouTubePlusI18n.t(key, params); if (window.YouTubeUtils?.t) return window.YouTubeUtils.t(key, params); if (!key) return ''; let r = String(key); for (const [k, v] of Object.entries(params || {})) { r = r.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); } return r; }; // Enhanced configuration with better defaults const CONFIG = { OPTIONS: ['subscribers', 'views', 'videos'], FONT_LINK: 'https://fonts.googleapis.com/css2?family=Rubik:wght@400;700&display=swap', STATS_API_URL: 'https://api.livecounts.io/youtube-live-subscriber-counter/stats/', DEFAULT_UPDATE_INTERVAL: 2000, DEFAULT_OVERLAY_OPACITY: 0.75, MAX_RETRIES: 3, CACHE_DURATION: 300000, // 5 minutes DEBOUNCE_DELAY: 100, STORAGE_KEY: 'youtube_channel_stats_settings', }; // Global state management const state = { overlay: null, isUpdating: false, intervalId: null, currentChannelName: null, currentChannelId: null, enabled: localStorage.getItem(CONFIG.STORAGE_KEY) !== 'false', updateInterval: parseInt(localStorage.getItem('youtubeEnhancerInterval'), 10) || CONFIG.DEFAULT_UPDATE_INTERVAL, overlayOpacity: parseFloat(localStorage.getItem('youtubeEnhancerOpacity')) || CONFIG.DEFAULT_OVERLAY_OPACITY, lastSuccessfulStats: new Map(), previousStats: new Map(), channelIdCache: new Map(), lastChannelIdWarnAt: 0, previousUrl: location.href, isChecking: false, documentListenerKeys: new Set(), }; // Utility functions const utils = { log: (message, ...args) => { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug('[YouTube+][Stats]', message, ...args); }, warn: (message, ...args) => { console.warn('[YouTube+][Stats]', message, ...args); }, error: (message, ...args) => { console.error('[YouTube+][Stats]', message, ...args); }, // Use shared debounce from YouTubeUtils debounce: window.YouTubeUtils?.debounce || ((func, wait) => { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }), }; const { OPTIONS } = CONFIG; const { FONT_LINK } = CONFIG; const { STATS_API_URL } = CONFIG; /** * Fetches channel data from YouTube * @param {string} url - The channel URL to fetch * @returns {Promise<Object|null>} The parsed channel data or null on error */ async function fetchChannel(url) { if (state.isChecking) return null; state.isChecking = true; try { const response = await fetch(url, { credentials: 'same-origin', }); if (!response.ok) return null; const html = await response.text(); const match = html.match(/var ytInitialData = (.+?);<\/script>/); return match && match[1] ? JSON.parse(match[1]) : null; } catch (error) { utils.warn('Failed to fetch channel data:', error); return null; } finally { state.isChecking = false; } } async function getChannelInfo(url) { const data = await fetchChannel(url); if (!data) return null; try { const channelName = data?.metadata?.channelMetadataRenderer?.title || t('unknown'); const channelId = data?.metadata?.channelMetadataRenderer?.externalId || null; return { channelName, channelId }; } catch { return null; } } function isChannelPageUrl(url) { return ( url.includes('youtube.com/') && (url.includes('/channel/') || url.includes('/@')) && !url.includes('/video/') && !url.includes('/watch') ); } function checkUrlChange() { const currentUrl = location.href; if (currentUrl !== state.previousUrl) { state.previousUrl = currentUrl; if (isChannelPageUrl(currentUrl)) { setTimeout(() => getChannelInfo(currentUrl), 500); } } } // YouTube SPA navigation — yt-navigate-finish fires for all pushState/replaceState // transitions on youtube.com, so wrapping history APIs is redundant and creates // an ever-growing wrapper chain when multiple modules do the same thing. window.addEventListener('yt-navigate-finish', checkUrlChange, { passive: true }); window.addEventListener('popstate', checkUrlChange, { passive: true }); function init() { try { utils.log('Initializing YouTube Enhancer v1.6'); loadFonts(); initializeLocalStorage(); addStyles(); if (state.enabled) { observePageChanges(); addNavigationListener(); if (isChannelPageUrl(location.href)) { getChannelInfo(location.href); } } utils.log('YouTube Enhancer initialized successfully'); } catch (error) { utils.error('Failed to initialize YouTube Enhancer:', error); } } function loadFonts() { const fontLink = document.createElement('link'); fontLink.rel = 'stylesheet'; fontLink.href = FONT_LINK; (document.head || document.documentElement).appendChild(fontLink); } function initializeLocalStorage() { OPTIONS.forEach(option => { if (localStorage.getItem(`show-${option}`) === null) { localStorage.setItem(`show-${option}`, 'true'); } }); } function addStyles() { const styles = ` .channel-banner-overlay{position:absolute;top:0;left:0;width:100%;height:100%;border-radius:12px;z-index:9;display:flex;justify-content:space-around;align-items:center;color:#fff;font-family:var(--stats-font-family,'Rubik',sans-serif);font-size:var(--stats-font-size,24px);box-sizing:border-box;transition:background-color .3s ease;backdrop-filter:blur(2px)} .settings-button{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;cursor:pointer;z-index:11;transition:all .2s ease;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.4);backdrop-filter:blur(4px);border:1px solid rgba(255,255,255,0.1);opacity:0.7} .channel-banner-overlay:hover .settings-button{opacity:1} .settings-button:hover{transform:rotate(30deg) scale(1.1);opacity:1;background:rgba(0,0,0,0.6);border-color:rgba(255,255,255,0.3)} .settings-button svg{width:18px;height:18px;fill:white;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.5))} .settings-menu{position:absolute;top:52px;right:12px;background:rgba(28,28,28,0.75);padding:16px;border-radius:16px;z-index:12;display:flex;flex-direction:column;gap:12px;backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(255,255,255,0.08);box-shadow:0 8px 32px rgba(0,0,0,0.6);min-width:320px;opacity:0;visibility:hidden;transform:translateY(-10px) scale(0.98);transition:all 0.2s cubic-bezier(0.2,0,0.2,1);pointer-events:none} .settings-menu.show{opacity:1;visibility:visible;transform:translateY(0) scale(1);pointer-events:auto} .settings-menu .ytp-plus-settings-item{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-radius:8px;background:rgba(255,255,255,0.02);} .settings-menu .ytp-plus-settings-item + .ytp-plus-settings-item{margin-top:6px} .settings-menu .ytp-plus-settings-item .ytp-plus-settings-item-label{color:#eee;font-size:14px;font-weight:500} .settings-menu label{color:#eee!important;font-size:14px!important;font-weight:500!important;margin-bottom:6px!important} .settings-menu input[type="range"]{-webkit-appearance:none;width:100%!important;height:4px;background:rgba(255,255,255,0.2)!important;border-radius:2px;margin:12px 0 4px 0!important;cursor:pointer} .settings-menu input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;height:16px;width:16px;border-radius:50%;background:#3ea6ff;margin-top:-6px;box-shadow:0 2px 4px rgba(0,0,0,0.3);border:2px solid #fff;transition:transform .1s;cursor:pointer} .settings-menu input[type="range"]::-webkit-slider-thumb:hover{transform:scale(1.2)} .settings-menu select{width:100%!important;background:rgba(255,255,255,0.1)!important;border:1px solid rgba(255,255,255,0.1)!important;color:#fff!important;padding:8px 12px!important;border-radius:6px!important;font-size:13px!important;margin-bottom:12px!important;cursor:pointer;outline:none} .settings-menu select:hover{background:rgba(255,255,255,0.15)!important} .settings-menu select option{background:#333;color:#fff} /* Don't override the shared settings checkbox styling; only target non-shared inputs */ .settings-menu input[type="checkbox"]:not(.ytp-plus-settings-checkbox){appearance:none;width:18px!important;height:18px!important;border:2px solid rgba(255,255,255,0.4)!important;border-radius:4px!important;background:transparent!important;cursor:pointer;position:relative;margin-right:12px!important;vertical-align:middle;transition:all .2s} .settings-menu input[type="checkbox"]:not(.ytp-plus-settings-checkbox):checked{background:#3ea6ff!important;border-color:#3ea6ff!important} .settings-menu input[type="checkbox"]:not(.ytp-plus-settings-checkbox):checked::after{content:'';position:absolute;left:5px;top:1px;width:4px;height:10px;border:solid white;border-width:0 2px 2px 0;transform:rotate(45deg)} .stat-container{display:flex;flex-direction:column;align-items:center;justify-content:center;visibility:hidden;width:33%;height:100%;padding:0 1rem;text-shadow:0 2px 4px rgba(0,0,0,0.3)} .number-container{display:flex;align-items:center;justify-content:center;font-weight:700;min-height:3rem} .label-container{display:flex;align-items:center;margin-top:.5rem;font-size:1.2rem;opacity:.9} .label-container svg{width:1.5rem;height:1.5rem;margin-right:.5rem;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))} .difference{font-size:1.8rem;height:2rem;margin-bottom:.5rem;transition:opacity .3s} .spinner-container{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center} .loading-spinner{animation:spin 1s linear infinite} @keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} @media(max-width:768px){.channel-banner-overlay{flex-direction:column;padding:8px;min-height:160px}.settings-menu{width:280px!important;right:4px!important;top:48px!important}} .setting-group{margin-bottom:12px} .setting-group:last-child{margin-bottom:0} .setting-value{color:#bbb;font-size:12px;margin-top:4px} `; YouTubeUtils.StyleManager.add('channel-stats-overlay', styles); } function createSettingsButton() { const button = document.createElement('div'); button.className = 'settings-button'; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('viewBox', '0 0 512 512'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('fill', 'white'); path.setAttribute( 'd', 'M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z' ); svg.appendChild(path); button.appendChild(svg); return button; } function createSettingsMenu() { const menu = document.createElement('div'); menu.className = 'settings-menu'; menu.style.gap = '15px'; menu.style.width = '360px'; menu.setAttribute('tabindex', '-1'); menu.setAttribute('aria-modal', 'true'); const displaySection = createDisplaySection(); const controlsSection = createControlsSection(); menu.appendChild(displaySection); menu.appendChild(controlsSection); return menu; } function createDisplaySection() { const displaySection = document.createElement('div'); displaySection.style.flex = '1'; const displayLabel = document.createElement('label'); displayLabel.textContent = t('displayOptions'); displayLabel.style.marginBottom = '10px'; displayLabel.style.display = 'block'; displayLabel.style.fontSize = '16px'; displayLabel.style.fontWeight = 'bold'; displaySection.appendChild(displayLabel); // Use event delegation for all checkboxes displaySection.addEventListener('change', e => { const checkbox = e.target; if (checkbox.type === 'checkbox' && checkbox.id.startsWith('show-')) { const option = checkbox.id.replace('show-', ''); localStorage.setItem(`show-${option}`, String(checkbox.checked)); updateDisplayState(); } }); // Render options as single-line settings items using shared classes OPTIONS.forEach(option => { const item = document.createElement('div'); item.className = 'ytp-plus-settings-item'; const left = document.createElement('div'); const label = document.createElement('label'); label.className = 'ytp-plus-settings-item-label'; label.htmlFor = `show-${option}`; label.textContent = t(option); left.appendChild(label); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = `show-${option}`; checkbox.checked = localStorage.getItem(`show-${option}`) !== 'false'; checkbox.className = 'ytp-plus-settings-checkbox'; item.appendChild(left); item.appendChild(checkbox); displaySection.appendChild(item); }); return displaySection; } function createControlsSection() { const controlsSection = document.createElement('div'); controlsSection.style.flex = '1'; // Use event delegation for all sliders and selects controlsSection.addEventListener('input', e => { const target = e.target; // Handle font size slider if (target.classList.contains('font-size-slider')) { const input = /** @type {HTMLInputElement} */ (target); const fontSizeValue = controlsSection.querySelector('.font-size-value'); if (fontSizeValue) fontSizeValue.textContent = `${input.value}px`; localStorage.setItem('youtubeEnhancerFontSize', input.value); if (state.overlay) { state.overlay .querySelectorAll('.subscribers-number,.views-number,.videos-number') .forEach(el => { el.style.fontSize = `${input.value}px`; }); } } // Handle interval slider if (target.classList.contains('interval-slider')) { const input = /** @type {HTMLInputElement} */ (target); const newInterval = parseInt(input.value, 10) * 1000; const intervalValue = controlsSection.querySelector('.interval-value'); if (intervalValue) intervalValue.textContent = `${input.value}s`; state.updateInterval = newInterval; localStorage.setItem('youtubeEnhancerInterval', String(newInterval)); if (state.intervalId) { clearInterval(state.intervalId); state.intervalId = setInterval(() => { updateOverlayContent(state.overlay, state.currentChannelName); }, newInterval); YouTubeUtils.cleanupManager.registerInterval(state.intervalId); } } // Handle opacity slider if (target.classList.contains('opacity-slider')) { const input = /** @type {HTMLInputElement} */ (target); const newOpacity = parseInt(input.value, 10) / 100; const opacityValue = controlsSection.querySelector('.opacity-value'); if (opacityValue) opacityValue.textContent = `${input.value}%`; state.overlayOpacity = newOpacity; localStorage.setItem('youtubeEnhancerOpacity', String(newOpacity)); if (state.overlay) { state.overlay.style.backgroundColor = `rgba(0, 0, 0, ${newOpacity})`; } } }); // Font family selector - using glass-dropdown style const fontLabel = document.createElement('label'); fontLabel.textContent = t('fontFamily'); fontLabel.style.display = 'block'; fontLabel.style.marginBottom = '5px'; fontLabel.style.fontSize = '16px'; fontLabel.style.fontWeight = 'bold'; const fonts = [ { name: 'Rubik', value: 'Rubik, sans-serif' }, { name: 'Impact', value: 'Impact, Charcoal, sans-serif' }, { name: 'Verdana', value: 'Verdana, Geneva, sans-serif' }, { name: 'Tahoma', value: 'Tahoma, Geneva, sans-serif' }, ]; const savedFont = localStorage.getItem('youtubeEnhancerFontFamily') || 'Rubik, sans-serif'; const savedFontName = fonts.find(f => f.value === savedFont)?.name || 'Rubik'; // Hidden native select for compatibility const fontSelect = document.createElement('select'); fontSelect.className = 'font-family-select'; fontSelect.style.display = 'none'; fonts.forEach(f => { const opt = document.createElement('option'); opt.value = f.value; opt.textContent = f.name; if (f.value === savedFont) opt.selected = true; fontSelect.appendChild(opt); }); // Glass dropdown const fontDropdown = document.createElement('div'); fontDropdown.className = 'glass-dropdown'; fontDropdown.id = 'stats-font-dropdown'; fontDropdown.tabIndex = 0; fontDropdown.setAttribute('role', 'listbox'); fontDropdown.setAttribute('aria-expanded', 'false'); fontDropdown.style.marginBottom = '12px'; fontDropdown.innerHTML = ` <button class="glass-dropdown__toggle" type="button" aria-haspopup="listbox"> <span class="glass-dropdown__label">${savedFontName}</span> <svg class="glass-dropdown__chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> </button> <ul class="glass-dropdown__list" role="presentation"> ${fonts .map(f => { const sel = f.value === savedFont ? ' aria-selected="true"' : ''; return `<li class="glass-dropdown__item" data-value="${f.value}" role="option"${sel}>${f.name}</li>`; }) .join('')} </ul> `; // Initialize glass dropdown interactions const initFontDropdown = () => { const toggle = fontDropdown.querySelector('.glass-dropdown__toggle'); const list = fontDropdown.querySelector('.glass-dropdown__list'); const label = fontDropdown.querySelector('.glass-dropdown__label'); // eslint-disable-next-line no-unused-vars let items = Array.from(fontDropdown.querySelectorAll('.glass-dropdown__item')); const closeList = () => { fontDropdown.setAttribute('aria-expanded', 'false'); if (list) list.style.display = 'none'; }; const openList = () => { fontDropdown.setAttribute('aria-expanded', 'true'); if (list) list.style.display = 'block'; items = Array.from(fontDropdown.querySelectorAll('.glass-dropdown__item')); }; closeList(); if (toggle) { toggle.addEventListener('click', e => { e.stopPropagation(); const expanded = fontDropdown.getAttribute('aria-expanded') === 'true'; if (expanded) closeList(); else openList(); }); } document.addEventListener('click', e => { if (!fontDropdown.contains(e.target)) closeList(); }); if (list) { list.addEventListener('click', e => { const it = e.target.closest('.glass-dropdown__item'); if (!it) return; const val = it.dataset.value; fontDropdown .querySelectorAll('.glass-dropdown__item') .forEach(i => i.removeAttribute('aria-selected')); it.setAttribute('aria-selected', 'true'); if (label) label.textContent = it.textContent; fontSelect.value = val; closeList(); // Apply font change localStorage.setItem('youtubeEnhancerFontFamily', val); if (state.overlay) { state.overlay .querySelectorAll('.subscribers-number,.views-number,.videos-number') .forEach(el => { el.style.fontFamily = val; }); } }); } }; // Delay initialization to ensure DOM is ready setTimeout(initFontDropdown, 0); // Font size slider const fontSizeLabel = document.createElement('label'); fontSizeLabel.textContent = t('fontSize'); fontSizeLabel.style.display = 'block'; fontSizeLabel.style.marginBottom = '5px'; fontSizeLabel.style.fontSize = '16px'; fontSizeLabel.style.fontWeight = 'bold'; const fontSizeSlider = document.createElement('input'); fontSizeSlider.type = 'range'; fontSizeSlider.min = '16'; fontSizeSlider.max = '72'; fontSizeSlider.value = localStorage.getItem('youtubeEnhancerFontSize') || '24'; fontSizeSlider.step = '1'; fontSizeSlider.className = 'font-size-slider'; const fontSizeValue = document.createElement('div'); fontSizeValue.className = 'font-size-value'; fontSizeValue.textContent = `${fontSizeSlider.value}px`; fontSizeValue.style.fontSize = '14px'; fontSizeValue.style.marginBottom = '15px'; // Update interval slider const intervalLabel = document.createElement('label'); intervalLabel.textContent = t('updateInterval'); intervalLabel.style.display = 'block'; intervalLabel.style.marginBottom = '5px'; intervalLabel.style.fontSize = '16px'; intervalLabel.style.fontWeight = 'bold'; const intervalSlider = document.createElement('input'); intervalSlider.type = 'range'; intervalSlider.min = '2'; intervalSlider.max = '10'; intervalSlider.value = String(state.updateInterval / 1000); intervalSlider.step = '1'; intervalSlider.className = 'interval-slider'; const intervalValue = document.createElement('div'); intervalValue.className = 'interval-value'; intervalValue.textContent = `${intervalSlider.value}s`; intervalValue.style.marginBottom = '15px'; intervalValue.style.fontSize = '14px'; // Opacity slider const opacityLabel = document.createElement('label'); opacityLabel.textContent = t('backgroundOpacity'); opacityLabel.style.display = 'block'; opacityLabel.style.marginBottom = '5px'; opacityLabel.style.fontSize = '16px'; opacityLabel.style.fontWeight = 'bold'; const opacitySlider = document.createElement('input'); opacitySlider.type = 'range'; opacitySlider.min = '50'; opacitySlider.max = '90'; opacitySlider.value = String(state.overlayOpacity * 100); opacitySlider.step = '5'; opacitySlider.className = 'opacity-slider'; const opacityValue = document.createElement('div'); opacityValue.className = 'opacity-value'; opacityValue.textContent = `${opacitySlider.value}%`; opacityValue.style.fontSize = '14px'; controlsSection.appendChild(fontLabel); controlsSection.appendChild(fontSelect); controlsSection.appendChild(fontDropdown); controlsSection.appendChild(fontSizeLabel); controlsSection.appendChild(fontSizeSlider); controlsSection.appendChild(fontSizeValue); controlsSection.appendChild(intervalLabel); controlsSection.appendChild(intervalSlider); controlsSection.appendChild(intervalValue); controlsSection.appendChild(opacityLabel); controlsSection.appendChild(opacitySlider); controlsSection.appendChild(opacityValue); return controlsSection; } function createSpinner() { const spinnerContainer = document.createElement('div'); spinnerContainer.style.position = 'absolute'; spinnerContainer.style.top = '0'; spinnerContainer.style.left = '0'; spinnerContainer.style.width = '100%'; spinnerContainer.style.height = '100%'; spinnerContainer.style.display = 'flex'; spinnerContainer.style.justifyContent = 'center'; spinnerContainer.style.alignItems = 'center'; spinnerContainer.classList.add('spinner-container'); const spinner = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); spinner.setAttribute('viewBox', '0 0 512 512'); spinner.setAttribute('width', '64'); spinner.setAttribute('height', '64'); spinner.classList.add('loading-spinner'); spinner.style.animation = 'spin 1s linear infinite'; const secondaryPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); secondaryPath.setAttribute( 'd', 'M0 256C0 114.9 114.1 .5 255.1 0C237.9 .5 224 14.6 224 32c0 17.7 14.3 32 32 32C150 64 64 150 64 256s86 192 192 192c69.7 0 130.7-37.1 164.5-92.6c-3 6.6-3.3 14.8-1 22.2c1.2 3.7 3 7.2 5.4 10.3c1.2 1.5 2.6 3 4.1 4.3c.8 .7 1.6 1.3 2.4 1.9c.4 .3 .8 .6 1.3 .9s.9 .6 1.3 .8c5 2.9 10.6 4.3 16 4.3c11 0 21.8-5.7 27.7-16c-44.3 76.5-127 128-221.7 128C114.6 512 0 397.4 0 256z' ); secondaryPath.style.opacity = '0.4'; secondaryPath.style.fill = 'white'; const primaryPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); primaryPath.setAttribute( 'd', 'M224 32c0-17.7 14.3-32 32-32C397.4 0 512 114.6 512 256c0 46.6-12.5 90.4-34.3 128c-8.8 15.3-28.4 20.5-43.7 11.7s-20.5-28.4-11.7-43.7c16.3-28.2 25.7-61 25.7-96c0-106-86-192-192-192c-17.7 0-32-14.3-32-32z' ); primaryPath.style.fill = 'white'; spinner.appendChild(secondaryPath); spinner.appendChild(primaryPath); spinnerContainer.appendChild(spinner); return spinnerContainer; } function createSVGIcon(path) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 640 512'); svg.setAttribute('width', '2rem'); svg.setAttribute('height', '2rem'); svg.style.marginRight = '0.5rem'; svg.style.display = 'none'; const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); svgPath.setAttribute('d', path); svgPath.setAttribute('fill', 'white'); svg.appendChild(svgPath); return svg; } function createStatContainer(className, iconPath) { const container = document.createElement('div'); Object.assign(container.style, { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', visibility: 'hidden', width: '33%', height: '100%', padding: '0 1rem', }); const numberContainer = document.createElement('div'); Object.assign(numberContainer.style, { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', }); const differenceElement = document.createElement('div'); differenceElement.classList.add(`${className}-difference`); Object.assign(differenceElement.style, { fontSize: '2.5rem', height: '2.5rem', marginBottom: '1rem', }); const digitContainer = createNumberContainer(); digitContainer.classList.add(`${className}-number`); Object.assign(digitContainer.style, { fontSize: `${localStorage.getItem('youtubeEnhancerFontSize') || '24'}px`, fontWeight: 'bold', lineHeight: '1', height: '4rem', fontFamily: localStorage.getItem('youtubeEnhancerFontFamily') || 'Rubik, sans-serif', letterSpacing: '0.025em', }); numberContainer.appendChild(differenceElement); numberContainer.appendChild(digitContainer); const labelContainer = document.createElement('div'); Object.assign(labelContainer.style, { display: 'flex', alignItems: 'center', marginTop: '0.5rem', }); const icon = createSVGIcon(iconPath); Object.assign(icon.style, { width: '2rem', height: '2rem', marginRight: '0.75rem', }); const labelElement = document.createElement('div'); labelElement.classList.add(`${className}-label`); labelElement.style.fontSize = '2rem'; labelContainer.appendChild(icon); labelContainer.appendChild(labelElement); container.appendChild(numberContainer); container.appendChild(labelContainer); return container; } /** * Create base overlay element with styling * @returns {HTMLElement} Overlay element */ function createOverlayElement() { const overlay = document.createElement('div'); overlay.classList.add('channel-banner-overlay'); Object.assign(overlay.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: `rgba(0, 0, 0, ${state.overlayOpacity})`, borderRadius: '15px', zIndex: '10', display: 'flex', justifyContent: 'space-around', alignItems: 'center', color: 'white', fontFamily: localStorage.getItem('youtubeEnhancerFontFamily') || 'Rubik, sans-serif', fontSize: `${localStorage.getItem('youtubeEnhancerFontSize') || '24'}px`, boxSizing: 'border-box', transition: 'background-color 0.3s ease', }); return overlay; } /** * Apply accessibility attributes to overlay * @param {HTMLElement} overlay - Overlay element */ function applyOverlayAccessibility(overlay) { overlay.setAttribute('role', 'region'); overlay.setAttribute('aria-label', t('overlayAriaLabel')); overlay.setAttribute('tabindex', '-1'); } /** * Apply responsive mobile styling * @param {HTMLElement} overlay - Overlay element */ function applyMobileResponsiveness(overlay) { if (window.innerWidth <= 768) { overlay.style.flexDirection = 'column'; overlay.style.padding = '10px'; overlay.style.minHeight = '200px'; } } /** * Setup settings button with accessibility * @returns {HTMLElement} Settings button */ function setupSettingsButton() { const button = createSettingsButton(); button.setAttribute('tabindex', '0'); button.setAttribute('aria-label', t('settingsAriaLabel')); button.setAttribute('role', 'button'); return button; } /** * Setup settings menu with accessibility * @returns {HTMLElement} Settings menu */ function setupSettingsMenu() { const menu = createSettingsMenu(); menu.setAttribute('aria-label', t('settingsMenuAriaLabel')); menu.setAttribute('role', 'dialog'); return menu; } /** * Attach menu toggle event handlers * @param {HTMLElement} settingsButton - Settings button * @param {HTMLElement} settingsMenu - Settings menu */ function attachMenuEventHandlers(settingsButton, settingsMenu) { const toggleMenu = show => { settingsMenu.classList.toggle('show', show); settingsButton.setAttribute('aria-expanded', show); if (show) settingsMenu.focus(); }; settingsButton.addEventListener('click', e => { e.stopPropagation(); toggleMenu(!settingsMenu.classList.contains('show')); }); settingsButton.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleMenu(!settingsMenu.classList.contains('show')); } }); // Register document-level event handlers const clickHandler = e => { const node = /** @type {EventTarget & Node} */ (e.target); if (!settingsMenu.contains(node) && !settingsButton.contains(node)) { toggleMenu(false); } }; const keyHandler = e => { if (e.key === 'Escape' && settingsMenu.classList.contains('show')) { toggleMenu(false); settingsButton.focus(); } }; const clickKey = YouTubeUtils.cleanupManager.registerListener(document, 'click', clickHandler); const keyKey = YouTubeUtils.cleanupManager.registerListener(document, 'keydown', keyHandler); state.documentListenerKeys.add(clickKey); state.documentListenerKeys.add(keyKey); } /** * Add stat containers to overlay * @param {HTMLElement} overlay - Overlay element */ function addStatContainers(overlay) { const subscribersElement = createStatContainer( 'subscribers', 'M144 160c-44.2 0-80-35.8-80-80S99.8 0 144 0s80 35.8 80 80s-35.8 80-80 80zm368 0c-44.2 0-80-35.8-80-80s35.8-80 80-80s80 35.8 80 80s-35.8 80-80 80zM0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0H21.3C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3H405.3zM416 224c0 53-43 96-96 96s-96-43-96-96s43-96 96-96s96 43 96 96zM128 485.3C128 411.7 187.7 352 261.3 352H378.7C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7H154.7c-14.7 0-26.7-11.9-26.7-26.7z' ); const viewsElement = createStatContainer( 'views', 'M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z' ); const videosElement = createStatContainer( 'videos', 'M0 128C0 92.7 28.7 64 64 64H320c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128zM559.1 99.8c10.4 5.6 16.9 16.4 16.9 28.2V384c0 11.8-6.5 22.6-16.9 28.2s-23 5-32.9-1.6l-96-64L416 337.1V320 192 174.9l14.2-9.5 96-64c9.8-6.5 22.4-7.2 32.9-1.6z' ); overlay.appendChild(subscribersElement); overlay.appendChild(viewsElement); overlay.appendChild(videosElement); } function createOverlay(bannerElement) { clearExistingOverlay(); if (!bannerElement) return null; const overlay = createOverlayElement(); applyOverlayAccessibility(overlay); applyMobileResponsiveness(overlay); const settingsButton = setupSettingsButton(); const settingsMenu = setupSettingsMenu(); overlay.appendChild(settingsButton); overlay.appendChild(settingsMenu); attachMenuEventHandlers(settingsButton, settingsMenu); const spinner = createSpinner(); overlay.appendChild(spinner); addStatContainers(overlay); bannerElement.appendChild(overlay); updateDisplayState(); return overlay; } function fetchWithGM(url, headers = {}) { const requestHeaders = { Accept: 'application/json', ...headers, }; // Access GM_xmlhttpRequest via window to avoid TS "Cannot find name" when d.ts isn't picked up const gm = /** @type {any} */ (window).GM_xmlhttpRequest; if (typeof gm === 'function') { return new Promise((resolve, reject) => { gm({ method: 'GET', url, headers: requestHeaders, timeout: 10000, onload: response => { if (response.status >= 200 && response.status < 300) { try { resolve(JSON.parse(response.responseText)); } catch (parseError) { reject(new Error(`Failed to parse response: ${parseError.message}`)); } } else { reject(new Error(`Failed to fetch: ${response.status}`)); } }, onerror: error => reject(error), ontimeout: () => reject(new Error('Request timed out')), }); }); } utils.warn('GM_xmlhttpRequest unavailable, falling back to fetch API'); return fetch(url, { method: 'GET', headers: requestHeaders, credentials: 'omit', mode: 'cors', }) .then(response => { if (!response.ok) { throw new Error(`Failed to fetch: ${response.status}`); } return response.json(); }) .catch(error => { utils.error('Fallback fetch failed:', error); throw error; }); } async function fetchChannelId(channelName) { const cacheKey = channelName || state.currentChannelName || window.location.pathname; if (cacheKey && state.channelIdCache.has(cacheKey)) { return state.channelIdCache.get(cacheKey); } if (state.currentChannelId) { return state.currentChannelId; } if (typeof channelName === 'string' && /^UC[\w-]{22}$/.test(channelName)) { state.currentChannelId = channelName; if (cacheKey) state.channelIdCache.set(cacheKey, channelName); return channelName; } // Try meta tag first const metaTag = $('meta[itemprop="channelId"]'); if (metaTag && metaTag.content) { state.currentChannelId = metaTag.content; if (cacheKey) state.channelIdCache.set(cacheKey, metaTag.content); return metaTag.content; } // Try URL pattern const urlMatch = window.location.href.match(/channel\/(UC[\w-]+)/); if (urlMatch && urlMatch[1]) { state.currentChannelId = urlMatch[1]; if (cacheKey) state.channelIdCache.set(cacheKey, urlMatch[1]); return urlMatch[1]; } // Try ytInitialData const channelInfo = await getChannelInfo(window.location.href); if (channelInfo && channelInfo.channelId) { state.currentChannelId = channelInfo.channelId; if (cacheKey) state.channelIdCache.set(cacheKey, channelInfo.channelId); return channelInfo.channelId; } return null; } /** * Fetch channel statistics with retry logic and fallback * Refactored to use channel-stats-helpers module * @param {string} channelId - Channel ID * @returns {Promise<Object>} Channel stats */ async function fetchChannelStats(channelId) { const helpers = typeof window !== 'undefined' && window.YouTubePlusChannelStatsHelpers ? window.YouTubePlusChannelStatsHelpers : null; if (!helpers) { utils.error('Channel stats helpers not loaded'); return { followerCount: 0, bottomOdos: [0, 0], error: true, timestamp: Date.now(), }; } try { // Attempt to fetch with retry logic const fetchFn = () => fetchWithGM(`${STATS_API_URL}${channelId}`, { origin: 'https://livecounts.io', referer: 'https://livecounts.io/', }); const stats = await helpers.fetchWithRetry(fetchFn, CONFIG.MAX_RETRIES, utils); // If fetch succeeded, cache and return if (stats) { helpers.cacheStats(state.lastSuccessfulStats, channelId, stats); return stats; } // Try to use cached data if fetch failed const cachedStats = helpers.getCachedStats( state.lastSuccessfulStats, channelId, CONFIG.CACHE_DURATION, utils ); if (cachedStats) { return cachedStats; } // Fallback: try to extract subscriber count from page const fallbackCount = helpers.extractSubscriberCountFromPage(); if (fallbackCount > 0) { utils.log('Extracted fallback subscriber count:', fallbackCount); } return helpers.createFallbackStats(fallbackCount); } catch (error) { utils.error('Failed to fetch channel stats:', error); return helpers.createFallbackStats(0); } } function clearExistingOverlay() { const existingOverlay = $('.channel-banner-overlay'); if (existingOverlay) { try { existingOverlay.remove(); } catch { console.warn('[YouTube+] Failed to remove overlay'); } } if (state.intervalId) { try { clearInterval(state.intervalId); YouTubeUtils.cleanupManager.unregisterInterval(state.intervalId); } catch { console.warn('[YouTube+] Failed to clear interval'); } state.intervalId = null; } if (state.documentListenerKeys && state.documentListenerKeys.size) { state.documentListenerKeys.forEach(key => { try { YouTubeUtils.cleanupManager.unregisterListener(key); } catch { console.warn('[YouTube+] Failed to unregister listener'); } }); state.documentListenerKeys.clear(); } if (state.lastSuccessfulStats) state.lastSuccessfulStats.clear(); if (state.previousStats) state.previousStats.clear(); state.currentChannelId = null; state.isUpdating = false; state.overlay = null; utils.log('Cleared existing overlay'); } function createDigitElement() { const digit = document.createElement('span'); Object.assign(digit.style, { display: 'inline-block', width: '0.6em', textAlign: 'center', marginRight: '0.025em', marginLeft: '0.025em', }); return digit; } function createCommaElement() { const comma = document.createElement('span'); comma.textContent = ','; Object.assign(comma.style, { display: 'inline-block', width: '0.3em', textAlign: 'center', }); return comma; } function createNumberContainer() { const container = document.createElement('div'); Object.assign(container.style, { display: 'flex', justifyContent: 'center', alignItems: 'center', letterSpacing: '0.025em', }); return container; } /** * Split number into groups of 3 digits for formatting * @param {string} valueStr - Number as string * @returns {string[]} Array of digit groups */ function splitIntoDigitGroups(valueStr) { const digits = []; for (let i = valueStr.length - 1; i >= 0; i -= 3) { const start = Math.max(0, i - 2); digits.unshift(valueStr.slice(start, i + 1)); } return digits; } /** * Clear all children from container * @param {HTMLElement} container - Container element */ function clearContainer(container) { while (container.firstChild) { container.removeChild(container.firstChild); } } /** * Render digit groups in container * @param {HTMLElement} container - Container element * @param {string[]} digitGroups - Array of digit groups */ function renderDigitGroups(container, digitGroups) { for (let i = 0; i < digitGroups.length; i++) { const group = digitGroups[i]; for (let j = 0; j < group.length; j++) { const digitElement = createDigitElement(); digitElement.textContent = group[j]; container.appendChild(digitElement); } if (i < digitGroups.length - 1) { container.appendChild(createCommaElement()); } } } /** * Animate digit changes in container * @param {HTMLElement} container - Container element * @param {string[]} digitGroups - Array of digit groups */ function animateDigitChanges(container, digitGroups) { let elementIndex = 0; for (let i = 0; i < digitGroups.length; i++) { const group = digitGroups[i]; for (let j = 0; j < group.length; j++) { const digitElement = container.children[elementIndex]; const newDigit = parseInt(group[j], 10); const currentDigit = parseInt(digitElement.textContent || '0', 10); if (currentDigit !== newDigit) { animateDigit(digitElement, currentDigit, newDigit); } elementIndex++; } if (i < digitGroups.length - 1) { elementIndex++; // Skip comma } } } function updateDigits(container, newValue) { const newValueStr = newValue.toString(); const digitGroups = splitIntoDigitGroups(newValueStr); clearContainer(container); renderDigitGroups(container, digitGroups); animateDigitChanges(container, digitGroups); } function animateDigit(element, start, end) { const duration = 1000; const startTime = performance.now(); function update(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easeOutQuart = 1 - Math.pow(1 - progress, 4); const current = Math.round(start + (end - start) * easeOutQuart); element.textContent = current; if (progress < 1) { requestAnimationFrame(update); } } requestAnimationFrame(update); } function showContent(overlay) { const spinnerContainer = overlay.querySelector('.spinner-container'); if (spinnerContainer) { spinnerContainer.remove(); } const containers = overlay.querySelectorAll('div[style*="visibility: hidden"]'); containers.forEach(container => { container.style.visibility = 'visible'; }); const icons = overlay.querySelectorAll('svg[style*="display: none"]'); icons.forEach(icon => { icon.style.display = 'block'; }); } function updateDifferenceElement(element, currentValue, previousValue) { if (!previousValue) return; const difference = currentValue - previousValue; if (difference === 0) { element.textContent = ''; return; } const sign = difference > 0 ? '+' : ''; element.textContent = `${sign}${difference.toLocaleString()}`; element.style.color = difference > 0 ? '#1ed760' : '#f3727f'; setTimeout(() => { element.textContent = ''; }, 1000); } function updateDisplayState() { const overlay = $('.channel-banner-overlay'); if (!overlay) return; const statContainers = overlay.querySelectorAll('div[style*="width"]'); if (!statContainers.length) return; let visibleCount = 0; const visibleContainers = []; statContainers.forEach(container => { const numberContainer = container.querySelector('[class$="-number"]'); if (!numberContainer) return; const type = numberContainer.className.replace('-number', ''); const isVisible = localStorage.getItem(`show-${type}`) !== 'false'; if (isVisible) { container.style.display = 'flex'; visibleCount++; visibleContainers.push(container); } else { container.style.display = 'none'; } }); visibleContainers.forEach(container => { container.style.width = ''; container.style.margin = ''; switch (visibleCount) { case 1: container.style.width = '100%'; break; case 2: container.style.width = '50%'; break; case 3: container.style.width = '33.33%'; break; default: container.style.display = 'none'; } }); // Only update font size and font family for .subscribers-number, .views-number, .videos-number const fontSize = localStorage.getItem('youtubeEnhancerFontSize') || '24'; const fontFamily = localStorage.getItem('youtubeEnhancerFontFamily') || 'Rubik, sans-serif'; overlay.querySelectorAll('.subscribers-number,.views-number,.videos-number').forEach(el => { el.style.fontSize = `${fontSize}px`; el.style.fontFamily = fontFamily; }); overlay.style.display = 'flex'; } /** * Check if overlay update should proceed * @param {string} channelName - Channel name to update * @returns {boolean} True if should proceed */ function shouldUpdateOverlay(channelName) { return !state.isUpdating && channelName === state.currentChannelName; } /** * Handle stats error by showing fallback values * @param {HTMLElement} overlay - Overlay element * @param {Object} stats - Stats object with error * @returns {void} */ function handleStatsError(overlay, stats) { const containers = overlay.querySelectorAll('[class$="-number"]'); containers.forEach(container => { if (container.classList.contains('subscribers-number') && stats.followerCount > 0) { updateDigits(container, stats.followerCount); } else { container.textContent = '---'; } }); utils.warn('Using fallback stats due to API error'); } /** * Get previous stat value for comparison * @param {string} channelId - Channel ID * @param {string} className - Stat type class name * @returns {number|null} Previous value or null */ function getPreviousStatValue(channelId, className) { const prevStats = state.previousStats.get(channelId); if (!prevStats) return null; if (className === 'subscribers') { return prevStats.followerCount; } const index = className === 'views' ? 0 : 1; return prevStats.bottomOdos[index]; } /** * Update single stat element in overlay * @param {HTMLElement} overlay - Overlay element * @param {string} channelId - Channel ID * @param {string} className - Stat class name * @param {number} value - Stat value * @param {string} label - Stat label * @returns {void} */ function updateStatElement(overlay, channelId, className, value, label) { const numberContainer = overlay.querySelector(`.${className}-number`); const differenceElement = overlay.querySelector(`.${className}-difference`); const labelElement = overlay.querySelector(`.${className}-label`); if (numberContainer) { updateDigits(numberContainer, value); } if (differenceElement && state.previousStats.has(channelId)) { const previousValue = getPreviousStatValue(channelId, className); if (previousValue !== null) { updateDifferenceElement(differenceElement, value, previousValue); } } if (labelElement) { labelElement.textContent = label; } } /** * Update all stat elements in overlay * @param {HTMLElement} overlay - Overlay element * @param {string} channelId - Channel ID * @param {Object} stats - Stats object * @returns {void} */ function updateAllStatElements(overlay, channelId, stats) { updateStatElement(overlay, channelId, 'subscribers', stats.followerCount, t('subscribers')); updateStatElement(overlay, channelId, 'views', stats.bottomOdos[0], t('views')); updateStatElement(overlay, channelId, 'videos', stats.bottomOdos[1], t('videos')); } /** * Show error state in overlay * @param {HTMLElement} overlay - Overlay element * @returns {void} */ function showOverlayError(overlay) { const containers = overlay.querySelectorAll('[class$="-number"]'); containers.forEach(container => { container.textContent = '---'; }); } /** * Update overlay content with channel stats * @param {HTMLElement} overlay - Overlay element * @param {string} channelName - Channel name * @returns {Promise<void>} */ async function updateOverlayContent(overlay, channelName) { if (!shouldUpdateOverlay(channelName)) return; if (!overlay || !overlay.isConnected) return; if (document.visibilityState === 'hidden') return; state.isUpdating = true; try { const channelId = await fetchChannelId(channelName); if (!channelId) { const now = Date.now(); if (now - state.lastChannelIdWarnAt > 15000) { state.lastChannelIdWarnAt = now; utils.warn('Skipping overlay update: channel ID is not available yet'); } return; } state.currentChannelId = channelId; const stats = await fetchChannelStats(channelId); // Check if channel changed during async operations if (channelName !== state.currentChannelName) { return; } if (stats.error) { handleStatsError(overlay, stats); return; } updateAllStatElements(overlay, channelId, stats); if (!state.previousStats.has(channelId)) { showContent(overlay); utils.log('Displayed initial stats for channel:', channelName); } state.previousStats.set(channelId, stats); } catch (error) { utils.error('Failed to update overlay content:', error); showOverlayError(overlay); } finally { state.isUpdating = false; } } // Add settings UI to experimental section function addSettingsUI() { const section = $('.ytp-plus-settings-section[data-section="experimental"]'); if (!section) return false; const existingItem = section.querySelector('.count-settings-item'); if (existingItem) { const label = existingItem.querySelector('.ytp-plus-settings-item-label'); const description = existingItem.querySelector('.ytp-plus-settings-item-description'); if (label) label.textContent = t('channelStatsTitle'); if (description) description.textContent = t('channelStatsDescription'); return true; } const item = document.createElement('div'); item.className = 'ytp-plus-settings-item count-settings-item'; item.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${t('channelStatsTitle')}</label> <div class="ytp-plus-settings-item-description">${t('channelStatsDescription')}</div> </div> <input type="checkbox" class="ytp-plus-settings-checkbox" ${state.enabled ? 'checked' : ''}> `; section.appendChild(item); item.querySelector('input')?.addEventListener('change', e => { const { target } = e; const input = /** @type {EventTarget & HTMLInputElement} */ (target); state.enabled = input.checked; localStorage.setItem(CONFIG.STORAGE_KEY, state.enabled ? 'true' : 'false'); if (state.enabled) { observePageChanges(); addNavigationListener(); setTimeout(() => { const bannerElement = byId('page-header-banner-sizer'); if (bannerElement && isChannelPage()) { addOverlay(bannerElement); } }, 100); } else { clearExistingOverlay(); } }); return true; } function ensureSettingsUI(attempt = 0) { const attached = addSettingsUI(); if (attached || attempt >= 20) return; setTimeout(() => ensureSettingsUI(attempt + 1), 100); } // Settings modal integration — use event instead of MutationObserver document.addEventListener('youtube-plus-settings-modal-opened', () => { ensureSettingsUI(); }); const experimentalNavClickHandler = e => { const { target } = e; const el = /** @type {EventTarget & HTMLElement} */ (target); const navItem = el?.closest?.('.ytp-plus-settings-nav-item'); if (navItem?.dataset?.section === 'experimental') { ensureSettingsUI(); } }; document.addEventListener('youtube-plus-language-changed', () => { ensureSettingsUI(); }); const listenerKey = YouTubeUtils.cleanupManager.registerListener( document, 'click', experimentalNavClickHandler, true ); state.documentListenerKeys.add(listenerKey); /** * Extract channel name from URL pathname * @param {string} pathname - URL pathname * @returns {string|null} Channel name or null */ function extractChannelName(pathname) { if (pathname.startsWith('/@')) { return pathname.split('/')[1].replace('@', ''); } if (pathname.startsWith('/channel/')) { return pathname.split('/')[2]; } if (pathname.startsWith('/c/')) { return pathname.split('/')[2]; } if (pathname.startsWith('/user/')) { return pathname.split('/')[2]; } return null; } /** * Check if overlay should be skipped * @param {string|null} channelName - Channel name * @returns {boolean} True if should skip */ function shouldSkipOverlay(channelName) { return !channelName || (channelName === state.currentChannelName && state.overlay); } /** * Ensure banner element has proper positioning * @param {HTMLElement} bannerElement - Banner element */ function ensureBannerPosition(bannerElement) { if (bannerElement && !bannerElement.style.position) { bannerElement.style.position = 'relative'; } } /** * Clear existing update interval */ function clearUpdateInterval() { if (state.intervalId) { clearInterval(state.intervalId); state.intervalId = null; } } /** * Create debounced update function * @param {HTMLElement} overlay - Overlay element * @param {string} channelName - Channel name * @returns {Function} Debounced update function */ function createDebouncedUpdate(overlay, channelName) { let lastUpdateTime = 0; return () => { if (!overlay || !overlay.isConnected) return; if (document.visibilityState === 'hidden') return; const now = Date.now(); if (now - lastUpdateTime >= state.updateInterval - 100) { updateOverlayContent(overlay, channelName); lastUpdateTime = now; } }; } /** * Set up overlay update interval * @param {HTMLElement} overlay - Overlay element * @param {string} channelName - Channel name */ function setupUpdateInterval(overlay, channelName) { const debouncedUpdate = createDebouncedUpdate(overlay, channelName); state.intervalId = setInterval(debouncedUpdate, state.updateInterval); YouTubeUtils.cleanupManager.registerInterval(state.intervalId); } /** * Add overlay to channel page banner * @param {HTMLElement} bannerElement - Banner element */ function addOverlay(bannerElement) { const channelName = extractChannelName(window.location.pathname); if (shouldSkipOverlay(channelName)) { return; } ensureBannerPosition(bannerElement); state.currentChannelName = channelName; state.overlay = createOverlay(bannerElement); if (state.overlay) { clearUpdateInterval(); setupUpdateInterval(state.overlay, channelName); updateOverlayContent(state.overlay, channelName); utils.log('Added overlay for channel:', channelName); } } function isChannelPage() { return ( window.location.pathname.startsWith('/@') || window.location.pathname.startsWith('/channel/') || window.location.pathname.startsWith('/c/') ); } /** * Find banner element with fallback selectors * @returns {HTMLElement|null} Banner element */ function findBannerElement() { let bannerElement = byId('page-header-banner-sizer'); if (!bannerElement) { const alternatives = [ '[id*="banner"]', '.ytd-c4-tabbed-header-renderer', '#channel-header', '.channel-header', ]; for (const selector of alternatives) { bannerElement = $(selector); if (bannerElement) break; } } return bannerElement; } /** * Ensure banner has proper positioning * @param {HTMLElement} bannerElement - Banner element * @returns {void} */ function ensureBannerPositioning(bannerElement) { if (bannerElement.style.position !== 'relative') { bannerElement.style.position = 'relative'; } } /** * Handle page update for banner overlay * @returns {void} */ function handleBannerUpdate() { const bannerElement = findBannerElement(); if (bannerElement && isChannelPage()) { ensureBannerPositioning(bannerElement); addOverlay(bannerElement); } else if (!isChannelPage()) { clearExistingOverlay(); state.currentChannelName = null; } } /** * Cleanup observer timeout * @param {MutationObserver} observer - Observer instance * @returns {void} */ function clearObserverTimeout(observer) { if (/** @type {any} */ (observer)._timeout) { YouTubeUtils.cleanupManager.unregisterTimeout(/** @type {any} */ (observer)._timeout); clearTimeout(/** @type {any} */ (observer)._timeout); } } /** * Setup observer for monitoring page changes * @param {MutationObserver} observer - Observer instance * @returns {void} */ function setupObserver(observer) { const observerConfig = { childList: true, subtree: true, attributes: false, }; // Scope to #page-manager or #content instead of full document.body const startObserver = () => { const target = document.querySelector('#page-manager') || document.querySelector('#content') || document.body; observer.observe(target, observerConfig); }; if (document.body) { startObserver(); } else { document.addEventListener('DOMContentLoaded', startObserver); } } /** * Observe page changes and update banner overlay * @returns {MutationObserver|undefined} Observer instance */ function observePageChanges() { if (!state.enabled) return undefined; const observer = new MutationObserver(_mutations => { clearObserverTimeout(observer); /** @type {any} */ (observer)._timeout = YouTubeUtils.cleanupManager.registerTimeout( setTimeout(handleBannerUpdate, 100) ); }); setupObserver(observer); // Store timeout reference for cleanup /** @type {any} */ (observer)._timeout = null; // Store observer for cleanup on page unload if (typeof state.observers === 'undefined') { state.observers = []; } state.observers.push(observer); return observer; } function addNavigationListener() { if (!state.enabled) return; window.addEventListener('yt-navigate-finish', () => { if (isChannelPage()) { const bannerElement = byId('page-header-banner-sizer'); if (bannerElement) { addOverlay(bannerElement); utils.log('Navigated to channel page'); } } else { clearExistingOverlay(); state.currentChannelName = null; utils.log('Navigated away from channel page'); } }); } // Cleanup function for page unload function cleanup() { // Disconnect all observers if (state.observers && Array.isArray(state.observers)) { state.observers.forEach(observer => { try { observer.disconnect(); } catch (e) { console.warn('[YouTube+] Failed to disconnect observer:', e); } }); state.observers = []; } // Clear overlay and intervals clearExistingOverlay(); utils.log('Cleanup completed'); } // Register cleanup on page unload window.addEventListener('beforeunload', cleanup); // Export module to global scope for module loader if (typeof window !== 'undefined') { window.YouTubeStats = { init, cleanup, version: '2.4.4', }; } init(); })(); // --- MODULE: comment.js --- /** * Comment Manager Module * Provides bulk delete functionality and comment management tools for YouTube * @module CommentManager */ (function () { 'use strict'; /** * Translation helper - uses centralized i18n system * @param {string} key - Translation key * @param {Object} params - Interpolation parameters * @returns {string} Translated string */ 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 { // Fallback to key if central i18n unavailable } return key; } /** * Configuration object for comment manager * @const {Object} */ const CONFIG = { selectors: { deleteButtons: 'div[class^="VfPpkd-Bz112c-"], button[aria-label*="Delete"], button[aria-label*="Удалить"], button[aria-label*="Remove"]', menuButton: '[aria-haspopup="menu"]', }, classes: { checkbox: 'comment-checkbox', checkboxAnchor: 'comment-checkbox-anchor', checkboxFloating: 'comment-checkbox-floating', container: 'comment-controls-container', panel: 'comment-controls-panel', header: 'comment-controls-header', title: 'comment-controls-title', actions: 'comment-controls-actions', button: 'comment-controls-button', buttonDanger: 'comment-controls-button--danger', buttonPrimary: 'comment-controls-button--primary', buttonSuccess: 'comment-controls-button--success', close: 'comment-controls-close', deleteButton: 'comment-controls-button-delete', }, debounceDelay: 100, deleteDelay: 200, enabled: true, storageKey: 'youtube_comment_manager_settings', }; // State management const state = { observer: null, isProcessing: false, settingsNavListenerKey: null, panelCollapsed: false, initialized: false, }; const COMMENT_HISTORY_URL = (() => { let lang = 'en'; try { if (window.YouTubePlusI18n?.getLanguage) lang = window.YouTubePlusI18n.getLanguage(); else if (document.documentElement.lang) lang = document.documentElement.lang.split('-')[0]; } catch {} return `https://myactivity.google.com/page?hl=${encodeURIComponent(lang)}&utm_medium=web&utm_source=youtube&page=youtube_comments`; })(); const isMyActivityCommentsPage = () => { try { const host = location.hostname || ''; if (!host.includes('myactivity.google.com')) return false; const params = new URLSearchParams(location.search || ''); return params.get('page') === 'youtube_comments'; } catch { return false; } }; const registerObserverSafe = observer => { try { if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { YouTubeUtils.cleanupManager.registerObserver(observer); } } catch {} }; const registerListenerSafe = (target, event, handler, options) => { try { if (window.YouTubeUtils && YouTubeUtils.cleanupManager) { return YouTubeUtils.cleanupManager.registerListener(target, event, handler, options); } } catch {} try { target.addEventListener(event, handler, options); } catch {} return null; }; const addStyleBlock = cssText => { try { if (window.YouTubeUtils && YouTubeUtils.StyleManager) { YouTubeUtils.StyleManager.add('comment-delete-styles', cssText); return; } } catch {} try { if (document.getElementById('comment-delete-styles')) return; const style = document.createElement('style'); style.id = 'comment-delete-styles'; style.textContent = cssText; (document.head || document.documentElement).appendChild(style); } catch {} }; // Optimized settings const settings = { load: () => { try { const saved = localStorage.getItem(CONFIG.storageKey); if (saved) CONFIG.enabled = JSON.parse(saved).enabled ?? true; } catch {} }, save: () => { try { localStorage.setItem(CONFIG.storageKey, JSON.stringify({ enabled: CONFIG.enabled })); } catch {} }, }; // Use shared debounce from YouTubeUtils (loaded before this module) const debounce = (func, wait) => { if (window.YouTubeUtils?.debounce) { const d = window.YouTubeUtils.debounce(func, wait); if (typeof d === 'function') return d; } let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; }; /** * Safely query a single element * @param {string} selector - CSS selector * @returns {HTMLElement|null} The first matching element or null */ const $ = selector => /** @type {HTMLElement|null} */ (document.querySelector(selector)); /** * Safely query multiple elements * @param {string} selector - CSS selector * @returns {NodeListOf<HTMLElement>} NodeList of matching elements */ const $$ = selector => /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(selector)); /** * Log error with error boundary integration * @param {string} context - Error context * @param {Error|string|unknown} error - Error object or message */ const logError = (context, error) => { const errorObj = error instanceof Error ? error : new Error(String(error)); if (window.YouTubeErrorBoundary) { window.YouTubeErrorBoundary.logError(errorObj, { context }); } else { console.error(`[YouTube+][CommentManager] ${context}:`, error); } }; /** * Wraps function with error boundary protection * @template {Function} T * @param {T} fn - Function to wrap * @param {string} context - Error context for debugging * @returns {T} Wrapped function */ // Use shared withErrorBoundary from YouTubeErrorBoundary const withErrorBoundary = (fn, context) => { if (window.YouTubeErrorBoundary?.withErrorBoundary) { return /** @type {any} */ ( window.YouTubeErrorBoundary.withErrorBoundary(fn, 'CommentManager') ); } return /** @type {any} */ ( (...args) => { try { return fn(...args); } catch (e) { logError(context, e); return null; } } ); }; /** * Add checkboxes to comment elements for selection * Core functionality for bulk operations */ const addCheckboxes = withErrorBoundary(() => { if (!CONFIG.enabled || state.isProcessing) return; const deleteButtons = $$(CONFIG.selectors.deleteButtons); deleteButtons.forEach(button => { const parent = button.parentNode; if ( button.closest(CONFIG.selectors.menuButton) || (parent && parent.querySelector && parent.querySelector(`.${CONFIG.classes.checkbox}`)) ) { return; } const commentElement = button.closest('[class*="comment"]') || button.closest('[role="article"]') || parent; if (commentElement && commentElement instanceof Element) { if (!commentElement.hasAttribute('data-comment-text')) { commentElement.setAttribute( 'data-comment-text', (commentElement.textContent || '').toLowerCase() ); } } const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = `${CONFIG.classes.checkbox} ytp-plus-settings-checkbox`; checkbox.setAttribute('aria-label', t('selectComment')); checkbox.addEventListener('change', updateDeleteButtonState); checkbox.addEventListener('click', e => e.stopPropagation()); // Optimized positioning const dateElement = commentElement && commentElement.querySelector ? commentElement.querySelector( '[class*="date"],[class*="time"],time,[title*="20"],[aria-label*="ago"]' ) : null; if (dateElement && dateElement instanceof Element) { dateElement.classList.add(CONFIG.classes.checkboxAnchor); checkbox.classList.add(CONFIG.classes.checkboxFloating); dateElement.appendChild(checkbox); } else if (parent && parent.insertBefore) { parent.insertBefore(checkbox, button); } }); }, 'addCheckboxes'); /** * Add control panel with bulk action buttons */ const addControlButtons = withErrorBoundary(() => { if (!CONFIG.enabled || $(`.${CONFIG.classes.container}`)) return; const deleteButtons = $$(CONFIG.selectors.deleteButtons); if (!deleteButtons.length) return; const first = deleteButtons[0]; const container = first && first.parentNode && first.parentNode.parentNode; if (!container || !(container instanceof Element)) return; const panel = document.createElement('div'); panel.className = `${CONFIG.classes.container} ${CONFIG.classes.panel} glass-panel`; panel.setAttribute('role', 'region'); panel.setAttribute('aria-label', t('commentManagerControls')); const header = document.createElement('div'); header.className = CONFIG.classes.header; const title = document.createElement('div'); title.className = CONFIG.classes.title; title.textContent = t('commentManager'); const collapseButton = document.createElement('button'); collapseButton.className = `${CONFIG.classes.close} ytp-plus-settings-close`; collapseButton.setAttribute('type', 'button'); collapseButton.setAttribute('aria-expanded', String(!state.panelCollapsed)); collapseButton.setAttribute('aria-label', t('togglePanel')); collapseButton.innerHTML = ` <svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> `; const togglePanelState = collapsed => { state.panelCollapsed = collapsed; header.classList.toggle('is-collapsed', collapsed); actions.classList.toggle('is-hidden', collapsed); collapseButton.setAttribute('aria-expanded', String(!collapsed)); panel.classList.toggle('is-collapsed', collapsed); }; collapseButton.addEventListener('click', () => { state.panelCollapsed = !state.panelCollapsed; togglePanelState(state.panelCollapsed); }); header.append(title, collapseButton); const actions = document.createElement('div'); actions.className = CONFIG.classes.actions; const createActionButton = (label, className, onClick, options = {}) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.className = `${CONFIG.classes.button} ${className}`; if (options.id) button.id = options.id; if (options.disabled) button.disabled = true; button.addEventListener('click', onClick); return button; }; const deleteAllButton = createActionButton( t('deleteSelected'), `${CONFIG.classes.buttonDanger} ${CONFIG.classes.deleteButton}`, deleteSelectedComments, { disabled: true } ); const selectAllButton = createActionButton(t('selectAll'), CONFIG.classes.buttonPrimary, () => { $$(`.${CONFIG.classes.checkbox}`).forEach(cb => (cb.checked = true)); updateDeleteButtonState(); }); const clearAllButton = createActionButton(t('clearAll'), CONFIG.classes.buttonSuccess, () => { $$(`.${CONFIG.classes.checkbox}`).forEach(cb => (cb.checked = false)); updateDeleteButtonState(); }); actions.append(deleteAllButton, selectAllButton, clearAllButton); togglePanelState(state.panelCollapsed); panel.append(header, actions); const refNode = deleteButtons[0] && deleteButtons[0].parentNode; if (refNode && refNode.parentNode) { container.insertBefore(panel, refNode); } else { container.appendChild(panel); } }, 'addControlButtons'); /** * Update delete button state based on checkbox selection */ const updateDeleteButtonState = withErrorBoundary(() => { const deleteAllButton = $(`.${CONFIG.classes.deleteButton}`); if (!deleteAllButton) return; const hasChecked = Array.from($$(`.${CONFIG.classes.checkbox}`)).some(cb => cb.checked); deleteAllButton.disabled = !hasChecked; deleteAllButton.style.opacity = hasChecked ? '1' : '0.6'; }, 'updateDeleteButtonState'); /** * Delete selected comments with confirmation */ const deleteSelectedComments = withErrorBoundary(() => { const checkedBoxes = Array.from($$(`.${CONFIG.classes.checkbox}`)).filter(cb => cb.checked); if (!checkedBoxes.length || !confirm(`Delete ${checkedBoxes.length} comment(s)?`)) return; state.isProcessing = true; checkedBoxes.forEach((checkbox, index) => { setTimeout(() => { const deleteButton = checkbox.nextElementSibling || checkbox.parentNode.querySelector(CONFIG.selectors.deleteButtons); deleteButton?.click(); }, index * CONFIG.deleteDelay); }); setTimeout(() => (state.isProcessing = false), checkedBoxes.length * CONFIG.deleteDelay + 1000); }, 'deleteSelectedComments'); /** * Clean up all comment manager elements */ const cleanup = withErrorBoundary(() => { $$(`.${CONFIG.classes.checkbox}`).forEach(el => el.remove()); $(`.${CONFIG.classes.container}`)?.remove(); }, 'cleanup'); /** * Initialize or cleanup script based on enabled state */ const initializeScript = withErrorBoundary(() => { if (CONFIG.enabled) { addCheckboxes(); addControlButtons(); updateDeleteButtonState(); } else { cleanup(); } }, 'initializeScript'); /** * Add enhanced CSS styles for comment manager UI */ const addStyles = withErrorBoundary(() => { if ($('#comment-delete-styles')) return; const styles = ` .${CONFIG.classes.checkboxAnchor}{position:relative;display:inline-flex;align-items:center;gap:8px;width:auto;} .${CONFIG.classes.checkboxFloating}{position:absolute;top:-4px;right:-32px;margin:0;} /* Panel styled to match shorts feedback: glassmorphism, rounded corners, soft shadow */ .${CONFIG.classes.panel}{position:fixed;top:50%;right:24px;transform:translateY(-50%);display:flex;flex-direction:column;gap:14px;z-index:10000;padding:16px 18px;background:var(--yt-glass-bg);border:1.5px solid var(--yt-glass-border);border-radius:20px;box-shadow:0 12px 40px rgba(0,0,0,0.45);backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%);min-width:220px;max-width:300px;color:var(--yt-text-primary);transition:transform .22s cubic-bezier(.4,0,.2,1),opacity .22s,box-shadow .2s} html:not([dark]) .${CONFIG.classes.panel}{background:var(--yt-glass-bg);} .${CONFIG.classes.header}{display:flex;align-items:center;justify-content:space-between;gap:12px;} .${CONFIG.classes.panel}.is-collapsed{padding:14px 18px;} .${CONFIG.classes.panel}.is-collapsed .${CONFIG.classes.title}{font-weight:500;opacity:.85;} .${CONFIG.classes.panel}.is-collapsed .${CONFIG.classes.close}{transform:rotate(45deg);} .${CONFIG.classes.panel}.is-collapsed .${CONFIG.classes.actions}{display:none!important;} .${CONFIG.classes.title}{font-size:15px;font-weight:600;letter-spacing:.3px;} .${CONFIG.classes.close}{background:transparent;border:none;cursor:pointer;padding:6px;border-radius:12px;display:flex;align-items:center;justify-content:center;color:var(--yt-text-primary);transition:all .2s ease;} .${CONFIG.classes.close}:hover{transform:rotate(90deg) scale(1.05);color:var(--yt-accent);} .${CONFIG.classes.actions}{display:flex;flex-direction:column;gap:10px;} .${CONFIG.classes.actions}.is-hidden{display:none!important;} .${CONFIG.classes.button}{padding:12px 16px;border-radius:var(--yt-radius-md);border:1px solid var(--yt-glass-border);cursor:pointer;font-size:13px;font-weight:500;background:var(--yt-button-bg);color:var(--yt-text-primary);transition:all .2s ease;text-align:center;} .${CONFIG.classes.button}:disabled{opacity:.5;cursor:not-allowed;} .${CONFIG.classes.button}:not(:disabled):hover{transform:translateY(-1px);box-shadow:var(--yt-shadow);} .${CONFIG.classes.buttonDanger}{background:rgba(255,99,71,.12);border-color:rgba(255,99,71,.25);color:#ff5c5c;} .${CONFIG.classes.buttonPrimary}{background:rgba(33,150,243,.12);border-color:rgba(33,150,243,.25);color:#2196f3;} .${CONFIG.classes.buttonSuccess}{background:rgba(76,175,80,.12);border-color:rgba(76,175,80,.25);color:#4caf50;} .${CONFIG.classes.buttonDanger}:not(:disabled):hover{background:rgba(255,99,71,.22);} .${CONFIG.classes.buttonPrimary}:not(:disabled):hover{background:rgba(33,150,243,.22);} .${CONFIG.classes.buttonSuccess}:not(:disabled):hover{background:rgba(76,175,80,.22);} @media(max-width:1280px){ .${CONFIG.classes.panel}{top:auto;bottom:24px;transform:none;right:16px;} } @media(max-width:768px){ .${CONFIG.classes.panel}{position:fixed;left:16px;right:16px;bottom:16px;top:auto;transform:none;max-width:none;} .${CONFIG.classes.actions}{flex-direction:row;flex-wrap:wrap;} .${CONFIG.classes.button}{flex:1;min-width:140px;} } `; addStyleBlock(styles); }, 'addStyles'); /** * Add comment manager settings to YouTube+ settings panel */ const addCommentManagerSettings = withErrorBoundary(() => { const experimentalSection = $('.ytp-plus-settings-section[data-section="experimental"]'); if (!experimentalSection) return; // If already exists, move it to the bottom to ensure Comment Manager is last const existing = $('.comment-manager-settings-item'); if (existing) { try { experimentalSection.appendChild(existing); } catch { // ignore } return; } const settingsItem = document.createElement('div'); settingsItem.className = 'ytp-plus-settings-item comment-manager-settings-item'; settingsItem.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${t('commentManagement')}</label> <div class="ytp-plus-settings-item-description">${t('bulkDeleteDescription')}</div> </div> <button class="ytp-plus-button" id="open-comment-history-page" style="margin:0 0 0 30px;padding:12px 16px;font-size:13px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2)"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15,3 21,3 21,9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </button> `; // Append to end (ensure it's the bottom-most item) experimentalSection.appendChild(settingsItem); $('#open-comment-history-page').addEventListener('click', () => { window.open(COMMENT_HISTORY_URL, '_blank'); }); }, 'addCommentManagerSettings'); const ensureCommentManagerSettings = (attempt = 0) => { const experimentalVisible = $( '.ytp-plus-settings-section[data-section="experimental"]:not(.hidden)' ); if (!experimentalVisible) { if (attempt < 20) setTimeout(() => ensureCommentManagerSettings(attempt + 1), 80); return; } addCommentManagerSettings(); if (!$('.comment-manager-settings-item') && attempt < 20) { setTimeout(() => ensureCommentManagerSettings(attempt + 1), 80); } }; /** * Initialize comment manager module * Sets up observers, event listeners, and initial state */ const init = withErrorBoundary(() => { // Early exit if already initialized to prevent duplicate work if (state.initialized && state.observer) return; settings.load(); addStyles(); // Setup observer with throttling — scope to #comments or #content for performance state.observer?.disconnect(); state.observer = new MutationObserver(debounce(initializeScript, CONFIG.debounceDelay)); registerObserverSafe(state.observer); const observeTarget = () => { const target = document.querySelector('#comments') || document.querySelector('#content') || document.body; state.observer.observe(target, { childList: true, subtree: true }); }; if (document.body) { observeTarget(); } else { document.addEventListener('DOMContentLoaded', observeTarget); } // Re-scope observer after navigation (comments container may change) window.addEventListener( 'yt-navigate-finish', () => { state.observer.disconnect(); setTimeout(observeTarget, 200); }, { passive: true } ); // Initial setup if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeScript); } else { initializeScript(); } // Settings modal integration — use event instead of MutationObserver document.addEventListener('youtube-plus-settings-modal-opened', () => { setTimeout(() => ensureCommentManagerSettings(), 100); }); const handleExperimentalNavClick = e => { const target = /** @type {EventTarget & HTMLElement} */ (e.target); const navItem = target?.closest?.('.ytp-plus-settings-nav-item'); if (navItem?.dataset?.section === 'experimental') { setTimeout(() => ensureCommentManagerSettings(), 50); } }; if (!state.settingsNavListenerKey) { state.settingsNavListenerKey = registerListenerSafe( document, 'click', handleExperimentalNavClick, { passive: true, capture: true } ); } }, 'init'); /** * Check if current route is relevant for comment manager * @returns {boolean} True if on /watch, /shorts, or channel pages */ const isRelevantRoute = () => { if (isMyActivityCommentsPage()) return true; const path = location.pathname; return ( path === '/watch' || path.startsWith('/shorts/') || path.startsWith('/@') || path.startsWith('/channel/') ); }; /** * Schedule lazy initialization with route checking */ const scheduleInit = () => { if (state.initialized || !isRelevantRoute()) return; requestIdleCallback( () => { if (!state.initialized && isRelevantRoute()) { init(); state.initialized = true; } }, { timeout: 2000 } ); }; // Navigation observer to trigger lazy init const navigationObserver = new MutationObserver( debounce(() => { if (!state.initialized && isRelevantRoute()) { scheduleInit(); } }, 300) ); // Watch for navigation changes if (document.body) { navigationObserver.observe(document.body, { childList: true, subtree: false, attributes: false, }); } // Start the module (lazy) scheduleInit(); })(); // --- MODULE: report.js --- /* Report module: populates the settings 'report' section and provides report submission helpers. * Features: * - Small reporting form (type, title, description, email optional) * - Prepares debug info (version, UA, page URL, settings snapshot) * - Opens a prefilled GitHub issue in a new tab or copies the report to clipboard * - Designed to work in a userscript (no server required) */ (function () { 'use strict'; // Minimal guards for shared utils const Y = /** @type {any} */ (window).YouTubeUtils || {}; /** * Translation function - uses centralized i18n system * @param {string} key - Translation key * @param {Object} params - Interpolation parameters * @returns {string} Translated text */ function t(key, params = {}) { try { if (typeof window !== 'undefined') { if (window.YouTubePlusI18n?.t && typeof window.YouTubePlusI18n.t === 'function') { return window.YouTubePlusI18n.t(key, params); } if (window.YouTubeUtils?.t && typeof window.YouTubeUtils.t === 'function') { return window.YouTubeUtils.t(key, params); } } } catch { // Fallback to key if central i18n unavailable } return key; } /** * Create DOM element with properties and children * @param {string} tag - HTML tag name * @param {Object} props - Element properties * @param {Array} children - Child elements or text * @returns {HTMLElement} Created element */ function mk(tag, props = {}, children = []) { const el = document.createElement(tag); Object.entries(props).forEach(([k, v]) => { if (k === 'class') { el.className = /** @type {string} */ (v); } else if (k === 'html') { if (typeof window._ytplusCreateHTML === 'function') { el.innerHTML = window._ytplusCreateHTML(/** @type {string} */ (v)); } else { // Fallback: sanitize and set el.innerHTML = sanitizeHTML(/** @type {string} */ (v)); } } else if (k.startsWith('on') && typeof v === 'function') { el.addEventListener(k.substring(2).toLowerCase(), /** @type {EventListener} */ (v)); } else { el.setAttribute(k, String(v)); } }); children.forEach(c => typeof c === 'string' ? el.appendChild(document.createTextNode(c)) : el.appendChild(c) ); return el; } /** * Sanitize HTML to prevent XSS attacks * @param {string} html - HTML string to sanitize * @returns {string} Sanitized HTML */ function sanitizeHTML(html) { if (Y?.sanitizeHTML && typeof Y.sanitizeHTML === 'function') { return Y.sanitizeHTML(html); } // Fallback sanitizer if (typeof html !== 'string') return ''; const map = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=', }; return html.replace(/[<>&"'\/`=]/g, char => map[char] || char); } /** * Validate email address format * @param {string} email - Email to validate * @returns {boolean} Whether email is valid */ function isValidEmail(email) { if (!email || typeof email !== 'string') return false; // Basic email regex - simple but effective const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email) && email.length <= 254; // RFC 5321 } /** * Validate and sanitize title input * @param {string} title - Title to validate * @returns {string} Sanitized title */ function validateTitle(title) { if (!title || typeof title !== 'string') return ''; // Limit length and sanitize return sanitizeHTML(title.trim().substring(0, 200)); } /** * Validate and sanitize description input * @param {string} description - Description to validate * @returns {string} Sanitized description */ function validateDescription(description) { if (!description || typeof description !== 'string') return ''; // Limit length and sanitize return sanitizeHTML(description.trim().substring(0, 5000)); } /** * Collect debug information for reports * @returns {Object} Debug information object */ function getDebugInfo() { try { const debug = { version: /** @type {any} */ (window.YouTubePlusDebug)?.version || 'unknown', userAgent: navigator?.userAgent || 'unknown', url: location?.href || 'unknown', language: document.documentElement?.lang || navigator?.language || 'unknown', settings: typeof Y?.SettingsManager === 'object' ? Y.SettingsManager.load() : null, }; return debug; } catch (err) { if (Y && typeof Y.logError === 'function') { Y.logError('Report', 'Failed to collect debug info', err); } return { version: 'unknown', userAgent: 'unknown', url: 'unknown', language: 'unknown', settings: null, error: 'Failed to collect debug info', }; } } /** * Build GitHub issue payload from report data * @param {Object} params - Report parameters * @param {string} params.type - Report type (bug/feature/other) * @param {string} params.title - Report title * @param {string} params.description - Report description * @param {string} params.email - Optional email * @param {boolean} params.includeDebug - Include debug info * @returns {{title: string, body: string}} Issue payload */ function buildIssuePayload({ type, title, description, email, includeDebug }) { const debug = includeDebug ? getDebugInfo() : null; const lines = []; const typeLabel = type === 'bug' ? t('typeBug') : type === 'feature' ? t('typeFeature') : t('typeOther'); lines.push(`**Type:** ${typeLabel}`); if (email) lines.push(`**Reporter email (optional):** ${email}`); lines.push('\n**Description:**\n'); lines.push(description || '(no description)'); if (debug) { lines.push('\n---\n**Debug info**\n'); lines.push('```json'); try { lines.push(JSON.stringify(debug, null, 2)); } catch (err) { if (Y && typeof Y.logError === 'function') { Y.logError('Report', 'Failed to stringify debug info', err); } // Fallback to minimal debug info const minimalDebug = { version: debug.version || 'unknown', userAgent: debug.userAgent || 'unknown', url: debug.url || 'unknown', }; try { lines.push(JSON.stringify(minimalDebug, null, 2)); } catch { lines.push('{ "error": "Failed to stringify debug info" }'); } } lines.push('```'); lines.push('\n_Please do not include sensitive personal data._'); } const body = lines.join('\n'); const issueTitle = `${type === 'bug' ? '[Bug]' : type === 'feature' ? '[Feature]' : '[Report]'} ${title || ''}`.trim(); return { title: issueTitle, body }; } /** * Open GitHub issue in a new tab * @param {{title: string, body: string}} payload - Issue payload */ function openGitHubIssue(payload) { try { // Repository configured for issue creation const repoOwner = 'diorhc'; const repo = 'YTP'; const url = `https://github.com/${repoOwner}/${repo}/issues/new?title=${encodeURIComponent( payload.title )}&body=${encodeURIComponent(payload.body)}`; window.open(url, '_blank'); } catch (err) { if (Y && typeof Y.logError === 'function') { Y.logError('Report', 'Failed to open GitHub issue', err); } throw err; } } /** * Copy text to clipboard with fallback * @param {string} text - Text to copy * @returns {Promise<void>} Promise that resolves when copied */ function copyToClipboard(text) { // Modern clipboard API if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); } // Fallback for older browsers return new Promise((resolve, reject) => { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.opacity = '0'; document.body.appendChild(ta); try { ta.select(); ta.setSelectionRange(0, text.length); const success = document.execCommand('copy'); document.body.removeChild(ta); if (success) { resolve(); } else { reject(new Error('execCommand failed')); } } catch (err) { document.body.removeChild(ta); reject(err); } }); } /** * Render report section in settings modal with glassmorphism styling * @param {HTMLElement} modal - Settings modal element */ function renderReportSection(modal) { if (!modal || !modal.querySelector) return; const section = modal.querySelector('.ytp-plus-settings-section[data-section="report"]'); if (!section) return; // Clear existing content and build a small form section.innerHTML = ''; const form = mk('div', { style: 'display:flex;flex-direction:column;gap:var(--yt-space-sm);margin-top:var(--yt-space-md);', }); // Hidden native select (kept for form value access); visible UI will be a custom glass-dropdown const typeSelect = mk('select', { style: 'display:none;' }, []); const typeOptions = [ { v: 'bug', l: t('typeBug') }, { v: 'feature', l: t('typeFeature') }, { v: 'other', l: t('typeOther') }, ]; typeOptions.forEach(opt => { const o = mk('option', { value: opt.v }, [opt.l]); typeSelect.appendChild(o); }); // Build visible glass-dropdown using shared styles const typeDropdown = mk('div', { class: 'glass-dropdown', id: 'report-type-dropdown', tabindex: '0', role: 'listbox', 'aria-expanded': 'false', }); const defaultLabel = typeOptions[0].l; const toggleBtn = mk( 'button', { class: 'glass-dropdown__toggle', type: 'button', 'aria-haspopup': 'listbox' }, [mk('span', { class: 'glass-dropdown__label' }, [defaultLabel])] ); // add chevron svg to toggle toggleBtn.appendChild( mk( 'svg', { class: 'glass-dropdown__chev', width: '12', height: '12', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', }, [mk('polyline', { points: '6 9 12 15 18 9' }, [])] ) ); const listEl = mk('ul', { class: 'glass-dropdown__list', role: 'presentation' }, []); typeOptions.forEach((opt, i) => { const li = mk('li', { class: 'glass-dropdown__item', 'data-value': opt.v, role: 'option' }, [ opt.l, ]); if (i === 0) li.setAttribute('aria-selected', 'true'); listEl.appendChild(li); }); typeDropdown.appendChild(toggleBtn); typeDropdown.appendChild(listEl); const inputStyle = 'padding:var(--yt-space-sm);border-radius:var(--yt-radius-sm);background:var(--yt-input-bg);color:var(--yt-text-primary);border:1px solid var(--yt-glass-border);backdrop-filter:var(--yt-glass-blur-light);-webkit-backdrop-filter:var(--yt-glass-blur-light);font-size:14px;transition:var(--yt-transition);box-sizing:border-box;'; const titleInput = mk('input', { placeholder: t('shortTitle'), style: inputStyle, }); const emailInput = mk('input', { placeholder: t('emailOptional'), type: 'email', style: inputStyle, }); const descInput = mk('textarea', { placeholder: t('descriptionPlaceholder'), rows: 6, style: inputStyle + 'resize:vertical;font-family:inherit;', }); // checkbox input is created separately so we can listen to changes and show a preview const debugCheckboxInput = mk('input', { type: 'checkbox', class: 'ytp-plus-settings-checkbox', }); const includeDebug = mk( 'label', { style: 'font-size:13px;display:flex;gap:var(--yt-space-sm);align-items:center;color:var(--yt-text-primary);cursor:pointer;align-self:center;', }, [debugCheckboxInput, ' ' + t('includeDebug')] ); const actions = mk('div', { style: 'display:flex;gap:var(--yt-space-sm);margin-top:var(--yt-space-sm);flex-wrap:wrap;', }); const submitBtn = mk('button', { class: 'glass-button' }, [t('openGitHub')]); const copyBtn = mk('button', { class: 'glass-button' }, [t('copyReport')]); const emailBtn = mk('button', { class: 'glass-button' }, [t('prepareEmail')]); actions.appendChild(submitBtn); actions.appendChild(copyBtn); actions.appendChild(emailBtn); // append hidden select and visible dropdown form.appendChild(typeSelect); form.appendChild(typeDropdown); form.appendChild(titleInput); form.appendChild(emailInput); form.appendChild(descInput); form.appendChild(includeDebug); // Debug preview area: hidden by default, placed directly under the includeDebug checkbox // Use a container `div` so we can build structured, safe DOM (header + collapsible JSON) const debugPreview = mk( 'div', { class: 'glass-card', style: 'overflow:auto;max-height:240px;font-size:11px;display:none;margin-top:var(--yt-space-sm);padding:8px;box-sizing:border-box;', }, [] ); form.appendChild(debugPreview); form.appendChild(actions); const privacy = mk( 'div', { class: 'ytp-plus-settings-item-description', style: 'margin-top:var(--yt-space-sm);font-size:12px;color:var(--yt-text-secondary);', }, [t('privacy')] ); section.appendChild(form); section.appendChild(privacy); // Initialize interactions for the glass-dropdown (sync to hidden select) (function initReportTypeDropdown() { try { const hidden = typeSelect; // the hidden native select const dropdown = typeDropdown; const toggle = dropdown.querySelector('.glass-dropdown__toggle'); const list = dropdown.querySelector('.glass-dropdown__list'); const label = dropdown.querySelector('.glass-dropdown__label'); let items = Array.from(list.querySelectorAll('.glass-dropdown__item')); let idx = items.findIndex(it => it.getAttribute('aria-selected') === 'true'); if (idx < 0) idx = 0; const openList = () => { dropdown.setAttribute('aria-expanded', 'true'); list.style.display = 'block'; items = Array.from(list.querySelectorAll('.glass-dropdown__item')); }; const closeList = () => { dropdown.setAttribute('aria-expanded', 'false'); list.style.display = 'none'; }; // ensure initial hidden select value matches selected item const selectedItem = items[idx]; if (selectedItem) { hidden.value = selectedItem.dataset.value || ''; label.textContent = selectedItem.textContent || ''; } toggle.addEventListener('click', () => { const expanded = dropdown.getAttribute('aria-expanded') === 'true'; if (expanded) closeList(); else openList(); }); document.addEventListener('click', e => { if (!dropdown.contains(e.target)) closeList(); }); list.addEventListener('click', e => { const it = e.target.closest('.glass-dropdown__item'); if (!it) return; const val = it.dataset.value; hidden.value = val; list .querySelectorAll('.glass-dropdown__item') .forEach(li => li.removeAttribute('aria-selected')); it.setAttribute('aria-selected', 'true'); label.textContent = it.textContent; hidden.dispatchEvent(new Event('change', { bubbles: true })); closeList(); }); // keyboard navigation dropdown.addEventListener('keydown', e => { const expanded = dropdown.getAttribute('aria-expanded') === 'true'; if (e.key === 'ArrowDown') { e.preventDefault(); if (!expanded) openList(); idx = Math.min(idx + 1, items.length - 1); items.forEach(it => it.removeAttribute('aria-selected')); items[idx].setAttribute('aria-selected', 'true'); items[idx].scrollIntoView({ block: 'nearest' }); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (!expanded) openList(); idx = Math.max(idx - 1, 0); items.forEach(it => it.removeAttribute('aria-selected')); items[idx].setAttribute('aria-selected', 'true'); items[idx].scrollIntoView({ block: 'nearest' }); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!expanded) { openList(); return; } const it = items[idx]; if (it) { hidden.value = it.dataset.value; hidden.dispatchEvent(new Event('change', { bubbles: true })); label.textContent = it.textContent; closeList(); } } else if (e.key === 'Escape') { closeList(); } }); } catch (err) { if (Y && typeof Y.logError === 'function') { Y.logError('Report', 'initReportTypeDropdown', err); } } })(); // (debugPreview is appended inside the form, directly under the checkbox) /** * Update debug preview based on checkbox state */ function updateDebugPreview() { try { if (debugCheckboxInput.checked) { const d = getDebugInfo(); // Clear previous content debugPreview.innerHTML = ''; // Header with important fields const header = mk( 'div', { style: 'display:flex;flex-direction:column;gap:6px;margin-bottom:6px;' }, [] ); header.appendChild( mk('div', {}, ['Version: ', mk('strong', {}, [String(d.version || 'unknown')])]) ); header.appendChild( mk('div', {}, [ 'User agent: ', mk('code', { style: 'font-size:11px;color:var(--yt-text-secondary);' }, [ String(d.userAgent || ''), ]), ]) ); const urlStr = String(d.url || 'unknown'); let urlEl = mk('span', {}, [urlStr]); try { if (/^https?:\/\//i.test(urlStr)) { urlEl = mk( 'a', { href: urlStr, target: '_blank', rel: 'noopener noreferrer', style: 'color:var(--yt-accent);word-break:break-all;', }, [urlStr] ); } } catch (e) { // leave as plain text if anything odd and log if (Y && typeof Y.logError === 'function') { Y.logError('Report', 'URL link creation failed', e); } urlEl = mk('span', {}, [String(urlStr)]); } header.appendChild(mk('div', {}, ['URL: ', urlEl])); header.appendChild( mk('div', {}, ['Language: ', mk('code', {}, [String(d.language || '')])]) ); debugPreview.appendChild(header); // Settings (if available) – collapsible if (d.settings) { const settingsDetails = mk('details', {}, [mk('summary', {}, ['Settings'])]); settingsDetails.appendChild( mk('pre', { style: 'white-space:pre-wrap;margin:6px 0 0 0;font-size:11px;' }, [ JSON.stringify(d.settings, null, 2), ]) ); debugPreview.appendChild(settingsDetails); } // Full debug JSON (collapsible) const fullDetails = mk('details', {}, [mk('summary', {}, ['Full debug JSON'])]); fullDetails.appendChild( mk('pre', { style: 'white-space:pre-wrap;margin:6px 0 0 0;font-size:11px;' }, [ JSON.stringify(d, null, 2), ]) ); debugPreview.appendChild(fullDetails); debugPreview.style.display = 'block'; } else { debugPreview.innerHTML = ''; debugPreview.style.display = 'none'; } } catch (err) { if (Y && typeof Y.logError === 'function') { Y.logError('Report', 'updateDebugPreview failed', err); } } } // wire up checkbox to preview debugCheckboxInput.addEventListener('change', updateDebugPreview); /** * Gather and validate form data * @returns {{type: string, title: string, description: string, email: string, includeDebug: boolean, errors: string[]}} */ function gather() { const type = /** @type {HTMLSelectElement} */ (typeSelect).value; const rawTitle = /** @type {HTMLInputElement} */ (titleInput).value.trim(); const rawDescription = /** @type {HTMLTextAreaElement} */ (descInput).value.trim(); const rawEmail = /** @type {HTMLInputElement} */ (emailInput).value.trim(); const includeDebugValue = /** @type {HTMLInputElement} */ ( includeDebug.querySelector('input') ).checked; const errors = []; // Validate title if (!rawTitle) { errors.push(t('titleRequired')); } else if (rawTitle.length < 5) { errors.push(t('titleMin')); } // Validate description if (!rawDescription) { errors.push(t('descRequired')); } else if (rawDescription.length < 10) { errors.push(t('descMin')); } // Validate email if provided if (rawEmail && !isValidEmail(rawEmail)) { errors.push(t('invalidEmail')); } return { type, title: validateTitle(rawTitle), description: validateDescription(rawDescription), email: rawEmail && isValidEmail(rawEmail) ? rawEmail : '', includeDebug: includeDebugValue, errors, }; } submitBtn.addEventListener('click', e => { e.preventDefault(); if (submitBtn.disabled) return; // Prevent double-click try { const data = gather(); // Check for validation errors if (data.errors && data.errors.length > 0) { const errorMsg = t('fixErrorsPrefix') + data.errors.join('\n• '); if (Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(errorMsg, { duration: 4000, type: 'error' }); } else { console.warn('[Report] Validation errors:', data.errors); } return; } // Add loading state const originalText = submitBtn.textContent; submitBtn.disabled = true; submitBtn.textContent = t('opening'); submitBtn.style.opacity = '0.6'; const payload = buildIssuePayload(data); openGitHubIssue(payload); if (Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(t('openingGithubNotification'), { duration: 2500 }); } // Reset button after a delay setTimeout(() => { submitBtn.disabled = false; submitBtn.textContent = originalText; submitBtn.style.opacity = '1'; }, 2000); } catch (err) { if (Y.logError) Y.logError('Report', 'Failed to open GitHub issue', err); if (Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(t('failedOpenGithub'), { duration: 3000, type: 'error', }); } submitBtn.disabled = false; submitBtn.textContent = t('openGitHub'); submitBtn.style.opacity = '1'; } }); copyBtn.addEventListener('click', e => { e.preventDefault(); if (copyBtn.disabled) return; // Prevent double-click try { const data = gather(); // Check for validation errors if (data.errors && data.errors.length > 0) { const errorMsg = t('fixErrorsPrefix') + data.errors.join('\n• '); if (Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(errorMsg, { duration: 4000, type: 'error' }); } else { console.warn('[Report] Validation errors:', data.errors); } return; } // Add loading state const originalText = copyBtn.textContent; copyBtn.disabled = true; copyBtn.textContent = t('copying'); copyBtn.style.opacity = '0.6'; const payload = buildIssuePayload(data); const full = `Title: ${payload.title}\n\n${payload.body}`; copyToClipboard(full) .then(() => { if (Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(t('reportCopied'), { duration: 2000 }); } copyBtn.textContent = t('copied'); copyBtn.style.opacity = '1'; setTimeout(() => { copyBtn.disabled = false; copyBtn.textContent = originalText; }, 2000); }) .catch(err => { if (Y && typeof Y.logError === 'function') Y.logError('Report', 'copy failed', err); if (Y && Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(t('copyFailed'), { duration: 3000, type: 'error', }); } else { console.warn('Copy failed; please copy manually', err); } copyBtn.disabled = false; copyBtn.textContent = originalText; copyBtn.style.opacity = '1'; }); } catch (err) { if (Y.logError) Y.logError('Report', 'Failed to copy report', err); copyBtn.disabled = false; copyBtn.textContent = t('copyReport'); copyBtn.style.opacity = '1'; } }); emailBtn.addEventListener('click', e => { e.preventDefault(); if (emailBtn.disabled) return; // Prevent double-click try { const data = gather(); // Check for validation errors if (data.errors && data.errors.length > 0) { const errorMsg = t('fixErrorsPrefix') + data.errors.join('\n• '); if (Y.NotificationManager && typeof Y.NotificationManager.show === 'function') { Y.NotificationManager.show(errorMsg, { duration: 4000, type: 'error' }); } else { console.warn('[Report] Validation errors:', data.errors); } return; } const originalText = emailBtn.textContent; emailBtn.disabled = true; emailBtn.textContent = t('opening'); emailBtn.style.opacity = '0.6'; const payload = buildIssuePayload(data); const subject = payload.title; // No dedicated mail address in userscript; open mail client with prefilled body const mailto = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent( payload.body )}`; window.location.href = mailto; setTimeout(() => { emailBtn.disabled = false; emailBtn.textContent = originalText; emailBtn.style.opacity = '1'; }, 2000); } catch (err) { if (Y.logError) Y.logError('Report', 'Failed to prepare email', err); emailBtn.disabled = false; emailBtn.textContent = t('prepareEmail'); emailBtn.style.opacity = '1'; } }); } // Expose render function try { /** @type {any} */ (window).youtubePlusReport = /** @type {any} */ (window).youtubePlusReport || {}; /** @type {any} */ (window).youtubePlusReport.render = renderReportSection; } catch (e) { if (Y.logError) Y.logError('Report', 'Failed to attach report module to window', e); } })(); // --- MODULE: update.js --- // Update checker module (function () { 'use strict'; // Use centralized i18n from YouTubePlusI18n or YouTubeUtils 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; }; // Language helper delegating to global i18n when available const getLanguage = () => { if (window.YouTubePlusI18n?.getLanguage) return window.YouTubePlusI18n.getLanguage(); if (window.YouTubeUtils?.getLanguage) return window.YouTubeUtils.getLanguage(); const lang = document.documentElement.lang || navigator.language || 'en'; return lang.startsWith('ru') ? 'ru' : 'en'; }; const UPDATE_CONFIG = { enabled: true, checkInterval: 24 * 60 * 60 * 1000, // 24 hours updateUrl: 'https://update.greasyfork.icu/scripts/537017/YouTube%20%2B.meta.js', currentVersion: '2.4.4', storageKey: 'youtube_plus_update_check', notificationDuration: 8000, autoInstallUrl: 'https://update.greasyfork.icu/scripts/537017/YouTube%20%2B.user.js', // If true, attempt to automatically initiate installation when an update is found // NOTE: This will try to open the install URL (GM_openInTab / window.open / navigation). // Keep disabled by default for safety; enable only if you want auto-install behavior. autoInstallOnCheck: false, // When false, hide the small SVG icon shown at the left of update notifications // Set to `true` to show the icon again. showNotificationIcon: false, }; const windowRef = typeof window === 'undefined' ? null : window; const GM_namespace = windowRef?.GM || null; const GM_info_safe = windowRef?.GM_info || null; const GM_openInTab_safe = (() => { if (windowRef) { if (typeof windowRef.GM_openInTab === 'function') { return windowRef.GM_openInTab.bind(windowRef); } if (GM_namespace?.openInTab) { return GM_namespace.openInTab.bind(GM_namespace); } } return null; })(); if (GM_info_safe?.script?.version) { UPDATE_CONFIG.currentVersion = GM_info_safe.script.version; } const updateState = { lastCheck: 0, lastVersion: UPDATE_CONFIG.currentVersion, updateAvailable: false, checkInProgress: false, updateDetails: null, }; // Pluralization helper for time units (available to this module) /** * Get Russian plural form index * @param {number} num - Number to check * @returns {number} Form index (0, 1, or 2) */ function getRussianPluralIndex(num) { const mod10 = num % 10; const mod100 = num % 100; if (mod10 === 1 && mod100 !== 11) return 0; if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return 1; return 2; } /** * Get plural forms for a unit in Russian * @param {string} unit - Time unit * @returns {string[]} Array of forms */ function getRussianForms(unit) { return { day: ['день', 'дня', 'дней'], hour: ['час', 'часа', 'часов'], minute: ['минута', 'минуты', 'минут'], }[unit]; } /** * Get plural forms for a unit in English * @param {string} unit - Time unit * @returns {string[]} Array of forms */ function getEnglishForms(unit) { return { day: ['day', 'days'], hour: ['hour', 'hours'], minute: ['minute', 'minutes'], }[unit]; } function pluralizeTime(n, unit) { const lang = getLanguage(); const num = Math.abs(Number(n)) || 0; if (lang === 'ru') { const forms = getRussianForms(unit); const idx = getRussianPluralIndex(num); return `${num} ${forms[idx]}`; } // English (default) const enForms = getEnglishForms(unit); return `${num} ${num === 1 ? enForms[0] : enForms[1]}`; } // Optimized utilities const utils = { /** * Load update settings from localStorage with validation * @returns {void} */ loadSettings: () => { try { const saved = localStorage.getItem(UPDATE_CONFIG.storageKey); if (!saved) { return; } const parsed = JSON.parse(saved); // Validate parsed object structure if (typeof parsed !== 'object' || parsed === null) { console.error('[YouTube+][Update]', 'Invalid settings structure'); return; } // Validate individual properties with type checking if (typeof parsed.lastCheck === 'number' && parsed.lastCheck >= 0) { updateState.lastCheck = parsed.lastCheck; } // Accept version formats like '2.2' or '2.2.0' or 'v2.2.0' if (typeof parsed.lastVersion === 'string') { const ver = parsed.lastVersion.replace(/^v/i, ''); if (/^\d+(?:\.\d+){0,2}$/.test(ver)) { updateState.lastVersion = ver; } } if (typeof parsed.updateAvailable === 'boolean') { updateState.updateAvailable = parsed.updateAvailable; } if (parsed.updateDetails && typeof parsed.updateDetails === 'object') { // Validate updateDetails properties if ( typeof parsed.updateDetails.version === 'string' && /^\d+\.\d+\.\d+/.test(parsed.updateDetails.version) ) { updateState.updateDetails = parsed.updateDetails; } } } catch (e) { console.error('[YouTube+][Update]', 'Failed to load update settings:', e); } }, /** * Save update settings to localStorage * @returns {void} */ saveSettings: () => { try { const dataToSave = { lastCheck: updateState.lastCheck, lastVersion: updateState.lastVersion, updateAvailable: updateState.updateAvailable, updateDetails: updateState.updateDetails, }; localStorage.setItem(UPDATE_CONFIG.storageKey, JSON.stringify(dataToSave)); } catch (e) { console.error('[YouTube+][Update]', 'Failed to save update settings:', e); } }, /** * Compare two version strings * @param {string} v1 - First version * @param {string} v2 - Second version * @returns {number} -1 if v1 < v2, 0 if equal, 1 if v1 > v2 */ compareVersions: (v1, v2) => { // Validate version format if (typeof v1 !== 'string' || typeof v2 !== 'string') { console.error('[YouTube+][Update]', 'Invalid version format - must be strings'); return 0; } const normalize = v => v .replace(/[^\d.]/g, '') .split('.') .map(n => parseInt(n, 10) || 0); const [parts1, parts2] = [normalize(v1), normalize(v2)]; const maxLength = Math.max(parts1.length, parts2.length); for (let i = 0; i < maxLength; i++) { const diff = (parts1[i] || 0) - (parts2[i] || 0); if (diff !== 0) { return diff; } } return 0; }, /** * Parse metadata from update script with validation * @param {string} text - Metadata text * @returns {Object} Parsed metadata with version, description, downloadUrl */ parseMetadata: text => { if (typeof text !== 'string' || text.length > 100000) { console.error('[YouTube+][Update]', 'Invalid metadata text'); return { version: null, description: '', downloadUrl: UPDATE_CONFIG.autoInstallUrl }; } const extractField = field => text.match(new RegExp(`@${field}\\s+([^\\r\\n]+)`))?.[1]?.trim(); let version = extractField('version'); const description = extractField('description') || ''; const downloadUrl = extractField('downloadURL') || UPDATE_CONFIG.autoInstallUrl; // Validate extracted version if (version) { version = version.replace(/^v/i, '').trim(); // Accept '2.2' or '2.2.0' or '2' if (!/^\d+(?:\.\d+){0,2}$/.test(version)) { console.error('[YouTube+][Update]', 'Invalid version format in metadata:', version); return { version: null, description: '', downloadUrl: UPDATE_CONFIG.autoInstallUrl }; } } return { version, description: description.substring(0, 500), // Limit description length downloadUrl, }; }, formatTimeAgo: timestamp => { if (!timestamp) return t('never'); const diffMs = Date.now() - timestamp; const diffDays = Math.floor(diffMs / 86400000); const diffHours = Math.floor(diffMs / 3600000); const diffMinutes = Math.floor(diffMs / 60000); if (diffDays > 0) return pluralizeTime(diffDays, 'day'); if (diffHours > 0) return pluralizeTime(diffHours, 'hour'); if (diffMinutes > 0) return pluralizeTime(diffMinutes, 'minute'); return t('justNow'); }, showNotification: (text, type = 'info', duration = 3000) => { try { YouTubeUtils.NotificationManager.show(text, { type, duration }); } catch (error) { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.debug && YouTubeUtils.logger.debug(`[YouTube+] ${type.toUpperCase()}:`, text, error); } }, }; /** * Validate update download URL for security * @param {string} downloadUrl - URL to validate * @returns {{valid: boolean, error: string|null}} Validation result */ const validateDownloadUrl = downloadUrl => { if (!downloadUrl || typeof downloadUrl !== 'string') { return { valid: false, error: 'Invalid download URL for installation' }; } try { const parsedUrl = new URL(downloadUrl); const allowedDomains = ['update.greasyfork.org', 'greasyfork.org']; if (parsedUrl.protocol !== 'https:') { return { valid: false, error: 'Only HTTPS URLs allowed for updates' }; } if (!allowedDomains.includes(parsedUrl.hostname)) { return { valid: false, error: `Update URL domain not in allowlist: ${parsedUrl.hostname}` }; } return { valid: true, error: null }; } catch (error) { return { valid: false, error: `Invalid URL format: ${error.message}` }; } }; /** * Mark update as dismissed in session storage * @param {Object} details - Update details */ const markUpdateDismissed = details => { if (details?.version && typeof details.version === 'string') { try { sessionStorage.setItem('update_dismissed', details.version); } catch (err) { console.error('[YouTube+][Update]', 'Failed to persist dismissal state:', err); } } }; /** * Try different methods to open update URL * @param {string} url - URL to open * @returns {boolean} Success status */ const tryOpenUpdateUrl = url => { // Method 1: GM_openInTab if (GM_openInTab_safe) { try { GM_openInTab_safe(url, { active: true, insert: true, setParent: true }); return true; } catch (gmError) { console.error('[YouTube+] GM_openInTab update install failed:', gmError); } } // Method 2: window.open try { const popup = window.open(url, '_blank', 'noopener'); if (popup) return true; } catch (popupError) { console.error('[YouTube+] window.open update install failed:', popupError); } // Method 3: Navigate try { window.location.assign(url); return true; } catch (navigationError) { console.error('[YouTube+] Navigation to update URL failed:', navigationError); } return false; }; /** * Install update with URL validation * @param {Object} details - Update details containing downloadUrl and version * @returns {boolean} True if installation initiated successfully */ const installUpdate = (details = updateState.updateDetails) => { const downloadUrl = details?.downloadUrl || UPDATE_CONFIG.autoInstallUrl; // Validate URL const validation = validateDownloadUrl(downloadUrl); if (!validation.valid) { console.error('[YouTube+][Update]', validation.error); return false; } // Try to open URL const success = tryOpenUpdateUrl(downloadUrl); if (success) { markUpdateDismissed(details); } return success; }; // Enhanced update notification const showUpdateNotification = updateDetails => { // Optionally render notification icon (can be disabled via config) const iconHtml = UPDATE_CONFIG.showNotificationIcon ? `<div style="background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); border-radius: 10px; padding: 10px; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.08); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 12c0 1-1 2-1 2s-1-1-1-2 1-2 1-2 1 1 1 2z"/> <path d="m21 12-5-5v3H8v4h8v3l5-5z"/> </svg> </div>` : ''; const notification = document.createElement('div'); notification.className = 'youtube-enhancer-notification update-notification'; // Use centralized notification container for consistent placement. Keep visual styles but remove fixed positioning. notification.style.cssText = ` z-index: 10001; max-width: 360px; background: rgba(255,255,255,0.04); padding: 16px 18px; border-radius: 14px; color: rgba(255,255,255,0.95); box-shadow: 0 8px 30px rgba(11, 15, 25, 0.45), inset 0 1px 0 rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.08); -webkit-backdrop-filter: blur(10px) saturate(120%); backdrop-filter: blur(10px) saturate(120%); animation: slideInFromBottom 0.4s ease-out; `; notification.innerHTML = ` <div style="position: relative; display: flex; align-items: flex-start; gap: 12px;"> ${iconHtml} <div style="flex: 1; min-width: 0;"> <div style="font-weight: 600; font-size: 15px; margin-bottom: 4px;">${t('updateAvailableTitle')}</div> <div style="font-size: 13px; opacity: 0.9; margin-bottom: 8px;"> ${t('version')} ${updateDetails.version} </div> ${ updateDetails.changelog || updateDetails.description ? (function () { const header = t('changelogHeader'); // Prefer fetched changelog, fall back to metadata description const raw = updateDetails.changelog && updateDetails.changelog.length > 0 ? updateDetails.changelog : updateDetails.description || ''; // Sanitize and normalize incoming text: convert HTML breaks to newlines, // strip tags and decode a few common entities. const sanitize = s => String(s) .replace(/<br\s*\/?>/gi, '\n') .replace(/<\/p>/gi, '\n') .replace(/<[^>]*>?/g, '') // strip complete AND incomplete HTML tags .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .trim(); const text = sanitize(raw); // Split into non-empty lines and render each as its own block so wrapping works. const lines = text .split(/\n+/) .map(l => l.trim()) .filter(Boolean); const listHtml = lines .map( l => `<div style="font-size:12px; opacity:0.85; margin-bottom:6px;">${l}</div>` ) .join(''); return ( `<div style="font-size:12px; font-weight:600; opacity:0.95; margin-bottom:6px;">${header}</div>` + `<div style="font-size:12px; line-height:1.4; max-height:120px; overflow-y:auto; padding:8px; background: rgba(0,0,0,0.2); border-radius:6px; border:1px solid rgba(255,255,255,0.05); white-space:normal;">${listHtml}</div>` ); })() : `<div style="font-size: 12px; opacity: 0.85; margin-bottom: 12px;">${t('newFeatures')}</div>` } <div style="display: flex; gap: 8px;"> <button id="update-install-btn" style=" background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03)); color: #ff5a1a; border: 1px solid rgba(255,90,30,0.12); padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 700; transition: transform 0.15s ease; box-shadow: 0 6px 18px rgba(90,30,0,0.12); backdrop-filter: blur(6px); ">${t('installUpdate')}</button> <button id="update-dismiss-btn" style=" background: transparent; color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; transition: all 0.12s ease; ">${t('later')}</button> </div> </div> <button id="update-close-btn" aria-label="${t('dismiss')}" style=" position: absolute; top: -8px; right: -8px; width: 28px; height: 28px; border-radius: 50%; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; line-height: 1; background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85); transition: background 0.18s ease; border: 1px solid rgba(255,255,255,0.06); ">×</button> </div> <style> @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; } } #update-close-btn:hover { background: rgba(255, 255, 255, 0.25); } </style> `; // 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('http://greasyfork.icu/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<string>} 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<string>} Changelog text */ const fetchChangelog = async version => { try { const lang = getLanguage(); const url = `http://greasyfork.icu/${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: <a ...>v2.4.4</a> ... <span class="version-changelog">...</span> const escapedVersion = version.replace(/\./g, '\\.'); // Match anchor tag content that contains the version number (handling prefixes like 'v', 'вер. ', etc.) const versionRegex = new RegExp( `>[^<]*?${escapedVersion}</a>[\\s\\S]*?class="version-changelog"[^>]*>([\\s\\S]*?)</span>`, '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(/<br\s*\/?>/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<Object>} 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<void>} */ /** * 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<void>} */ 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<void>} */ 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 = ` <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;"> <h3 style="margin: 0; font-size: 16px; font-weight: 600; color: var(--yt-spec-text-primary);"> ${t('enhancedExperience')} </h3> </div> <div style="display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: center; padding: 16px; background: rgba(255, 255, 255, 0.03); border-radius: 10px; margin-bottom: 16px;"> <div> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;"> <span style="font-size: 14px; font-weight: 600; color: var(--yt-spec-text-primary);">${t('currentVersion')}</span> <span style="font-size: 13px; font-weight: 600; color: var(--yt-spec-text-primary); padding: 3px 10px; background: rgba(255, 255, 255, 0.1); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.2);">${UPDATE_CONFIG.currentVersion}</span> </div> <div style="font-size: 12px; color: var(--yt-spec-text-secondary);"> ${t('lastChecked')}: <span style="font-weight: 500;">${lastCheckTime}</span> ${ updateState.lastVersion && updateState.lastVersion !== UPDATE_CONFIG.currentVersion ? `<br>${t('latestAvailable')}: <span style="color: #ff6666; font-weight: 600;">${updateState.lastVersion}</span>` : '' } </div> </div> ${ updateState.updateAvailable ? ` <div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: linear-gradient(135deg, rgba(255, 68, 68, 0.2), rgba(255, 68, 68, 0.3)); border: 1px solid rgba(255, 68, 68, 0.4); border-radius: 20px;"> <div style="width: 6px; height: 6px; background: #ff4444; border-radius: 50%; animation: pulse 2s infinite;"></div> <span style="font-size: 11px; color: #ff6666; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;"> ${t('updateAvailable')} </span> </div> <button id="install-update-btn" style="background: linear-gradient(135deg, #ff4500, #ff6b35); color: white; border: none; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.3s ease; box-shadow: 0 4px 12px rgba(255, 69, 0, 0.3);">${t('installUpdate')}</button> </div> ` : ` <div style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.3)); border: 1px solid rgba(34, 197, 94, 0.4); border-radius: 20px;"> <div style="width: 6px; height: 6px; background: #22c55e; border-radius: 50%;"></div> <span style="font-size: 11px; color: #22c55e; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;"> ${t('upToDate')} </span> </div> ` } </div> <div style="display: flex; gap: 12px;"> <button class="ytp-plus-button ytp-plus-button-primary" id="manual-update-check" style="flex: 1; padding: 12px; font-size: 13px; font-weight: 600;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 6px;"> <path d="M21.5 2v6h-6M2.5 22v-6h6M19.13 11.48A10 10 0 0 0 12 2C6.48 2 2 6.48 2 12c0 .34.02.67.05 1M4.87 12.52A10 10 0 0 0 12 22c5.52 0 10-4.48 10-10 0-.34-.02-.67-.05-1"/> </svg> ${t('checkForUpdates')} </button> <button class="ytp-plus-button" id="open-update-page" style="padding: 12px 16px; font-size: 13px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2);"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2"> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <polyline points="15,3 21,3 21,9"/> <line x1="10" y1="14" x2="21" y2="3"/> </svg> </button> </div> <style> @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.1); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } </style> `; 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 = ` <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 6px; animation: spin 1s linear infinite;"> <path d="M21.5 2v6h-6M2.5 22v-6h6M19.13 11.48A10 10 0 0 0 12 2C6.48 2 2 6.48 2 12c0 .34.02.67.05 1M4.87 12.52A10 10 0 0 0 12 22c5.52 0 10-4.48 10-10 0-.34-.02-.67-.05-1"/> </svg> ${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('http://greasyfork.icu/en/scripts/537017-youtube', '_blank'); } }); attachClickHandler('open-update-page', () => { utils.showNotification(t('updatePageFallback')); window.open('http://greasyfork.icu/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<typeof readMusicSettings>} */ 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 <style> for live updates. if (musicStyleEl && musicStyleEl.isConnected) { musicStyleEl.textContent = allStyles; window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Styles updated'); return; } try { if (typeof GM_addStyle !== 'undefined') { const el = GM_addStyle(allStyles); if (el && el.tagName === 'STYLE') { musicStyleEl = /** @type {HTMLStyleElement} */ (el); try { musicStyleEl.id = 'youtube-plus-music-styles'; } catch {} } } } catch { // ignore and fallback } if (!musicStyleEl || !musicStyleEl.isConnected) { const style = document.createElement('style'); style.id = 'youtube-plus-music-styles'; style.textContent = allStyles; document.head.appendChild(style); musicStyleEl = style; } window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Styles applied'); } /** * Reference to global i18n instance * @type {Object|null} * @private */ const _globalI18n_music = typeof window !== 'undefined' && window.YouTubePlusI18n ? window.YouTubePlusI18n : null; /** * Get debounce utility from YouTubeUtils or provide fallback * @function getDebounce * @returns {Function} Debounce function * @private */ const getDebounce = () => { if (window.YouTubeUtils?.debounce) { return window.YouTubeUtils.debounce; } // Fallback debounce implementation return (fn, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; }; }; /** * Translation helper function with fallback support * @function t * @param {string} key - Translation key * @param {Object} [params={}] - Optional parameters for interpolation * @returns {string} Translated string or key if translation not found */ const t = (key, params = {}) => { try { if (_globalI18n_music && typeof _globalI18n_music.t === 'function') { return _globalI18n_music.t(key, params); } if ( typeof window !== 'undefined' && window.YouTubeUtils && typeof window.YouTubeUtils.t === 'function' ) { return window.YouTubeUtils.t(key, params); } } catch { // fallback } if (!key || typeof key !== 'string') return ''; if (Object.keys(params).length === 0) return key; let result = key; for (const [k, v] of Object.entries(params)) result = result.split(`{${k}}`).join(String(v)); return result; }; /** * Create button element with attributes * @returns {HTMLElement} Button element * @private */ function createButton() { const button = document.createElement('button'); button.id = 'ytmusic-side-panel-top-button'; // Add both music-specific and shared class so global styles from enhanced.js // (the `.top-button` rules) can be applied when present. button.className = 'ytmusic-top-button top-button'; button.title = t('scrollToTop'); button.setAttribute('aria-label', t('scrollToTop')); button.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>'; // Add data attribute for debugging button.setAttribute('data-ytmusic-scroll-button', 'true'); window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Button element created', { id: button.id, className: button.className, }); return button; } /** * Cache for scroll containers to avoid repeated searches * @type {WeakMap<HTMLElement, HTMLElement|null>} * @private */ const scrollContainerCache = new WeakMap(); /** * Find scrollable container in side panel * @param {HTMLElement} sidePanel - Side panel element * @param {Object} MusicUtils - Utility module * @returns {HTMLElement|null} Scroll container or null * @private */ function findScrollContainer(sidePanel, MusicUtils) { // Check cache first if (scrollContainerCache.has(sidePanel)) { const cached = scrollContainerCache.get(sidePanel); // Verify cached element is still in DOM and scrollable if ( cached && document.body.contains(cached) && cached.scrollHeight > cached.clientHeight + 10 ) { return cached; } // Cache invalidated scrollContainerCache.delete(sidePanel); } if (MusicUtils.findScrollContainer) { const result = MusicUtils.findScrollContainer(sidePanel); if (result) scrollContainerCache.set(sidePanel, result); return result; } // Try multiple selectors for scroll container // Prioritize queue/playlist containers from the screenshot const selectors = [ // Tab-specific content containers (most specific) 'ytmusic-tab-renderer[tab-identifier="FEmusic_queue"] #contents', 'ytmusic-tab-renderer[tab-identifier="FEmusic_up_next"] #contents', 'ytmusic-tab-renderer[tab-identifier="FEmusic_lyrics"] #contents', 'ytmusic-tab-renderer[selected] #contents', // Currently selected tab 'ytmusic-tab-renderer #contents', // Any tab contents // Queue and playlist containers 'ytmusic-queue-renderer #contents', 'ytmusic-playlist-shelf-renderer #contents', // Generic selectors '#side-panel #contents', '#contents.ytmusic-tab-renderer', '.ytmusic-section-list-renderer', '[role="tabpanel"]', '.ytmusic-player-queue', // Broader fallbacks 'ytmusic-tab-renderer', '.scroller', '[scroll-container]', ]; for (const selector of selectors) { const container = sidePanel?.querySelector(selector); if (container) { const isScrollable = container.scrollHeight > container.clientHeight + 10; window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `Checking ${selector}: scrollHeight=${container.scrollHeight}, clientHeight=${container.clientHeight}, isScrollable=${isScrollable}` ); if (isScrollable) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `✓ Found scroll container: ${selector}` ); scrollContainerCache.set(sidePanel, container); return container; } } } // Fallback: check if side-panel itself is scrollable if (sidePanel && sidePanel.scrollHeight > sidePanel.clientHeight + 10) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', '✓ Using side-panel as scroll container' ); scrollContainerCache.set(sidePanel, sidePanel); return sidePanel; } // Last resort: walk direct children and a few levels deep for scrollable elements // Avoids querySelectorAll('*') + getComputedStyle which is extremely expensive if (sidePanel) { const fallbackSelectors = [ 'div[id]', 'div[class]', '[role="tabpanel"]', '[role="list"]', '[role="listbox"]', ]; let best = null; let bestDelta = 0; for (const sel of fallbackSelectors) { try { const candidates = sidePanel.querySelectorAll(sel); for (const el of candidates) { const delta = (el.scrollHeight || 0) - (el.clientHeight || 0); if (delta > 10 && delta > bestDelta) { bestDelta = delta; best = el; } } } catch { // ignore } } if (best) { const tag = best.tagName.toLowerCase(); const id = best.id ? `#${best.id}` : ''; window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `✓ Best scroll container chosen: ${tag}${id}`, { scrollHeight: best.scrollHeight, clientHeight: best.clientHeight } ); scrollContainerCache.set(sidePanel, best); return best; } } // Don't cache null result - content may load asynchronously window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', '✗ No scroll container found in side-panel' ); return null; } /** * Setup scroll to top click behavior * @param {HTMLElement} button - Button element * @param {HTMLElement} sc - Scroll container * @param {Object} MusicUtils - Utility module * @private */ function setupScrollBehavior(button, sc, MusicUtils, sidePanel) { if (MusicUtils.setupScrollToTop) { MusicUtils.setupScrollToTop(button, sc); return; } const findNearestScrollable = startEl => { let el = startEl; while (el && el !== document.body) { try { if (el.scrollHeight > el.clientHeight + 10) return el; } catch { // ignore errors accessing scroll properties on cross-origin or detached nodes } el = el.parentElement; } return null; }; const clickHandler = ev => { // Prevent other handlers or navigation from interfering try { ev.preventDefault?.(); } catch {} try { ev.stopPropagation?.(); } catch {} // Determine best candidate to scroll: provided sc, fallback to nearest scrollable in sidePanel, then walk from button let target = sc; if (!target || !(target.scrollHeight > target.clientHeight + 1)) { target = sidePanel && findNearestScrollable(sidePanel); } if (!target) { target = findNearestScrollable(button.parentElement); } // As a last resort, use document.scrollingElement or window if (!target) { target = document.scrollingElement || document.documentElement || document.body; } // Debug info: record chosen target and sizes try { const info = { chosen: target && (target.id || target.tagName || '(window)'), scrollTop: target && 'scrollTop' in target ? target.scrollTop : null, scrollHeight: target && 'scrollHeight' in target ? target.scrollHeight : null, clientHeight: target && 'clientHeight' in target ? target.clientHeight : null, }; // Expose last click debug info for manual inspection try { window.YouTubeMusic = window.YouTubeMusic || {}; window.YouTubeMusic._lastClickDebug = info; } catch {} // Log via available logger or console window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'ScrollToTop click target', info); } catch {} // Try smooth scroll then fallback to instant. Attempt multiple targets (target, sc, document) const tryScroll = el => { if (!el) return false; try { if (typeof el.scrollTo === 'function') { el.scrollTo({ top: 0, behavior: 'smooth' }); return true; } if ('scrollTop' in el) { el.scrollTop = 0; return true; } } catch { // ignore and continue } return false; }; let scrolled = false; scrolled = tryScroll(target) || scrolled; // If we have a provided sc and it differs from target, try it too if (sc && sc !== target) scrolled = tryScroll(sc) || scrolled; // Finally, try document/window scrolled = tryScroll(document.scrollingElement || document.documentElement || document.body) || scrolled; if (!scrolled) { // Last-resort direct window scroll try { window.scrollTo(0, 0); } catch (err2) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Final scroll fallback failed', err2 ); } } }; // Use non-passive so preventDefault works if needed button.addEventListener('click', clickHandler, { passive: false }); } /** * Setup button positioning styles * @param {HTMLElement} button - Button element * @param {HTMLElement} sidePanel - Side panel element (not used with fixed positioning) * @param {Object} MusicUtils - Utility module * @private */ function setupButtonPosition(button, sidePanel, MusicUtils, options = {}) { // options.insideSidePanel: boolean - if true, position the button inside the side panel if (MusicUtils.setupButtonStyles) { MusicUtils.setupButtonStyles(button, sidePanel, options); return; } if (options.insideSidePanel && sidePanel) { // When visually aligning with the side-panel but appending to `body`, // use fixed positioning so the button won't be clipped by panel transforms. button.style.setProperty('position', 'absolute', 'important'); button.style.setProperty('bottom', '20px', 'important'); button.style.setProperty('right', '20px', 'important'); // Keep z-index high enough to be above panel content but below full-screen overlays button.style.setProperty('z-index', '1200', 'important'); button.style.setProperty('pointer-events', 'auto', 'important'); button.style.display = 'flex'; } else { // Use fixed positioning so button stays visible regardless of side-panel state button.style.position = 'fixed'; button.style.bottom = '100px'; // Above player bar (player bar is ~72px height) button.style.right = '20px'; // Match CSS definition button.style.zIndex = '10000'; // Higher than side-panel button.style.pointerEvents = 'auto'; button.style.display = 'flex'; // Ensure flex display } window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Button positioned:', { position: button.style.position, bottom: button.style.bottom, right: button.style.right, zIndex: button.style.zIndex, insideSidePanel: !!options.insideSidePanel, }); } /** * Setup scroll visibility toggle handler * @param {HTMLElement} button - Button element * @param {HTMLElement} sc - Scroll container * @param {Object} MusicUtils - Utility module * @private */ function setupScrollVisibility(button, sc, MusicUtils) { // Try to use ScrollManager for better performance if (window.YouTubePlusScrollManager && window.YouTubePlusScrollManager.addScrollListener) { try { const cleanup = window.YouTubePlusScrollManager.addScrollListener( sc, () => { const shouldShow = sc.scrollTop > 100; button.classList.toggle('visible', shouldShow); window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `Scroll position: ${sc.scrollTop}px, button visible: ${shouldShow}` ); }, { debounce: 100, runInitial: true } ); button._scrollCleanup = cleanup; window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Using ScrollManager for scroll handling' ); return; } catch { console.error('[YouTube+][Music] ScrollManager failed, using fallback'); } } if (MusicUtils.setupScrollVisibility) { MusicUtils.setupScrollVisibility(button, sc, 100); return; } // Fallback implementation let isTabVisible = !document.hidden; let rafId = null; const updateVisibility = () => { // Cancel any pending animation frame if (rafId) { cancelAnimationFrame(rafId); } rafId = requestAnimationFrame(() => { // Don't update if tab is hidden (performance optimization) if (!isTabVisible) return; const currentScroll = sc.scrollTop || 0; const shouldShow = currentScroll > 100; const wasVisible = button.classList.contains('visible'); button.classList.toggle('visible', shouldShow); // Log only on state changes to reduce noise if (shouldShow !== wasVisible) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `Button visibility changed: ${shouldShow ? 'SHOWN' : 'HIDDEN'} (scroll: ${currentScroll}px)` ); } }); }; const debounce = getDebounce(); const scrollHandler = debounce(updateVisibility, 100); // Listen for page visibility changes const visibilityHandler = () => { isTabVisible = !document.hidden; if (isTabVisible) { updateVisibility(); } }; sc.addEventListener('scroll', scrollHandler, { passive: true }); document.addEventListener('visibilitychange', visibilityHandler); // Initial check with slight delay to ensure layout is complete setTimeout(updateVisibility, 100); // Additional check after longer delay in case content loads asynchronously setTimeout(updateVisibility, 500); // Store cleanup function button._scrollCleanup = () => { if (rafId) cancelAnimationFrame(rafId); sc.removeEventListener('scroll', scrollHandler); document.removeEventListener('visibilitychange', visibilityHandler); }; window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Using fallback scroll handler'); } /** * Attach button to container with all setup * @param {HTMLElement} button - Button element * @param {HTMLElement} sidePanel - Side panel element (for context, not attachment) * @param {HTMLElement} sc - Scroll container * @param {Object} MusicUtils - Utility module * @private */ function attachButtonToContainer(button, sidePanel, sc, MusicUtils) { try { setupScrollBehavior(button, sc, MusicUtils, sidePanel); // Prefer to visually align the button with the side-panel, but always // append to `document.body` to avoid clipping when the panel uses transforms. const attachInsidePanel = !!sidePanel; setupButtonPosition(button, sidePanel, MusicUtils, { insideSidePanel: attachInsidePanel }); // Always append to `body` so the button is never clipped by panel // transforms/overflow. If `attachInsidePanel` is true we'll try panel first. if (attachInsidePanel) { try { sidePanel.appendChild(button); } catch (err) { // Fallback to body if append fails for any reason document.body.appendChild(button); // Reference the error to avoid "defined but never used" lint errors void err; window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Appending to sidePanel failed, appended to body', err ); } } else { document.body.appendChild(button); } setupScrollVisibility(button, sc, MusicUtils); // Initial visibility check - show immediately if already scrolled const initialScroll = sc.scrollTop || 0; if (initialScroll > 100) { button.classList.add('visible'); window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `Button shown immediately (scroll: ${initialScroll}px)` ); } window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Scroll to top button created successfully', { buttonId: button.id, scrollContainer: sc.tagName, scrollContainerId: sc.id || 'no-id', scrollHeight: sc.scrollHeight, clientHeight: sc.clientHeight, scrollTop: initialScroll, position: button.style.position, computedDisplay: window.getComputedStyle(button).display, computedOpacity: window.getComputedStyle(button).opacity, computedVisibility: window.getComputedStyle(button).visibility, } ); } catch (err) { console.error('[YouTube+][Music] attachButton error:', err); } } /** * State tracking for button creation attempts * @type {Object} * @private */ const buttonCreationState = { attempts: 0, maxAttempts: 5, lastAttempt: 0, minInterval: 500, // Minimum time between attempts }; /** * Creates a "Scroll to Top" button in YouTube Music's side panel * Button appears when scrollable content is detected and user scrolls down * @function createScrollToTopButton * @returns {void} */ function createScrollToTopButton() { try { // Early exit checks if (window.location.hostname !== 'music.youtube.com') return; // Check if button already exists and is properly attached const existingButton = document.getElementById('ytmusic-side-panel-top-button'); if (existingButton) { // Verify it's in the DOM and has event listeners if (document.body.contains(existingButton) && existingButton._scrollCleanup) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Button already exists and is properly attached' ); return; } else { // Button exists but is orphaned, remove it window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Removing orphaned button'); existingButton.remove(); } } // Rate limiting const now = Date.now(); if (now - buttonCreationState.lastAttempt < buttonCreationState.minInterval) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Rate limited, skipping button creation' ); return; } buttonCreationState.attempts++; buttonCreationState.lastAttempt = now; if (buttonCreationState.attempts > buttonCreationState.maxAttempts) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `Max attempts (${buttonCreationState.maxAttempts}) reached, stopping retries` ); return; } window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', `Creating button (attempt ${buttonCreationState.attempts}/${buttonCreationState.maxAttempts})` ); const sidePanel = qs('#side-panel'); const MusicUtils = window.YouTubePlusMusicUtils || {}; const button = createButton(); // If no side-panel, try to find the main content area or queue if (!sidePanel) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'No side-panel found, checking for main content or queue' ); // Try queue renderer (shown in playlist/queue view) const queueRenderer = qs('ytmusic-queue-renderer'); if (queueRenderer) { const queueContents = queueRenderer.querySelector('#contents'); if (queueContents) { attachButtonToContainer(button, queueRenderer, queueContents, MusicUtils); buttonCreationState.attempts = 0; // Reset on success return; } } // Try to find main scrollable area on homepage/explore pages const mainContent = qs('ytmusic-browse'); if (mainContent) { const scrollContainer = mainContent.querySelector('ytmusic-section-list-renderer'); if (scrollContainer) { attachButtonToContainer(button, mainContent, scrollContainer, MusicUtils); buttonCreationState.attempts = 0; // Reset on success return; } } // Retry later setTimeout(createScrollToTopButton, 1000); return; } const scrollContainer = findScrollContainer(sidePanel, MusicUtils); if (!scrollContainer) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'No scroll container found, will retry with backoff' ); // Retry with exponential backoff const backoffDelay = Math.min(500 * buttonCreationState.attempts, 3000); setTimeout(createScrollToTopButton, backoffDelay); return; } attachButtonToContainer(button, sidePanel, scrollContainer, MusicUtils); buttonCreationState.attempts = 0; // Reset on success window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', '✓ Button created successfully'); } catch (error) { console.error('[YouTube+][Music] Error creating scroll to top button:', error); // Retry on error if we haven't exceeded max attempts if (buttonCreationState.attempts < buttonCreationState.maxAttempts) { setTimeout(createScrollToTopButton, 1000); } } } /** * Checks if side panel exists and creates scroll-to-top button if needed * @function checkAndCreateButton * @returns {void} */ function checkAndCreateButton() { try { const existingButton = document.getElementById('ytmusic-side-panel-top-button'); // Clean up if button exists but is orphaned (no scroll listener) if (existingButton) { if (!existingButton._scrollCleanup || !document.body.contains(existingButton)) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Cleaning up orphaned/detached button' ); if (existingButton._scrollCleanup) { try { existingButton._scrollCleanup(); } catch { // ignore cleanup errors } } if (existingButton._positionCleanup) { try { existingButton._positionCleanup(); } catch { // ignore cleanup errors } } existingButton.remove(); } else { // Button exists and is healthy window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Button is healthy, no action needed' ); return; } } // Look for containers that need a button const sidePanel = qs('#side-panel'); const mainContent = qs('ytmusic-browse'); const queueRenderer = qs('ytmusic-queue-renderer'); const tabRenderer = qs('ytmusic-tab-renderer[tab-identifier]'); if (sidePanel || mainContent || queueRenderer || tabRenderer) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Found container, scheduling button creation' ); setTimeout(createScrollToTopButton, 300); } else { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'No suitable container found yet' ); } } catch (error) { console.error('[YouTube+][Music] Error in checkAndCreateButton:', error); } } // Lazy init: do not inject styles/observers until settings enable it. /** * Create and configure the mutation observer * @function createObserver * @returns {MutationObserver} * @private */ const createObserver = () => { const debounce = getDebounce(); const debouncedCheck = debounce(checkAndCreateButton, 200); let lastCheckTime = 0; const minCheckInterval = 300; // Minimum 300ms between checks (reduced for better responsiveness) return new MutationObserver(mutations => { // Rate limiting: skip if checked too recently const now = Date.now(); if (now - lastCheckTime < minCheckInterval) return; // Don't disconnect - keep observing for tab changes and navigation const existingButton = document.getElementById('ytmusic-side-panel-top-button'); // If button exists and is properly attached, just verify it's working if ( existingButton && document.body.contains(existingButton) && existingButton._scrollCleanup ) { // Button is healthy, no action needed return; } // Check if any mutation added side-panel, main content, or queue const hasRelevantChange = mutations.some(mutation => { // Fast path: skip mutations with no added nodes if (mutation.addedNodes.length === 0) return false; // Early filter: check if any added node is an Element let hasElements = false; for (let i = 0; i < mutation.addedNodes.length; i++) { if (mutation.addedNodes[i].nodeType === 1) { hasElements = true; break; } } if (!hasElements) return false; return Array.from(mutation.addedNodes).some(node => { if (node.nodeType !== 1) return false; const element = /** @type {Element} */ (node); // Direct ID check is fastest if (element.id === 'side-panel' || element.id === 'contents') return true; // Tag name check is faster than querySelector const tagName = element.tagName; if ( tagName === 'YTMUSIC-BROWSE' || tagName === 'YTMUSIC-PLAYER-PAGE' || tagName === 'YTMUSIC-QUEUE-RENDERER' || tagName === 'YTMUSIC-TAB-RENDERER' ) { return true; } // Only do querySelector as last resort return ( element.querySelector?.( '#side-panel, #contents, ytmusic-browse, ytmusic-queue-renderer, ytmusic-tab-renderer' ) != null ); }); }); // Also check for attribute changes that might indicate tab switches const hasTabChange = mutations.some( mutation => mutation.type === 'attributes' && mutation.attributeName === 'selected' && mutation.target instanceof Element && mutation.target.matches?.('ytmusic-tab-renderer, tp-yt-paper-tab') ); if (hasRelevantChange || hasTabChange) { lastCheckTime = now; window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Detected relevant DOM change, checking button' ); debouncedCheck(); } }); }; /** * Safely observe document body for side-panel appearance * @function observeDocumentBodySafely * @returns {void} */ const observeDocumentBodySafely = () => { if (observer) return; // Already observing const startObserving = () => { if (!document.body) return; try { observer = createObserver(); observer.observe(document.body, { childList: true, subtree: true, attributes: true, // Watch for attribute changes (tab switches) attributeFilter: ['selected', 'tab-identifier', 'page-type'], // Only specific attributes }); window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', '✓ Observer started with enhanced config' ); } catch (observeError) { console.error('[YouTube+][Music] Failed to observe document.body:', observeError); // Retry with basic config try { observer = createObserver(); observer.observe(document.body, { childList: true, subtree: true, }); window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', '✓ Observer started with basic config' ); } catch (retryError) { console.error('[YouTube+][Music] Failed to start observer (retry):', retryError); } } }; if (document.body) { startObserving(); } else { document.addEventListener('DOMContentLoaded', startObserving, { once: true }); } }; function stopScrollToTopRuntime() { try { if (healthCheckIntervalId != null) { clearInterval(healthCheckIntervalId); healthCheckIntervalId = null; } if (observer) { observer.disconnect(); observer = null; } if (detachNavigationListeners) { try { detachNavigationListeners(); } catch {} detachNavigationListeners = null; } const button = document.getElementById('ytmusic-side-panel-top-button'); if (button?._scrollCleanup) { try { button._scrollCleanup(); } catch {} } if (button?._positionCleanup) { try { button._positionCleanup(); } catch {} } if (button) button.remove(); } catch (e) { console.error('[YouTube+][Music] stopScrollToTopRuntime error:', e); } } function startScrollToTopRuntime() { if (!isScrollToTopEnabled(musicSettingsSnapshot)) return; // Already running if (observer || healthCheckIntervalId != null || detachNavigationListeners) return; // Ensure styles (button relies on CSS) applyStyles(); // Create button on load if (document.readyState === 'loading') { document.addEventListener( 'DOMContentLoaded', () => { checkAndCreateButton(); }, { once: true } ); } else { checkAndCreateButton(); } // Navigation hooks (non-invasive: no history monkeypatch) const debounce = getDebounce(); const onNavigate = debounce(() => { if (!isScrollToTopEnabled(musicSettingsSnapshot)) return; applyStyles(); buttonCreationState.attempts = 0; buttonCreationState.lastAttempt = 0; checkAndCreateButton(); }, 150); const popstateHandler = () => onNavigate(); const ytNavigateHandler = () => onNavigate(); window.addEventListener('popstate', popstateHandler); window.addEventListener('yt-navigate-finish', ytNavigateHandler); detachNavigationListeners = () => { window.removeEventListener('popstate', popstateHandler); window.removeEventListener('yt-navigate-finish', ytNavigateHandler); }; // Start observer observeDocumentBodySafely(); // Periodic health check healthCheckIntervalId = setInterval(() => { try { if (!isScrollToTopEnabled(musicSettingsSnapshot)) return; if (document.hidden) return; const button = document.getElementById('ytmusic-side-panel-top-button'); if (button && (!button._scrollCleanup || !document.body.contains(button))) { window.YouTubeUtils?.logger?.debug?.( '[YouTube+][Music]', 'Health check: removing unhealthy button' ); button.remove(); checkAndCreateButton(); } if (!button) { const sidePanel = qs('#side-panel'); if (sidePanel) checkAndCreateButton(); } } catch (error) { console.error('[YouTube+][Music] Health check error:', error); } }, 30000); } function startIfEnabled() { // Never start outside YouTube Music. if (window.location.hostname !== 'music.youtube.com') return; musicSettingsSnapshot = readMusicSettings(); if (!isMusicModuleEnabled(musicSettingsSnapshot)) return; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', applyStyles, { once: true }); } else { applyStyles(); } if (isScrollToTopEnabled(musicSettingsSnapshot)) { startScrollToTopRuntime(); } } function applySettingsChanges() { // Re-read persisted settings and (re)apply. musicSettingsSnapshot = readMusicSettings(); // If disabled, tear everything down immediately, regardless of hostname. if (!isMusicModuleEnabled(musicSettingsSnapshot)) { stopScrollToTopRuntime(); if (musicStyleEl && musicStyleEl.isConnected) musicStyleEl.remove(); // Defensive: remove any stray YouTube Music style tags we may have added earlier. try { document .querySelectorAll('#youtube-plus-music-styles') .forEach(el => el !== musicStyleEl && el.remove()); } catch {} musicStyleEl = null; return; } if (window.location.hostname !== 'music.youtube.com') return; // Styles applyStyles(); // Scroll-to-top runtime if (isScrollToTopEnabled(musicSettingsSnapshot)) { if (!observer) startScrollToTopRuntime(); } else { stopScrollToTopRuntime(); } } function saveSettings(s) { // Caller already saves to localStorage; keep an in-memory snapshot. if (s && typeof s === 'object') { musicSettingsSnapshot = { ...musicSettingsSnapshot, ...s }; } else { musicSettingsSnapshot = readMusicSettings(); } } // Export module to global scope for settings live-apply if (typeof window !== 'undefined') { window.YouTubeMusic = { observeDocumentBodySafely, checkAndCreateButton, createScrollToTopButton, saveSettings, applySettingsChanges, version: '2.4.1', }; } // Cleanup on page unload window.addEventListener('beforeunload', () => { try { stopScrollToTopRuntime(); if (musicStyleEl && musicStyleEl.isConnected) musicStyleEl.remove(); musicStyleEl = null; window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Cleanup completed'); } catch (error) { console.error('[YouTube+][Music] Cleanup error:', error); } }); // Start only if enabled; otherwise remain dormant. startIfEnabled(); // Cross-subdomain live sync: react to changes made on youtube.com settings UI. try { if (typeof GM_addValueChangeListener !== 'undefined') { GM_addValueChangeListener('youtube-plus-music-settings', (_name, _oldValue, newValue) => { try { if (typeof newValue === 'string' && newValue) { const parsed = JSON.parse(newValue); musicSettingsSnapshot = mergeMusicSettings(parsed); } else { musicSettingsSnapshot = readMusicSettings(); } } catch { musicSettingsSnapshot = readMusicSettings(); } // Apply immediately (will teardown if disabled). applySettingsChanges(); }); } } catch {} window.YouTubeUtils?.logger?.debug?.('[YouTube+][Music]', 'Module loaded (lazy)', { version: '2.4.1', hostname: window.location.hostname, enabled: window.location.hostname === 'music.youtube.com' && isMusicModuleEnabled(musicSettingsSnapshot), }); })(); // --- MODULE: end.js --- // YouTube End Screen Remover (function () { 'use strict'; // DOM helpers const _getDOMCache = () => typeof window !== 'undefined' && window.YouTubeDOMCache; const $ = (sel, ctx) => _getDOMCache()?.querySelector(sel, ctx) || (ctx || document).querySelector(sel); const $$ = (sel, ctx) => _getDOMCache()?.querySelectorAll(sel, ctx) || Array.from((ctx || document).querySelectorAll(sel)); 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); }; })(); // Optimized configuration const CONFIG = { enabled: true, storageKey: 'youtube_endscreen_settings', // Added .teaser-carousel to cover variants named 'teaser-carousel' selectors: '.ytp-ce-element-show,.ytp-ce-element,.ytp-endscreen-element,.ytp-ce-covering-overlay,.ytp-cards-teaser,.teaser-carousel,.ytp-cards-button,.iv-drawer,.iv-branding,.video-annotations,.ytp-cards-teaser-text', debounceMs: 32, batchSize: 20, }; // Minimal state with better tracking const state = { observer: null, styleEl: null, isActive: false, removeCount: 0, lastCheck: 0, ytNavigateListenerKey: null, settingsNavListenerKey: null, }; // High-performance utilities: use shared debounce when available const debounce = (fn, ms) => { try { if (window.YouTubeUtils?.debounce) { return window.YouTubeUtils.debounce(fn, ms); } let id; return (...args) => { clearTimeout(id); id = setTimeout(() => fn(...args), ms); }; } catch { let id; return (...args) => { clearTimeout(id); id = setTimeout(() => fn(...args), ms); }; } }; const fastRemove = elements => { const len = Math.min(elements.length, CONFIG.batchSize); for (let i = 0; i < len; i++) { const el = elements[i]; if (el?.isConnected) { el.style.cssText = 'display:none!important;visibility:hidden!important'; try { el.remove(); state.removeCount++; } catch {} } } }; // Settings with caching const settings = { load: () => { try { const data = localStorage.getItem(CONFIG.storageKey); CONFIG.enabled = data ? (JSON.parse(data).enabled ?? true) : true; } catch { CONFIG.enabled = true; } }, save: () => { try { localStorage.setItem(CONFIG.storageKey, JSON.stringify({ enabled: CONFIG.enabled })); } catch {} settings.apply(); }, apply: () => (CONFIG.enabled ? init() : cleanup()), }; // Optimized core functions const injectCSS = () => { if (state.styleEl || !CONFIG.enabled) return; const styles = `${CONFIG.selectors}{display:none!important;opacity:0!important;visibility:hidden!important;pointer-events:none!important;transform:scale(0)!important}`; YouTubeUtils.StyleManager.add('end-screen-remover', styles); // store the style id so it can be removed via StyleManager.remove state.styleEl = 'end-screen-remover'; }; const removeEndScreens = () => { if (!CONFIG.enabled) return; const now = performance.now(); if (now - state.lastCheck < CONFIG.debounceMs) return; state.lastCheck = now; const elements = $$(CONFIG.selectors); if (elements.length) fastRemove(elements); }; const getClassNameValue = node => { if (typeof node.className === 'string') { return node.className; } if (node.className && typeof node.className === 'object' && 'baseVal' in node.className) { return /** @type {any} */ (node.className).baseVal; } return ''; }; /** * Check if node is relevant for end screen removal * @param {Node} node - DOM node to check * @returns {boolean} True if relevant */ const isRelevantNode = node => { if (!(node instanceof Element)) return false; const classNameValue = getClassNameValue(node); return classNameValue.includes('ytp-') || node.querySelector?.('.ytp-ce-element'); }; /** * Check if mutations contain relevant changes * @param {MutationRecord[]} mutations - Mutation records * @returns {boolean} True if has relevant changes */ const hasRelevantChanges = mutations => { for (const { addedNodes } of mutations) { for (const node of addedNodes) { if (isRelevantNode(node)) return true; } } return false; }; /** * Create mutation observer for end screens * @param {Function} throttledRemove - Throttled remove function * @returns {MutationObserver} Observer instance */ const createEndScreenObserver = throttledRemove => { return new MutationObserver(mutations => { if (hasRelevantChanges(mutations)) { throttledRemove(); } }); }; /** * Setup watcher for end screens * @returns {void} */ const setupWatcher = () => { if (state.observer || !CONFIG.enabled) return; const throttledRemove = debounce(removeEndScreens, CONFIG.debounceMs); state.observer = createEndScreenObserver(throttledRemove); YouTubeUtils.cleanupManager.registerObserver(state.observer); const target = $('#movie_player') || document.body; state.observer.observe(target, { childList: true, subtree: true, attributeFilter: ['class', 'style'], }); }; const cleanup = () => { state.observer?.disconnect(); state.observer = null; if (state.styleEl) { try { YouTubeUtils.StyleManager.remove(state.styleEl); } catch {} } state.styleEl = null; state.isActive = false; }; const init = () => { if (state.isActive || !CONFIG.enabled) return; state.isActive = true; injectCSS(); removeEndScreens(); setupWatcher(); }; const setupEndscreenSettingsDelegation = (() => { let attached = false; return () => { if (attached) return; attached = true; const delegator = window.YouTubePlusEventDelegation; const handler = (ev, target) => { const input = /** @type {HTMLInputElement | null} */ (target); if (!input) return; if (!input.classList?.contains('ytp-plus-settings-checkbox')) return; if (!input.closest?.('.endscreen-settings')) return; CONFIG.enabled = input.checked; settings.save(); void ev; }; if (delegator?.on) { delegator.on( document, 'change', '.endscreen-settings .ytp-plus-settings-checkbox', handler, { passive: true } ); } else { document.addEventListener( 'change', ev => { const target = ev.target?.closest?.('.ytp-plus-settings-checkbox'); if (target) handler(ev, target); }, { passive: true, capture: true } ); } }; })(); // Streamlined settings UI const addSettingsUI = () => { const enhancedSlot = $('.endscreen-settings-slot'); const enhancedCard = $('.enhanced-submenu .glass-card'); const host = enhancedSlot || enhancedCard; if (!host || $('.endscreen-settings', host)) return; const container = document.createElement('div'); container.className = 'ytp-plus-settings-item endscreen-settings'; container.innerHTML = ` <div> <label class="ytp-plus-settings-item-label">${YouTubeUtils.t('endscreenHideLabel')}</label> <div class="ytp-plus-settings-item-description">${YouTubeUtils.t('endscreenHideDesc')}${state.removeCount ? ` (${state.removeCount} ${YouTubeUtils.t('removedSuffix').replace('{n}', '')?.trim() || 'removed'})` : ''}</div> </div> <input type="checkbox" class="ytp-plus-settings-checkbox" ${CONFIG.enabled ? 'checked' : ''}> `; if (enhancedSlot) { enhancedSlot.replaceWith(container); } else { host.appendChild(container); } setupEndscreenSettingsDelegation(); }; // Optimized navigation handler const handlePageChange = debounce(() => { if (location.pathname === '/watch') { cleanup(); requestIdleCallback ? requestIdleCallback(init) : setTimeout(init, 1); } }, 50); // Initialize settings.load(); onDomReady(init); const handleSettingsNavClick = e => { const { target } = /** @type {{ target: HTMLElement }} */ (e); if (target?.dataset?.section === 'advanced') { setTimeout(addSettingsUI, 10); } }; if (!state.ytNavigateListenerKey) { state.ytNavigateListenerKey = YouTubeUtils.cleanupManager.registerListener( document, 'yt-navigate-finish', /** @type {EventListener} */ (handlePageChange), { passive: true } ); } // Settings modal integration — use event instead of MutationObserver const settingsModalHandler = () => setTimeout(addSettingsUI, 25); document.addEventListener('youtube-plus-settings-modal-opened', settingsModalHandler); if (!state.settingsNavListenerKey) { state.settingsNavListenerKey = YouTubeUtils.cleanupManager.registerListener( document, 'click', handleSettingsNavClick, { passive: true, capture: true } ); } })(); // --- MODULE: playall.js --- // Play All (async function () { 'use strict'; let featureEnabled = true; let stopRandomPlayTimers = null; let scheduleApplyRandomPlay = null; let addButtonRetryTimer = null; let addButtonRetryAttempts = 0; const loadFeatureEnabled = () => { try { const settings = localStorage.getItem('youtube_plus_settings'); if (settings) { const parsed = JSON.parse(settings); return parsed.enablePlayAll !== false; } } catch {} return true; }; const setFeatureEnabled = nextEnabled => { featureEnabled = nextEnabled !== false; if (!featureEnabled) { try { removeButton(); } catch {} try { if (addButtonRetryTimer) clearTimeout(addButtonRetryTimer); addButtonRetryTimer = null; addButtonRetryAttempts = 0; } catch {} try { if (typeof stopRandomPlayTimers === 'function') stopRandomPlayTimers(); } catch {} } else { try { queueDesktopAddButton(); } catch {} try { if (typeof scheduleApplyRandomPlay === 'function') scheduleApplyRandomPlay(); } catch {} } }; featureEnabled = loadFeatureEnabled(); // DOM helpers const _getDOMCache = () => typeof window !== 'undefined' && window.YouTubeDOMCache; const $ = (sel, ctx) => _getDOMCache()?.querySelector(sel, ctx) || (ctx || document).querySelector(sel); const $$ = (sel, ctx) => _getDOMCache()?.querySelectorAll(sel, ctx) || Array.from((ctx || document).querySelectorAll(sel)); 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 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 hasTranslation = key => { try { if (window.YouTubePlusI18n?.hasTranslation) return window.YouTubePlusI18n.hasTranslation(key); } catch {} return false; }; const getPlayAllLabel = () => { if (hasTranslation('playAllButton')) { const localized = t('playAllButton'); if (localized && localized !== 'playAllButton') return localized; } return 'Play All'; }; const getPlayAllAriaLabel = () => { const localized = t('enablePlayAllLabel'); return localized && localized !== 'enablePlayAllLabel' ? localized : getPlayAllLabel(); }; const scheduleNonCritical = fn => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(fn, { timeout: 2000 }); } else { setTimeout(fn, 200); } }; /** @type {any} */ const globalContext = typeof unsafeWindow !== 'undefined' ? /** @type {any} */ (unsafeWindow) : /** @type {any} */ (window); const gmInfo = globalContext?.GM_info ?? null; const scriptVersion = gmInfo?.script?.version ?? null; if (scriptVersion && /-(alpha|beta|dev|test)$/.test(scriptVersion)) { try { window.YouTubeUtils && YouTubeUtils.logger && YouTubeUtils.logger.info && YouTubeUtils.logger.info( '%cytp - YouTube Play All\n', 'color: #bf4bcc; font-size: 32px; font-weight: bold', 'You are currently running a test version:', scriptVersion ); } catch {} } if ( Object.prototype.hasOwnProperty.call(window, 'trustedTypes') && !window.trustedTypes.defaultPolicy ) { window.trustedTypes.createPolicy('default', { createHTML: string => string }); } const insertStylesSafely = html => { try { const target = document.head || document.documentElement; if (target && typeof target.insertAdjacentHTML === 'function') { target.insertAdjacentHTML('beforeend', html); return; } // If head isn't available yet, wait for DOMContentLoaded and insert then. const onReady = () => { try { const t = document.head || document.documentElement; if (t && typeof t.insertAdjacentHTML === 'function') { t.insertAdjacentHTML('beforeend', html); } } catch {} }; onDomReady(onReady); } catch {} }; scheduleNonCritical(() => insertStylesSafely(`<style> .ytp-btn {border-radius: 8px; font-family: 'Roboto', 'Arial', sans-serif; font-size: 1.4rem; line-height: 3.2rem; font-weight: 500; padding: 0 12px; margin-left: 0; user-select: none; white-space: nowrap;} .ytp-btn, .ytp-btn > * {text-decoration: none; cursor: pointer;} .ytp-badge {border-radius: 8px; padding: 0.2em; font-size: 0.8em; vertical-align: top;} .ytp-random-badge, .ytp-random-notice {background-color: #2b66da; color: white;} /* Style Play All as a YouTube chip button */ .ytp-play-all-btn {display:inline-flex;align-items:center;justify-content:center;height:32px;padding:0 12px;white-space:nowrap;flex-shrink:0;max-width:fit-content;border-radius:8px;font-size:1.4rem;line-height:2rem;font-weight:500;background-color:var(--yt-spec-badge-chip-background,rgba(255,255,255,0.1));color:var(--yt-spec-text-primary,#fff);border:none;transition:background-color .2s;cursor:pointer;text-decoration:none;} .ytp-play-all-btn:hover {background-color:var(--yt-spec-badge-chip-background-hover,rgba(255,255,255,0.2));} html:not([dark]) .ytp-play-all-btn {background-color:var(--yt-spec-badge-chip-background,rgba(0,0,0,0.05));color:var(--yt-spec-text-primary,#0f0f0f);} html:not([dark]) .ytp-play-all-btn:hover {background-color:var(--yt-spec-badge-chip-background-hover,rgba(0,0,0,0.1));} .ytp-button-row-wrapper {width: 100%; display: block; margin: 0 0 0.6rem 0;} .ytp-button-container {display: inline-flex; align-items: center; gap: 0.6em; width: auto; margin: 0; flex-wrap: nowrap; overflow-x: auto; max-width: 100%;} /* Ensure Play All sits inside chip bar container flow */ ytd-feed-filter-chip-bar-renderer .ytp-play-all-btn, yt-chip-cloud-renderer .ytp-play-all-btn, chip-bar-view-model.ytChipBarViewModelHost .ytp-play-all-btn, .ytp-button-container .ytp-play-all-btn {height:32px;line-height:32px;vertical-align:middle;} ytd-rich-grid-renderer .ytp-button-row-wrapper {margin-left: 0;} /* fetch() API introduces a race condition. This hides the occasional duplicate buttons */ .ytp-play-all-btn ~ .ytp-play-all-btn {display: none;} /* Fix for mobile view */ ytm-feed-filter-chip-bar-renderer .ytp-btn {margin-right: 12px; padding: 0.4em;} body:has(#secondary ytd-playlist-panel-renderer[ytp-random]) .ytp-prev-button.ytp-button, body:has(#secondary ytd-playlist-panel-renderer[ytp-random]) .ytp-next-button.ytp-button:not([ytp-random="applied"]) {display: none !important;} #secondary ytd-playlist-panel-renderer[ytp-random] ytd-menu-renderer.ytd-playlist-panel-renderer {height: 1em; visibility: hidden;} #secondary ytd-playlist-panel-renderer[ytp-random]:not(:hover) ytd-playlist-panel-video-renderer {filter: blur(2em);} #secondary ytd-playlist-panel-renderer[ytp-random] #header {display: flex; align-items: center; gap: 8px; flex-wrap: nowrap;} .ytp-random-notice {padding: 0.3em 0.7em; z-index: 1000; white-space: nowrap;} </style>`) ); 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', '<div class="ytp-button-container"></div>'); 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<ID> adds shorts into the playlist, list=UULF<ID> 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', `<a class="ytp-btn ytp-play-all-btn" href="/playlist?list=${allPlaylist}${playlistSuffix}&playnext=1&ytp-random=random&ytp-random-initial=1" title="${getPlayAllAriaLabel()}" aria-label="${getPlayAllAriaLabel()}">${getPlayAllLabel()}</a>` ); 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( /<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/ ); if (canonicalMatch && canonicalMatch[1]) { id = canonicalMatch[1]; } else { // Try alternative extraction methods const channelIdMatch = html.match(/"channelId":"(UC[a-zA-Z0-9_-]{22})"/); if (channelIdMatch && channelIdMatch[1]) { id = channelIdMatch[1]; } } if (id) { apply(); } else { console.warn('[Play All] Could not extract channel ID'); } } catch (e) { console.error('[Play All] Error fetching channel data:', e); } }; const stopAddButtonRetries = () => { 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', `<span class="ytp-random-notice">Play All mode</span>` ); } 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', ` <span class="ytp-badge ytp-random-badge">Play All <span style="font-size: 2rem; vertical-align: top">×</span></span>` ); 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 = ` <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <path d="M12 4V1l-5 5 5 5V7a7 7 0 1 1-7 7" stroke="currentColor" stroke-width="2" fill="none"/> </svg> `; 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 = ` <div class="ytp-plus-voting"> <div class="ytp-plus-voting-header"> <h3>${tf('featureRequests', 'Feature Requests')}</h3> <button class="ytp-plus-voting-add-btn" id="ytp-plus-show-add-feature"> + ${tf('addFeature', 'Add Feature')} </button> </div> <div class="ytp-plus-voting-list" id="ytp-plus-voting-list"> <div class="ytp-plus-voting-loading">${tf('loading', 'Loading...')}</div> </div> <div class="ytp-plus-voting-add-form" id="ytp-plus-voting-add-form" style="display:none;"> <input type="text" id="ytp-plus-feature-title" placeholder="${tf('featureTitle', 'Feature title')}" /> <textarea id="ytp-plus-feature-desc" placeholder="${tf('featureDescription', 'Description')}"></textarea> <div class="ytp-plus-voting-form-actions"> <button class="ytp-plus-voting-cancel" id="ytp-plus-cancel-feature">${tf('cancel', 'Cancel')}</button> <button class="ytp-plus-voting-submit" id="ytp-plus-submit-feature">${tf('submit', 'Submit')}</button> </div> </div> </div> `; } 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 = `<div class="ytp-plus-voting-empty">${tf('noFeatures', 'No feature requests yet')}</div>`; // 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 ` <div class="ytp-plus-voting-item" data-feature-id="${f.id}"> <div class="ytp-plus-voting-item-content"> <div class="ytp-plus-voting-item-title">${escapeHtml(f.title)}</div> <div class="ytp-plus-voting-item-desc">${escapeHtml(f.description || '')}</div> <div class="ytp-plus-voting-item-status ${statusMeta.className}">${escapeHtml(statusMeta.label)}</div> </div> <div class="ytp-plus-voting-item-votes"> <div class="ytp-plus-voting-score"> <span class="ytp-plus-vote-total">${totalVotes} ${tf('votes', 'votes')}</span> </div> <div class="ytp-plus-voting-buttons"> <div class="ytp-plus-voting-buttons-track" style="background:linear-gradient(to right, #4caf50 ${upPercent}%, #f44336 ${upPercent}%);"></div> <button class="ytp-plus-vote-btn ${userVote === 1 ? 'active' : ''}" data-vote="1" title="${tf('like', 'Like')}" type="button" aria-label="${tf('like', 'Like')}"> <svg class="ytp-plus-vote-icon" viewBox="0 0 24 24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg> </button> <button class="ytp-plus-vote-btn ${userVote === -1 ? 'active' : ''}" data-vote="-1" title="${tf('dislike', 'Dislike')}" type="button" aria-label="${tf('dislike', 'Dislike')}"> <svg class="ytp-plus-vote-icon" viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg> </button> </div> </div> </div> `; }) .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; })();