Netflix Debug Overlay

Click the button or press Ctrl+Shift+Alt+D to activate. Read-only, does not modify Netflix in any way.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Netflix Debug Overlay
// @namespace    https://github.com/nicopasla/Netflix-Debug-Overlay
// @version      1.0.0
// @license      MIT
// @author       nicopasla
// @description  Click the button or press Ctrl+Shift+Alt+D to activate. Read-only, does not modify Netflix in any way.
// @match        https://www.netflix.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const POLL_PLAYING = 1000;
  const POLL_PAUSED  = 3000;

  const styleEl = document.createElement('style');
  styleEl.textContent = `
    #nfdo {
      position: fixed;
      z-index: 2147483647;
      top: 50%;
      left: 16px;
      transform: translateY(-50%);
      width: 260px;
      padding: 8px 11px;
      background: rgba(0,0,0,0.85);
      border-radius: 6px;
      pointer-events: none;
      user-select: none;
      font: 13px/2 'Netflix Sans', system-ui, -apple-system, sans-serif;
      color: #fff;
    }
    #nfdo hr {
      border: none;
      border-top: 1px solid rgba(255,255,255,0.1);
      margin: 4px 0;
    }
    #nfdo .row { display: flex; justify-content: space-between; }
    #nfdo .row span:last-child {
      font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
      font-size: 12px;
    }
    #nfdo .lbl { color: rgba(255,255,255,0.5); }
    #nfdo .playing { color: #4caf50; }
    #nfdo .paused  { color: #ff9800; }
    #nfdo-activate {
      position: fixed;
      z-index: 2147483647;
      top: 16px;
      left: 50%;
      transform: translateX(-50%);
      padding: 5px 18px;
      background: rgba(0,0,0,0.85);
      border: 1px solid rgba(255,255,255,0.15);
      border-radius: 4px;
      color: #fff;
      font: 13px/1.6 'Netflix Sans', system-ui, -apple-system, sans-serif;
      cursor: pointer;
    }
    #nfdo-activate:hover { background: rgba(255,255,255,0.12); }
    #nfdo .topbar {
      display: flex;
      justify-content: space-between;
      margin-bottom: 4px;
    }
    #nfdo .tbtn {
      font-size: 11px;
      color: rgba(255,255,255,0.3);
      cursor: pointer;
      pointer-events: all;
      line-height: 1;
    }
    #nfdo .tbtn:hover { color: #fff; }
    #nfdo .sec {
      font-size: 10px;
      font-weight: 700;
      letter-spacing: .1em;
      text-transform: uppercase;
      color: #5bc8d4 !important;
      margin: 2px 0 0;
    }
  `;
  document.head.appendChild(styleEl);

  const row = (lbl, val) =>
    `<div class="row"><span class="lbl">${lbl}</span><span>${val}</span></div>`;

  const sectionLabel = label => `<div class="sec">${label}</div>`;

  function fireShortcut() {
    ['keydown', 'keyup'].forEach(type =>
      document.dispatchEvent(new KeyboardEvent(type, {
        key: 'D', code: 'KeyD', keyCode: 68,
        ctrlKey: true, shiftKey: true, altKey: true,
        bubbles: true, cancelable: true,
      }))
    );
  }

  function getDebugTextarea() {
    for (const ta of document.querySelectorAll('textarea[readonly]'))
      if (ta.value.toLowerCase().includes('playing bitrate')) return ta;
    return null;
  }

  function hideNetflixPanel(ta) {
    let el = ta.parentElement;
    while (el && el !== document.body) {
      const pos = getComputedStyle(el).position;
      if (pos === 'absolute' || pos === 'fixed') {
        el.style.setProperty('display', 'none', 'important');
        return;
      }
      el = el.parentElement;
    }
    ta.parentElement.style.setProperty('display', 'none', 'important');
  }

  function parse(text) {
    const kv = {};
    for (const line of text.split('\n')) {
      const i = line.indexOf(':');
      if (i === -1) continue;
      const key = line.slice(0, i).trim();
      const val = line.slice(i + 1).trim();
      if (key && val) kv[key] = val;
    }
    const get = k => kv[k] ?? null;

    let aBr = null, vBr = null, res = null;
    const brRaw = get('Playing bitrate (a/v)');
    if (brRaw) {
      const m = brRaw.match(/(\d+)\s*\/\s*(\d+)(?:\s*\((\d+x\d+)\))?/);
      if (m) { aBr = +m[1]; vBr = +m[2]; res = m[3] || null; }
    }

    let vBrBuf = null;
    const brBufRaw = get('Buffering bitrate (a/v)');
    if (brBufRaw) {
      const m = brBufRaw.match(/\d+\s*\/\s*(\d+)/);
      if (m) vBrBuf = +m[1];
    }

    let bufA = null, bufV = null;
    const bufRaw = get('Buffer size in Seconds (a/v)');
    if (bufRaw) {
      const m = bufRaw.match(/([\d.]+)\s*\/\s*([\d.]+)/);
      if (m) { bufA = +m[1]; bufV = +m[2]; }
    }

    const tpRaw = get('Throughput');
    const throughput = tpRaw ? parseInt(tpRaw.match(/\d+/)?.[0]) : null;

    let cdn = null;
    const cdnRaw = get('Current CDN (a/v/t)');
    if (cdnRaw) {
      const m = cdnRaw.match(/([a-z0-9._-]+\.[a-z]{2,})/i);
      if (m) cdn = m[1];
    }

    let aCodec = null, aCh = null, aLang = null;
    const aRaw = get('Audio Track');
    if (aRaw) {
      const lang = aRaw.match(/^([a-z]{2,3})\b/i);
      const ch   = aRaw.match(/Channels:\s*([\d.]+)/i);
      const ac   = aRaw.match(/\(([^)]+)\)\s*$/);
      aLang  = lang ? lang[1].toLowerCase() : null;
      aCh    = ch   ? ch[1] : null;
      aCodec = ac   ? ac[1] : null;
    }

    let vmafP = null, vmafB = null;
    const vmafRaw = get('Playing/Buffering vmaf');
    if (vmafRaw) {
      const m = vmafRaw.match(/(\d+)\s*\/\s*(\d+)/);
      if (m) { vmafP = +m[1]; vmafB = +m[2]; }
    }

    let vCodec = null;
    const vRaw = get('Video Track');
    if (vRaw) {
      const m = vRaw.match(/codecs=([^\s;,]+)(?:\s*\(([^)]+)\))?/);
      if (m) vCodec = m[2] ? `${m[1]} (${m[2]})` : m[1];
    }

    const ksRaw = get('KeySystem');
    let ks = null;
    if (ksRaw) {
      const r = ksRaw.toLowerCase();
      if      (r.includes('apple.fps'))  ks = 'FairPlay';
      else if (r.includes('playready'))  { const v = ksRaw.match(/(\d+)$/); ks = v ? `PlayReady ${v[1]}` : 'PlayReady'; }
      else if (r.includes('widevine'))   { const v = ksRaw.split('.').pop(); ks = v.replace(/_/g, ' ').toLowerCase(); }
      else                               ks = ksRaw.split('.').pop();
    }

    let keyRes = null;
    const ksStatus = get('KeyStatus');
    if (ksStatus) {
      const m = ksStatus.match(/,\s*((?:\d+,?\s*)+),\s*(?:usable|expired|output-restricted)/i);
      if (m) keyRes = m[1].trim().replace(/\s*,\s*/g, '/');
    }

    const hdrRaw = get('HDR support');
    let hdr = null, hdrType = null;
    if (hdrRaw) {
      hdr = hdrRaw.toLowerCase().startsWith('true');
      const hm = hdrRaw.match(/\(([^)]+)\)/);
      const noise = ['non-hdr-display', 'is-type-supported'];
      const raw = hm ? hm[1].toLowerCase() : null;
      hdrType = raw && !noise.includes(raw) ? raw : null;
    }

    return {
      aBr, vBr, res, vBrBuf, bufA, bufV, throughput, vmafP, vmafB,
      cdn, aCodec, aCh, aLang, vCodec,
      framerate:   get('Framerate'),
      totalF:      get('Total Frames'),
      droppedF:    get('Total Dropped Frames'),
      renderState: get('Rendering state'),
      pos:         get('Position') != null ? parseFloat(get('Position')) : null,
      dur:         get('Duration') != null ? parseFloat(get('Duration')) : null,
      volume:      get('Volume'),
      hdr, hdrType, ks, keyRes,
    };
  }

  const fmtBr = kbps => kbps == null ? '—' : `${kbps} kb/s`;

  function fmtTime(s) {
    if (s == null) return '—';
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const secs = Math.floor(s % 60);
    return h > 0
      ? `${h}:${String(m).padStart(2,'0')}:${String(secs).padStart(2,'0')}`
      : `${m}:${String(secs).padStart(2,'0')}`;
  }

  const CDN_REGIONS = {
    ams: 'Amsterdam', bru: 'Brussels', cdg: 'Paris', lhr: 'London',
    fra: 'Frankfurt', mad: 'Madrid', mxp: 'Milan', arn: 'Stockholm',
    dub: 'Dublin', waw: 'Warsaw', atl: 'Atlanta', iad: 'Washington',
    lax: 'Los Angeles', jfk: 'New York', ord: 'Chicago', dfw: 'Dallas',
    sea: 'Seattle', mia: 'Miami', sfo: 'San Francisco', yyz: 'Toronto',
    nrt: 'Tokyo', hkg: 'Hong Kong', sin: 'Singapore', syd: 'Sydney',
    gru: 'São Paulo', bog: 'Bogotá', scl: 'Santiago',
  };
  function cdnRegion(cdn) {
    if (!cdn) return null;
    const m = cdn.match(/\b([a-z]{3})\d{3}\b/);
    return m ? (CDN_REGIONS[m[1]] || m[1].toUpperCase()) : null;
  }

  let pollTimer = null;

  function setPoll(state, overlay, btn, ms) {
    if (state.currentPoll === ms) return;
    state.currentPoll = ms;
    clearInterval(pollTimer);
    pollTimer = setInterval(() => render(overlay, btn, state), ms);
  }

  function render(overlay, btn, state) {
    const ta = getDebugTextarea();

    if (!ta) {
      state.closed = false;
      btn.style.display     = 'block';
      overlay.style.display = 'none';
      return;
    }

    if (state.closed) {
      btn.style.display     = 'block';
      overlay.style.display = 'none';
      return;
    }

    btn.style.display     = 'none';
    overlay.style.display = 'block';

    hideNetflixPanel(ta);
    const d = parse(ta.value);
    const isPlaying = d.renderState !== 'Paused';

    setPoll(state, overlay, btn, isPlaying ? POLL_PLAYING : POLL_PAUSED);

    const stateCls = isPlaying ? 'playing' : 'paused';
    const stateStr = isPlaying ? 'PLAYING' : 'PAUSED';

    let html = row('State', `<span class="${stateCls}">${stateStr}</span>`);

    html += `<hr>` + sectionLabel('VIDEO');
    if (d.res)            html += row('Resolution',  d.res);
    if (d.vBr)            html += row('Bitrate',     fmtBr(d.vBr));
    if (d.vBrBuf != null) html += row('Buffering',   fmtBr(d.vBrBuf));
    if (d.bufV != null)   html += row('Buffer',      `${d.bufV.toFixed(1)}s`);
    if (d.vCodec)         html += row('Codec',       d.vCodec);
    if (d.framerate)      html += row('Frame rate',  `${d.framerate} fps`);
    if (d.vmafP != null)  html += row('VMAF p/b',    `${d.vmafP} / ${d.vmafB}`);
    if (d.hdr !== null)   html += row('HDR',         d.hdr ? `YES${d.hdrType ? ` (${d.hdrType})` : ''}` : 'NO');
    if (d.throughput && d.vBr)
      html += row('ABR usage', `${Math.round((d.vBr / d.throughput) * 100)}%`);

    html += `<hr>` + sectionLabel('AUDIO');
    html += row('Bitrate',  fmtBr(d.aBr));
    if (d.aCh)            html += row('Channels', d.aCh);
    if (d.bufA != null)   html += row('Buffer',   `${d.bufA.toFixed(1)}s`);
    if (d.aCodec)         html += row('Codec',    d.aCodec);
    if (d.aLang)          html += row('Language', d.aLang);

    html += `<hr>` + sectionLabel('NETWORK');
    if (d.throughput)     html += row('Throughput', fmtBr(d.throughput));
    if (d.cdn) {
      const region = cdnRegion(d.cdn);
      html += row('CDN', region
        ? `${d.cdn.replace('.nflxvideo.net', '')} (${region})`
        :  d.cdn.replace('.nflxvideo.net', ''));
    }
    if (d.ks)             html += row('DRM',     d.ks);
    if (d.keyRes)         html += row('DRM res', d.keyRes);

    html += `<hr>` + sectionLabel('PLAYBACK');
    if (d.totalF)              html += row('Dropped',  `${parseInt(d.droppedF) || 0} / ${d.totalF}`);
    if (d.volume)              html += row('Volume',   d.volume);
    if (d.pos != null && d.dur != null)
      html += row('Position', `${fmtTime(d.pos)} / ${fmtTime(d.dur)}`);

    overlay.innerHTML = html;

    const topBar = document.createElement('div');
    topBar.className = 'topbar';

    const mkBtn = (label, onClick) => {
      const s = document.createElement('span');
      s.className = 'tbtn';
      s.textContent = label;
      s.addEventListener('click', onClick);
      return s;
    };

    topBar.appendChild(mkBtn('copy', () => navigator.clipboard.writeText(ta.value)));

    topBar.appendChild(mkBtn('copy json', () => {
      navigator.clipboard.writeText(JSON.stringify({
        resolution:     d.res,
        video_bitrate:  d.vBr,
        audio_bitrate:  d.aBr,
        video_codec:    d.vCodec,
        audio_codec:    d.aCodec,
        audio_lang:     d.aLang,
        audio_channels: d.aCh,
        framerate:      d.framerate,
        vmaf:           d.vmafP,
        hdr:            d.hdr,
        hdr_type:       d.hdrType,
        throughput:     d.throughput,
        abr_usage:      (d.throughput && d.vBr) ? Math.round((d.vBr / d.throughput) * 100) : null,
        cdn:            d.cdn?.replace('.nflxvideo.net', '') ?? null,
        cdn_region:     cdnRegion(d.cdn),
        drm:            d.ks,
        drm_res:        d.keyRes,
        dropped_frames: parseInt(d.droppedF) || 0,
        total_frames:   d.totalF ? parseInt(d.totalF) : null,
        timestamp: new Date().toISOString(),
        debug_raw:      ta.value,
      }, null, 2));
    }));

    topBar.appendChild(mkBtn('x', () => {
      state.closed = true;
      overlay.style.display = 'none';
      btn.style.display = 'block';
    }));

    overlay.prepend(topBar);
  }

  let retryTimer = null;

  function boot() {
    if (document.getElementById('nfdo')) return true;
    if (!document.body) return false;

    const overlay = document.createElement('div');
    overlay.id = 'nfdo';
    overlay.style.display = 'none';
    document.body.appendChild(overlay);

    const btn = document.createElement('button');
    btn.id = 'nfdo-activate';
    btn.textContent = 'Show Debug';
    document.body.appendChild(btn);

    const state = { closed: false, currentPoll: POLL_PLAYING };
    pollTimer = setInterval(() => render(overlay, btn, state), POLL_PLAYING);

    btn.addEventListener('click', () => {
      state.closed = false;
      if (!getDebugTextarea()) fireShortcut();
    });
    return true;
  }

  function teardown() {
    clearInterval(pollTimer);
    clearTimeout(retryTimer);
    pollTimer = retryTimer = null;
    document.getElementById('nfdo')?.remove();
    document.getElementById('nfdo-activate')?.remove();
  }

  function tryBoot(attempts = 40) {
    if (!location.pathname.startsWith('/watch')) return;
    if (boot()) return;
    if (attempts > 0) retryTimer = setTimeout(() => tryBoot(attempts - 1), 500);
  }

  function onNavigate() {
    teardown();
    tryBoot();
  }

  const _push    = history.pushState.bind(history);
  const _replace = history.replaceState.bind(history);
  history.pushState    = (...a) => { _push(...a);    onNavigate(); };
  history.replaceState = (...a) => { _replace(...a); onNavigate(); };
  window.addEventListener('popstate', onNavigate);

  let lastPath = location.pathname;
  new MutationObserver(() => {
    if (location.pathname !== lastPath) {
      lastPath = location.pathname;
      onNavigate();
    }
  }).observe(document.documentElement, { childList: true, subtree: true });

  tryBoot();

})();