YouTube Defaulter

Set speed, quality and subtitles as default globally or specialize for each channel

目前為 2023-05-10 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Defaulter
// @namespace    https://greasyfork.org/ru/users/901750-gooseob
// @version      1.5.5.2
// @description  Set speed, quality and subtitles 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 () {
const PREFIX = 'YTDef-', CONT_ID = PREFIX + 'cont', MENU_ID = PREFIX + 'menu', BTN_ID = PREFIX + 'btn', SUBTITLES = 'subtitles', SPEED = 'speed', QUALITY = 'quality';
const text = {
	OPEN_SETTINGS: 'Open additional settings',
	SUBTITLES: 'Subtitles',
	SPEED: 'Speed',
	QUALITY: 'Quality',
	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: 'Don\'t change speed on artist channels',
	SAVE: 'Save',
	SAVED: 'Saved',
	DEFAULT: '-'
};
const translations = {
	'be-BY': {
		OPEN_SETTINGS: 'Адкрыць дадатковыя налады',
		SUBTITLES: 'Субтытры',
		SPEED: 'Хуткасьць',
		QUALITY: 'Якасьць',
		GLOBAL: 'глябальна',
		LOCAL: 'гэты канал',
		SHORTS: 'Адкрываць shorts як звычайныя',
		NEW_TAB: 'Адкрываць відэа ў новай картцы',
		COPY_SUBS: 'Капіяваць субтытры ў поўнаэкранным, Ctrl+C',
		STANDARD_MUSIC_SPEED: 'Не мяняць хуткасьць на каналах музыкаў',
		SAVE: 'Захаваць',
		SAVED: 'Захавана',
		DEFAULT: '-'
	}
};
Object.assign(text, translations[document.documentElement.lang]);
const cfgLocalStorage = localStorage["YTDefaulter"];
const cfg = cfgLocalStorage ? JSON.parse(cfgLocalStorage) : {
	_v: 4,
	global: {},
	channels: {},
	flags: {
		shortsToUsual: false,
		newTab: false,
		copySubs: false,
		standardMusicSpeed: false
	}
};
const copyObj = (obj) => Object.assign({}, obj);
const saveCfg = () => {
	const cfgCopy = copyObj(cfg);
	const channelsCfgCopy = copyObj(cfg.channels);
	for (const key in channelsCfgCopy) {
		const channelCfg = channelsCfgCopy[key];
		const { length } = Object.keys(channelCfg);
		if (!length || (length === 1 && channelCfg.subtitles === false))
			delete channelsCfgCopy[key];
	}
	cfgCopy.channels = channelsCfgCopy;
	localStorage["YTDefaulter"] = JSON.stringify(cfgCopy);
};
if (cfg._v !== 4) {
	switch (cfg._v) {
		case 1:
			const { shortsToUsual, newTab } = cfg;
			cfg.flags = {
				shortsToUsual, newTab,
				copySubs: false
			};
			delete cfg.shortsToUsual;
			delete cfg.newTab;
			cfg._v = 2;
		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();
}
function debounce(callback, delay) {
	let timeout;
	return function (...args) {
		clearTimeout(timeout);
		timeout = window.setTimeout(() => {
			callback.apply(this, args);
		}, delay);
	};
}
const restoreFocusAfter = (cb) => {
	const el = document.activeElement;
	cb();
	el.focus();
};
const until = (getItem, check, msToWait = 10000, msReqDelay = 20) => new Promise((res, rej) => {
	const reqLimit = msToWait / msReqDelay;
	let i = 0;
	const interval = setInterval(() => {
		if (i++ > reqLimit)
			exit(rej);
		const item = getItem();
		if (!check(item))
			return;
		exit(() => res(item));
	}, msReqDelay);
	const exit = (cb) => {
		clearInterval(interval);
		cb();
	};
});
const untilAppear = (getItem, msToWait) => until(getItem, Boolean, msToWait);
let channelCfg, channelName;
let isTheSameChannel = true;
const $ = (id) => document.getElementById(id);
const getChannelName = () => new URLSearchParams(location.search).get('ab_channel');
const getChannelUsername = () => document.querySelector('span[itemprop="author"] > link[itemprop="url"]')?.href.replace(/.*\/@/, '');
const getPlr = () => $('movie_player');
const getAboveTheFold = () => $('above-the-fold');
const getActionsBar = () => $('actions')?.querySelector('ytd-menu-renderer');
const untilChannelUsernameAppear = () => untilAppear(getChannelUsername).catch(() => '');
const isMusicChannel = async () => {
	const el = await untilAppear(getAboveTheFold);
	return !!el.querySelector('.badge-style-type-verified-artist');
};
const addValueSetter = (el, setValue, setting) => Object.assign(el, { setValue, setting }), el = (tag, props) => Object.assign(document.createElement(tag), props);
let ytMenu, ytSettingItems, SPEED_NORMAL, menuCont;
let isSpeedChanged = false;
const comparators = {
	[QUALITY]: (value, current) => +value >= parseInt(current),
	[SPEED]: (value, current) => value === current
};
function setValue(value) {
	const compare = comparators[this.setting];
	for (const btn of ytMenu.openItem(this))
		if (compare(value, btn.textContent)) {
			btn.click();
			break;
		}
	ytMenu.close();
}
function setSpeedValue(value) {
	setValue.apply(this, [isSpeedChanged ? SPEED_NORMAL : value]);
	isSpeedChanged = !isSpeedChanged;
}
function setSubtitlesValue(value) {
	if (this.ariaPressed !== value.toString())
		this.click();
}
const valueProps = {
	[SPEED]: 'value',
	[QUALITY]: 'value',
	[SUBTITLES]: 'checked'
};
const updateMenuVisibility = async () => {
	const name = await untilAppear(getChannelName);
	if (menuCont) {
		isTheSameChannel = channelName === name;
		menuCont.style.display = isTheSameChannel ? 'block' : 'none';
	}
	else
		channelName = name;
};
const delay = (ms) => new Promise(res => setTimeout(res, ms));
const onPageChange = async () => {
	if (location.pathname !== '/watch')
		return;
	updateMenuVisibility();
	if (!channelCfg) {
		const channelUsername = await untilChannelUsernameAppear();
		channelCfg = 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 = Object.assign(plr.querySelector('.ytp-settings-menu'), {
		_btn: plr.querySelector('.ytp-settings-button'),
		isOpen() { return this.style.display !== 'none'; },
		open() { this.isOpen() || this._btn.click(); },
		close() { this.isOpen() && this._btn.click(); },
		openItem(item) {
			this.open();
			item.click();
			return Array.from(this.querySelectorAll('.ytp-panel-animate-forward .ytp-menuitem-label'));
		}
	});
	restoreFocusAfter(() => {
		ytMenu.open();
		ytMenu.close();
	});
	const getMenuItems = () => ytMenu.querySelectorAll('.ytp-menuitem[role="menuitem"]');
	const menuItemArr = Array.from(await until(getMenuItems, arr => arr.length));
	const areSubtitles = menuItemArr.length === 3;
	ytSettingItems = {
		[QUALITY]: addValueSetter(menuItemArr.at(-1), setValue, QUALITY),
		[SPEED]: addValueSetter(menuItemArr[0], setSpeedValue, SPEED)
	};
	if (areSubtitles)
		ytSettingItems[SUBTITLES] = addValueSetter(plr.querySelector('.ytp-subtitles-button'), setSubtitlesValue, SUBTITLES);
	if (!SPEED_NORMAL)
		restoreFocusAfter(() => {
			const labels = ytMenu.openItem(ytSettingItems[SPEED]);
			for (const label of labels) {
				const text = label.textContent;
				if (!+text) {
					SPEED_NORMAL = text;
					break;
				}
			}
			ytMenu.close();
		});
	const doNotChangeSpeed = cfg.global.speed && cfg.flags.standardMusicSpeed && (await isMusicChannel());
	const settings = Object.assign({}, cfg.global, doNotChangeSpeed && { [SPEED]: SPEED_NORMAL }, isTheSameChannel && channelCfg);
	isSpeedChanged = false;
	restoreFocusAfter(() => {
		for (const setting in settings)
			ytSettingItems[setting].setValue(settings[setting]);
	});
	if (menuCont)
		return;
	const div = (props) => el('div', props), input = (props) => el('input', props), checkbox = (props) => input({ type: 'checkbox', ...props }), labelEl = (forId, props) => {
		const elem = el('label', props);
		elem.setAttribute('for', forId);
		return elem;
	}, btnClass = 'yt-spec-button-shape-next', button = (text, props) => el('button', Object.assign({
		textContent: text,
		className: `${btnClass} ${btnClass}--tonal ${btnClass}--mono ${btnClass}--size-m`,
		onfocus() { this.classList.add(btnClass + '--focused'); },
		onblur() { this.classList.remove(btnClass + '--focused'); }
	}, props));
	menuCont = div({ id: CONT_ID });
	menuCont.style.position = 'relative';
	const menu = div({
		id: MENU_ID,
		isOpen: false,
		closeListener: {
			listener(e) {
				const el = e.target;
				if (el === menu || el.closest('#' + menu.id))
					return;
				menu.toggle();
			},
			add() { document.addEventListener('click', this.listener); },
			remove() { document.removeEventListener('click', this.listener); },
		},
		toggle: debounce(function () {
			if (this.isOpen) {
				this.style.visibility = 'hidden';
				this.closeListener.remove();
			}
			else {
				this.style.visibility = 'visible';
				this.closeListener.add();
			}
			this.isOpen = !this.isOpen;
		}, 100)
	});
	const createSection = (sectionId, title, sectionCfg) => {
		const section = div({ role: 'group' });
		section.setAttribute('aria-labelledby', sectionId);
		const addItem = (name, textContent, elem) => {
			const item = div();
			const id = PREFIX + name + '-' + sectionId;
			const label = labelEl(id, { textContent });
			const valueProp = valueProps[name];
			Object.assign(elem, {
				id,
				name: name,
				onchange() {
					const value = this[valueProp];
					if (value === '' || value === '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);
			return { elem };
		};
		const toOptions = (values, getText) => {
			const result = Array(values.length + 1);
			result[0] = el('option', {
				value: text.DEFAULT,
				textContent: text.DEFAULT,
				checked: true
			});
			for (let i = 0; i < values.length; i++)
				result[i + 1] = el('option', {
					value: values[i],
					textContent: getText(values[i])
				});
			return result;
		};
		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 addSelectItem = (name, text, options, getText) => addItem(name, text, el('select')).elem.append(...toOptions(options, getText));
		section.append(el('span', { textContent: title, id: sectionId }));
		addSelectItem(SPEED, text.SPEED, speedValues, val => val);
		addSelectItem(QUALITY, text.QUALITY, qualityValues, val => val + 'p');
		addItem(SUBTITLES, text.SUBTITLES, checkbox());
		return section;
	};
	const sections = div({ className: PREFIX + 'sections' });
	sections.append(createSection("global", text.GLOBAL, cfg.global), createSection("thisChannel", text.LOCAL, channelCfg));
	const checkboxDiv = (id, prop, text) => {
		const cont = div({ className: 'check-cont' });
		id = PREFIX + id;
		cont.append(labelEl(id, { textContent: text }), checkbox({
			id,
			checked: cfg.flags[prop],
			onclick() { cfg.flags[prop] = this.checked; }
		}));
		return cont;
	};
	menu.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), button(text.SAVE, {
		setTextDefer: debounce(function (text) { this.textContent = text; }, 1000),
		onclick() {
			saveCfg();
			this.textContent = text.SAVED;
			this.setTextDefer(text.SAVE);
		}
	}));
	menu.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'));
	const btn = button('', {
		id: BTN_ID,
		ariaLabel: text.OPEN_SETTINGS,
		tabIndex: 0,
		onclick() { menu.toggle(); }
	});
	btn.setAttribute('aria-controls', MENU_ID);
	btn.classList.add(btnClass + '--icon-button');
	btn.append(settingsIcon);
	menuCont.append(btn);
	const actionsBar = await untilAppear(getActionsBar);
	actionsBar.insertBefore(menuCont, actionsBar.lastChild);
	menu.style.top = (btn.offsetHeight + 10) + 'px';
	menuCont.append(menu);
};
let lastHref;
setInterval(() => {
	if (lastHref === location.href)
		return;
	lastHref = location.href;
	onPageChange();
}, 1000);
const 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();
	}
};
const getCfgValue = (key) => (isTheSameChannel && channelCfg?.[key]) || cfg.global[key];
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 text = Array.from(plr.querySelectorAll('.captions-text > span')).map(line => line.textContent).join(' ');
		navigator.clipboard.writeText(text);
		return;
	}
	if (e.code !== 'Space')
		return;
	const setting = e.shiftKey ? QUALITY : SPEED;
	restoreFocusAfter(() => {
		ytSettingItems[setting].setValue(getCfgValue(setting));
	});
	e.stopPropagation();
	e.preventDefault();
});
const m = '#' + MENU_ID, d = ' div', i = ' input', s = ' select', bg = 'var(--yt-spec-menu-background)', underline = 'border-bottom: 2px solid var(--yt-spec-text-primary);';
document.head.append(el('style', { textContent: `
#${CONT_ID} {color: var(--yt-spec-text-primary); font-size: 14px}
#${BTN_ID} {margin-left: 8px}
${m} {
display: flex;
visibility: hidden;
flex-direction: column;
position: absolute;
right: 0;
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;
}
${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 + 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}` }));
})();