YouTube Volume Assistant

Enhances the volume control on YouTube by providing additional information and features.

  1. // ==UserScript==
  2. // @name YouTube Volume Assistant
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.6
  5. // @description Enhances the volume control on YouTube by providing additional information and features.
  6. // @author CY Fung
  7. // @license MIT License
  8. // @match https://www.youtube.com/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  10. // @grant none
  11. // @run-at document-start
  12. // @unwrap
  13. // @allFrames
  14. // @inject-into page
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. // AudioContext.prototype._createGain = AudioContext.prototype.createGain;
  21.  
  22. const insp = o => o ? (o.polymerController || o.inst || o || 0) : (o || 0);
  23.  
  24.  
  25. /** @type {globalThis.PromiseConstructor} */
  26. const Promise = (async () => { })().constructor; // YouTube hacks Promise in WaterFox Classic and "Promise.resolve(0)" nevers resolve.
  27.  
  28. const PromiseExternal = ((resolve_, reject_) => {
  29. const h = (resolve, reject) => { resolve_ = resolve; reject_ = reject };
  30. return class PromiseExternal extends Promise {
  31. constructor(cb = h) {
  32. super(cb);
  33. if (cb === h) {
  34. /** @type {(value: any) => void} */
  35. this.resolve = resolve_;
  36. /** @type {(reason?: any) => void} */
  37. this.reject = reject_;
  38. }
  39. }
  40. };
  41. })();
  42.  
  43. let wm = new WeakMap();
  44. /*
  45. AudioContext.prototype.createGain = function(...args){
  46. return this.createdGain || (this.createdGain = this._createGain(...args));
  47. }
  48. */
  49.  
  50. function getMediaElementSource() {
  51. return wm.get(this) || null;
  52. }
  53. function getGainNode() {
  54. return wm.get(this) || null;
  55. }
  56.  
  57. AudioContext.prototype._createMediaElementSource = AudioContext.prototype.createMediaElementSource;
  58.  
  59. AudioContext.prototype.createMediaElementSource = function (video, ...args) {
  60. let createdMediaElementSource = wm.get(video);
  61. if (createdMediaElementSource) return createdMediaElementSource;
  62. wm.set(video, createdMediaElementSource = this._createMediaElementSource(video, ...args));
  63. video.getMediaElementSource = getMediaElementSource;
  64. return createdMediaElementSource;
  65. }
  66.  
  67.  
  68. MediaElementAudioSourceNode.prototype._connect = MediaElementAudioSourceNode.prototype.connect;
  69.  
  70. MediaElementAudioSourceNode.prototype.connect = function (gainNode, ...args) {
  71.  
  72. this._connect(gainNode, ...args);
  73. wm.set(this, gainNode);
  74.  
  75. this.getGainNode = getGainNode;
  76. }
  77.  
  78.  
  79.  
  80. function addDblTap(element, doubleClick) {
  81. // https://stackoverflow.com/questions/45804917/dblclick-doesnt-work-on-touch-devices
  82.  
  83. let expired
  84.  
  85.  
  86. let doubleTouch = function (e) {
  87. if (e.touches.length === 1) {
  88. if (!expired) {
  89. expired = e.timeStamp + 400
  90. } else if (e.timeStamp <= expired) {
  91. // remove the default of this event ( Zoom )
  92. e.preventDefault()
  93. doubleClick(e)
  94. // then reset the variable for other "double Touches" event
  95. expired = null
  96. } else {
  97. // if the second touch was expired, make it as it's the first
  98. expired = e.timeStamp + 400
  99. }
  100. }
  101. }
  102.  
  103. element.addEventListener('touchstart', doubleTouch)
  104. element.addEventListener('dblclick', doubleClick)
  105. }
  106.  
  107.  
  108. function createCSS() {
  109.  
  110. if (document.querySelector('#iTFoh')) return;
  111. let style = document.createElement('style');
  112. style.id = 'iTFoh';
  113. style.textContent = `
  114. .video-tip-offseted {
  115. margin-top:-1em;
  116. }
  117. .volume-tip-gain{
  118. opacity:0.52;
  119. }
  120. .volume-tip-normalized{
  121. opacity:0.4;
  122. }
  123. `;
  124.  
  125. document.head.appendChild(style)
  126.  
  127. }
  128.  
  129. let volumeSlider = null;
  130. let volumeTitle = '';
  131.  
  132. let volumeSpan = null;
  133. let lastContent = null;
  134. let gainNode = null;
  135.  
  136. function refreshDOM() {
  137. volumeSlider = document.querySelector('.ytp-volume-panel[role="slider"][title]');
  138. if (volumeSlider) {
  139. volumeTitle = volumeSlider.getAttribute('title');
  140. } else {
  141. volumeTitle = '';
  142. }
  143. }
  144.  
  145. function setDblTap() {
  146. if (!volumeSlider) return;
  147. if (volumeSlider.hasAttribute('pKRyA')) return;
  148. volumeSlider.setAttribute('pKRyA', '');
  149.  
  150. addDblTap(volumeSlider, (e) => {
  151. let target = null;
  152. try {
  153. target = e.target.closest('.ytp-volume-area').querySelector('.ytp-mute-button');
  154. } catch (e) { }
  155. if (target !== null) {
  156. const e2 = new MouseEvent('contextmenu', {
  157. bubbles: true,
  158. cancelable: true,
  159. view: window
  160. });
  161. target.dispatchEvent(e2);
  162. }
  163. });
  164. }
  165.  
  166. let template = document.createElement('template');
  167.  
  168. let ktid = 0;
  169. async function changeVolumeText() {
  170.  
  171. try {
  172.  
  173. if (ktid > 1e9) ktid = 9;
  174. const tid = ++ktid;
  175.  
  176.  
  177. const volumeSpan_ = volumeSpan;
  178. if (!volumeSpan_ || !lastContent) return;
  179. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  180. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  181.  
  182. let video = document.querySelector('#player video[src]');
  183. if (!video) return;
  184.  
  185. const ytdPlayerElement = document.querySelector('ytd-player');
  186. if (!ytdPlayerElement) return;
  187. const ytdPlayerCntX = insp(ytdPlayerElement);
  188. const ytdPlayerCnt = ytdPlayerCntX.getPlayerPromise ? ytdPlayerCntX : ytdPlayerElement;
  189.  
  190. let ytdPlayerPlayer_ = ytdPlayerElement.player_ || insp(ytdPlayerElement).player_ || 0;
  191. if (!ytdPlayerPlayer_ && typeof ytdPlayerCnt.getPlayerPromise === 'function') ytdPlayerPlayer_ = await ytdPlayerCnt.getPlayerPromise();
  192. if (tid !== ktid) return;
  193. if (!ytdPlayerPlayer_ || !ytdPlayerPlayer_.getVolume) return;
  194. if (typeof ytdPlayerPlayer_.getVolume !== 'function') console.error('ytdPlayerPlayer_.getVolume is not a function', typeof ytdPlayerPlayer_.getVolume);
  195.  
  196. let actualVolume = null;
  197. try {
  198. actualVolume = await ytdPlayerPlayer_.getVolume();
  199. } catch (e) { }
  200.  
  201. if (tid !== ktid) return;
  202.  
  203. if (!volumeSpan_ || !lastContent || actualVolume === null) return;
  204. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  205. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  206. if (video.isConnected !== true) return;
  207.  
  208.  
  209. if (gainNode === null) {
  210. let source = video.getMediaElementSource ? video.getMediaElementSource() : null;
  211. if (source) {
  212. gainNode = source.getGainNode ? source.getGainNode() : null;
  213. }
  214. }
  215.  
  216. let gainValue = (((gainNode || 0).gain || 0).value || 0);
  217. let m = gainValue || 1.0;
  218.  
  219. let normalized = video.volume * 100;
  220.  
  221. if (!volumeSpan_ || !lastContent) return;
  222. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  223. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  224.  
  225. let gainText = gainValue ? `<span class="volume-tip-gain">Gain = ${+(gainValue.toFixed(2))}</span><br>` : '';
  226.  
  227. template.innerHTML = `
  228. <span class="volume-tip-offset">
  229. ${gainText}
  230. <span class="volume-tip-volume">Volume: ${(m * actualVolume).toFixed(1)}% </span><br>
  231. <span class="volume-tip-normalized"> Normalized: ${(m * normalized).toFixed(1)}%</span>
  232. </span>
  233. `.trim().replace(/\s*[\r\n]+\s*/g,'');
  234. if (volumeSpan.textContent !== template.content.textContent && lastContent === volumeSpan.textContent) {
  235.  
  236. volumeSpan.innerHTML = template.innerHTML;
  237. lastContent = volumeSpan.textContent;
  238.  
  239. }
  240. } catch (e) {
  241. console.warn(e);
  242. }
  243. }
  244.  
  245. function addVideoEvents() {
  246. let video = document.querySelector('#player video[src]');
  247. if (!video) return;
  248. if (video.hasAttribute('zHbT0')) return;
  249. video.setAttribute('zHbT0', '');
  250. video.addEventListener('volumechange', changeVolumeText, false)
  251. }
  252.  
  253.  
  254. // let ktid = 0;
  255. let goChecking = false;
  256.  
  257. const asyncNavigateFinish = async () => {
  258. goChecking = false;
  259. createCSS();
  260. const f = () => {
  261. refreshDOM();
  262. if (!volumeSlider) return;
  263. setDblTap();
  264. addVideoEvents();
  265. goChecking = true;
  266. return true;
  267. };
  268. f() || setTimeout(f, 300);
  269. }
  270.  
  271. const onNavigateFinish = () => {
  272. asyncNavigateFinish();
  273. };
  274. document.addEventListener('yt-navigate-finish', onNavigateFinish, true);
  275.  
  276. let r80Promise = null;
  277.  
  278. setInterval(() => {
  279. if (r80Promise) {
  280. r80Promise.resolve();
  281. r80Promise = null;
  282. }
  283. }, 80);
  284.  
  285.  
  286.  
  287. const filterFn = t => t.textContent === volumeTitle;
  288. // const r0Fn = r => requestAnimationFrame(r);
  289. const laterFn = async () => {
  290.  
  291. // let tid = Date.now();
  292. // ktid = tid;
  293. // r80Promise = new PromiseExternal();
  294. // await r80Promise.then();
  295. if (!goChecking) return;
  296. // if (ktid !== tid) return;
  297.  
  298. if (!volumeSpan) {
  299. let elms = [...document.querySelectorAll('#player .ytp-tooltip div.ytp-tooltip-text-wrapper span.ytp-tooltip-text')];
  300. if (elms.length > 0) {
  301. elms = elms.filter(filterFn);
  302. }
  303.  
  304. if (elms[0]) {
  305. HTMLElement.prototype.closest.call(elms[0], '#player .ytp-tooltip').classList.add('video-tip-offseted');
  306. volumeSpan = elms[0];
  307. lastContent = volumeSpan.textContent;
  308. }
  309. }
  310.  
  311. if (volumeSpan && (!volumeSpan.isConnected || volumeSpan.textContent !== lastContent)) {
  312. // volumeSpan.textContent = volumeTitle;
  313. let p = document.querySelector('.video-tip-offseted');
  314. if (p) p.classList.remove('video-tip-offseted');
  315. let m = document.querySelector('.volume-tip-offset');
  316. if (m) m.remove();
  317. volumeSpan = null;
  318. lastContent = null;
  319. }
  320.  
  321. if (volumeSpan) {
  322.  
  323. // await new Promise(r0Fn);
  324. // if (ktid === tid) {
  325. changeVolumeText();
  326. // }
  327.  
  328. }
  329.  
  330. }
  331. new MutationObserver(function () {
  332.  
  333. Promise.resolve().then(laterFn);
  334.  
  335. }).observe(document, { subtree: true, childList: true });
  336.  
  337.  
  338.  
  339. })();