您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Set speed, quality, subtitles and volume as default globally or specialize for each channel
当前为
// ==UserScript== // @name YouTube Defaulter // @namespace https://greasyfork.org/ru/users/901750-gooseob // @version 1.9.1 // @description Set speed, quality, subtitles and volume as default globally or specialize for each channel // @author GooseOb // @license MIT // @match https://www.youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // ==/UserScript== (function () { // index.ts var debounce = function (callback, delay) { let timeout; return function (...args) { clearTimeout(timeout); timeout = window.setTimeout(() => { callback.apply(this, args); }, delay); }; }; var translations = { 'be-BY': { OPEN_SETTINGS: 'Адкрыць дадатковыя налады', SUBTITLES: 'Субтытры', SPEED: 'Хуткасьць', CUSTOM_SPEED: 'Свая хуткасьць', CUSTOM_SPEED_HINT: 'Калі вызначана, будзе выкарыстоўвацца замест "хуткасьць"', QUALITY: 'Якасьць', VOLUME: 'Гучнасьць, %', GLOBAL: 'глябальна', LOCAL: 'гэты канал', SHORTS: 'Адкрываць shorts як звычайныя', NEW_TAB: 'Адкрываць відэа ў новай картцы', COPY_SUBS: 'Капіяваць субтытры ў поўнаэкранным, Ctrl+C', STANDARD_MUSIC_SPEED: 'Звычайная хуткасьць на каналах музыкаў', ENHANCED_BITRATE: 'Палепшаны бітрэйт (для карыстальнікаў Premium)', SAVE: 'Захаваць', EXPORT: 'Экспарт', IMPORT: 'Імпарт', REFRESH: 'Зроблена. Абнавіце старонку', }, }; var text = { OPEN_SETTINGS: 'Open additional settings', SUBTITLES: 'Subtitles', SPEED: 'Speed', CUSTOM_SPEED: 'Custom speed', CUSTOM_SPEED_HINT: 'If defined, will be used instead of "speed"', QUALITY: 'Quality', VOLUME: 'Volume, %', GLOBAL: 'global', LOCAL: 'this channel', SHORTS: 'Open shorts as a usual video', NEW_TAB: 'Open videos in a new tab', COPY_SUBS: 'Copy subtitles by Ctrl+C in fullscreen mode', STANDARD_MUSIC_SPEED: 'Normal speed on artist channels', ENHANCED_BITRATE: 'Quality: Enhanced bitrate (for Premium users)', SAVE: 'Save', DEFAULT: '-', EXPORT: 'Export', IMPORT: 'Import', REFRESH: 'Done. Refresh the page', ...translations[document.documentElement.lang], }; var cfgLocalStorage = localStorage['YTDefaulter']; var cfg = cfgLocalStorage ? JSON.parse(cfgLocalStorage) : { _v: 4, global: {}, channels: {}, flags: { shortsToUsual: false, newTab: false, copySubs: false, standardMusicSpeed: false, enhancedBitrate: false, }, }; var isDescendantOrTheSame = (child, parents) => { while (child !== null) { if (parents.includes(child)) return true; child = child.parentNode; } return false; }; var saveCfg = () => { const cfgCopy = { ...cfg }; const channelsCfgCopy = { ...cfg.channels }; outer: for (const key in channelsCfgCopy) { const channelCfg = channelsCfgCopy[key]; if (channelCfg.subtitles) continue; for (const cfgKey in channelCfg) if (cfgKey !== 'subtitles') continue outer; delete channelsCfgCopy[key]; } cfgCopy.channels = channelsCfgCopy; localStorage['YTDefaulter'] = JSON.stringify(cfgCopy); }; var updateCfg = () => { const doUpdate = cfg._v !== 4; if (doUpdate) { switch (cfg._v) { case 2: cfg.flags.standardMusicSpeed = false; cfg._v = 3; case 3: cfg.global.quality = cfg.global.qualityMax; delete cfg.global.qualityMax; for (const key in cfg.channels) { const currCfg = cfg.channels[key]; currCfg.quality = currCfg.qualityMax; delete currCfg.qualityMax; } cfg._v = 4; } saveCfg(); } return doUpdate; }; updateCfg(); var restoreFocusAfter = (cb) => { const el = document.activeElement; cb(); el.focus(); }; var until = (getItem, check, msToWait = 1e4, msReqTimeout = 20) => new Promise((res, rej) => { const reqLimit = msToWait / msReqTimeout; let i = 0; const interval = setInterval(() => { if (i++ > reqLimit) exit(rej); const item = getItem(); if (!check(item)) return; exit(() => res(item)); }, msReqTimeout); const exit = (cb) => { clearInterval(interval); cb(); }; }); var untilAppear = (getItem, msToWait) => until(getItem, Boolean, msToWait); var ytSettingItems = {}; var channelConfig = { current: null }; var video; var subtitlesBtn; var muteBtn; var SPEED_NORMAL; var isSpeedChanged = false; var menu = { element: null, btn: null, isOpen: false, width: 0, _closeListener: { onClick(e) { const el = e.target; if (isDescendantOrTheSame(el, [menu.element, menu.btn])) return; menu.toggle(); }, onKeyUp(e) { if (e.code !== 'Escape') return; menu._setOpen(false); menu.btn.focus(); }, add() { document.addEventListener('click', this.onClick); document.addEventListener('keyup', this.onKeyUp); }, remove() { document.removeEventListener('click', this.onClick); document.removeEventListener('keyup', this.onKeyUp); }, }, firstElement: null, _setOpen(bool) { if (bool) { this.fixPosition(); this.element.style.visibility = 'visible'; this._closeListener.add(); this.firstElement.focus(); } else { this.element.style.visibility = 'hidden'; this._closeListener.remove(); } this.isOpen = bool; }, toggle: debounce(function () { this._setOpen(!this.isOpen); }, 100), fixPosition() { const { y, height, width, left } = this.btn.getBoundingClientRect(); this.element.style.top = y + height + 8 + 'px'; this.element.style.left = left + width - this.width + 'px'; }, }; var $ = (id) => document.getElementById(id); var getChannelUsername = (aboveTheFold) => aboveTheFold .querySelector('.ytd-channel-name > a') .href.match(/(?<=@).+?$/)[0]; var getPlr = () => $('movie_player'); var getAboveTheFold = () => $('above-the-fold'); var getActionsBar = () => $('actions')?.querySelector('ytd-menu-renderer'); var iconD = { ['quality']: 'M15,17h6v1h-6V17z M11,17H3v1h8v2h1v-2v-1v-2h-1V17z M14,8h1V6V5V3h-1v2H3v1h11V8z M18,5v1h3V5H18z M6,14h1v-2v-1V9H6v2H3v1 h3V14z M10,12h11v-1H10V12z', ['speed']: 'M10,8v8l6-4L10,8L10,8z M6.3,5L5.7,4.2C7.2,3,9,2.2,11,2l0.1,1C9.3,3.2,7.7,3.9,6.3,5z M5,6.3L4.2,5.7C3,7.2,2.2,9,2,11 l1,.1C3.2,9.3,3.9,7.7,5,6.3z M5,17.7c-1.1-1.4-1.8-3.1-2-4.8L2,13c0.2,2,1,3.8,2.2,5.4L5,17.7z M11.1,21c-1.8-0.2-3.4-0.9-4.8-2 l-0.6,.8C7.2,21,9,21.8,11,22L11.1,21z M22,12c0-5.2-3.9-9.4-9-10l-0.1,1c4.6,.5,8.1,4.3,8.1,9s-3.5,8.5-8.1,9l0.1,1 C18.2,21.5,22,17.2,22,12z', }; var getYtElementFinder = (elems) => (name) => findInNodeList( elems, (el) => !!el.querySelector(`path[d="${iconD[name]}"]`) ); var untilChannelUsernameAppear = (aboveTheFold) => untilAppear(() => getChannelUsername(aboveTheFold)).catch(() => ''); var isMusicChannel = (aboveTheFold) => !!aboveTheFold.querySelector('.badge-style-type-verified-artist'); var findInNodeList = (list, callback) => { for (const item of list) if (callback(item)) return item; }; var ytMenu = { updatePlayer(plr) { this.element = plr.querySelector('.ytp-settings-menu'); this._btn = plr.querySelector('.ytp-settings-button'); restoreFocusAfter(() => { this._btn.click(); this._btn.click(); }); }, element: null, _btn: null, isOpen() { return this.element.style.display !== 'none'; }, setOpen(bool) { if (bool !== this.isOpen()) this._btn.click(); }, openItem(item) { this.setOpen(true); item.click(); return this.element.querySelectorAll( '.ytp-panel-animate-forward .ytp-menuitem-label' ); }, findInItem(item, callback) { return findInNodeList(this.openItem(item), callback); }, }; var validateVolume = (value) => { const num = +value; return num < 0 || num > 100 ? 'out of range' : isNaN(num) ? 'not a number' : false; }; var getElCreator = (tag) => (props) => Object.assign(document.createElement(tag), props); var comparators = { ['quality']: (target, current) => +target >= parseInt(current) && (cfg.flags.enhancedBitrate || !current.toLowerCase().includes('premium')), ['speed']: (target, current) => target === current, }; var logger = { prefix: '[YT-Defaulter]', err(...msgs) { console.error(this.prefix, ...msgs); }, outOfRange(what) { this.err(what, 'value is out of range'); }, }; var valueSetters = { _ytSettingItem(value, settingName) { const isOpen = ytMenu.isOpen(); const compare = comparators[settingName]; ytMenu .findInItem(ytSettingItems[settingName], (btn) => compare(value, btn.textContent) ) ?.click(); ytMenu.setOpen(isOpen); }, speed(value) { this._ytSettingItem(isSpeedChanged ? SPEED_NORMAL : value, 'speed'); isSpeedChanged = !isSpeedChanged; }, customSpeed(value) { try { video.playbackRate = isSpeedChanged ? 1 : +value; } catch { logger.outOfRange('Custom speed'); return; } isSpeedChanged = !isSpeedChanged; }, subtitles(value) { if (subtitlesBtn.ariaPressed !== value.toString()) subtitlesBtn.click(); }, volume(value) { const num = +value; muteBtn ||= document.querySelector('.ytp-mute-button'); const isMuted = muteBtn.dataset.titleNoTooltip !== 'Mute'; if (num === 0) { if (!isMuted) muteBtn.click(); return; } if (isMuted) muteBtn.click(); try { video.volume = num / 100; } catch { logger.outOfRange('Volume'); } }, quality(value) { this._ytSettingItem(value, 'quality'); }, }; var delay = (ms) => new Promise((res) => setTimeout(res, ms)); var onPageChange = async () => { if (location.pathname !== '/watch') return; const aboveTheFold = await untilAppear(getAboveTheFold); const channelUsername = await untilChannelUsernameAppear(aboveTheFold); channelConfig.current = cfg.channels[channelUsername] ||= {}; const plr = await untilAppear(getPlr); await delay(1000); const getAd = () => plr.querySelector('.ytp-ad-player-overlay'); if (getAd()) await until(getAd, (ad) => !ad, 200000); ytMenu.updatePlayer(plr); const getMenuItems = () => ytMenu.element.querySelectorAll('.ytp-menuitem[role="menuitem"]'); const getYtElement = getYtElementFinder( await until(getMenuItems, (arr) => !!arr.length) ); Object.assign(ytSettingItems, { quality: getYtElement('quality'), speed: getYtElement('speed'), }); if (!SPEED_NORMAL) restoreFocusAfter(() => { const btn = ytMenu.findInItem( ytSettingItems.speed, (btn2) => !+btn2.textContent ); if (btn) SPEED_NORMAL = btn.textContent; }); const doNotChangeSpeed = cfg.flags.standardMusicSpeed && isMusicChannel(aboveTheFold); const settings = { ...cfg.global, ...channelConfig.current, }; const isChannelSpeed = 'speed' in channelConfig.current; const isChannelCustomSpeed = 'customSpeed' in channelConfig.current; if ((doNotChangeSpeed && !isChannelCustomSpeed) || isChannelSpeed) delete settings.customSpeed; if (doNotChangeSpeed && !isChannelSpeed) settings.speed = SPEED_NORMAL; if (doNotChangeSpeed) { settings.speed = SPEED_NORMAL; delete settings.customSpeed; } const { customSpeed } = settings; delete settings.customSpeed; isSpeedChanged = false; video ||= plr.querySelector('.html5-main-video'); subtitlesBtn ||= plr.querySelector('.ytp-subtitles-button'); restoreFocusAfter(() => { for (const setting in settings) valueSetters[setting](settings[setting]); if (!isNaN(+customSpeed)) { isSpeedChanged = false; valueSetters.customSpeed(customSpeed); } ytMenu.setOpen(false); }); if (menu.element) { const getInput = (name) => $('YTDef-' + name + '-thisChannel'); for (const name of ['speed', 'customSpeed', 'quality', 'volume']) { getInput(name).value = channelConfig.current[name]; } getInput('subtitles').checked = channelConfig.current.subtitles; return; } const div = getElCreator('div'), input = getElCreator('input'), checkbox = (props) => input({ type: 'checkbox', ...props }), option = getElCreator('option'), _label = getElCreator('label'), labelEl = (forId, props) => { const elem = _label(props); elem.setAttribute('for', forId); return elem; }, selectEl = getElCreator('select'), btnClass = 'yt-spec-button-shape-next', _button = getElCreator('button'), button = (text2, props) => _button({ textContent: text2, className: `${btnClass} ${btnClass}--tonal ${btnClass}--mono ${btnClass}--size-m`, onfocus() { this.classList.add(btnClass + '--focused'); }, onblur() { this.classList.remove(btnClass + '--focused'); }, ...props, }); menu.element = div({ id: 'YTDef-menu', }); menu.btn = button('', { id: 'YTDef-btn', ariaLabel: text.OPEN_SETTINGS, tabIndex: 0, onclick() { menu.toggle(); }, }); const toOptions = (values, getText) => [ option({ value: text.DEFAULT, textContent: text.DEFAULT, }), ].concat( values.map((value) => option({ value, textContent: getText(value), }) ) ); const speedValues = [ '2', '1.75', '1.5', '1.25', SPEED_NORMAL, '0.75', '0.5', '0.25', ]; const qualityValues = [ '144', '240', '360', '480', '720', '1080', '1440', '2160', '4320', ]; const createSection = (sectionId, title, sectionCfg) => { const section = div({ role: 'group' }); section.setAttribute('aria-labelledby', sectionId); const getLocalId = (name) => 'YTDef-' + name + '-' + sectionId; const addItem = (name, innerHTML, elem) => { const item = div(); const id = getLocalId(name); const label = labelEl(id, { innerHTML }); const valueProp = elem.type === 'checkbox' ? 'checked' : 'value'; Object.assign(elem, { id, name, onchange() { const value = this[valueProp]; if (value === '' || value === text.DEFAULT) delete sectionCfg[name]; else sectionCfg[name] = value; }, }); const cfgValue = sectionCfg[name]; if (cfgValue) setTimeout(() => { elem[valueProp] = cfgValue; }); item.append(label, elem); section.append(item); if (elem.hint) section.append(elem.hint.element); return { elem }; }; const addSelectItem = (name, label, options, getText) => { const { elem } = addItem( name, label, selectEl({ value: text.DEFAULT }) ); elem.append(...toOptions(options, getText)); return elem; }; section.append( getElCreator('span')({ textContent: title, id: sectionId }) ); const createHint = (prefix, props) => { const obj = { element: div({ className: 'YTDef-setting-hint', ...props, }), hide() { this.element.style.display = 'none'; }, show(msg) { this.element.style.display = 'block'; if (msg) this.element.textContent = prefix + msg; }, }; obj.hide(); return obj; }; const firstElement = addSelectItem( 'speed', text.SPEED, speedValues, (val) => val ); if (sectionId === 'global') menu.firstElement = firstElement; addItem( 'customSpeed', text.CUSTOM_SPEED, input({ type: 'number', onfocus() { this.hint.show(); }, onblur() { this.hint.hide(); }, hint: createHint(null, { textContent: text.CUSTOM_SPEED_HINT }), }) ); addSelectItem('quality', text.QUALITY, qualityValues, (val) => val + 'p'); addItem( 'volume', text.VOLUME, input({ type: 'number', min: '0', max: '100', oninput() { settings.volume = this.value; const warning = validateVolume(this.value); if (warning) { this.hint.show(warning); } else { this.hint.hide(); } }, hint: createHint('Warning: '), }) ); addItem('subtitles', text.SUBTITLES, checkbox()); return section; }; const sections = div({ className: 'YTDef-sections' }); sections.append( createSection('global', text.GLOBAL, cfg.global), createSection('thisChannel', text.LOCAL, channelConfig.current) ); const checkboxDiv = (id, prop, text2) => { const cont = div({ className: 'check-cont' }); id = 'YTDef-' + id; cont.append( labelEl(id, { textContent: text2 }), checkbox({ id, checked: cfg.flags[prop], onclick() { cfg.flags[prop] = this.checked; }, }) ); return cont; }; const controlStatus = div(); const updateControlStatus = (content) => { controlStatus.textContent = `[${new Date().toLocaleTimeString()}] ${content}`; }; const controlDiv = div({ className: 'control-cont' }); controlDiv.append( button(text.SAVE, { onclick() { saveCfg(); updateControlStatus(text.SAVE); }, }), button(text.EXPORT, { onclick: () => { navigator.clipboard .writeText(localStorage['YTDefaulter']) .then(() => { updateControlStatus(text.EXPORT); }); }, }), button(text.IMPORT, { onclick: async () => { try { const raw = await navigator.clipboard.readText(); const newCfg = JSON.parse(raw); if (typeof newCfg !== 'object' || !newCfg._v) { throw new Error('Import: Invalid data'); } if (!updateCfg()) { localStorage['YTDefaulter'] = raw; cfg = newCfg; } } catch (e) { updateControlStatus(e.message); return; } updateControlStatus(text.IMPORT + ': ' + text.REFRESH); }, }) ); menu.element.append( sections, checkboxDiv('shorts', 'shortsToUsual', text.SHORTS), checkboxDiv('new-tab', 'newTab', text.NEW_TAB), checkboxDiv('copy-subs', 'copySubs', text.COPY_SUBS), checkboxDiv( 'standard-music-speed', 'standardMusicSpeed', text.STANDARD_MUSIC_SPEED ), checkboxDiv('enhanced-bitrate', 'enhancedBitrate', text.ENHANCED_BITRATE), controlDiv, controlStatus ); menu.element.addEventListener('keyup', (e) => { const el = e.target; if (e.code === 'Enter' && el.type === 'checkbox') el.checked = !el.checked; }); const settingsIcon = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ); const iconStyle = { viewBox: '0 0 24 24', width: '24', height: '24', fill: 'var(--yt-spec-text-primary)', }; for (const key in iconStyle) settingsIcon.setAttribute(key, iconStyle[key]); settingsIcon.append($('settings')); menu.btn.setAttribute('aria-controls', 'YTDef-menu'); menu.btn.classList.add(btnClass + '--icon-button'); menu.btn.append(settingsIcon); const actionsBar = await untilAppear(getActionsBar); actionsBar.insertBefore(menu.btn, actionsBar.lastChild); document.querySelector('ytd-popup-container').append(menu.element); menu.width = menu.element.getBoundingClientRect().width; sections.style.maxWidth = sections.offsetWidth + 'px'; }; var lastHref; setInterval(() => { if (lastHref === location.href) return; lastHref = location.href; setTimeout(onPageChange, 1000); }, 1000); var onClick = (e) => { const { shortsToUsual, newTab } = cfg.flags; if (!shortsToUsual && !newTab) return; let el = e.target; if (el.tagName !== 'A') { el = el.closest('a'); if (!el) return; } if (!/shorts\/|watch\?v=/.test(el.href)) return; if (shortsToUsual) el.href = el.href.replace('shorts/', 'watch?v='); if (newTab) { el.target = '_blank'; e.stopPropagation(); } }; document.addEventListener('click', onClick, { capture: true }); document.addEventListener( 'keyup', (e) => { if (e.code === 'Enter') return onClick(e); if (!e.ctrlKey) return; if (cfg.flags.copySubs && e.code === 'KeyC') { const plr = document.querySelector('.html5-video-player'); if (!plr?.classList.contains('ytp-fullscreen')) return; const text2 = Array.from( plr.querySelectorAll('.captions-text > span'), (line) => line.textContent ).join(' '); navigator.clipboard.writeText(text2); return; } if (e.code !== 'Space') return; e.stopPropagation(); e.preventDefault(); let setting; if (e.shiftKey) { setting = 'quality'; } else { const value = channelConfig.current ? channelConfig.current.customSpeed || (!channelConfig.current.speed && cfg.global.customSpeed) : cfg.global.customSpeed; if (value) return valueSetters.customSpeed(value); setting = 'speed'; } restoreFocusAfter(() => { valueSetters[setting]((channelConfig.current || cfg.global)[setting]); }); }, { capture: true } ); var listener = () => { if (menu.isOpen) menu.fixPosition(); }; window.addEventListener('scroll', listener); window.addEventListener('resize', listener); var m = '#YTDef-menu'; var d = ' div'; var i = ' input'; var s = ' select'; var bg = 'var(--yt-spec-menu-background)'; var underline = 'border-bottom: 2px solid var(--yt-spec-text-primary);'; document.head.append( getElCreator('style')({ textContent: ` #YTDef-btn {position: relative; margin-left: 8px} ${m} { display: flex; visibility: hidden; color: var(--yt-spec-text-primary); font-size: 14px; flex-direction: column; position: fixed; background: ${bg}; border-radius: 2rem; padding: 1rem; text-align: center; box-shadow: 0px 4px 32px 0px var(--yt-spec-static-overlay-background-light); z-index: 2202 } .control-cont > button {margin: .2rem} ${m + d} {display: flex; margin-bottom: 1rem} ${m + d + d} { flex-direction: column; margin: 0 2rem } ${m + d + d + d} { flex-direction: row; margin: 1rem 0 } ${m + s}, ${m + i} { text-align: center; background: ${bg}; border: none; ${underline} color: inherit; width: 5rem; padding: 0; margin-left: auto } ${m} .YTDef-setting-hint {margin: 0; text-align: end} ${m + i} {outline: none} ${m + d + d + d}:focus-within > label, ${m} .check-cont:focus-within > label {${underline}} ${m} .check-cont {padding: 0 1rem} ${m + s} {appearance: none; outline: none} ${m} label {margin-right: 1.5rem; white-space: nowrap} ${m + i}::-webkit-outer-spin-button, ${m + i}::-webkit-inner-spin-button {-webkit-appearance: none; margin: 0} ${m + i}[type=number] {-moz-appearance: textfield} ${m + s}::-ms-expand {display: none}`, }) ); })();