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.2
  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. if (!volumeSpan || !lastContent) return;
  152.  
  153. let video = document.querySelector('#player video[src]');
  154. if (!video) return;
  155.  
  156. const ytdPlayerElement = document.querySelector('ytd-player');
  157. if (!ytdPlayerElement) return;
  158. const ytdPlayerPlayer_ = ytdPlayerElement.player_ || insp(ytdPlayerElement).player_ || 0;
  159. if (!ytdPlayerPlayer_ || !ytdPlayerPlayer_.getVolume) return;
  160. if (typeof ytdPlayerPlayer_.getVolume !== 'function') console.error('ytdPlayerPlayer_.getVolume is not a function', typeof ytdPlayerPlayer_.getVolume);
  161.  
  162.  
  163. if (gainNode === null) {
  164. let source = video.getMediaElementSource ? video.getMediaElementSource() : null;
  165. if (source) {
  166. gainNode = source.getGainNode ? source.getGainNode() : null;
  167. }
  168. }
  169.  
  170. let gainValue = (((gainNode || 0).gain || 0).value || 0);
  171. let m = gainValue || 1.0;
  172.  
  173. let actualVolume = await ytdPlayerPlayer_.getVolume();
  174. let normalized = video.volume * 100;
  175.  
  176. let gainText = gainValue ? `<span class="volume-tip-gain">Gain = ${+(gainValue.toFixed(2))}</span><br>` : '';
  177.  
  178. template.innerHTML = `
  179. <span class="volume-tip-offset">
  180. ${gainText}
  181. <span class="volume-tip-volume">Volume: ${(m * actualVolume).toFixed(1)}% </span><br>
  182. <span class="volume-tip-normalized"> Normalized: ${(m * normalized).toFixed(1)}%</span>
  183. </span>`.trim();
  184. if (volumeSpan.textContent !== template.content.textContent && lastContent === volumeSpan.textContent) {
  185.  
  186. volumeSpan.innerHTML = template.innerHTML;
  187. lastContent = volumeSpan.textContent;
  188.  
  189. }
  190. }
  191.  
  192. function addVideoEvents() {
  193. let video = document.querySelector('#player video[src]');
  194. if (!video) return;
  195. if (video.hasAttribute('zHbT0')) return;
  196. video.setAttribute('zHbT0', '');
  197. video.addEventListener('volumechange', changeVolumeText, false)
  198. }
  199.  
  200.  
  201. let ktid = 0;
  202. let goChecking = false;
  203.  
  204. const asyncNavigateFinish = async () => {
  205. goChecking = false;
  206. createCSS();
  207. const f = () => {
  208. refreshDOM();
  209. if (!volumeSlider) return;
  210. setDblTap();
  211. addVideoEvents();
  212. goChecking = true;
  213. return true;
  214. };
  215. f() || setTimeout(f, 300);
  216. }
  217.  
  218. const onNavigateFinish = () => {
  219. asyncNavigateFinish();
  220. };
  221. document.addEventListener('yt-navigate-finish', onNavigateFinish, true);
  222.  
  223.  
  224. (async function () {
  225.  
  226. const filterFn = t => t.textContent === volumeTitle;
  227. const r80Fn = r => setTimeout(r, 80);
  228. const r0Fn = r => requestAnimationFrame(r);
  229.  
  230. while (true) {
  231.  
  232. let tid = Date.now();
  233. ktid = tid;
  234. await new Promise(r80Fn);
  235. if (!goChecking) continue;
  236.  
  237. if (!volumeSpan) {
  238. let elms = [...document.querySelectorAll('#player .ytp-tooltip div.ytp-tooltip-text-wrapper span.ytp-tooltip-text')];
  239. if (elms.length > 0) {
  240. elms = elms.filter(filterFn);
  241. }
  242.  
  243. if (elms[0]) {
  244. HTMLElement.prototype.closest.call(elms[0], '#player .ytp-tooltip').classList.add('video-tip-offseted');
  245. volumeSpan = elms[0];
  246. lastContent = volumeSpan.textContent;
  247. }
  248. }
  249.  
  250. if (volumeSpan && (!volumeSpan.isConnected || volumeSpan.textContent !== lastContent)) {
  251. // volumeSpan.textContent = volumeTitle;
  252. let p = document.querySelector('.video-tip-offseted');
  253. if (p) p.classList.remove('video-tip-offseted');
  254. let m = document.querySelector('.volume-tip-offset');
  255. if (m) m.remove();
  256. volumeSpan = null;
  257. lastContent = null;
  258. }
  259.  
  260. if (volumeSpan) {
  261.  
  262. await new Promise(r0Fn);
  263. if (ktid === tid) {
  264. changeVolumeText();
  265. }
  266.  
  267. }
  268.  
  269.  
  270. }
  271.  
  272. })();
  273.  
  274.  
  275. })();