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.3
  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. let wm = new WeakMap();
  25. /*
  26. AudioContext.prototype.createGain = function(...args){
  27. return this.createdGain || (this.createdGain = this._createGain(...args));
  28. }
  29. */
  30.  
  31. function getMediaElementSource() {
  32. return wm.get(this) || null;
  33. }
  34. function getGainNode() {
  35. return wm.get(this) || null;
  36. }
  37.  
  38. AudioContext.prototype._createMediaElementSource = AudioContext.prototype.createMediaElementSource;
  39.  
  40. AudioContext.prototype.createMediaElementSource = function (video, ...args) {
  41. let createdMediaElementSource = wm.get(video);
  42. if (createdMediaElementSource) return createdMediaElementSource;
  43. wm.set(video, createdMediaElementSource = this._createMediaElementSource(video, ...args));
  44. video.getMediaElementSource = getMediaElementSource;
  45. return createdMediaElementSource;
  46. }
  47.  
  48.  
  49. MediaElementAudioSourceNode.prototype._connect = MediaElementAudioSourceNode.prototype.connect;
  50.  
  51. MediaElementAudioSourceNode.prototype.connect = function (gainNode, ...args) {
  52.  
  53. this._connect(gainNode, ...args);
  54. wm.set(this, gainNode);
  55.  
  56. this.getGainNode = getGainNode;
  57. }
  58.  
  59.  
  60.  
  61. function addDblTap(element, doubleClick) {
  62. // https://stackoverflow.com/questions/45804917/dblclick-doesnt-work-on-touch-devices
  63.  
  64. let expired
  65.  
  66.  
  67. let doubleTouch = function (e) {
  68. if (e.touches.length === 1) {
  69. if (!expired) {
  70. expired = e.timeStamp + 400
  71. } else if (e.timeStamp <= expired) {
  72. // remove the default of this event ( Zoom )
  73. e.preventDefault()
  74. doubleClick(e)
  75. // then reset the variable for other "double Touches" event
  76. expired = null
  77. } else {
  78. // if the second touch was expired, make it as it's the first
  79. expired = e.timeStamp + 400
  80. }
  81. }
  82. }
  83.  
  84. element.addEventListener('touchstart', doubleTouch)
  85. element.addEventListener('dblclick', doubleClick)
  86. }
  87.  
  88.  
  89. function createCSS() {
  90.  
  91. if (document.querySelector('#iTFoh')) return;
  92. let style = document.createElement('style');
  93. style.id = 'iTFoh';
  94. style.textContent = `
  95. .video-tip-offseted {
  96. margin-top:-1em;
  97. }
  98. .volume-tip-gain{
  99. opacity:0.52;
  100. }
  101. .volume-tip-normalized{
  102. opacity:0.4;
  103. }
  104. `;
  105.  
  106. document.head.appendChild(style)
  107.  
  108. }
  109.  
  110. let volumeSlider = null;
  111. let volumeTitle = '';
  112.  
  113. let volumeSpan = null;
  114. let lastContent = null;
  115. let gainNode = null;
  116.  
  117. function refreshDOM() {
  118. volumeSlider = document.querySelector('.ytp-volume-panel[role="slider"][title]');
  119. if (volumeSlider) {
  120. volumeTitle = volumeSlider.getAttribute('title');
  121. } else {
  122. volumeTitle = '';
  123. }
  124. }
  125.  
  126. function setDblTap() {
  127. if (!volumeSlider) return;
  128. if (volumeSlider.hasAttribute('pKRyA')) return;
  129. volumeSlider.setAttribute('pKRyA', '');
  130.  
  131. addDblTap(volumeSlider, (e) => {
  132. let target = null;
  133. try {
  134. target = e.target.closest('.ytp-volume-area').querySelector('.ytp-mute-button');
  135. } catch (e) { }
  136. if (target !== null) {
  137. const e2 = new MouseEvent('contextmenu', {
  138. bubbles: true,
  139. cancelable: true,
  140. view: window
  141. });
  142. target.dispatchEvent(e2);
  143. }
  144. });
  145. }
  146.  
  147. let template = document.createElement('template');
  148.  
  149. async function changeVolumeText() {
  150.  
  151. const volumeSpan_ = volumeSpan;
  152. if (!volumeSpan_ || !lastContent) return;
  153. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  154. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  155.  
  156. let video = document.querySelector('#player video[src]');
  157. if (!video) return;
  158.  
  159. const ytdPlayerElement = document.querySelector('ytd-player');
  160. if (!ytdPlayerElement) return;
  161. const ytdPlayerPlayer_ = ytdPlayerElement.player_ || insp(ytdPlayerElement).player_ || 0;
  162. if (!ytdPlayerPlayer_ || !ytdPlayerPlayer_.getVolume) return;
  163. if (typeof ytdPlayerPlayer_.getVolume !== 'function') console.error('ytdPlayerPlayer_.getVolume is not a function', typeof ytdPlayerPlayer_.getVolume);
  164.  
  165.  
  166. if (gainNode === null) {
  167. let source = video.getMediaElementSource ? video.getMediaElementSource() : null;
  168. if (source) {
  169. gainNode = source.getGainNode ? source.getGainNode() : null;
  170. }
  171. }
  172.  
  173. let gainValue = (((gainNode || 0).gain || 0).value || 0);
  174. let m = gainValue || 1.0;
  175.  
  176. let actualVolume = await ytdPlayerPlayer_.getVolume();
  177. let normalized = video.volume * 100;
  178.  
  179. if (!volumeSpan_ || !lastContent) return;
  180. if (lastContent && lastContent !== volumeSpan_.textContent) return;
  181. if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
  182.  
  183. let gainText = gainValue ? `<span class="volume-tip-gain">Gain = ${+(gainValue.toFixed(2))}</span><br>` : '';
  184.  
  185. template.innerHTML = `
  186. <span class="volume-tip-offset">
  187. ${gainText}
  188. <span class="volume-tip-volume">Volume: ${(m * actualVolume).toFixed(1)}% </span><br>
  189. <span class="volume-tip-normalized"> Normalized: ${(m * normalized).toFixed(1)}%</span>
  190. </span>`.trim();
  191. if (volumeSpan.textContent !== template.content.textContent && lastContent === volumeSpan.textContent) {
  192.  
  193. volumeSpan.innerHTML = template.innerHTML;
  194. lastContent = volumeSpan.textContent;
  195.  
  196. }
  197. }
  198.  
  199. function addVideoEvents() {
  200. let video = document.querySelector('#player video[src]');
  201. if (!video) return;
  202. if (video.hasAttribute('zHbT0')) return;
  203. video.setAttribute('zHbT0', '');
  204. video.addEventListener('volumechange', changeVolumeText, false)
  205. }
  206.  
  207.  
  208. let ktid = 0;
  209. let goChecking = false;
  210.  
  211. const asyncNavigateFinish = async () => {
  212. goChecking = false;
  213. createCSS();
  214. const f = () => {
  215. refreshDOM();
  216. if (!volumeSlider) return;
  217. setDblTap();
  218. addVideoEvents();
  219. goChecking = true;
  220. return true;
  221. };
  222. f() || setTimeout(f, 300);
  223. }
  224.  
  225. const onNavigateFinish = () => {
  226. asyncNavigateFinish();
  227. };
  228. document.addEventListener('yt-navigate-finish', onNavigateFinish, true);
  229.  
  230.  
  231. (async function () {
  232.  
  233. const filterFn = t => t.textContent === volumeTitle;
  234. const r80Fn = r => setTimeout(r, 80);
  235. const r0Fn = r => requestAnimationFrame(r);
  236.  
  237. while (true) {
  238.  
  239. let tid = Date.now();
  240. ktid = tid;
  241. await new Promise(r80Fn);
  242. if (!goChecking) continue;
  243.  
  244. if (!volumeSpan) {
  245. let elms = [...document.querySelectorAll('#player .ytp-tooltip div.ytp-tooltip-text-wrapper span.ytp-tooltip-text')];
  246. if (elms.length > 0) {
  247. elms = elms.filter(filterFn);
  248. }
  249.  
  250. if (elms[0]) {
  251. HTMLElement.prototype.closest.call(elms[0], '#player .ytp-tooltip').classList.add('video-tip-offseted');
  252. volumeSpan = elms[0];
  253. lastContent = volumeSpan.textContent;
  254. }
  255. }
  256.  
  257. if (volumeSpan && (!volumeSpan.isConnected || volumeSpan.textContent !== lastContent)) {
  258. // volumeSpan.textContent = volumeTitle;
  259. let p = document.querySelector('.video-tip-offseted');
  260. if (p) p.classList.remove('video-tip-offseted');
  261. let m = document.querySelector('.volume-tip-offset');
  262. if (m) m.remove();
  263. volumeSpan = null;
  264. lastContent = null;
  265. }
  266.  
  267. if (volumeSpan) {
  268.  
  269. await new Promise(r0Fn);
  270. if (ktid === tid) {
  271. changeVolumeText();
  272. }
  273.  
  274. }
  275.  
  276.  
  277. }
  278.  
  279. })();
  280.  
  281.  
  282. })();