YouTube Volume Assistant

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

目前為 2024-07-11 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name YouTube Volume Assistant
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.4
  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. async function changeVolumeText() {
  169.  
  170. const volumeSpan_ = volumeSpan;
  171. if (!volumeSpan_ || !lastContent) return;
  172. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  173. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  174.  
  175. let video = document.querySelector('#player video[src]');
  176. if (!video) return;
  177.  
  178. const ytdPlayerElement = document.querySelector('ytd-player');
  179. if (!ytdPlayerElement) return;
  180. const ytdPlayerPlayer_ = ytdPlayerElement.player_ || insp(ytdPlayerElement).player_ || 0;
  181. if (!ytdPlayerPlayer_ || !ytdPlayerPlayer_.getVolume) return;
  182. if (typeof ytdPlayerPlayer_.getVolume !== 'function') console.error('ytdPlayerPlayer_.getVolume is not a function', typeof ytdPlayerPlayer_.getVolume);
  183.  
  184.  
  185. if (gainNode === null) {
  186. let source = video.getMediaElementSource ? video.getMediaElementSource() : null;
  187. if (source) {
  188. gainNode = source.getGainNode ? source.getGainNode() : null;
  189. }
  190. }
  191.  
  192. let gainValue = (((gainNode || 0).gain || 0).value || 0);
  193. let m = gainValue || 1.0;
  194.  
  195. let actualVolume = await ytdPlayerPlayer_.getVolume();
  196. let normalized = video.volume * 100;
  197.  
  198. if (!volumeSpan_ || !lastContent) return;
  199. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  200. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  201.  
  202. let gainText = gainValue ? `<span class="volume-tip-gain">Gain = ${+(gainValue.toFixed(2))}</span><br>` : '';
  203.  
  204. template.innerHTML = `
  205. <span class="volume-tip-offset">
  206. ${gainText}
  207. <span class="volume-tip-volume">Volume: ${(m * actualVolume).toFixed(1)}% </span><br>
  208. <span class="volume-tip-normalized"> Normalized: ${(m * normalized).toFixed(1)}%</span>
  209. </span>`.trim();
  210. if (volumeSpan.textContent !== template.content.textContent && lastContent === volumeSpan.textContent) {
  211.  
  212. volumeSpan.innerHTML = template.innerHTML;
  213. lastContent = volumeSpan.textContent;
  214.  
  215. }
  216. }
  217.  
  218. function addVideoEvents() {
  219. let video = document.querySelector('#player video[src]');
  220. if (!video) return;
  221. if (video.hasAttribute('zHbT0')) return;
  222. video.setAttribute('zHbT0', '');
  223. video.addEventListener('volumechange', changeVolumeText, false)
  224. }
  225.  
  226.  
  227. let ktid = 0;
  228. let goChecking = false;
  229.  
  230. const asyncNavigateFinish = async () => {
  231. goChecking = false;
  232. createCSS();
  233. const f = () => {
  234. refreshDOM();
  235. if (!volumeSlider) return;
  236. setDblTap();
  237. addVideoEvents();
  238. goChecking = true;
  239. return true;
  240. };
  241. f() || setTimeout(f, 300);
  242. }
  243.  
  244. const onNavigateFinish = () => {
  245. asyncNavigateFinish();
  246. };
  247. document.addEventListener('yt-navigate-finish', onNavigateFinish, true);
  248.  
  249. let r80Promise = null;
  250.  
  251. setInterval(() => {
  252. if (r80Promise) {
  253. r80Promise.resolve();
  254. r80Promise = null;
  255. }
  256. }, 80);
  257.  
  258. (async function () {
  259.  
  260. const filterFn = t => t.textContent === volumeTitle;
  261. const r0Fn = r => requestAnimationFrame(r);
  262.  
  263. while (true) {
  264.  
  265. let tid = Date.now();
  266. ktid = tid;
  267. r80Promise = new PromiseExternal();
  268. await r80Promise.then();
  269. if (!goChecking) continue;
  270.  
  271. if (!volumeSpan) {
  272. let elms = [...document.querySelectorAll('#player .ytp-tooltip div.ytp-tooltip-text-wrapper span.ytp-tooltip-text')];
  273. if (elms.length > 0) {
  274. elms = elms.filter(filterFn);
  275. }
  276.  
  277. if (elms[0]) {
  278. HTMLElement.prototype.closest.call(elms[0], '#player .ytp-tooltip').classList.add('video-tip-offseted');
  279. volumeSpan = elms[0];
  280. lastContent = volumeSpan.textContent;
  281. }
  282. }
  283.  
  284. if (volumeSpan && (!volumeSpan.isConnected || volumeSpan.textContent !== lastContent)) {
  285. // volumeSpan.textContent = volumeTitle;
  286. let p = document.querySelector('.video-tip-offseted');
  287. if (p) p.classList.remove('video-tip-offseted');
  288. let m = document.querySelector('.volume-tip-offset');
  289. if (m) m.remove();
  290. volumeSpan = null;
  291. lastContent = null;
  292. }
  293.  
  294. if (volumeSpan) {
  295.  
  296. await new Promise(r0Fn);
  297. if (ktid === tid) {
  298. goChecking = false;
  299. changeVolumeText();
  300. }
  301.  
  302. }
  303.  
  304.  
  305. }
  306.  
  307. })();
  308.  
  309.  
  310. })();