Skip to content

Instantly share code, notes, and snippets.

@samueleastdev
Last active November 3, 2025 14:22
Show Gist options
  • Select an option

  • Save samueleastdev/e85fe1d07f757c31d7875a28ff1f85ee to your computer and use it in GitHub Desktop.

Select an option

Save samueleastdev/e85fe1d07f757c31d7875a28ff1f85ee to your computer and use it in GitHub Desktop.
TVProbe UI Debugger
type DebugTag = 'NET' | 'PLAYER' | 'DRM' | 'ERROR' | 'INFO';
export interface DebugUIOptions {
maxLines?: number;
startHidden?: boolean;
}
export default class DebugUI {
private maxLines: number;
private filters: Record<DebugTag, boolean> = {
NET: true,
PLAYER: true,
DRM: true,
ERROR: true,
INFO: true,
};
private paused = false;
private buf: string[] = [];
private bufLen = 0;
private inited = false;
private cont: HTMLDivElement | null = null;
private list: HTMLDivElement | null = null;
private header: HTMLDivElement | null = null;
private raw: Array<{ time: string; tag: DebugTag; msg: string; data?: unknown }> = [];
constructor(opts?: DebugUIOptions) {
this.maxLines = opts?.maxLines ?? 500;
this.init(opts ?? {});
}
private init(opts: DebugUIOptions): void {
if (this.inited) return;
this.inited = true;
// container (top-right)
const cont = document.createElement('div');
cont.id = 'otnet-debug-ui';
cont.style.cssText = [
'position:fixed',
'top:2%',
'right:1%',
'z-index:999999',
'width:600px',
'height:96%',
'display:flex',
'flex-direction:column',
'background:rgba(0,0,0,0.8)',
'color:#dfe7ef',
'font-family:ui-monospace, Menlo, Consolas, monospace',
'font-size:13px',
'border-radius:12px',
'box-shadow:0 6px 24px rgba(0,0,0,.5)',
'border:1px solid rgba(255,255,255,0.1)',
'overflow:hidden',
].join(';');
// header (no drag)
const header = document.createElement('div');
header.style.cssText = [
'display:flex',
'align-items:center',
'gap:8px',
'padding:6px 8px',
'background:rgba(255,255,255,0.06)',
'user-select:none',
].join(';');
const title = document.createElement('div');
title.textContent = 'Debug';
title.style.cssText = 'font-weight:700;opacity:.9';
const makeBtn = (label: string, cb: () => void) => {
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = [
'background:rgba(255,255,255,.12)',
'color:#fff',
'border:1px solid rgba(255,255,255,.15)',
'border-radius:8px',
'padding:2px 6px',
'font-size:12px',
'cursor:pointer',
].join(';');
b.onclick = (e) => {
e.stopPropagation();
cb();
};
return b;
};
const pauseBtn = makeBtn('Pause (0)', () => this.togglePause(pauseBtn));
const clearBtn = makeBtn('Clear', () => this.clear());
const copyBtn = makeBtn('Copy', () => this.copy());
const hideBtn = makeBtn('Hide (9)', () => this.toggle());
const filterWrap = document.createElement('div');
filterWrap.style.cssText = 'margin-left:auto; display:flex; gap:4px;';
(['NET', 'PLAYER', 'DRM', 'ERROR', 'INFO'] as DebugTag[]).forEach((tag) => {
const f = document.createElement('button');
f.textContent = tag;
f.style.cssText = this.filterStyle(this.filters[tag]);
f.onclick = (e) => {
e.stopPropagation();
this.filters[tag] = !this.filters[tag];
f.style.cssText = this.filterStyle(this.filters[tag]);
};
filterWrap.appendChild(f);
});
header.appendChild(title);
header.appendChild(pauseBtn);
header.appendChild(clearBtn);
header.appendChild(copyBtn);
header.appendChild(hideBtn);
header.appendChild(filterWrap);
const list = document.createElement('div');
list.style.cssText = 'flex:1;overflow:auto;padding:6px 8px;line-height:1.3;word-break:break-word;';
cont.appendChild(header);
cont.appendChild(list);
document.body.appendChild(cont);
this.cont = cont;
this.header = header;
this.list = list;
// keys
window.addEventListener('keydown', (e: any) => {
this.net('keydown', { key: e.key, code: e.code, keyCode: e.keyCode, which: e.which });
// Red Hide
if (e.keyCode === 403) {
this.toggle();
e.preventDefault();
}
// Green Pause
if (e.keyCode === 404) {
this.togglePause(pauseBtn);
e.preventDefault();
}
// Yellow Clear
if (e.keyCode === 405) {
this.clear();
e.preventDefault();
}
// Blue Send
if (e.keyCode === 406) {
e.preventDefault();
const endpoint = 'https://aws.com/dev/data';
this.info('Sending logs to endpoint…', { count: this.raw.length });
this.sendLogs(endpoint)
.then(() => {
this.info('Logs sent OK.');
})
.catch((err) => {
this.error('Send failed', String(err));
});
}
});
if (opts.startHidden) cont.style.display = 'none';
this.log('INFO', 'DebugUI ready');
}
toggle(): void {
if (!this.cont) return;
this.cont.style.display = this.cont.style.display === 'none' ? 'flex' : 'none';
}
private togglePause(btn?: HTMLButtonElement): void {
this.paused = !this.paused;
if (btn) btn.textContent = this.paused ? 'Resume (0)' : 'Pause (0)';
}
clear(): void {
this.buf = [];
this.bufLen = 0;
this.raw = [];
if (this.list) this.list.innerHTML = '';
}
copy(): void {
if (!this.list) return;
const text = this.list.innerText;
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {
// Ignore
}
document.body.removeChild(ta);
this.log('INFO', 'Copied logs');
}
log(tag: DebugTag, msg: string, data?: unknown): void {
if (!this.filters[tag]) return;
if (this.paused) return;
const nowIso = new Date().toISOString();
this.raw.push({ time: nowIso, tag, msg, data });
if (this.raw.length > this.maxLines) this.raw.shift();
let payload = '';
if (typeof data !== 'undefined') {
try {
payload = ' — ' + JSON.stringify(data);
} catch {
payload = ' — [unserializable]';
}
}
const line =
`<div><span style="opacity:.6">${this.ts()}</span> ` +
`<span style="color:${this.colorFor(tag)};font-weight:700">${tag}</span> ` +
`${this.escape(msg)}${payload ? `<span style="opacity:.9">${this.escape(payload)}</span>` : ''}</div>`;
if (this.bufLen >= this.maxLines) {
this.buf.shift();
this.bufLen--;
if (this.list?.firstChild) this.list.removeChild(this.list.firstChild);
}
this.buf.push(line);
this.bufLen++;
if (this.list) {
const wrap = document.createElement('div');
wrap.innerHTML = line;
if (wrap.firstChild) this.list.appendChild(wrap.firstChild);
this.list.scrollTop = this.list.scrollHeight;
}
}
private async sendLogs(endpoint: string): Promise<void> {
const payload = { logs: this.raw.slice() };
if (typeof fetch === 'function') {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return;
}
await new Promise<void>((resolve, reject) => {
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(JSON.stringify(payload));
} catch (e) {
reject(e);
}
});
}
net(msg: string, data?: unknown): void {
this.log('NET', msg, data);
}
player(msg: string, data?: unknown): void {
this.log('PLAYER', msg, data);
}
drm(msg: string, data?: unknown): void {
this.log('DRM', msg, data);
}
error(msg: string, data?: unknown): void {
this.log('ERROR', msg, data);
}
info(msg: string, data?: unknown): void {
this.log('INFO', msg, data);
}
private filterStyle(on: boolean): string {
return [
`background:${on ? 'rgba(0,200,120,.25)' : 'rgba(255,255,255,.08)'}`,
'color:#fff',
'border:1px solid rgba(255,255,255,.15)',
'border-radius:8px',
'padding:2px 4px',
'font-size:11px',
'cursor:pointer',
].join(';');
}
private colorFor(tag: DebugTag): string {
switch (tag) {
case 'NET':
return '#6ad1ff';
case 'PLAYER':
return '#ffd166';
case 'DRM':
return '#b794f6';
case 'ERROR':
return '#ff6b6b';
default:
return '#a8dadc';
}
}
private ts(): string {
const d = new Date();
const p = (n: number) => (n < 10 ? '0' : '') + n;
const ms = ('' + d.getMilliseconds()).padStart(3, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${ms}`;
}
private escape(s: string): string {
return String(s).replace(
/[&<>"']/g,
(c) =>
(
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}) as Record<string, string>
)[c]
);
}
}
@samueleastdev
Copy link
Author

Use it like this at this to the boot file!!!

import DebugUI from './platforms/Debugger';

declare global {
    interface Window {
        debug?: DebugUI; 
    }
}

if (typeof window !== 'undefined' && !window.debug) {
    window.debug = new DebugUI({ maxLines: 1000, startHidden: false });
    window.debug.info('DebugUI initialized globally');
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment