Netflix Debug Overlay

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

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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

})();