YouTube Volume Assistant

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

目前為 2023-05-21 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Volume Assistant
// @namespace    http://tampermonkey.net/
// @version      0.1.2
// @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;


    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;
    }




    let finished = false;
    const onNavigateFinish = () => {
        if (finished) return;
        finished = true;
    document.removeEventListener('yt-navigate-finish', onNavigateFinish, true);

        document.head.appendChild(document.createElement('style')).textContent = `
        .video-tip-offseted {
        margin-top:-1em;
        }
        .volume-tip-gain{
        opacity:0.52;
        }
        .volume-tip-normalized{
        opacity:0.4;
        }
        `;


        setTimeout(() => {

            let volumeSlider = document.querySelector('.ytp-volume-panel[role="slider"][title]');

            let volumeTitle = volumeSlider.getAttribute('title');



            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)
            }

            addDblTap(volumeSlider, (e) => {

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

                if (target) target.dispatchEvent(e2);


            })



            let volumeSpan = null;
            let lastContent = null;



            let video = document.querySelector('#player video[src]');
            let source = null;
            let gainNode = null;
            video.addEventListener('volumechange', changeVolumeText, false)


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

            function changeVolumeText() {

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

                if (gainNode === null) {

                    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 actualVolume = document.querySelector('ytd-player').player_.getVolume();
                let normalized = video.volume * 100;

                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();
                if (volumeSpan.textContent !== template.content.textContent && lastContent === volumeSpan.textContent) {

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

                }
            }

            setInterval(() => {

                if (!volumeSpan) {
                    let elms = [...document.querySelectorAll('#player .ytp-tooltip div.ytp-tooltip-text-wrapper span.ytp-tooltip-text')];
                    elms = elms.filter(t => t.textContent === volumeTitle);

                    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) return;
                let tid = Date.now();
                ktid = tid;
                requestAnimationFrame(() => {
                    if (ktid !== tid) return;
                    changeVolumeText();

                });

            }, 80)

        }, 300);


    };
    document.addEventListener('yt-navigate-finish', onNavigateFinish, true);
    setTimeout(onNavigateFinish, 800);


})();