Skip to content

Instantly share code, notes, and snippets.

@solarshado
Last active November 18, 2023 07:59
Show Gist options
  • Save solarshado/1273d2239aaee91479f6d1bdb92a1703 to your computer and use it in GitHub Desktop.
Save solarshado/1273d2239aaee91479f6d1bdb92a1703 to your computer and use it in GitHub Desktop.
time remaining for youtube taking playbackRate into account
Youtube Progress Display
"use strict";
chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
if(typeof message === 'object' && 'showPageAction' in message) {
const show = !!message.showPageAction;
chrome.pageAction[show ? "show" : "hide"](sender.tab.id);
}
sendResponse();
});
chrome.pageAction.onClicked.addListener(function(tab) {
chrome.tabs.sendMessage(tab.id, {doThing: true});
});
"use strict";
const MODE = (
window.location.host.includes("youtube.com") ? "YT" :
window.location.host.includes("nebula.app") ? "N" :
window.location.host.includes("nebula.tv") ? "N" :
"YT" // fallback
);
/**
* @template T
* @param {{[key in MODE]: T}} map
*/
const ms = (map) => map[MODE]; // helper for constructing MODE_HELPER
const MODE_HELPER = {
mode: MODE,
/** @type {(display:HTMLElement) => boolean} */
addDisplay: ms({
"YT": (display) => {
const container = document.querySelector("div.ytp-left-controls div.ytp-time-display.notranslate");
if(!container)
return false;
container.appendChild(display);
return true;
},
"N": (display) => {
const container = document.querySelector("#video-controls .icon-spacing")?.lastChild;
if(!container)
return false;
// steal a css class to get the font right
display.classList.add(container.firstChild.classList);
// margin adjustment for personal taste
display.style.marginLeft = "0.4em";
container.appendChild(display);
return true;
}
}),
findVideo: ms({
"YT": () => {
const videos = Array.from(document.getElementsByTagName("video"));
// exclude videos within a 'class="ad-interrupting"' container
// https://stackoverflow.com/a/60537660/
const noYTFuckery = videos.filter(el=>!el.closest(".ad-interrupting"))
// if we're too early, the bad vid might not be in that
// div yet... so try again later... gross but works
if(noYTFuckery.length > 1) return null;
return noYTFuckery[0];
},
"N": () => {
return document.getElementsByTagName("video")[0];
}
}),
retryAfterVideoNotFound: ms({
"YT": () => {
// tactic found here: https://stackoverflow.com/a/34100952
document.addEventListener("yt-navigate-finish",tryCreateAndWireUpDisplay, {once: true, passive: true});
// above no longer working consistently, so:
// lazy option; kinda gross, but works
setTimeout(tryCreateAndWireUpDisplay, 100);
},
"N": () => {
// try to only run on video pages
if(!window.location.pathname.includes("/videos/"))
return;
// lazy option; kinda gross, but works
setTimeout(tryCreateAndWireUpDisplay, 100);
}
}),
};
// temp hack, I hope
function messageListener(msg, sender, sendResponse) {
chrome.runtime.onMessage.removeListener(messageListener);
tryCreateAndWireUpDisplay();
sendResponse();
}
function queueRetry() {
MODE_HELPER.retryAfterVideoNotFound();
// temp hack, I hope
chrome.runtime.onMessage.addListener(messageListener);
chrome.runtime.sendMessage({showPageAction: true});
}
// main entry-point
function tryCreateAndWireUpDisplay() {
const newId = "customTimeDisplay";
const video = MODE_HELPER.findVideo();
if(!video) { // not on a video page, or page not done loading
queueRetry();
return;
}
// temp hack, I hope
chrome.runtime.sendMessage({showPageAction: false});
const newElem =(function getOrBuild(id) {
let r = document.getElementById(id);
if(!r){
r = document.createElement("span");
r.id = id;
r.innerText = "awaiting event to update";
const added = MODE_HELPER.addDisplay(r);
if(!added) { // page not done loaded? hopefully that's it. should probably try to confirm
return null;
}
for(const event of ["click","auxclick","contextmenu"])
r.addEventListener(event, clickListener)
for(const event of ["timeupdate","ratechange"])
video.addEventListener(event, listener)
}
return r;
})(newId);
if(newElem === null) {
queueRetry();
return;
}
function listener(evt) {
newElem.innerText = buildText(video);
}
//const speeds = [1,1.25,1.5,1.75,2,2.5,3];
const speeds = [1,1.5,1.75,2,2.5];
function speed(i) { return speeds[Math.min(speeds.length-1,Math.max(0,i))]; }
function clickListener(evt) {
evt.preventDefault(); evt.stopImmediatePropagation();
if(evt.type === "contextmenu") return;
const faster = (evt.button === 0);
const curSpeed = video.playbackRate;
const curIndex = speeds.indexOf(curSpeed);
const curSpeedInList = curIndex !== -1;
const newSpeed = speed(
curSpeedInList ?
curIndex + (faster ? 1 : -1) :
(faster ? 2 : 1) );
// this alone works, but desyncs youtube's *idea* of the playback rate
// from the actual rate, causing < and > to possibly behave unexpectedly
video.playbackRate = newSpeed;
if(MODE === "YT") {
// tactic found here: https://stackoverflow.com/a/9517879
// (running the same code here results in a `player` missing those funcs,
// plus setPlaybackRate() ignores non-standard rates)
(function inject(actualCode){
const script = document.createElement('script');
script.textContent = `(function(){${actualCode}})()`;
(document.head||document.documentElement).appendChild(script);
script.remove();
})(`
const player = document.querySelector("#movie_player");
player.setPlaybackRate(${newSpeed});
`);
}
}
function buildText(video) {
const now = video.currentTime;
const end = video.duration;
const rate = video.playbackRate;
const n = (end-now)/rate;
const t = (!isNaN(n) && n >= 0) ?
new Date(n * 1000).toISOString().substring( ( n>3600 ? 11 : 14 ), 19) :
"??:??";
return ` -- ${t} left @ ${rate}x`;
}
}
tryCreateAndWireUpDisplay();
/* vim: set et sw=0 tabstop=2: */
// ==UserScript==
// @name YT progress display
// @version 1
// @grant none
// @match *://*.youtube.com/*
// @match *://*.nebula.app/*
// @require https://gist.githubusercontent.com/solarshado/1273d2239aaee91479f6d1bdb92a1703/raw/contentScript.js
// ==/UserScript==
{
"name": "Youtube Progress Display",
"version": "0.1",
"description": "Shows YT time remaining considering playback rate",
"permissions": [
"*://*.youtube.com/*",
"*://*.nebula.app/*"
],
"content_scripts": [{
"matches": [
"*://*.youtube.com/*",
"*://*.nebula.tv/*",
"*://*.nebula.app/*"
],
"js": ["contentScript.js"]
}],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"page_action": {
"default_title": "Force add timer"
},
"manifest_version": 2
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment