Show YouTube Volume % Badge

Volume % badge

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