// ==UserScript==
// @name video, faster
// @namespace Violentmonkey Scripts
// @include *://*/*
// @grant none
// @version 3.10
// @author KraXen72
// @description speed up video on any site
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
// TODO
// highlight currently selected playbackRate
// per-site speed & remember toggles, with a default remember toggle? could be useful
// use ratechange event
// NOTE: to get compact video speed buttons, add this css in violentMonkey custom css in settings
/*
[data-message="video, faster"] + .submenu-buttons + .submenu-commands {
display: flex;
justify-content: center;
}
[data-message="video, faster"] + .submenu-buttons + .submenu-commands .menu-item {
padding: 0.5rem;
margin: 0px;
width: auto;
}
[data-message="video, faster"] + .submenu-buttons + .submenu-commands .menu-item .icon {
display: none;
}
*/
GM_addStyle(`
div.userscript-video-top-bar.userscript-specificity-vf {
box-sizing: border-box;
background-color: black;
color: white;
position: absolute;
top: 0;
left: 0;
width: 100% !important;
height: 22px !important;
z-index: 100;
padding: 0;
transition: opacity 0.2s ease-in-out;
display: grid;
grid-template-columns: max-content max-content 1fr;
grid-template-rows: min-content;
overflow-x: auto;
}
.userscript-hoverinv { opacity: 0; }
.userscript-hoverinv:hover { opacity: 1 }
.userscript-video-top-bar::-webkit-scrollbar { display: none }
.userscript-video-top-bar button,
.userscript-simple-btn {
margin: 0 3px;
height: 100%;
padding: 2px;
font-size: 14px;
line-height: 14px;
width: max-content;
}
.userscript-bar-wrap {
display: flex;
position: relative !important;
height: 22px;
padding: 2px 16px;
box-sizing: border-box;
}
.userscript-simple-btn {
background: #262626 !important;
border: 1px solid #191919 !important;
border-radius: 2px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
font-weight: normal;
}
.userscript-simple-btn,
.userscript-simple-btn:hover,
.userscript-simple-btn:active {
color: white !important;
background-image: none !important;
box-sizing: border-box !important;
box-shadow: none !important;
text-shadow: none !important;
}
.userscript-simple-btn:hover {
background: #303030 !important;
border-color: #383838 !important;
}
.userscript-simple-btn:active { opacity: 0.8; }
.userscript-hl-rate.userscript-simple-btn,
.userscript-hl-rate.userscript-simple-btn:hover,
.userscript-hl-rate.userscript-simple-btn:active {
background-color: #3aa99fb2 !important;
border-color: #3aa99f !important;
}
.userscript-bar-separator {
border-left: 2px solid #616161e5;
width: 0px;
margin: 0 6px;
}
.userscript-cb-wrap {
display: flex;
white-space: nowrap;
padding: 0 3px;
align-items: center;
}
.userscript-cb-wrap,
.userscript-cb-wrap > * {
width: max-content;
margin-top: 0;
margin-bottom: 0;
user-select: none;
}
.userscript-cb-wrap > *:not(::last-child) {
margin-right: 3px;
}
.userscript-speed-display {
min-width: 32px;
max-width: max-content;
pointer-events: none;
}`);
const jumpVal = 5
let videoElem = null
const rates = [1, 1.5, 2, 2.5, 2.75, 3, 3.5, 4]
const commands = {}
const barAborts = {}
for (const r of rates) { commands[`${r}x`] = () => playbackRate(r) }
function findVideoElement(debug = false) { // should maybe be removed later
if (!document.body) return;
if (document.body.contains(videoElem)) return;
let qs = "video";
videoElem = document.querySelector(qs);
if (videoElem !== null) {
if (debug) console.log("found video elem", videoElem, qs);
} else {
// Look for video elements within iframes
const iframes = document.querySelectorAll("iframe");
iframes.forEach(iframe => {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const iframeVideoElem = iframeDoc.querySelector("video");
if (iframeVideoElem) {
videoElem = iframeVideoElem;
if (debug) console.log("found video elem in iframe", videoElem, iframe.src);
}
});
}
}
function get_uuid() {
return window.btoa(String(new Date().getTime()))
}
function get_restoreSpeed(video) {
const lastSpeed = GM_getValue("lastSpeed", 0)
return {
lastSpeed,
restoreSpeed: () => playbackRate(Number(lastSpeed), null, video)
}
}
function playbackRate(rate, e = null, video = null) {
if (video == null) {
findVideoElement()
video = videoElem
}
if (e != null) cancelEvent(e);
video.playbackRate = rate
GM_setValue("lastSpeed", rate)
}
function registerCommands() {
Object.keys(commands).forEach(command => {
try {
GM_unregisterMenuCommand(command)
} catch (e) { console.error(e) }
})
Object.entries(commands).forEach(command => {
GM_registerMenuCommand(command[0], command[1])
})
}
registerCommands()
findVideoElement()
function cancelEvent(e) {
if (!e || e == null) return;
e.preventDefault()
e.stopPropagation()
}
function ff(vid = null, e) {
cancelEvent(e);
if (!vid) vid = videoElem;
vid.currentTime += jumpVal;
}
function rw(vid = null, e) {
cancelEvent(e);
if (!vid) vid = videoElem;
vid.currentTime -= jumpVal;
}
function pp(vid = null, e) {
cancelEvent(e);
if (vid.paused) {
vid.play()
} else {
vid.pause()
}
}
const btnDefaults = { classList: "userscript-simple-btn" }
function checkBox(emoji, valueKey = "", title = "", defaultv = false) {
const wrap = document.createElement("span")
wrap.classList.add("userscript-simple-btn", "userscript-cb-wrap")
wrap.textContent = emoji
wrap.onclick = cancelEvent
if (title) wrap.title = title
const cb = Object.assign(document.createElement("input"), { type: "checkbox" })
cb.style.marginLeft = "7px"
cb.checked = valueKey ? GM_getValue(valueKey, defaultv) : false
cb.onclick = (e) => {
if (!valueKey) return;
e.stopPropagation();
GM_setValue(valueKey, e.target.checked)
}
wrap.appendChild(cb)
return wrap
}
function addVideoTopBar(video) {
if (video.dataset.vfUserscriptBar) return;
if (video.previousElementSibling !== null && video.previousElementSibling.classList.contains("userscript-video-top-bar")) return;
const videoUUID = get_uuid()
barAborts[videoUUID] = new AbortController()
const rateButtons = {} // so they're bound to the current bar
const pe = video.parentElement
// don't inject to shorts hover previews (might not work)
if (pe.parentElement && pe.parentElement.classList.contains("ytp-inline-preview-mode") && pe.parentElement.classList.contains("ytp-tiny-mode")) return;
const topBar = document.createElement('div');
topBar.classList.add('userscript-video-top-bar', 'userscript-specificity-vf')
const shouldBePinned = GM_getValue("pinned", false)
if (!shouldBePinned) topBar.classList.add("userscript-hoverinv");
const leftdiv = Object.assign(document.createElement("div"), { classList: "userscript-bar-wrap" })
const centerdiv = Object.assign(document.createElement("div"), { classList: "userscript-bar-wrap" })
const rightdiv = Object.assign(document.createElement("div"), { classList: "userscript-bar-wrap", style: "justify-content: end" })
for (const r of rates) {
const btn = document.createElement("button")
btn.textContent = `${r}x`
btn.classList = "userscript-simple-btn"
btn.dataset.rate = r
btn.onclick = (e) => playbackRate(Number(r), e, video)
rateButtons[String(r)] = btn
leftdiv.appendChild(btn)
}
centerdiv.appendChild(Object.assign(document.createElement("button"), {...btnDefaults, textContent: "<<", onclick: (e) => rw(video, e), title:`rewind ${jumpVal}s` }))
centerdiv.appendChild(Object.assign(document.createElement("button"), {...btnDefaults, textContent: "⏯", onclick: (e) => pp(video, e), title:`play/pause` }))
centerdiv.appendChild(Object.assign(document.createElement("button"), {...btnDefaults, textContent: ">>", onclick: (e) => ff(video, e), title:`forward ${jumpVal}s` }))
const fallbackRateDisplay = Object.assign(document.createElement("span"), {
classList: "userscript-speed-display userscript-simple-btn",
textContent: `${video.playbackRate}x`,
style: "opacity: 0"
})
// console.log(barAborts[videoUUID], barAborts[videoUUID].signal, videoUUID)
video.addEventListener("ratechange", (e) => {
const _rate = video?.playbackRate ?? e?.target?.playbackRate ?? 1;
fallbackRateDisplay.textContent = `${_rate}x`
document.querySelectorAll(".userscript-hl-rate").forEach(el => el.classList.remove("userscript-hl-rate"))
if (rates.includes(_rate)) {
fallbackRateDisplay.style.opacity = 0
rateButtons[String(_rate)].classList.add("userscript-hl-rate")
//rateButtons[String(_rate)].setAttribute("class", "userscript-hl-rate userscript-simple-btn") // this makes it more reliable for whatever reason
console.log("ratechange", _rate, rateButtons[String(_rate)], rateButtons[String(_rate)].classList.toString())
} else {
fallbackRateDisplay.style.opacity = 1
}
}, { signal: barAborts[videoUUID].signal })
rightdiv.appendChild(fallbackRateDisplay)
rightdiv.appendChild(checkBox("💾🐇", "rememberSpeed", "remember video speed across sites."))
rightdiv.appendChild(Object.assign(document.createElement("button"), {
...btnDefaults,
onclick: function(e) {
cancelEvent(e);
topBar.classList.toggle("userscript-hoverinv");
const isPinned = !topBar.classList.contains("userscript-hoverinv")
this.textContent = isPinned ? "📍" : "📌"
this.title = isPinned ? "unpin" : "pin"
GM_setValue("pinned", isPinned)
},
textContent: shouldBePinned ? "📍": "📌",
title: shouldBePinned ? "unpin" : "pin",
style: "postition: relative;"
}))
topBar.appendChild(leftdiv)
topBar.appendChild(centerdiv)
topBar.appendChild(rightdiv)
video.dataset.vfUserscriptBar = videoUUID
// topBar.dataset.vfUserscriptFor = videoUUID
topBar.id = `vf-userscript-bar-${videoUUID}`
if ([...pe.children].filter(node => node.nodeName && node.nodeName.toLowerCase() === "video").length > 1) { // wrapper-less videos
topBar.style.position = "relative"
video.addEventListener("resize", () => {
topBar.style.width = `${video.clientWidth}px`;
}, { signal: barAborts[videoUUID].signal })
topBar.querySelectorAll(".userscript-bar-wrap").forEach(wrapper => { Object.assign(wrapper.style, { paddingLeft: 0, paddingRight: 0 })})
topBar.style.width = `${video.clientWidth}px`;
pe.insertBefore(topBar, video);
} else { // yt / yt embeds
if (pe.classList.contains("html5-video-container") && (
(pe?.parentElement?.classList?.contains("ytp-embed") ?? false) ||
(Array.from(pe?.parentElement?.classList) ?? []).some(cl => cl.startsWith("ytp-"))
)) {
pe.parentElement.insertBefore(topBar, pe);
} else { // rest
pe.insertBefore(topBar, video);
}
}
const { lastSpeed, restoreSpeed } = get_restoreSpeed(video)
// console.log(lastSpeed, restoreSpeed)
if (GM_getValue("rememberSpeed", false) && lastSpeed) {
if (!!video.paused) {
video.addEventListener("play", () => {
if (GM_getValue("rememberSpeed", false) === false) return;
const { lastSpeed, restoreSpeed } = get_restoreSpeed(video)
setTimeout(restoreSpeed, 1) // hopefully run after any external code
}, { once: true, signal: barAborts[videoUUID].signal })
video.addEventListener("loadeddata", () => {
if (GM_getValue("rememberSpeed", false) === false) return;
console.log("loadeddata")
const { lastSpeed, restoreSpeed } = get_restoreSpeed(video)
setTimeout(() => { if (video.playbackRate !== Number(lastSpeed)) restoreSpeed() }, 1)
}, { once: true, signal: barAborts[videoUUID].signal })
} else {
restoreSpeed()
}
}
console.log(`injected bar for newly added video, id: ${video.dataset.vfUserscriptBar}`)
}
let observer = null;
if (observer === null) {
observer = new MutationObserver(MOCallback)
observer.observe(document.body, { childList: true, subtree: true });
} else {
console.log("observer is already defined:", observer, "restarting it...")
observer.disconnect() // script has re-ran, but the observer is defined. restart observer to be safe
observer.observe(document.body, { childList: true, subtree: true })
}
function MOCallback(mutationsList, observer) {
for(const mutation of mutationsList) {
if (mutation.type !== 'childList') continue;
mutation.addedNodes.forEach(node => {
if (node.tagName && node.tagName.toLowerCase() === 'video') {
// console.log('A video element has been added to the document:', node);
addVideoTopBar(node)
}
});
mutation.removedNodes.forEach(node => {
if (node.tagName && node.tagName.toLowerCase() === 'video') {
const uuid = node.dataset.vfUserscriptBar
if (!uuid) return;
document.getElementById(`vf-userscript-bar-${uuid}`).remove()
barAborts[uuid].abort();
delete barAborts[uuid];
}
})
}
};
document.querySelectorAll("video").forEach(addVideoTopBar)