YouTube Volume Assistant

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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 });



})();