video, faster

speed up video on any site

2024-02-18 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

// ==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)