// ==UserScript== // @name YouTube Plus // @namespace https://github.com/haoch1/userscripts // @version 2.1.1 // @icon https://www.youtube.com/s/desktop/af0a3c1e/img/favicon_144x144.png // @icon64 https://www.youtube.com/s/desktop/af0a3c1e/img/favicon_144x144.png // @description 支持最高画质、详细统计信息与评论中文翻译的自动启用设置,保留统计快捷开关及 MB/s 网速换算 // @downloadURL https://raw.githubusercontent.com/haoch1/userscripts/main/scripts/youtube-plus.user.js // @updateURL https://raw.githubusercontent.com/haoch1/userscripts/main/scripts/youtube-plus.user.js // @match https://www.youtube.com/* // @match https://youtube.com/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @noframes // @license MIT // ==/UserScript== (() => { 'use strict'; const BUTTON_ID = 'youtube-speed-toggle'; const SPEED_ID = 'youtube-speed-converted'; const settings = { highestQuality: GM_getValue('highestQuality', true), autoStats: GM_getValue('autoStats', true), translateComments: GM_getValue('translateComments', true), }; // Icon used by YouTube's "Stats for nerds" context-menu item. const ICON_PATH = 'M22 34h4V22h-4v12zm2-30C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm0 36c-8.82 0-16-7.18-16-16S15.18 8 24 8s16 7.18 16 16-7.18 16-16 16zm-2-22h4v-4h-4v4z'; let button = null; let openedStats = null; let currentVideo = null; const menuIds = {}; let observedSpeedValue = null; const speedObserver = new MutationObserver(updateSpeedDisplay); const panelObserver = new MutationObserver(() => { if (updateSpeedDisplay()) panelObserver.disconnect(); }); const commentRoots = new Set(); const translationQueue = new Set(); let translatedComments = new WeakSet(); let translationFrame = 0; let translationTimer = 0; let commentsNavigating = false; const commentObserver = new MutationObserver(scheduleCommentScan); function setSetting(key, enabled) { settings[key] = enabled; GM_setValue(key, enabled); updateMenus(); if (currentVideo) { if (key === 'highestQuality') currentVideo.qualityApplied = false; else if (key === 'autoStats') currentVideo.statsApplied = false; } if (key === 'autoStats' && !enabled && openedStats?.automatic) closeOpenedStats(); if (key === 'translateComments') resetCommentTranslation(); sync(); } function updateMenus() { const options = [ ['highestQuality', '自动最高画质'], ['autoStats', '自动详细统计信息'], ['translateComments', '自动翻译评论'], ]; for (const [key, label] of options) { const enabled = settings[key]; menuIds[key] = GM_registerMenuCommand( `${enabled ? '✅' : '❌'} ${label}:${enabled ? '开启' : '关闭'}`, () => setSetting(key, !settings[key]), { id: menuIds[key], autoClose: false } ); } } function isChineseTranslation(button) { const label = (button.textContent || button.getAttribute('aria-label') || '').trim(); return /^(?:翻[译譯](?:成|为|為)(?:(?:简体|簡體|繁体|繁體)?中文)|Translate to (?:Chinese|Simplified Chinese|Traditional Chinese))/i.test(label); } function translateCommentBatch() { translationTimer = 0; if (!settings.translateComments || commentsNavigating || !videoIdFromUrl()) return; let translated = 0; for (const button of translationQueue) { translationQueue.delete(button); const comment = button.closest('ytd-comment-view-model, ytd-comment-renderer') || button; if (!button.isConnected || translatedComments.has(comment) || button.matches(':disabled, [aria-disabled="true"]') || !isChineseTranslation(button)) continue; // Record before clicking so later DOM updates and "View original" never enqueue it again. translatedComments.add(comment); button.click(); if (++translated === 3) break; } if (translationQueue.size) translationTimer = setTimeout(translateCommentBatch, 250); } function scheduleCommentScan() { if (!settings.translateComments || commentsNavigating || translationFrame) return; translationFrame = requestAnimationFrame(() => { translationFrame = 0; for (const root of commentRoots) { for (const button of root.querySelectorAll('button, [role="button"]')) { const comment = button.closest('ytd-comment-view-model, ytd-comment-renderer') || button; if (button.querySelector('button, [role="button"]') || translatedComments.has(comment)) continue; if (/^(?:查看原文|檢視原文|View original)/i.test(button.textContent.trim())) { translatedComments.add(comment); } else if (isChineseTranslation(button)) { translationQueue.add(button); } } } if (translationQueue.size && !translationTimer) { translationTimer = setTimeout(translateCommentBatch, 0); } }); } function resetCommentTranslation(clearHistory = false) { commentObserver.disconnect(); commentRoots.clear(); translationQueue.clear(); if (clearHistory) translatedComments = new WeakSet(); cancelAnimationFrame(translationFrame); clearTimeout(translationTimer); translationFrame = 0; translationTimer = 0; } function syncCommentTranslation() { if (!settings.translateComments || commentsNavigating || !videoIdFromUrl()) return; for (const root of document.querySelectorAll('ytd-comments, ytd-engagement-panel-section-list-renderer[target-id*="comments"]')) { if (commentRoots.has(root)) continue; commentRoots.add(root); commentObserver.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'aria-disabled'], }); scheduleCommentScan(); } } function videoIdFromUrl() { const url = new URL(location.href); if (url.pathname === '/watch') return url.searchParams.get('v'); const match = url.pathname.match(/^\/live\/([^/]+)/); return match?.[1] || null; } function getReadyPlayer(videoId) { const player = unsafeWindow.document.getElementById('movie_player'); if (typeof player?.showVideoInfo !== 'function' || typeof player.hideVideoInfo !== 'function' || typeof player.isVideoInfoVisible !== 'function') return null; try { if (typeof player.getVideoData === 'function' && player.getVideoData()?.video_id !== videoId) return null; } catch (_) { return null; } return player; } function updateButton(player) { const visible = player.isVideoInfoVisible(); if (!visible) panelObserver.disconnect(); if (!visible && openedStats?.player === player) openedStats = null; const label = `${visible ? '关闭' : '打开'}详细统计信息`; button.setAttribute('aria-pressed', String(visible)); button.setAttribute('aria-label', label); button.title = label; } function toggleStats(event) { event.preventDefault(); event.stopPropagation(); const videoId = videoIdFromUrl(); const player = videoId && getReadyPlayer(videoId); if (!player) return; try { if (player.isVideoInfoVisible()) { panelObserver.disconnect(); player.hideVideoInfo(); openedStats = null; } else { showStats(player); } if (currentVideo) currentVideo.statsApplied = true; updateButton(player); } catch (_) { panelObserver.disconnect(); // The player may be changing videos; the next sync will restore the button. } } function showStats(player, automatic = false) { // Observe before opening so asynchronously rendered speed data is converted immediately. panelObserver.disconnect(); panelObserver.observe(player, { childList: true, characterData: true, subtree: true }); player.showVideoInfo(); openedStats = { player, automatic }; if (updateSpeedDisplay()) panelObserver.disconnect(); } function closeOpenedStats() { panelObserver.disconnect(); if (!openedStats) return; try { const { player } = openedStats; if (player.isVideoInfoVisible()) player.hideVideoInfo(); } catch (_) { // The previous player may already have been removed. } openedStats = null; } function applyVideoDefaults(player, videoId) { if (currentVideo?.player !== player || currentVideo.id !== videoId) { currentVideo = { player, id: videoId, statsApplied: false, qualityApplied: false }; } if (settings.autoStats && !currentVideo.statsApplied) { if (!player.isVideoInfoVisible()) showStats(player, true); currentVideo.statsApplied = true; } if (!settings.highestQuality || currentVideo.qualityApplied || player.classList.contains('ad-showing')) return; const rank = quality => quality === 'highres' ? Infinity : Number(quality.match(/^hd(\d+)$/)?.[1]) || ({ large: 480, medium: 360, small: 240, tiny: 144 }[quality] || 0); const levels = player.getAvailableQualityLevels?.().filter(quality => rank(quality) > 0); if (!levels?.length) return; const highest = levels.reduce((best, quality) => rank(quality) > rank(best) ? quality : best); if (typeof player.setPlaybackQualityRange === 'function') player.setPlaybackQualityRange(highest, highest); else if (typeof player.setPlaybackQuality === 'function') player.setPlaybackQuality(highest); else return; currentVideo.qualityApplied = true; } function observeSpeedValue(value) { if (value === observedSpeedValue) return; speedObserver.disconnect(); observedSpeedValue = value; if (value) speedObserver.observe(value, { characterData: true, childList: true, subtree: true }); } function updateSpeedDisplay() { const panel = document.querySelector('.html5-video-info-panel-content, .ytp-sfn-content'); if (!panel) { observeSpeedValue(null); return; } const row = Array.from(panel.children).find(child => /^(?:Connection Speed|连接速度)$/i.test(child.firstElementChild?.textContent?.trim() || '') ); const valueCell = row?.children[1]; if (!valueCell) { observeSpeedValue(null); return; } // Avoid a duplicate value when another converter is installed. if (row.querySelector('#yt-speed-converter-mbps-display')) { valueCell.querySelector(`#${SPEED_ID}`)?.remove(); observeSpeedValue(null); return true; } const nativeSpeedValue = Array.from(valueCell.children).find(child => child.id !== SPEED_ID && /Kbps\b/i.test(child.textContent) ); observeSpeedValue(nativeSpeedValue || null); const match = (nativeSpeedValue || valueCell).textContent.match(/([\d,]+(?:\.\d+)?)\s*Kbps\b/i); if (!match) return; const kbps = Number(match[1].replaceAll(',', '')); if (!Number.isFinite(kbps)) return; let converted = valueCell.querySelector(`#${SPEED_ID}`); if (!converted) { converted = document.createElement('span'); converted.id = SPEED_ID; converted.style.cssText = 'margin-left:6px;color:#63c9ff;font-weight:600;white-space:nowrap;'; valueCell.appendChild(converted); } const display = `(${(kbps / 8192).toFixed(2)} MB/s)`; if (converted.textContent !== display) converted.textContent = display; return true; } function ensureButton(player) { if (!button) { const style = document.createElement('style'); style.textContent = ` #${BUTTON_ID} { position: absolute; top: 32px; right: 32px; z-index: 1000; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 5px; background: rgba(0, 0, 0, .62); color: #fff; cursor: pointer; opacity: .9; outline: none; pointer-events: auto; transition: background-color .15s ease, opacity .15s ease; } #${BUTTON_ID}[aria-pressed="true"] { background: rgba(70, 70, 70, .88); opacity: 1; } #${BUTTON_ID}:hover, #${BUTTON_ID}:focus-visible { background: rgba(90, 90, 90, .94); opacity: 1; } #${BUTTON_ID} svg { display: block; width: 20px; height: 20px; fill: currentColor; } #movie_player.ytp-autohide #${BUTTON_ID} { opacity: 0 !important; pointer-events: none !important; } `; (document.head || document.documentElement).appendChild(style); button = document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 48 48'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', ICON_PATH); svg.appendChild(path); button.appendChild(svg); button.addEventListener('click', toggleStats); for (const type of ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'dblclick', 'keydown']) { button.addEventListener(type, event => event.stopPropagation()); } } if (button.parentElement !== player) player.appendChild(button); } function sync() { updateSpeedDisplay(); syncCommentTranslation(); const videoId = videoIdFromUrl(); const player = videoId && getReadyPlayer(videoId); if (!player) { button?.remove(); return; } ensureButton(player); try { applyVideoDefaults(player, videoId); updateButton(player); } catch (_) { // Retry when YouTube finishes initializing or replacing the player. } } document.addEventListener('yt-navigate-start', () => { closeOpenedStats(); commentsNavigating = true; resetCommentTranslation(true); currentVideo = null; }); document.addEventListener('yt-navigate-finish', () => { commentsNavigating = false; sync(); }); document.addEventListener('loadedmetadata', sync, true); document.addEventListener('DOMContentLoaded', sync, { once: true }); window.addEventListener('pagehide', closeOpenedStats); updateMenus(); setInterval(sync, 750); sync(); })();