video, faster

speed up video on any site

// ==UserScript==
// @name        video, faster
// @namespace   Violentmonkey Scripts
// @include     *://*/*
// @grant       none
// @version     4.3
// @author      KraXen72
// @description speed up video on any site
// @grant       GM_registerMenuCommand
// @grant       GM_unregisterMenuCommand
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @license     MIT
// ==/UserScript==


const vfCSS = /*css*/`
div.userscript-video-top-bar {
  box-sizing: border-box;
  background-color: black;
  color: white;
  width: 100%;
  height: 22px;
  padding: 0 16px;
  display: grid;
  column-gap: 16px;
  grid-template-columns: max-content max-content 1fr;
  grid-template-rows: min-content;
  overflow-x: auto;
  transition: opacity 0.2s ease-in-out;
  scrollbar-width: none;
}
.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 !important;
  line-height: 14px !important;
  width: max-content;
}
.userscript-bar-wrap {
  display: flex !important;
  position: relative !important;
  height: 22px !important;
  padding: 2px 0px !important;
  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: 0;
  max-width: max-content;
  pointer-events: none;
}
`
const pageCSS = /*css*/`
.userscript-hoverinv { opacity: 0; }
.userscript-hoverinv:hover { opacity: 1 }

.userscript-video-shadow-host {
  box-sizing: border-box;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 100 !important;
  width: 100% !important;
  height: 22px !important;
  transition: opacity 0.2s ease-in-out !important;
}
.userscript-video-shadow-host::-webkit-scrollbar { display: none }
`
function ensureCSSInjected() {
  if (vfStyleTag !== null) return;
  vfStyleTag = GM_addStyle(pageCSS)
}
let vfStyleTag = null


const jumpVal = 5
let videoElem = null

const rates = [1, 1.5, 1.75, 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;
  if (video.parentElement === document.body && [...video.parentElement.children].filter(node => node.nodeName && node.nodeName.toLowerCase() === "video").length === 1) return; // don't inject auto-generated video sites ???
  ensureCSSInjected()

  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;

  // setting mode to "open" for now, not sure how well would the abortcontrollers or shadowHost hoverinv toggling work without it
  // if it causes, trouble, we can set it to closed later
  const shadowHost = document.createElement('div')
  const shadow = shadowHost.attachShadow({ mode: "open" });
  shadowHost.classList.add("userscript-video-shadow-host")

  const topBar = document.createElement('div');
  topBar.classList.add('userscript-video-top-bar')

  const styleTag = document.createElement("style")
  styleTag.innerHTML = vfCSS
  shadow.appendChild(styleTag)

  const shouldBePinned = GM_getValue("pinned", false)
  if (!shouldBePinned) shadowHost.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`
    shadow.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);
      shadowHost.classList.toggle("userscript-hoverinv");
      const isPinned = !shadowHost.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
  shadowHost.id = `vf-userscript-bar-${videoUUID}`

  // small video, yeet paddings
  if (video.clientWidth < 540) {
    topBar.style.paddingLeft = "4px";
    topBar.style.paddingRight = "4px";
    topBar.querySelectorAll(".userscript-bar-wrap").forEach(wrapper => { Object.assign(wrapper.style, { paddingLeft: 0, paddingRight: 0 })})
    topBar.style.columnGap = "8px"
  }

  shadow.appendChild(topBar)
  if ([...pe.children].filter(node => node.nodeName && node.nodeName.toLowerCase() === "video").length > 1) { // wrapper-less videos
    shadowHost.style.position = "relative"
    video.addEventListener("resize", () => {
      shadowHost.style.width = `${video.clientWidth}px`;
    }, { signal: barAborts[videoUUID].signal })
    shadowHost.style.width = `${video.clientWidth}px`;
    pe.insertBefore(shadowHost, 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(shadowHost, pe);
    } else { // rest
      if (pe.style.position !== 'relative') pe.style.position = 'relative'
      pe.insertBefore(shadowHost, 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()
    }
  }

  // site specific fixes
  if (window.location.origin === 'https://www.prageru.com') { video.style.height = 'unset' }
  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];
      }
    })
  }
};

GM_registerMenuCommand('force add bars to all videos', () => document.querySelectorAll("video").forEach(addVideoTopBar));
document.querySelectorAll("video").forEach(addVideoTopBar)