// ==UserScript==
// @name Youtube Remember Speed
// @name:zh-TW YouTube 播放速度記憶
// @name:zh-CN YouTube 播放速度记忆
// @name:ja YouTube 再生速度メモリー
// @icon https://www.google.com/s2/favicons?domain=youtube.com
// @author ElectroKnight22
// @namespace electroknight22_youtube_remember_playback_rate_namespace
// @version 2.1.1
// @match *://www.youtube.com/*
// @match *://www.youtube-nocookie.com/*
// @exclude *://music.youtube.com/*
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.listValues
// @license MIT
// @description Remembers the speed that you last used. Now hijacks YouTube's custom speed slider and gives you up to 8x speed.
// @description:zh-TW 記住上次使用的播放速度,並改造YouTube的速度調整滑桿,最高支援8倍速。
// @description:zh-CN 记住上次使用的播放速度,并改造YouTube的速度调整滑杆,最高支持8倍速。
// @description:ja 最後に使った再生速度を覚えておき、YouTubeの速度スライダーを改造して最大8倍速まで対応させます。
// ==/UserScript==
/*jshint esversion: 11 */
(function () {
'use strict';
class SpeedOverrideManager {
constructor(maxSpeed, getSettings, setSpeed, updateSavedSpeed) {
// Dependencies are injected for better encapsulation
this.maxSpeed = maxSpeed;
this.getSettings = getSettings;
this.setSpeed = setSpeed;
this.updateSavedSpeed = updateSavedSpeed;
this.DOM = {
speedTextElement: null,
speedLabel: null,
parentMenu: null,
};
this.OBSERVERS = {
speedTextObserver: null,
sliderObserver: null,
};
this.initialized = false;
}
setSpeedText(targetString) {
try {
if (!this.DOM.speedTextElement) return;
const text = this.DOM.speedTextElement.textContent;
const newValue = targetString;
this.DOM.speedTextElement.textContent = /\(.*?\)/.test(text) ? text.replace(/\(.*?\)/, `(${newValue})`) : newValue;
} catch (error) {
console.error('Failed to set speed text.', error);
}
}
overrideSpeedLabel(sliderElement) {
try {
if (!this.DOM.speedLabel || !this.DOM.speedLabel.isConnected) {
this.DOM.speedLabel = sliderElement.closest('.ytp-menuitem-with-footer').querySelector('.ytp-menuitem-label');
}
if (this.DOM.speedLabel?.textContent && !this.DOM.speedLabel.textContent.includes(`(${sliderElement.value})`)) {
this.DOM.speedLabel.textContent = this.DOM.speedLabel.textContent.replace(/\(.*?\)/, `(${sliderElement.value})`);
}
} catch (error) {
console.error('Failed to override speed label.', error);
}
}
overrideSliderStyle(sliderElement) {
if (!sliderElement) return;
this.overrideSpeedLabel(sliderElement);
sliderElement.style = `--yt-slider-shape-gradient-percent: ${(sliderElement.value / this.maxSpeed) * 100}%;`;
document.querySelector('.ytp-speedslider-text').textContent = sliderElement.value + 'x';
}
overrideCustomSpeedItem(sliderElement) {
const speedMenuItems = sliderElement.closest('.ytp-panel-menu');
const customSpeedItem = speedMenuItems.children[0];
const selectCustomSpeedItem = () => {
if (
customSpeedItem?.classList.contains('ytp-menuitem-with-footer') &&
customSpeedItem.getAttribute('aria-checked') !== 'true'
) {
const currentActiveItem = speedMenuItems.querySelector('[aria-checked="true"]');
if (currentActiveItem && currentActiveItem !== customSpeedItem) {
currentActiveItem.setAttribute('aria-checked', 'false');
}
customSpeedItem.setAttribute('aria-checked', 'true');
}
};
if (!customSpeedItem.dataset.listenerAttached) {
customSpeedItem.addEventListener('click', () => {
userSettings.targetSpeed = parseFloat(sliderElement.value);
this.setSpeedText(userSettings.targetSpeed);
this.setSpeed(userSettings.targetSpeed);
this.updateSavedSpeed(userSettings.targetSpeed);
});
customSpeedItem.dataset.listenerAttached = 'true';
}
selectCustomSpeedItem();
}
overrideSliderFunction(sliderElement) {
sliderElement.addEventListener('input', () => {
try {
const newSpeed = parseFloat(sliderElement.value);
this.updateSavedSpeed(newSpeed);
this.setSpeed(newSpeed);
this.overrideSliderStyle(sliderElement);
this.overrideCustomSpeedItem(sliderElement);
} catch (error) {
console.error('Error during slider input event.', error);
}
});
sliderElement.addEventListener(
'change',
(event) => {
this.setSpeedText(sliderElement.value);
event.stopImmediatePropagation();
},
true,
);
}
overrideSlider() {
const sliderElement = this.DOM.parentMenu.querySelector('input.ytp-input-slider.ytp-speedslider');
if (!sliderElement) throw new Error('Slider element not found.');
this.overrideSpeedLabel(sliderElement);
if (sliderElement.initialized) return;
const targetSpeed = this.getSettings().targetSpeed;
sliderElement.max = this.maxSpeed.toString();
sliderElement.setAttribute('value', targetSpeed.toString());
this.setSpeed(targetSpeed);
this.overrideSliderStyle(sliderElement);
this.overrideCustomSpeedItem(sliderElement);
this.overrideSliderFunction(sliderElement);
if (this.OBSERVERS.sliderObserver) this.OBSERVERS.sliderObserver.disconnect();
sliderElement.initialized = true;
}
async findSpeedTextElement() {
const magicSpeed = 1.05;
const pollForElement = () => {
const settingItems = document.querySelectorAll('.ytp-menuitem');
return Array.from(settingItems).find((item) => item.textContent.includes(magicSpeed.toString()));
};
const targetSpeed = this.getSettings().targetSpeed;
const youtubeApi = document.querySelector('#movie_player');
// YouTube's API **MUST** be called here or things break for some unknown reason.
youtubeApi.setPlaybackRate(magicSpeed);
const pollingLimit = 500;
let attempts = 0;
while (attempts < pollingLimit) {
const matchingItem = pollForElement();
if (matchingItem) {
this.DOM.speedTextElement = matchingItem.querySelector('.ytp-menuitem-content');
break;
}
await new Promise((resolve) => setTimeout(resolve, 10));
attempts++;
}
this.setSpeed(targetSpeed);
this.updateSavedSpeed(targetSpeed);
this.setSpeedText(targetSpeed);
}
async initializeSpeedTextElement() {
try {
if (this.DOM.speedTextElement) {
if (this.DOM.speedTextElement.initialized) return;
this.DOM.speedTextElement.initialized = true;
const youtubeApi = document.querySelector('#movie_player');
youtubeApi?.addEventListener('onPlaybackRateChange', this.updateSavedSpeed, true);
this.setSpeedText(this.getSettings().targetSpeed);
this.OBSERVERS.speedTextObserver?.disconnect(); // Disconnect after finding
} else {
await this.findSpeedTextElement();
}
} catch (error) {
console.error('Failed to initialize speed text element.', error);
}
}
init() {
try {
this.DOM.parentMenu = document.querySelector('.ytp-popup.ytp-settings-menu');
if (!this.DOM.parentMenu) throw new Error('The parent menu was not found.');
if (!this.OBSERVERS.sliderObserver || this.initialized === false) {
this.OBSERVERS.sliderObserver?.disconnect();
this.OBSERVERS.sliderObserver = new MutationObserver(this.overrideSlider.bind(this));
this.OBSERVERS.sliderObserver.observe(this.DOM.parentMenu, { childList: true, subtree: true });
}
if (!this.OBSERVERS.speedTextObserver || this.initialized === false) {
this.OBSERVERS.speedTextObserver?.disconnect();
this.OBSERVERS.speedTextObserver = new MutationObserver(this.initializeSpeedTextElement.bind(this));
this.OBSERVERS.speedTextObserver.observe(this.DOM.parentMenu, { childList: true, subtree: true, attributes: true });
}
this.initialized = true;
} catch (error) {
console.error('Failed to initialize speed override manager.', error);
}
}
}
// --- Main Script Logic ---
const DEFAULT_SETTINGS = { targetSpeed: 1 };
let userSettings = { ...DEFAULT_SETTINGS };
const maxSpeed = 8;
let manager = null;
function setSpeed(targetSpeed) {
try {
const video = document.querySelector('video');
if (video) {
video.playbackRate = targetSpeed;
}
} catch (error) {
console.error('Failed to set playback speed.', error);
}
}
function updateSavedSpeed(speed) {
userSettings.targetSpeed = speed;
GM.setValue('targetSpeed', userSettings.targetSpeed);
}
async function applySettings() {
try {
const storedSpeed = await GM.getValue('targetSpeed', DEFAULT_SETTINGS.targetSpeed);
userSettings.targetSpeed = storedSpeed;
console.log(`Loaded speed setting: ${userSettings.targetSpeed}`);
} catch (error) {
console.error('Failed to apply stored settings.', error.message);
}
}
function handleNewVideoLoad() {
if (!manager) {
// Pass helper functions and settings to the class instance
manager = new SpeedOverrideManager(
maxSpeed,
() => userSettings, // Pass a function to get the latest settings
setSpeed,
updateSavedSpeed,
);
}
setSpeed(userSettings.targetSpeed);
manager.init();
}
function main() {
window.addEventListener(
'pageshow',
() => {
handleNewVideoLoad();
window.addEventListener(
'yt-player-updated',
() => {
if (manager) {
manager.initialized = false;
manager.DOM.speedTextElement = null;
}
handleNewVideoLoad();
},
true,
);
},
true,
);
}
applySettings().then(main);
})();