Show YouTube Volume % Badge

Volume % badge

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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 });
})();