// ==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}`,
})
);
})();