Netflix Debug Overlay

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();