Show YouTube Volume % Badge

Volume % badge

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Show YouTube Volume % Badge
// @namespace    yt.volbadge.icon
// @version      1.0
// @description  Volume % badge
// @match        *://*.youtube.com/*
// @match        *://youtu.be/*
// @grant        none
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(() => {
  const BADGE_CLASS = 'ytp-volbadge';
  const STYLE_ID = 'ytp-volbadge-style';
  const stateByPlayer = new WeakMap();

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const s = document.createElement('style');
    s.id = STYLE_ID;
    s.textContent = `
      .ytp-volume-icon { position: relative !important; }
      .${BADGE_CLASS}{
        position: absolute; top: -2px; right: 2px;
        height: 16px; line-height: 16px; padding: 0 6px;
        border-radius: 10px; background: rgba(0,0,0,.65);
        color: #fff; font-size: 10px; font-family: Roboto, Arial, Helvetica, sans-serif;
        user-select: none; pointer-events: none; white-space: nowrap;
      }`;
    document.head.appendChild(s);
  }

  function getMainPlayer() {
    const list = Array.from(document.querySelectorAll('.html5-video-player'));
    return list.find(el => el.classList.contains('playing-mode') || el.classList.contains('paused-mode')) || list[0] || null;
  }

  function attachToPlayer(player) {
    injectStyle();
    let st = stateByPlayer.get(player);
    if (!st) { st = {}; stateByPlayer.set(player, st); }

    const video = player.querySelector('video');
    const muteBtn = player.querySelector('.ytp-mute-button');
    const volumePanel = player.querySelector('.ytp-volume-panel');

    const ensureBadge = () => {
      const icon = player.querySelector('.ytp-volume-icon');
      if (!icon) return null;
      let badge = icon.querySelector('.' + BADGE_CLASS);
      if (!badge) {
        badge = document.createElement('span');
        badge.className = BADGE_CLASS;
        badge.textContent = '--%';
        icon.appendChild(badge);
      }
      st.icon = icon;
      st.badge = badge;
      return badge;
    };

    const getSlider = () =>
      volumePanel?.querySelector('[role="slider"][aria-valuenow]') || null;

    const compute = () => {
      if (!st.badge) return;
      const muted = (muteBtn?.getAttribute('aria-pressed') === 'true') || (video?.muted ?? false);
      let val = 0;
      if (st.slider && st.slider.hasAttribute('aria-valuenow')) {
        const raw = parseInt(st.slider.getAttribute('aria-valuenow') || '0', 10);
        val = isNaN(raw) ? 0 : raw;
      } else if (video) {
        val = Math.round((video.muted ? 0 : video.volume) * 100);
      }
      st.badge.textContent = `${muted ? 0 : val}`;
      st.badge.title = muted ? 'Muted' : `Volume: ${val}%`;
    };

    const bindSliderObs = () => {
      if (st.sliderObs) st.sliderObs.disconnect();
      st.slider = getSlider();
      if (!st.slider) return;
      st.sliderObs = new MutationObserver(muts => {
        if (muts.some(m => m.attributeName === 'aria-valuenow')) compute();
      });
      st.sliderObs.observe(st.slider, { attributes: true, attributeFilter: ['aria-valuenow'] });
      compute();
    };

    // First ensure badge + slider
    ensureBadge();
    bindSliderObs();
    compute();

    // Bind once: mute + video events + lazy slider creation on hover/focus
    if (!st.bound) {
      if (muteBtn) {
        const mo = new MutationObserver(muts => {
          if (muts.some(m => m.attributeName === 'aria-pressed')) compute();
        });
        mo.observe(muteBtn, { attributes: true, attributeFilter: ['aria-pressed'] });
      }
      if (video) {
        ['volumechange', 'loadedmetadata', 'play'].forEach(ev =>
          video.addEventListener(ev, compute, { passive: true })
        );
      }
      if (volumePanel) {
        volumePanel.addEventListener('mouseenter', bindSliderObs, { passive: true });
        volumePanel.addEventListener('focusin', bindSliderObs, { passive: true });
      }
      st.bound = true;
    }

    // Tiny, throttled observer on the player's controls only
    if (!st.controlsObs) {
      const controls = player.querySelector('.ytp-chrome-bottom');
      if (controls) {
        let scheduled = false;
        const scheduleEnsure = () => {
          if (scheduled) return;
          scheduled = true;
          requestAnimationFrame(() => {
            scheduled = false;
            // If icon or badge got replaced/removed, restore
            ensureBadge();
            // If slider node got swapped, rebind
            bindSliderObs();
            compute();
          });
        };
        st.controlsObs = new MutationObserver(scheduleEnsure);
        st.controlsObs.observe(controls, { childList: true, subtree: true });
      }
    }
  }

  function bootstrap() {
    const p = getMainPlayer();
    if (p) attachToPlayer(p);
  }

  // Start + keep alive across SPA navigations
  bootstrap();
  window.addEventListener('yt-navigate-finish', () => setTimeout(bootstrap, 150), { passive: true });
})();