|
// ==UserScript== |
|
// @name Video Speed Control |
|
// @description Adjust HTML5 video playback speed by pressing shortcut keys. |
|
// @version 1.1.5 |
|
|
|
// @match https://*/* |
|
//// YouTube has it's own speed control |
|
// @exclude https://www.youtube.com/* |
|
|
|
//// or disable "https://*/*" above and keep just some |
|
// @match https://twitter.com/* |
|
// @match https://www.reddit.com/* |
|
// @match https://x.com/* |
|
|
|
// @run-at document-end |
|
|
|
// @license MIT |
|
// @author Anmol, paponius |
|
// ==/UserScript== |
|
|
|
// mod from: https://gist.github.com/Anmol-Sharma/bdfb563f8a65ee0c84958cb3fee74187 |
|
|
|
|
|
// Keymap: |
|
const SLOWER = ","; // -0.1x |
|
const FASTER = "."; // +0.1x |
|
const NORMAL = "/"; // 1.0 |
|
const SHOW = ";"; // show speed |
|
const FF5 = 'ArrowRight'; // Forward 5 secs |
|
const RW5 = 'ArrowLeft'; // Reverse 5 secs |
|
const FF10 = 'l'; // Forward 10 secs |
|
const RW10 = 'j'; // Reverse 10 secs |
|
const MUTE = 'm'; // Mute/Unmute |
|
|
|
|
|
/** |
|
* elVideo [HTMLElement] currently playing MediaElement |
|
* speed [Number] to save resources, a call to elVideo.playbackRate. (not huge save) |
|
* elToast [HTMLElement] to reuse toast element |
|
* timToast [timer "handle"] to cancel toast message's timer |
|
* currentlyPressedKeys [Array] keeps record of currently pressed keys |
|
* LOG [Any] LOG can be assigned in another UserScript to something true |
|
*/ |
|
var elVideo, speed, elToast, timToast, currentlyPressedKeys = []; |
|
var LOG = unsafeWindow?.LOG || null; |
|
|
|
// For other scripts to detect if this is running. To not duplicate keyboard shortcuts.; can't do: unsafeWindow?.videoSpeedControl |
|
if (typeof unsafeWindow !== 'undefined') { unsafeWindow.videoSpeedControl = true; } |
|
|
|
|
|
// capture: true: playing/pause do not bubble. Need capture to be able to capture the event on any ancestor element. |
|
document.addEventListener("playing", assignVideo, { capture: true }); |
|
document.addEventListener("pause", divestVideo, { capture: true }); |
|
|
|
function assignVideo(event) { |
|
if (LOG) { console.log('[video_controls] assignVideo', event.target, event.target === elVideo ? " | ignoring: it's an old one" : " | registering: new video"); } |
|
// e.g. tiktok is changing mute state based on its internal state |
|
if (elVideo) { elVideo.muted = (elVideo.dataset.muted === 'true'); } |
|
|
|
if (event.target === elVideo) { return; } // elVideo could also be undefined/null here |
|
if (!elVideo) { |
|
// capture: true: to be able to cancel the event soon as possible. To avoid possible site's listeners. |
|
document.addEventListener("keydown", handlePressedKey, { capture: true }); |
|
document.addEventListener("keypress", handlePressedKey, { capture: true }); |
|
document.addEventListener("keyup", handlePressedKey, { capture: true }); |
|
// document.addEventListener("keypress", stopProp, { capture: true }); |
|
// document.addEventListener("keyup", stopProp, { capture: true }); |
|
} |
|
elVideo = event.target; |
|
speed = Math.round(elVideo.playbackRate * 10) / 10; |
|
} |
|
|
|
function divestVideo(event) { |
|
if (LOG) { console.log('[video_controls] divestVideo', event.target, event.target === elVideo ? " | removing: it's the one playing" : " | ignoring: some older"); } |
|
|
|
// second assignVideo() could have been called, before the first one is paused and elVideo divested (assumption) |
|
if (event.target !== elVideo) { return; } |
|
document.removeEventListener("keydown", handlePressedKey); |
|
document.removeEventListener("keypress", handlePressedKey); |
|
document.removeEventListener("keyup", handlePressedKey); |
|
// document.removeEventListener("keypress", stopProp); |
|
// document.removeEventListener("keyup", stopProp); |
|
elVideo = null; |
|
} |
|
|
|
function showNotification(message, delay = 1400) { |
|
if (!elToast) { |
|
elToast = document.createElement('DIV'); |
|
elToast.style.position = 'fixed'; |
|
elToast.style.top = '50%'; |
|
elToast.style.left = '50%'; |
|
elToast.style.transform = 'translate(-50%, -50%)'; |
|
elToast.style.backgroundColor = '#1c1b1b'; |
|
elToast.style.color = '#fff'; |
|
elToast.style.padding = '15px 25px'; |
|
elToast.style.borderRadius = '5px'; |
|
elToast.style.zIndex = '1000'; // Ensure it's on top |
|
elToast.style.fontSize = '15px'; // Adjust as needed |
|
} else { clearTimeout(timToast); } |
|
elToast.textContent = message; |
|
document.body.appendChild(elToast); |
|
|
|
// need to cancel when new arrives, as the el is reused |
|
timToast = setTimeout(() => { |
|
elToast.remove(); |
|
}, delay); // disappear after delay |
|
} |
|
|
|
// todo maybe remove. was used before listening to all three events code was added to handlePressedKey() |
|
function stopProp(event) { |
|
if ([SLOWER, FASTER, NORMAL, SHOW, FF5, RW5, FF10, RW10, MUTE].includes(event.key)) { |
|
if (LOG) { console.debug('%c[video_controls] event: stopProp(event)','color: lightpink;', event.key, event.target, elVideo); } |
|
event.stopImmediatePropagation(); |
|
} |
|
} |
|
|
|
function handlePressedKey(event) { |
|
// If the pressed key is coming from any input field, do nothing. |
|
const target = event.target; |
|
if (target.localName === "input" || target.localName === "textarea" || target.isContentEditable) return; |
|
if (LOG) { console.debug(`%c[video_controls] event: handlePressedKey(event) | key: ${event.key} | type: ${event.type}` ,'color: cyan;', event.target, elVideo); } |
|
|
|
// Watching all three events, also the deprecated keypress. On e.g. TikTok, keydown is blocked for all keys, keypress does not work for arrows, keyup is not optimal, but it's at least something. |
|
// Act either on down/push or up, not both. Each pressed button is remembered to disable acting on it on the way up. |
|
if (event.type === 'keydown' || event.type === 'keypress') { |
|
if (currentlyPressedKeys.includes(event.key)) { |
|
// This `if` condition (not its body) can be removed to disallow repeating on a key hold. (Maybe allow just some) |
|
if (event.type === 'keypress') { |
|
if (LOG) { console.debug('[video_controls] event: handlePressedKey(event) | ignored'); } |
|
// need to stop other listeners for each type separately |
|
stopProp(event); |
|
return; |
|
} |
|
} else { currentlyPressedKeys.push(event.key); } |
|
} else { // 'keyup' |
|
const idx = currentlyPressedKeys.indexOf(event.key); |
|
if (idx !== -1) { |
|
currentlyPressedKeys.splice(idx, 1); |
|
if (LOG) { console.debug('[video_controls] event: handlePressedKey(event) | ignored'); } |
|
stopProp(event); |
|
return; |
|
} |
|
} |
|
// This line should be after saving of key event. Key could be pressed before elVideo is found and needs to be remembered anyway. |
|
if (!elVideo) { return; } |
|
if (LOG) { console.debug('[video_controls] event: handlePressedKey(event) | acting'); } |
|
|
|
switch (event.key) { |
|
case SLOWER: |
|
// Math.round(): in JS `num = 7.9; num -= 0.1;` is sometimes 7.800000000000001 |
|
if (speed === 0) { break; } |
|
speed = Math.round((speed - 0.1) * 10) / 10; |
|
elVideo.playbackRate = speed; |
|
showNotification(speed.toFixed(2).toString() + "x", 250); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case FASTER: |
|
speed = Math.round((speed + 0.1) * 10) / 10; |
|
elVideo.playbackRate = speed; |
|
showNotification(speed.toFixed(2).toString() + "x", 250); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case NORMAL: |
|
speed = elVideo.playbackRate = 1; |
|
showNotification(speed.toFixed(2).toString() + "x", 250); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case SHOW: |
|
showNotification(speed.toFixed(2).toString() + "x"); |
|
event.stopImmediatePropagation(); |
|
break; |
|
|
|
case FF5: |
|
showNotification('+ 5 sec'); |
|
elVideo.currentTime = Math.min(elVideo.duration, elVideo.currentTime + 5); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case RW5: |
|
showNotification('- 5 sec'); |
|
elVideo.currentTime = Math.max(0, elVideo.currentTime - 5); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case FF10: |
|
showNotification('+ 10 sec'); |
|
elVideo.currentTime = Math.min(elVideo.duration, elVideo.currentTime + 10); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case RW10: |
|
showNotification('- 10 sec'); |
|
elVideo.currentTime = Math.max(0, elVideo.currentTime - 10); |
|
event.stopImmediatePropagation(); |
|
break; |
|
case MUTE: |
|
// Player on the page sometimes remember its mute state and force it on video when its play button is pushed. (TikTok) |
|
// This does not always correlate with a site. i.e. When mute icon is pressed after keyboard mute key was used, the next time the state from site is ignored. It can be made to follow it on e.g. Twitter, but not on TikTok. |
|
if (elVideo.dataset.muted === undefined) { elVideo.dataset.muted = elVideo.muted; } |
|
if (elVideo.dataset.muted === 'true') { // it's a String, not Boolean |
|
elVideo.dataset.muted = elVideo.muted = false; |
|
showNotification('Unmute'); |
|
} else { |
|
elVideo.dataset.muted = elVideo.muted =true; |
|
showNotification('Mute'); |
|
} |
|
event.stopImmediatePropagation(); |
|
break; |
|
} |
|
} |