Last active
November 3, 2025 14:22
-
-
Save samueleastdev/e85fe1d07f757c31d7875a28ff1f85ee to your computer and use it in GitHub Desktop.
TVProbe UI Debugger
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) => | |
| ( | |
| ({ | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| "'": ''', | |
| }) as Record<string, string> | |
| )[c] | |
| ); | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Use it like this at this to the boot file!!!