YouTube Volume Assistant

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

// ==UserScript==
// @name         YouTube Volume Assistant
// @namespace    http://tampermonkey.net/
// @version      0.2.6
// @description  Enhances the volume control on YouTube by providing additional information and features.
// @author       CY Fung
// @license      MIT License
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        none
// @run-at       document-start
// @unwrap
// @allFrames
// @inject-into page
// ==/UserScript==

(function () {
    'use strict';

    //    AudioContext.prototype._createGain = AudioContext.prototype.createGain;

    const insp = o => o ? (o.polymerController || o.inst || o || 0) : (o || 0);


    /** @type {globalThis.PromiseConstructor} */
    const Promise = (async () => { })().constructor; // YouTube hacks Promise in WaterFox Classic and "Promise.resolve(0)" nevers resolve.

    const PromiseExternal = ((resolve_, reject_) => {
        const h = (resolve, reject) => { resolve_ = resolve; reject_ = reject };
        return class PromiseExternal extends Promise {
            constructor(cb = h) {
                super(cb);
                if (cb === h) {
                    /** @type {(value: any) => void} */
                    this.resolve = resolve_;
                    /** @type {(reason?: any) => void} */
                    this.reject = reject_;
                }
            }
        };
    })();

    let wm = new WeakMap();
    /*
        AudioContext.prototype.createGain = function(...args){
            return this.createdGain || (this.createdGain = this._createGain(...args));
        }
    */

    function getMediaElementSource() {
        return wm.get(this) || null;
    }
    function getGainNode() {
        return wm.get(this) || null;
    }

    AudioContext.prototype._createMediaElementSource = AudioContext.prototype.createMediaElementSource;

    AudioContext.prototype.createMediaElementSource = function (video, ...args) {
        let createdMediaElementSource = wm.get(video);
        if (createdMediaElementSource) return createdMediaElementSource;
        wm.set(video, createdMediaElementSource = this._createMediaElementSource(video, ...args));
        video.getMediaElementSource = getMediaElementSource;
        return createdMediaElementSource;
    }


    MediaElementAudioSourceNode.prototype._connect = MediaElementAudioSourceNode.prototype.connect;

    MediaElementAudioSourceNode.prototype.connect = function (gainNode, ...args) {

        this._connect(gainNode, ...args);
        wm.set(this, gainNode);

        this.getGainNode = getGainNode;
    }



    function addDblTap(element, doubleClick) {
        // https://stackoverflow.com/questions/45804917/dblclick-doesnt-work-on-touch-devices

        let expired


        let doubleTouch = function (e) {
            if (e.touches.length === 1) {
                if (!expired) {
                    expired = e.timeStamp + 400
                } else if (e.timeStamp <= expired) {
                    // remove the default of this event ( Zoom )
                    e.preventDefault()
                    doubleClick(e)
                    // then reset the variable for other "double Touches" event
                    expired = null
                } else {
                    // if the second touch was expired, make it as it's the first
                    expired = e.timeStamp + 400
                }
            }
        }

        element.addEventListener('touchstart', doubleTouch)
        element.addEventListener('dblclick', doubleClick)
    }


    function createCSS() {

        if (document.querySelector('#iTFoh')) return;
        let style = document.createElement('style');
        style.id = 'iTFoh';
        style.textContent = `
        .video-tip-offseted {
        margin-top:-1em;
        }
        .volume-tip-gain{
        opacity:0.52;
        }
        .volume-tip-normalized{
        opacity:0.4;
        }
        `;

        document.head.appendChild(style)

    }

    let volumeSlider = null;
    let volumeTitle = '';

    let volumeSpan = null;
    let lastContent = null;
    let gainNode = null;

    function refreshDOM() {
        volumeSlider = document.querySelector('.ytp-volume-panel[role="slider"][title]');
        if (volumeSlider) {
            volumeTitle = volumeSlider.getAttribute('title');
        } else {
            volumeTitle = '';
        }
    }

    function setDblTap() {
        if (!volumeSlider) return;
        if (volumeSlider.hasAttribute('pKRyA')) return;
        volumeSlider.setAttribute('pKRyA', '');

        addDblTap(volumeSlider, (e) => {
            let target = null;
            try {
                target = e.target.closest('.ytp-volume-area').querySelector('.ytp-mute-button');
            } catch (e) { }
            if (target !== null) {
                const e2 = new MouseEvent('contextmenu', {
                    bubbles: true,
                    cancelable: true,
                    view: window
                });
                target.dispatchEvent(e2);
            }
        });
    }

    let template = document.createElement('template');

    let ktid = 0;
    async function changeVolumeText() {

        try {

            if (ktid > 1e9) ktid = 9;
            const tid = ++ktid;


            const volumeSpan_ = volumeSpan;
            if (!volumeSpan_ || !lastContent) return;
            if (lastContent && lastContent !== volumeSpan_.textContent) return;
            if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;

            let video = document.querySelector('#player video[src]');
            if (!video) return;

            const ytdPlayerElement = document.querySelector('ytd-player');
            if (!ytdPlayerElement) return;
            const ytdPlayerCntX = insp(ytdPlayerElement);
            const ytdPlayerCnt = ytdPlayerCntX.getPlayerPromise ? ytdPlayerCntX : ytdPlayerElement;

            let ytdPlayerPlayer_ = ytdPlayerElement.player_ || insp(ytdPlayerElement).player_ || 0;
            if (!ytdPlayerPlayer_ && typeof ytdPlayerCnt.getPlayerPromise === 'function') ytdPlayerPlayer_ = await ytdPlayerCnt.getPlayerPromise();
            if (tid !== ktid) return;
            if (!ytdPlayerPlayer_ || !ytdPlayerPlayer_.getVolume) return;
            if (typeof ytdPlayerPlayer_.getVolume !== 'function') console.error('ytdPlayerPlayer_.getVolume is not a function', typeof ytdPlayerPlayer_.getVolume);

            let actualVolume = null;
            try {
                actualVolume = await ytdPlayerPlayer_.getVolume();
            } catch (e) { }

            if (tid !== ktid) return;

            if (!volumeSpan_ || !lastContent || actualVolume === null) return;
            if (lastContent && lastContent !== volumeSpan_.textContent) return;
            if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;
            if (video.isConnected !== true) return;


            if (gainNode === null) {
                let source = video.getMediaElementSource ? video.getMediaElementSource() : null;
                if (source) {
                    gainNode = source.getGainNode ? source.getGainNode() : null;
                }
            }

            let gainValue = (((gainNode || 0).gain || 0).value || 0);
            let m = gainValue || 1.0;

            let normalized = video.volume * 100;

            if (!volumeSpan_ || !lastContent) return;
            if (lastContent && lastContent !== volumeSpan_.textContent) return;
            if (volumeSpan_ !== volumeSpan || volumeSpan_.isConnected !== true) return;

            let gainText = gainValue ? `<span class="volume-tip-gain">Gain = ${+(gainValue.toFixed(2))}</span><br>` : '';

            template.innerHTML = `
                <span class="volume-tip-offset">
                ${gainText}
                <span class="volume-tip-volume">Volume: ${(m * actualVolume).toFixed(1)}% </span><br>
                <span class="volume-tip-normalized"> Normalized: ${(m * normalized).toFixed(1)}%</span>
                </span>
            `.trim().replace(/\s*[\r\n]+\s*/g,'');
            if (volumeSpan.textContent !== template.content.textContent && lastContent === volumeSpan.textContent) {

                volumeSpan.innerHTML = template.innerHTML;
                lastContent = volumeSpan.textContent;

            }
        } catch (e) {
            console.warn(e);
        }
    }

    function addVideoEvents() {
        let video = document.querySelector('#player video[src]');
        if (!video) return;
        if (video.hasAttribute('zHbT0')) return;
        video.setAttribute('zHbT0', '');
        video.addEventListener('volumechange', changeVolumeText, false)
    }


    // let ktid = 0;
    let goChecking = false;

    const asyncNavigateFinish = async () => {
        goChecking = false;
        createCSS();
        const f = () => {
            refreshDOM();
            if (!volumeSlider) return;
            setDblTap();
            addVideoEvents();
            goChecking = true;
            return true;
        };
        f() || setTimeout(f, 300);
    }

    const onNavigateFinish = () => {
        asyncNavigateFinish();
    };
    document.addEventListener('yt-navigate-finish', onNavigateFinish, true);

    let r80Promise = null;

    setInterval(() => {
        if (r80Promise) {
            r80Promise.resolve();
            r80Promise = null;
        }
    }, 80);



    const filterFn = t => t.textContent === volumeTitle;
    // const r0Fn = r => requestAnimationFrame(r);
    const laterFn = async () => {

        // let tid = Date.now();
        // ktid = tid;
        // r80Promise = new PromiseExternal();
        // await r80Promise.then();
        if (!goChecking) return;
        // if (ktid !== tid) return;

        if (!volumeSpan) {
            let elms = [...document.querySelectorAll('#player .ytp-tooltip div.ytp-tooltip-text-wrapper span.ytp-tooltip-text')];
            if (elms.length > 0) {
                elms = elms.filter(filterFn);
            }

            if (elms[0]) {
                HTMLElement.prototype.closest.call(elms[0], '#player .ytp-tooltip').classList.add('video-tip-offseted');
                volumeSpan = elms[0];
                lastContent = volumeSpan.textContent;
            }
        }

        if (volumeSpan && (!volumeSpan.isConnected || volumeSpan.textContent !== lastContent)) {
            // volumeSpan.textContent = volumeTitle;
            let p = document.querySelector('.video-tip-offseted');
            if (p) p.classList.remove('video-tip-offseted');
            let m = document.querySelector('.volume-tip-offset');
            if (m) m.remove();
            volumeSpan = null;
            lastContent = null;
        }

        if (volumeSpan) {

            // await new Promise(r0Fn);
            // if (ktid === tid) {
                changeVolumeText();
            // }

        }

    }
    new MutationObserver(function () {

        Promise.resolve().then(laterFn);

    }).observe(document, { subtree: true, childList: true });



})();