您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds an ergonomic right-side tab panel (Info / Comments / Videos / Transcript), fast speed controls, ad-marking, keyboard shortcuts, last-tab+scroll restore, and a quick settings UI for YouTube. Stable, performant, and accessible.
// ==UserScript== // @name Youtube Enhancer By Domopremo // @namespace DomopremoScripts // @version 1.0.0 // @description Adds an ergonomic right-side tab panel (Info / Comments / Videos / Transcript), fast speed controls, ad-marking, keyboard shortcuts, last-tab+scroll restore, and a quick settings UI for YouTube. Stable, performant, and accessible. // @author Domopremo // @license MIT // @match https://www.youtube.com/* // @match https://m.youtube.com/* // @icon https://www.youtube.com/s/desktop/5e8b1b8a/img/favicon_144x144.png // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_openInTab // @run-at document-start // ==/UserScript== (() => { 'use strict'; /******************************************************************** * Minimal utilities ********************************************************************/ const onReady = (fn) => { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn, { once: true }); }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const SKEY = { SETTINGS: 'de/settings/v1', LAST: 'de/lastTabState', // { tabId, scroll: { "#tab-info": n, ... }, videoId } SPEED_GLOBAL: 'de/speed/global', SPEED_BY_CHANNEL: (channelId) => `de/speed/ch/${channelId}` }; const defaults = { ui: { compactHeader: false, theme: 'system', // 'system' | 'light' | 'dark' rounded: 12 }, features: { speedControl: true, markAds: true, autoExpandDesc: true, transcriptTab: true, lastTabRestore: true, scrollRestore: true, rememberSpeedByChannel: false }, shortcuts: { goInfo: 'g i', goComments: 'g c', goVideos: 'g v', goTranscript: 'g t', screenshot: 'ctrl+shift+s', focusTabs: 'g g' } }; const store = { get() { try { return JSON.parse(GM_getValue(SKEY.SETTINGS, '')) || JSON.parse(JSON.stringify(defaults)); } catch { return JSON.parse(JSON.stringify(defaults)); } }, set(val) { GM_setValue(SKEY.SETTINGS, JSON.stringify(val)); } }; const state = { settings: store.get(), currentTab: '', scrollCache: { '#tab-info': 0, '#tab-comments': 0, '#tab-videos': 0, '#tab-transcript': 0 }, channelId: null, videoId: null, }; const SEL = { flexy: ['ytd-watch-flexy[flexy]', 'ytd-watch-flexy'], secondaryInner: ['#secondary-inner.style-scope.ytd-watch-flexy'], related: ['#related.ytd-watch-flexy', '#related'], comments: ['#comments'], infoBlock: ['ytd-expandable-video-description-body-renderer', 'ytd-expander#expander'], rightControls: ['.ytp-right-controls'], player: ['#movie_player'], sizeBtn: ['#ytd-player .ytp-size-button', '.ytp-size-button'], ytcApp: ['ytd-app'], playlistPanel: ['ytd-playlist-panel-renderer#playlist'], transcriptPanel: ['ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"]'], }; const $first = (candidates) => { for (const sel of candidates) { const el = document.querySelector(sel); if (el) return el; } return null; }; /******************************************************************** * Styles (compact, theme-aware, accessible tabs) ********************************************************************/ GM_addStyle(` :root { --de-rounded: ${state.settings.ui.rounded}px; } #de-right-tabs { display:flex; flex-direction:column; gap:0; border:1px solid var(--ytd-searchbox-legacy-border-color); border-radius: var(--de-rounded); overflow:hidden; } #de-tabs-bar { display:flex; align-items:stretch; border-bottom:1px solid var(--ytd-searchbox-legacy-border-color); } #de-tabs-bar button[role="tab"]{ flex:1 1 0; padding:${state.settings.ui.compactHeader ? '8px 6px' : '12px 10px'}; background: var(--ytd-searchbox-legacy-button-color); color: var(--yt-spec-text-secondary); border:0; border-right:1px solid var(--ytd-searchbox-legacy-border-color); text-transform:var(--yt-button-text-transform, none); cursor:pointer; outline: none; } #de-tabs-bar button[role="tab"]:last-child{ border-right:0; } #de-tabs-bar button[aria-selected="true"]{ background: var(--ytd-searchbox-legacy-button-focus-color); color: var(--yt-spec-text-primary); box-shadow: inset 0 -2px var(--yt-brand-light-red); } #de-tabs-body { position:relative; height:100%; } .de-tabpane { display:none; padding: var(--ytd-margin-4x); } .de-tabpane[aria-hidden="false"]{ display:block; } #de-speed-btn { width: 3.5em; text-align:center; font-size:12px; border-radius:8px; cursor:pointer; } #de-speed-menu{ position:absolute; bottom:calc(100% + 10px); right:0; background:#303031; color:#fff; border-radius:6px; display:none; min-width:56px; z-index:99999; } #de-speed-menu .opt{ padding:6px 8px; cursor:pointer; text-align:center; } #de-speed-menu .opt.active, #de-speed-menu .opt:hover{ font-weight:600; } #de-speed-toast{ position:absolute; inset:0; margin:auto; width:80px; height:80px; line-height:80px; background:#303031; color:#f3f3f3; font-size:30px; border-radius:20px; text-align:center; opacity:.9; display:none; z-index:9999999; } .de-gear { margin-left:auto; padding:0 8px; cursor:pointer; } .de-settings { position:fixed; inset:auto 16px 16px auto; width:320px; max-width:90vw; background:var(--yt-spec-brand-background-primary); color:var(--yt-spec-text-primary); border:1px solid var(--ytd-searchbox-legacy-border-color); border-radius:12px; padding:12px; box-shadow:0 10px 24px rgba(0,0,0,.3); z-index:999999; display:none; } .de-settings h3{ margin:0 0 8px; font-size:16px; } .de-row{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; } .de-row label{ font-size:13px; } .de-shortcut{ width:140px; } [data-de-hidden="true"]{ display:none !important; } `); /******************************************************************** * Core DOM build ********************************************************************/ const Tabs = (() => { const ids = ['#tab-info', '#tab-comments', '#tab-videos', '#tab-transcript']; const pretty = { '#tab-info':'Info', '#tab-comments':'Comments', '#tab-videos':'Videos', '#tab-transcript':'Transcript' }; function buildContainer() { const wrap = document.createElement('div'); wrap.id = 'de-right-tabs'; const bar = document.createElement('div'); bar.id = 'de-tabs-bar'; bar.setAttribute('role','tablist'); wrap.append(bar); const gear = document.createElement('button'); gear.type='button'; gear.className='de-gear'; gear.title='Settings'; gear.textContent='⚙︎'; gear.addEventListener('click', Settings.toggle); bar.append(gear); const body = document.createElement('div'); body.id = 'de-tabs-body'; wrap.append(body); // panes for (const id of ids) { const pane = document.createElement('div'); pane.id = id.slice(1); pane.className = 'de-tabpane'; pane.setAttribute('role','tabpanel'); pane.setAttribute('aria-hidden','true'); body.append(pane); } // tabs (insert before gear) for (const id of ids) { if (id === '#tab-transcript' && !state.settings.features.transcriptTab) continue; const btn = document.createElement('button'); btn.type='button'; btn.setAttribute('role','tab'); btn.setAttribute('aria-controls', id.slice(1)); btn.dataset.deTabTarget = id; btn.textContent = pretty[id]; btn.addEventListener('click', () => switchTo(id)); bar.insertBefore(btn, gear); } return wrap; } function switchTo(id) { // Save previous scroll if (state.currentTab && state.settings.features.scrollRestore) { const oldPane = document.querySelector(state.currentTab); if (oldPane) state.scrollCache[state.currentTab] = oldPane.scrollTop || 0; } // Update pane visibility for (const pane of document.querySelectorAll('.de-tabpane')) { const active = `#${pane.id}` === id; pane.setAttribute('aria-hidden', String(!active)); pane.hidden = !active; if (active && state.settings.features.scrollRestore) { const sc = state.scrollCache[id] || 0; pane.scrollTop = sc; } } // Update tabs aria for (const btn of document.querySelectorAll('#de-tabs-bar [role="tab"]')) { btn.setAttribute('aria-selected', String(btn.dataset.deTabTarget === id)); } state.currentTab = id; if (state.settings.features.lastTabRestore) persistLastTab(); } function ensureInSecondary(container) { const secondaryInner = $first(SEL.secondaryInner); if (!secondaryInner) return false; // Wrap in absolute column container (no layout shift in 2-column mode) container.style.marginTop = 'var(--ytd-margin-3x)'; if (!secondaryInner.querySelector('#de-right-tabs')) { // Ensure the wrapper container for proper height behavior let wrapper = secondaryInner.querySelector('secondary-wrapper'); if (!wrapper) { wrapper = document.createElement('secondary-wrapper'); // Move all children into wrapper to keep YouTube logic intact while (secondaryInner.firstChild) wrapper.appendChild(secondaryInner.firstChild); secondaryInner.appendChild(wrapper); } // Place our tabs at the top wrapper.insertBefore(container, wrapper.firstChild); } return true; } function mount() { if (document.getElementById('de-right-tabs')) return true; const box = buildContainer(); return ensureInSecondary(box); } function persistLastTab() { const payload = { tabId: state.currentTab, scroll: state.settings.features.scrollRestore ? state.scrollCache : {}, videoId: state.videoId || null }; GM_setValue(SKEY.LAST, JSON.stringify(payload)); } function restoreLastTab() { if (!state.settings.features.lastTabRestore) return; try { const raw = GM_getValue(SKEY.LAST, ''); if (!raw) return switchTo('#tab-videos'); const obj = JSON.parse(raw); // If video changed, prefer Info tab; else restore last tab const id = (obj && obj.videoId === state.videoId && obj.tabId) ? obj.tabId : '#tab-videos'; state.scrollCache = obj.scroll || state.scrollCache; switchTo(id); } catch { switchTo('#tab-videos'); } } return { mount, switchTo, restoreLastTab }; })(); /******************************************************************** * Settings panel + GM menu ********************************************************************/ const Settings = (() => { let panel = null; function open() { if (!panel) build(); panel.style.display = 'block'; } function close() { if (panel) panel.style.display = 'none'; } function toggle() { (panel && panel.style.display === 'block') ? close() : open(); } function saverefresh() { store.set(state.settings); } function build() { panel = document.createElement('div'); panel.className = 'de-settings'; panel.innerHTML = ` <h3>Youtube Enhancer – Settings</h3> <div class="de-row"> <label><input type="checkbox" data-k="features.speedControl"> Speed control</label> </div> <div class="de-row"> <label><input type="checkbox" data-k="features.markAds"> Mark ads</label> </div> <div class="de-row"> <label><input type="checkbox" data-k="features.autoExpandDesc"> Auto-expand description</label> </div> <div class="de-row"> <label><input type="checkbox" data-k="features.transcriptTab"> Transcript tab</label> </div> <div class="de-row"> <label><input type="checkbox" data-k="features.lastTabRestore"> Remember last tab</label> </div> <div class="de-row"> <label><input type="checkbox" data-k="features.scrollRestore"> Restore scroll per tab</label> </div> <div class="de-row"> <label>Theme</label> <select data-k="ui.theme"> <option value="system">System</option> <option value="light">Light</option> <option value="dark">Dark</option> </select> </div> <div class="de-row"> <label>Rounded (px)</label> <input type="number" min="0" max="32" data-k="ui.rounded" style="width:64px"> </div> <hr> <div><strong>Shortcuts</strong></div> <div class="de-row"><label>Go Info</label><input class="de-shortcut" data-k="shortcuts.goInfo"></div> <div class="de-row"><label>Go Comments</label><input class="de-shortcut" data-k="shortcuts.goComments"></div> <div class="de-row"><label>Go Videos</label><input class="de-shortcut" data-k="shortcuts.goVideos"></div> <div class="de-row"><label>Go Transcript</label><input class="de-shortcut" data-k="shortcuts.goTranscript"></div> <div class="de-row"><label>Screenshot</label><input class="de-shortcut" data-k="shortcuts.screenshot"></div> <div class="de-row"><label>Focus tabs</label><input class="de-shortcut" data-k="shortcuts.focusTabs"></div> <div style="text-align:right; margin-top:8px;"> <button id="de-settings-close">Close</button> </div> `; document.body.append(panel); // bind panel.querySelector('#de-settings-close').addEventListener('click', close); for (const input of panel.querySelectorAll('[data-k]')) { const path = input.dataset.k.split('.'); // set initial let ref = state.settings; for (let i=0;i<path.length-1;i++) ref = ref[path[i]]; const key = path[path.length-1]; if (input.type === 'checkbox') input.checked = !!ref[key]; else input.value = ref[key]; input.addEventListener('change', () => { let r = state.settings; for (let i=0;i<path.length-1;i++) r = r[path[i]]; r[key] = (input.type === 'checkbox') ? input.checked : (input.classList.contains('de-shortcut') ? input.value.trim() : (input.type==='number' ? Number(input.value) : input.value)); saverefresh(); if (key === 'rounded') document.documentElement.style.setProperty('--de-rounded', `${state.settings.ui.rounded}px`); }); } } GM_registerMenuCommand('Open settings', open); return { open, close, toggle }; })(); /******************************************************************** * Speed control ********************************************************************/ const Speed = (() => { let btn, menu, toast, current = Number(GM_getValue(SKEY.SPEED_GLOBAL, 1)) || 1; const list = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]; function mount() { if (!state.settings.features.speedControl) return; const rc = $first(SEL.rightControls); if (!rc || rc.querySelector('#de-speed-btn')) return; btn = document.createElement('div'); btn.id = 'de-speed-btn'; btn.className = 'ytp-button'; btn.textContent = `${current.toFixed(2).replace(/\.00$/,'')}×`; btn.style.position='relative'; menu = document.createElement('div'); menu.id = 'de-speed-menu'; for (const v of list) { const opt = document.createElement('div'); opt.className = 'opt' + (v===current?' active':''); opt.textContent = `${v}x`; opt.dataset.v = String(v); opt.addEventListener('click', () => setRate(v, true)); menu.append(opt); } btn.append(menu); btn.addEventListener('mouseenter', ()=> menu.style.display='block'); btn.addEventListener('mouseleave', ()=> menu.style.display='none'); toast = document.createElement('div'); toast.id = 'de-speed-toast'; const player = $first(SEL.player) || document.body; player.appendChild(toast); rc.prepend(btn); applyToCurrentVideo(current); } function setRate(v, persist=false) { current = v; btn && (btn.firstChild.nodeType===3) && (btn.firstChild.nodeValue = `${v}×`); for (const x of menu.querySelectorAll('.opt')) x.classList.toggle('active', Number(x.dataset.v)===v); showToast(`${v}×`); applyToCurrentVideo(v); if (persist) { if (state.settings.features.rememberSpeedByChannel && state.channelId) { GM_setValue(SKEY.SPEED_BY_CHANNEL(state.channelId), String(v)); } else { GM_setValue(SKEY.SPEED_GLOBAL, String(v)); } } } function showToast(text) { if (!toast) return; toast.textContent = text; toast.style.display='block'; toast.style.opacity='0.9'; requestAnimationFrame(()=>{ setTimeout(()=> toast && (toast.style.display='none'), 1200); }); } function applyToCurrentVideo(v) { const set = () => { const vid = document.querySelector('video'); if (vid) vid.playbackRate = v; }; set(); // If YT swaps src, keep rate const iv = setInterval(()=>{ const vid = document.querySelector('video'); if (!vid) return; clearInterval(iv); const mo = new MutationObserver((m)=>{ for (const mu of m) if (mu.attributeName==='src') set(); }); mo.observe(vid, { attributes:true }); }, 1000); } function loadPreferred() { if (state.settings.features.rememberSpeedByChannel && state.channelId) { const ch = Number(GM_getValue(SKEY.SPEED_BY_CHANNEL(state.channelId), current)); current = ch || current; } else { current = Number(GM_getValue(SKEY.SPEED_GLOBAL, current)) || current; } } return { mount, setRate, loadPreferred }; })(); /******************************************************************** * Keyboard shortcuts ********************************************************************/ const Shortcuts = (() => { const parseCombo = (s) => s.trim().toLowerCase(); const matches = (evt, combo) => { if (!combo) return false; const parts = combo.split('+').map(x=>x.trim()); const needCtrl = parts.includes('ctrl'); const needShift = parts.includes('shift'); const needAlt = parts.includes('alt'); const key = parts[parts.length-1]; const pressedKey = evt.key?.toLowerCase(); return (!!evt.ctrlKey===needCtrl) && (!!evt.shiftKey===needShift) && (!!evt.altKey===needAlt) && (pressedKey===key); }; let glueMode = false; // for "g i" style combos let glueTimer = 0; function onKeydown(evt) { const sc = state.settings.shortcuts; // single combos if (matches(evt, parseCombo(sc.screenshot))) { evt.preventDefault(); Screenshot.capture(); return; } if (matches(evt, parseCombo(sc.focusTabs))) { evt.preventDefault(); document.querySelector('#de-tabs-bar [role="tab"]')?.focus(); return; } // leader "g" if (evt.key.toLowerCase()==='g' && !evt.ctrlKey && !evt.shiftKey && !evt.altKey) { glueMode = true; clearTimeout(glueTimer); glueTimer = setTimeout(()=> glueMode=false, 850); return; } if (glueMode) { if (evt.key.toLowerCase()==='i') { glueMode=false; Tabs.switchTo('#tab-info'); } else if (evt.key.toLowerCase()==='c') { glueMode=false; Tabs.switchTo('#tab-comments'); } else if (evt.key.toLowerCase()==='v') { glueMode=false; Tabs.switchTo('#tab-videos'); } else if (evt.key.toLowerCase()==='t') { glueMode=false; Tabs.switchTo('#tab-transcript'); } } } function init() { window.addEventListener('keydown', onKeydown, true); } return { init }; })(); /******************************************************************** * Screenshot ********************************************************************/ const Screenshot = (() => { function sanitize(name) { return name.replace(/[\\/:*?"<>|]+/g,' ').slice(0,150).trim(); } function titleStamp() { const h1 = document.querySelector('h1.title') || document.querySelector('h1.ytd-watch-metadata'); const t = h1 ? (h1.textContent||'').trim() : 'YouTube'; const vid = document.querySelector('video'); let stamp = '0-00'; if (vid) { const ct = Math.floor(vid.currentTime||0); const m = Math.floor(ct/60), s = ct%60; stamp = `${m}-${s.toString().padStart(2,'0')}`; } return `${sanitize(t)} ${stamp} screenshot.png`; } function capture() { const v = document.querySelector('video'); if (!v) return; const canvas = document.createElement('canvas'); canvas.width = v.videoWidth; canvas.height = v.videoHeight; canvas.getContext('2d').drawImage(v,0,0,canvas.width,canvas.height); canvas.toBlob((blob)=>{ const a = document.createElement('a'); a.download = titleStamp(); a.href = URL.createObjectURL(blob); a.click(); URL.revokeObjectURL(a.href); }, 'image/png'); } return { capture }; })(); /******************************************************************** * Ad marking (visual only, low risk) ********************************************************************/ const Ads = (() => { const CSS = ` #masthead-ad, ytd-ad-slot-renderer, ytd-display-ad-renderer, .video-ads.ytp-ad-module, ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"], #related #player-ads, #related ytd-ad-slot-renderer, ad-slot-renderer, ytm-companion-ad-renderer { outline: 2px dashed #f66 !important; filter: saturate(.6) brightness(.95); } `; let added = false; function activate() { if (added || !state.settings.features.markAds) return; GM_addStyle(CSS); added = true; } return { activate }; })(); /******************************************************************** * Transcript Tab (toggle engagement panel) ********************************************************************/ const Transcript = (() => { function openPanel(show=true) { // show/hide via YT actions by clicking the transcript button if present const btn = document.querySelector('button[aria-label*="Transcript"], ytd-menu-service-item-download-renderer[is-transcript] button'); if (btn) { btn.click(); return; } // fallback: try to toggle any expanded panel except ours // (kept simple to avoid fragile deep calls) } function ensureTabVisibility() { const tab = document.querySelector('#de-tabs-bar [data-de-tab-target="#tab-transcript"]')?.closest('[role="tab"]'); if (!tab) return; tab.parentElement?.parentElement?.querySelector('#tab-transcript') ?.setAttribute('data-de-hidden', String(!state.settings.features.transcriptTab)); tab.setAttribute('data-de-hidden', String(!state.settings.features.transcriptTab)); } return { openPanel, ensureTabVisibility }; })(); /******************************************************************** * Observers: wire up Info / Comments / Videos into tabs, auto-expand desc ********************************************************************/ const WireUp = (() => { function moveIfPresent(selList, targetPaneSelector) { const pane = document.querySelector(targetPaneSelector); if (!pane) return; const el = $first(selList); if (el && !pane.contains(el)) { pane.append(el); } } async function run() { // Wait flexy for (let i=0; i<60; i++){ const flexy = $first(SEL.flexy); if (flexy) break; // ok await sleep(250); } // Build right tabs and mount Tabs.mount(); // Move known blocks when they appear const mo = new MutationObserver(()=>{ moveIfPresent(SEL.infoBlock, '#tab-info'); moveIfPresent(SEL.comments, '#tab-comments'); moveIfPresent(SEL.related, '#tab-videos'); if (state.settings.features.transcriptTab) { const tp = $first(SEL.transcriptPanel); if (tp) document.querySelector('#tab-transcript')?.append(tp); } }); mo.observe(document, { subtree:true, childList:true }); // Auto-expand description (where supported) if (state.settings.features.autoExpandDesc) { const tryExpand = () => { const more = document.querySelector('#expand, tp-yt-paper-button[aria-label*="more"], #description tp-yt-paper-button[aria-label*="more"]'); if (more) more.click(); }; tryExpand(); setTimeout(tryExpand, 1000); } Speed.loadPreferred(); Speed.mount(); Ads.activate(); Transcript.ensureTabVisibility(); // Identify video & channel ids (best-effort) try { const app = $first(SEL.ytcApp); const data = app?.__data?.data?.response || app?.data?.response || null; state.videoId = document.querySelector('ytd-watch-flexy')?.__data?.playerResponse?.videoDetails?.videoId || new URL(location.href).searchParams.get('v') || null; state.channelId = data?.contents?.twoColumnWatchNextResults?.results?.results?.contents ?.find(c=>c.videoSecondaryInfoRenderer)?.videoSecondaryInfoRenderer?.owner?.videoOwnerRenderer?.navigationEndpoint?.browseEndpoint?.browseId || null; } catch {} Tabs.restoreLastTab(); } return { run }; })(); /******************************************************************** * Theme (system / light / dark) ********************************************************************/ const Theme = (() => { function apply() { const mode = state.settings.ui.theme; // Keep it simple: let YouTube manage its theme; only nudge if set explicitly. document.documentElement.removeAttribute('de-theme'); if (mode==='light') document.documentElement.setAttribute('de-theme', 'light'); else if (mode==='dark') document.documentElement.setAttribute('de-theme', 'dark'); // (If you want a cookie-based PREF hack, we can add it, but it’s invasive.) } return { apply }; })(); /******************************************************************** * Bootstrap ********************************************************************/ function init() { // Apply theme CSS var document.documentElement.style.setProperty('--de-rounded', `${state.settings.ui.rounded}px`); Theme.apply(); Shortcuts.init(); onReady(() => { WireUp.run(); }); } init(); })();