Created
May 6, 2024 08:23
-
-
Save b3x206/3c4c191ce1a705bfaaa13f7db3fb191f to your computer and use it in GitHub Desktop.
An userscript that shows a volume slider on videos displayed in instagram.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Instagram Video Volume Slider | |
// @description Adds a volume slider to any video shown. | |
// @author b3x | |
// @version 1.0 | |
// @license MIT | |
// @include https://instagram.com/* | |
// @include https://*.instagram.com/* | |
// @namespace https://gist.github.com/b3x206 | |
// @grant GM.getValue | |
// @grant GM.setValue | |
// @run-at document-end | |
// @icon https://upload.wikimedia.org/wikipedia/commons/a/a5/Instagram_icon.png | |
// ==/UserScript== | |
"use strict"; | |
// Note(s) : | |
// 1) The video will not play sound until you have interacted with the video in some way (probably firefox/autoplay rule thing?) | |
// If this is an actual issue caused by this script instead of the autoplay rule it will be fixed. | |
// 2) GM.getValue and GM.setValue are async. | |
// Hmm, totally normal API design. I have no idea why I was this mad. Maybe because there's literally almost NO GreaseMonkey CLASS DOCS? | |
// This script involved of some suffering.. From firefox devtools lagging and stuttering to instagram's react stuff and to bad documentation. | |
(function () { | |
// If these are not defined, define a dummy function | |
// This is useful in such cases where you want to directly run the script inside the devtools | |
/* | |
if (this.GM === undefined) { | |
this.GM = {}; | |
} | |
if (this.GM.getValue === undefined) { | |
this.GM.getValue = function (valueKey, defaultValue) { | |
console.log(`[dummy::GM_getValue] k:${valueKey}, default:${defaultValue}`); | |
return Promise.resolve(defaultValue); | |
}; | |
} | |
if (this.GM.setValue === undefined) { | |
this.GM.setValue = function (valueKey, setValue) { | |
console.log(`[dummy::GM.setValue] k:${valueKey}, v:${setValue}`); | |
return new Promise(r => setTimeout(r, 10)); | |
}; | |
} | |
*/ | |
const LastVolumeGMKey = "[insta-volume-slider(dot)js::LastVolume]"; | |
/** @param {any[]} array Array to get the first element. (because no jquery) */ | |
/** @returns {any} The first element in given array. */ | |
function arrayFirst(array) { | |
if (typeof array !== "object") { | |
throw new Error("first is not on array."); | |
} | |
if (array.length === undefined) { | |
throw new Error("first is not on array."); | |
} | |
try { | |
return array[0]; | |
} catch { | |
return null; | |
} | |
} | |
/** | |
* @summary A function used with event listeners to optimize. | |
* This function checks for n-ms waits after the event was called and invokes after the given wait. | |
* @param {*} func | |
* @param {Number} wait | |
* @param {Boolean} immediate | |
* @returns {function():void} The created debounce function. This function is a special function that waits until it's no longer called for 'wait'-ms time and invokes the 'func'. | |
*/ | |
function debounce(func, wait, immediate) { | |
// current timeout in "function stack" (lol) | |
let timeout; | |
return function () { | |
let context = this, args = arguments; | |
let later = function () { | |
timeout = null; | |
if (!immediate) { | |
func.apply(context, args); | |
} | |
}; | |
let callNow = immediate && !timeout; | |
clearTimeout(timeout); | |
timeout = setTimeout(later, wait); | |
if (callNow) { | |
func.apply(context, args); | |
} | |
}; | |
}; | |
/** | |
* Injects the volume button HTML + CSS. | |
* @param {HTMLVideoElement} videoElement Element to inject to it's button. | |
*/ | |
async function injectVolumeButton(videoElement) { | |
// get from the common parent, this is the button to add the on hover and the children that has the slider. | |
/** @type {HTMLButtonElement} */ | |
const muteButtonElement = arrayFirst(videoElement.parentNode.getElementsByTagName("button")); | |
if (muteButtonElement === null) { | |
console.warn(`Given video element ${videoElement} does not have a button tagged element in mutual parent ${videoElement.parentNode}`); | |
return; | |
} | |
// !! TODO !! : Most likely the muteButtonElement.onclick is frozen, (using Object.freeze) | |
// To avoid this thing, most likely have to create the button from scratch.. | |
// The stupid button overrides all types of muting, this is because react is a mistake. | |
muteButtonElement.onclick = async (_) => { | |
let prevGMValue = Number.parseFloat(await GM.getValue(LastVolumeGMKey, 0.0)); | |
if (Number.isNaN(prevGMValue) || !Number.isFinite(prevGMValue)) { | |
prevGMValue = 0.0; | |
} | |
if (prevGMValue < (Number.EPSILON * 4.0)) { | |
prevGMValue = 1.0; | |
} | |
// set volume to 0 if it's higher than 0 | |
// to 1 if it's equal to 0 | |
videoElement.volume = (videoElement.volume < (Number.EPSILON * 4.0)) ? prevGMValue : 0.0; | |
await GM.setValue(LastVolumeGMKey, videoElement.volume); | |
}; | |
let lastLoadedVolume = Number.parseFloat(await GM.getValue(LastVolumeGMKey, videoElement.volume)); | |
if (Number.isNaN(lastLoadedVolume) || !Number.isFinite(lastLoadedVolume)) { | |
console.log(`[insta-volume-slider] last loaded volume isn't a finite number or is NaN : ${lastLoadedVolume}, getValue returned ${GM.getValue(LastVolumeGMKey, videoElement.volume)}`); | |
lastLoadedVolume = videoElement.volume; | |
await GM.setValue(LastVolumeGMKey, videoElement.volume); | |
} | |
// Add the slider towards the left of the element. | |
const sliderElement = document.createElement("input"); | |
sliderElement.setAttribute("type", "range"); | |
sliderElement.setAttribute("min", "0"); | |
sliderElement.setAttribute("max", "100"); | |
sliderElement.value = lastLoadedVolume * 100.0; | |
sliderElement.style.display = "none"; | |
sliderElement.style.marginBottom = "auto"; | |
// sliderElement.ondrag = (_) => { | |
sliderElement.addEventListener("drag", (_) => { | |
videoElement.volume = sliderElement.value / 100.0; | |
}); | |
// }; | |
sliderElement.addEventListener("change", debounce(() => { | |
videoElement.volume = sliderElement.value / 100.0; | |
GM.setValue(LastVolumeGMKey, videoElement.volume); | |
}, 5)); | |
// listen to the mute button hover thing. (this probably can be done wholly using CSS, but i really don't care..) | |
muteButtonElement.parentNode.addEventListener("pointerover", (_) => { | |
// show the slider | |
sliderElement.style.display = "unset"; | |
}); | |
muteButtonElement.parentNode.addEventListener("pointerleave", (e) => { | |
// hide the slider | |
sliderElement.style.display = "none"; | |
}); | |
// Append the slider and actually make it position good | |
// Since i don't know how flex css works i have to style this in a disgusting way.. | |
// Or maybe they are trying their hardest to make their website a miserable experience so you end up using the data collection app on your phone instead. | |
// (and then they proceed to dmca open source clients) | |
muteButtonElement.parentNode.appendChild(sliderElement); | |
muteButtonElement.parentNode.style.flexDirection = "row-reverse"; | |
muteButtonElement.parentNode.style.gap = "10px"; | |
videoElement.volume = lastLoadedVolume; | |
} | |
// HTMLVideoElement.volume goes between 0 ~ 1 | |
// maybe use a hashmap? | |
const injectedElementsList = []; | |
setInterval(async () => { | |
const elements = document.getElementsByTagName("video"); | |
for (let i = 0; i < elements.length; i++) { | |
if (elements[i] !== null && elements[i] !== undefined && !injectedElementsList.includes(elements[i])) { | |
await injectVolumeButton(elements[i]); | |
injectedElementsList.push(elements[i]); | |
} | |
} | |
// clean the injectedElementsList (.filter() filters out false values) | |
injectedElementsList.filter((value) => value !== null && value !== undefined); | |
}, 300); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment