Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ outgoing:
While the app runs it serves its debug information over HTTP on port **8085**, for a browser or `curl` on the same network:

- `http://<device>:8085/` — the snapshot fills the page, with each cell's camera, size, fps and losses (in red) drawn over it; the header shows the version, the device time and uptime;
- `http://<device>:8085/snapshot.jpg` (`?labels=0`: frames only) — a small picture laid out like the screen, made from the frames the app shows right now; a cell without video is black with its status. Taken only when asked for; the page refreshes it every 3, 5 or 10 s while the tab is visible;
- `http://<device>:8085/snapshot.jpg` (`?labels=0`: frames only) — a picture laid out like the screen, at the screen's resolution (at most 1920×1080), made from the frames the app shows right now; a cell without video is black with its status. Taken only when asked for; the page refreshes it every 3, 5 or 10 s while the tab is visible;
- `http://<device>:8085/stats.json` — the same as JSON, for scripts;
- `http://<device>:8085/log` — the last 500 lines of the app's log.

Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ outgoing:
Пока приложение работает, оно отдаёт отладочную информацию по HTTP на порту **8085** — для браузера или `curl` из той же сети:

- `http://<приставка>:8085/` — снимок на всю страницу, поверх каждой ячейки камера, размер, fps и потери (красным); в шапке версия, время приставки и аптайм;
- `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — маленькая картинка с раскладкой экрана из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна;
- `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — картинка с раскладкой экрана в разрешении экрана (не больше 1920×1080) из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна;
- `http://<приставка>:8085/stats.json` — то же в JSON, для скриптов;
- `http://<приставка>:8085/log` — последние 500 строк лога приложения.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ outgoing:
应用运行时会在 **8085** 端口通过 HTTP 提供调试信息,可在同一网络中用浏览器或 `curl` 访问:

- `http://<设备>:8085/` — 快照铺满页面,每个画面上叠加摄像机、分辨率、帧率和丢帧(红色);页头显示版本、设备时间和运行时长;
- `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的小图,取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次;
- `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的图片,分辨率与屏幕相同(最大 1920×1080),取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次;
- `http://<设备>:8085/stats.json` — 相同内容的 JSON,便于脚本使用;
- `http://<设备>:8085/log` — 应用日志的最后 500 行。

Expand Down
184 changes: 114 additions & 70 deletions android/app/src/main/java/com/openipc/decoder/DebugServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -394,98 +394,142 @@ private static void closeQuietly(Socket s) {
// ====================== Page ======================

/**
* Static page: a header, the links, and the snapshot filling the rest with the text of
* each cell drawn over it. It fetches /stats.json every 1.5 s and the snapshot (without
* labels) every 3, 5 or 10 s, both only while the tab is visible (#98).
* Static page: a one-line header (state, version, device time, links, snapshot interval)
* and the snapshot filling the rest with the text of each cell drawn over it; on a narrow
* screen the snapshot takes the full width and the cells are listed below it (#103). It
* fetches /stats.json every 1.5 s and the snapshot (without labels) every 3, 5 or 10 s,
* both only while the tab is visible (#98).
*/
private static final String PAGE = "<!doctype html>\n"
+ "<html><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>OpenIPC Decoder</title><style>"
+ "<html><head><meta charset=\"utf-8\"><meta name=\"viewport\""
+ " content=\"width=device-width, initial-scale=1\"><title>OpenIPC Decoder</title>"
+ "<style>"
+ "html,body{height:100%;margin:0}"
+ "body{display:flex;flex-direction:column;background:#111;color:#ddd;"
+ "font:14px/1.3 system-ui,sans-serif}"
+ "header{padding:8px 12px 0}"
+ "h1{font-size:20px;margin:0;font-weight:600;color:#fff}"
+ "h1 small{font-size:13px;font-weight:normal;color:#999;margin-left:12px}"
+ "nav{padding:4px 12px 8px;color:#999}a{color:#8cf}#err{color:#ff5a5a}"
+ "main{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;"
+ "padding:0 8px 8px}"
+ "#wall{position:relative;background:#000}"
+ "body{display:flex;flex-direction:column;background:#111;color:#ddd;font:14px/1.35"
+ " system-ui,sans-serif}"
+ "header{display:flex;flex-wrap:wrap;align-items:center;gap:6px 18px;padding:8px"
+ " 12px;background:#181818;border-bottom:1px solid #262626}"
+ ".brand{display:flex;align-items:center;gap:8px;white-space:nowrap}"
+ ".brand b{font-size:17px;font-weight:600;color:#fff}"
+ ".dot{width:9px;height:9px;border-radius:50%;background:#555;flex:none}"
+ ".dot.ok{background:#3c3}.dot.warn{background:#fb4}.dot.bad{background:#f55}"
+ ".meta{display:flex;flex-wrap:wrap;gap:4px 14px;color:#999;font-size:13px;"
+ "font-variant-numeric:tabular-nums}"
+ ".meta span{white-space:nowrap}#err{color:#ff5a5a}"
+ "nav{display:flex;align-items:center;gap:6px;margin-left:auto;white-space:nowrap;"
+ "font-size:13px;color:#999}"
+ ".btn,.seg button{font:inherit;color:#cde;background:#222;border:1px solid #363636;"
+ "border-radius:6px;padding:3px 10px;text-decoration:none;cursor:pointer}"
+ ".btn:hover,.seg button:hover{background:#2c2c2c}"
+ ".seg{display:inline-flex;margin-left:4px}.seg button{border-radius:0;"
+ "margin-left:-1px}"
+ ".seg button:first-child{border-radius:6px 0 0 6px}.seg"
+ " button:last-child{border-radius:0 6px 6px 0}"
+ ".seg button.on{background:#2f4a66;border-color:#3d6488;color:#fff}"
+ "main{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;"
+ "padding:8px;gap:8px;overflow:auto}"
+ "#wall{position:relative;background:#000;flex:none}"
+ "#snap,#cells{position:absolute;top:0;left:0;width:100%;height:100%}"
+ "#cells{display:grid}"
+ ".cell{position:relative;border:1px solid #333;overflow:hidden}"
+ ".top,.bot{position:absolute;left:0;right:0;padding:4px 8px;font-size:13px;"
+ "color:#fff;text-shadow:0 0 3px #000}"
+ ".cell{position:relative;border:1px solid #333;overflow:hidden;container-type:size}"
+ ".top,.bot{position:absolute;left:0;right:0;padding:.3em .6em;font-size:clamp(9px,"
+ "3.4cqw,13px);color:#fff;text-shadow:0 0 3px #000;white-space:nowrap;"
+ "overflow:hidden;text-overflow:ellipsis}"
+ ".top{top:0;background:linear-gradient(rgba(0,0,0,.65),transparent)}"
+ ".bot{bottom:0;background:linear-gradient(transparent,rgba(0,0,0,.75))}"
+ ".slot{font-weight:700;margin-right:6px}.dim{color:#bbb}"
+ ".bad{color:#ff5a5a;font-weight:700}.warn{color:#ffb84d}"
+ ".off{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;"
+ "flex-direction:column;align-items:center;justify-content:center;"
+ "background:#000;color:#ff5a5a;text-align:center;padding:8px}"
+ ".off b{font-size:28px;color:#666}"
+ ".off{position:absolute;inset:0;display:flex;flex-direction:column;"
+ "align-items:center;justify-content:center;background:#000;color:#ff5a5a;"
+ "text-align:center;padding:8px;font-size:clamp(9px,4cqw,14px)}"
+ ".off b{font-size:clamp(16px,14cqh,30px);color:#666}"
+ "#list{display:none;width:100%;border-collapse:collapse;font-size:13px}"
+ "#list td{padding:5px 6px;border-top:1px solid #262626;vertical-align:top}"
+ "#list td:first-child{font-weight:700;width:1.5em;color:#fff}"
+ "#list .h{color:#bbb}"
+ "body.small #list{display:table}"
+ "body.small .cell .top .dim,body.small .cell .bot,body.small .off span{display:none}"
+ "</style></head><body>"
+ "<header><h1>OpenIPC Decoder<small id=\"ver\"></small><small id=\"clock\"></small>"
+ "</h1></header>"
+ "<nav><a href=\"/stats.json\">JSON</a> \u00b7 <a href=\"/log\">LOG</a>"
+ " \u00b7 snapshot every <select id=\"every\"><option value=\"3\">3 s</option>"
+ "<option value=\"5\" selected>5 s</option><option value=\"10\">10 s</option></select>"
+ " <span id=\"err\"></span></nav>"
+ "<main id=\"main\"><div id=\"wall\"><img id=\"snap\" alt=\"\">"
+ "<div id=\"cells\"></div></div></main>"
+ "<header>"
+ "<div class=\"brand\"><span id=\"dot\" class=\"dot\"></span><b>OpenIPC Decoder</b>"
+ "</div>"
+ "<div class=\"meta\"><span id=\"ver\"></span><span id=\"clock\"></span><span"
+ " id=\"up\"></span><span id=\"err\"></span></div>"
+ "<nav><a class=\"btn\" href=\"/stats.json\">JSON</a><a class=\"btn\" href=\"/log\">"
+ "Log</a>"
+ "<span>Snapshot<span class=\"seg\" id=\"every\"><button value=\"3\">3 s</button>"
+ "<button value=\"5\">5 s</button><button value=\"10\">10 s</button></span></span>"
+ "</nav>"
+ "</header>"
+ "<main id=\"main\"><div id=\"wall\"><img id=\"snap\" alt=\"\"><div id=\"cells\">"
+ "</div></div><table id=\"list\"></table></main>"
+ "<script>\n"
+ "function $(id){return document.getElementById(id)}\n"
+ "function esc(s){return String(s==null?'':s).replace(/[&<>\"]/g,function(c){"
+ "return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]})}\n"
// The wall keeps 16:9 (the snapshot's shape) and fills the space left
+ "function fit(){var m=$('main'),w=m.clientWidth-16,h=m.clientHeight-8;"
+ "if(w*9/16>h)w=h*16/9;$('wall').style.width=Math.floor(w)+'px';"
+ "function esc(s){return String(s==null?'':s).replace(/[&<>\"]/g,function(c){return"
+ " {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]})}\n"
// The wall keeps 16:9 (the snapshot's shape) and fills the space left; on a narrow
// screen it takes the full width and the cells are listed below it
+ "function fit(){var m=$('main'),w=m.clientWidth-16,h=m.clientHeight-16,small=w<720;\n"
+ "document.body.classList.toggle('small',small);\n"
+ "if(!small&&w*9/16>h)w=h*16/9;$('wall').style.width=Math.floor(w)+'px';"
+ "$('wall').style.height=Math.floor(w*9/16)+'px'}\n"
+ "window.onresize=fit;fit();\n"
+ "function later(f,ms){setTimeout(function(){if(document.hidden)"
+ "document.addEventListener('visibilitychange',function v(){if(!document.hidden)"
+ "{document.removeEventListener('visibilitychange',v);f()}});else f()},ms)}\n"
+ "function later(f,ms){setTimeout(function(){if(document.hidden)document.addEventLis"
+ "tener('visibilitychange',function v(){if(!document.hidden){document.removeEventLis"
+ "tener('visibilitychange',v);f()}});else f()},ms)}\n"
+ "function up(s){var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),"
+ "m=Math.floor(s%3600/60);return (d?d+'d ':'')+(d||h?h+'h ':'')+m+'m'}\n"
+ "function host(u){var m=/\\/\\/(?:[^@\\/]*@)?(\\[[^\\]]*\\]|[^\\/:?]*)/.exec(u||'');"
+ "return m?m[1]:''}\n"
+ "function num(v){return Math.round(v*10)/10}\n"
+ "function where(c){return esc(host(c.url))+(c.transport?' \u00b7 '+"
+ "esc(c.transport):'')+(c.audio===true?' \u00b7 \u266a':'')}\n"
+ "function info(c){if(!c.video)return '<span class=\"bad\">'+esc(c.text)+'</span>';\n"
+ "var t=esc(c.text),r=/(\\d+)\\/(\\d+) fps/.exec(c.text||'');\n"
+ "if(r&&+r[1]<0.9*r[2])t=t.replace(r[0],'<span class=\"warn\">'+r[0]+'</span>');\n"
+ "if(c.lost_fps>0)t+=' \u00b7 <span class=\"bad\">lost '+num(c.lost_fps)"
+ "+'/s</span>';\n"
+ "if(c.behind_s>0)t+=' \u00b7 <span class=\"bad\">behind '+c.behind_s"
+ "+' s</span>';return t}\n"
// One cell: slot, camera and transport on top; size, fps and losses below
+ "function cell(c){var top='<span class=\"slot\">'+esc(c.slot)+'</span>'"
+ "+'<span class=\"dim\">'+esc(host(c.url))+(c.transport?' \u00b7 '+esc(c.transport):'')"
+ "+(c.audio===true?' \u00b7 \u266a':'')+'</span>';"
+ "if(!c.video)return '<div class=\"cell\">"
+ "<div class=\"off\"><b>'+esc(c.slot)+'</b>'+esc(c.text)+'</div>"
+ "<div class=\"top\">'+top+'</div></div>';"
+ "var t=esc(c.text),r=/(\\d+)\\/(\\d+) fps/.exec(c.text||'');"
+ "if(r&&+r[1]<0.9*r[2])t=t.replace(r[0],'<span class=\"warn\">'+r[0]+'</span>');"
+ "if(c.lost_fps>0)t+=' \u00b7 <span class=\"bad\">lost '+num(c.lost_fps)+'/s</span>';"
+ "if(c.behind_s>0)t+=' \u00b7 <span class=\"bad\">behind '+c.behind_s+' s</span>';"
+ "return '<div class=\"cell\"><div class=\"top\">'+top+'</div>"
+ "<div class=\"bot\">'+t+'</div></div>'}\n"
+ "function render(s){$('ver').textContent='v'+(s.version||'?')+' \u00b7 '+(s.git||'');"
+ "$('clock').textContent=(s.time||'')+(s.uptime_s!=null?' \u00b7 up '+up(s.uptime_s):'')"
+ "+(s.ui_thread&&s.ui_thread!=='ok'?' \u00b7 UI '+s.ui_thread:'');"
+ "var list,side=1;"
+ "if(s.mode==='paused'||s.mode==='udp'||!(s.cells||s.single))"
+ "list=[s.mode==='udp'?{slot:'U',text:'UDP RTP port '+(s.udp?s.udp.port:'')}"
+ ":{text:s.mode==='paused'?'App in background':(s.mode||'')}];"
+ "function cell(c){var top='<span class=\"slot\">'+esc(c.slot)+'</span><span"
+ " class=\"dim\">'+where(c)+'</span>';\n"
+ "if(!c.video)return '<div class=\"cell\"><div class=\"off\"><b>'+esc(c.slot)+'</b>"
+ "<span>'+esc(c.text)+'</span></div><div class=\"top\">'+top+'</div></div>';\n"
+ "return '<div class=\"cell\"><div class=\"top\">'+top+'</div><div class=\"bot\">"
+ "'+info(c)+'</div></div>'}\n"
+ "function row(c){return '<tr><td>'+esc(c.slot)+'</td><td><span class=\"h\">"
+ "'+where(c)+'</span><br>'+info(c)+'</td></tr>'}\n"
+ "function render(s){"
+ "$('ver').textContent='v'+(s.version||'?')+' \u00b7 '+(s.git||'');\n"
+ "$('clock').textContent=s.time||'';$('up').textContent=s.uptime_s!=null?'up '+"
+ "up(s.uptime_s):'';\n"
// Orange: the UI thread is stuck, or there is no activity behind the server at all
+ "var ui=s.ui_thread&&s.ui_thread!=='ok',none=!s.ui_thread;\n"
+ "$('err').textContent=ui?'UI '+s.ui_thread:none?(s.mode||'no data'):'';\n"
+ "$('dot').className='dot '+(ui||none?'warn':'ok');\n"
+ "var list,side=1;\n"
+ "if(s.mode==='paused'||s.mode==='udp'||!(s.cells||s.single))list=[s.mode==='udp'?{s"
+ "lot:'U',text:'UDP RTP port '+(s.udp?s.udp.port:'')}:{text:s.mode==='paused'?'App"
+ " in background':(s.mode||'')}];\n"
+ "else if(s.cells){side=s.cells.length>4?3:2;list=s.cells.slice();"
+ "while(list.length<side*side)list.push({slot:'U',text:'no receiver yet'})}"
+ "else if(s.single){var c=s.single;c.slot=s.active_slot;c.text=c.video"
+ "?(c.size||'')+' '+c.codec:c.status;list=[c]}"
+ "while(list.length<side*side)list.push({slot:'U',text:'no receiver yet'})}\n"
+ "else if(s.single){var c=s.single;c.slot=s.active_slot;"
+ "c.text=c.video?(c.size||'')+' '+c.codec:c.status;list=[c]}\n"
+ "$('cells').style.gridTemplateColumns='repeat('+side+',1fr)';"
+ "$('cells').style.gridTemplateRows='repeat('+side+',1fr)';"
+ "$('cells').innerHTML=list.map(cell).join('')}\n"
+ "function tick(){fetch('/stats.json',{cache:'no-store'}).then(function(r){return r.json()})"
+ ".then(function(s){render(s);$('err').textContent=''})"
+ ".catch(function(e){$('err').textContent='offline: '+e})"
+ ".finally(function(){later(tick,1500)})}\n"
+ "var every=$('every');"
+ "try{var e=localStorage.getItem('every');if(e)every.value=e}catch(x){}\n"
+ "every.onchange=function(){try{localStorage.setItem('every',every.value)}catch(x){}};\n"
+ "var img=$('snap');img.onload=img.onerror=function(){later(snap,every.value*1000)};\n"
+ "$('cells').style.gridTemplateRows='repeat('+side+',1fr)';\n"
+ "$('cells').innerHTML=list.map(cell).join('');$('list').innerHTML=list.map(row).joi"
+ "n('')}\n"
+ "function tick(){fetch('/stats.json',{cache:'no-store'}).then(function(r){return"
+ " r.json()}).then(function(s){render(s)})\n"
+ ".catch(function(e){$('err').textContent='offline';$('dot').className='dot bad'}"
+ ").finally(function(){later(tick,1500)})}\n"
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
+ "var every=5,seg=$('every');try{every=+localStorage.getItem('every')||5}catch(x){}\n"
+ "function mark(){[].forEach.call(seg.children,function(b){b.className=+b.value===ev"
+ "ery?'on':''})}\n"
+ "seg.onclick=function(e){if(!e.target.value)return;every=+e.target.value;mark();"
+ "try{localStorage.setItem('every',every)}catch(x){}};mark();\n"
+ "var img=$('snap');img.onload=img.onerror=function(){later(snap,every*1000)};\n"
+ "function snap(){img.src='/snapshot.jpg?labels=0&t='+Date.now()}\n"
+ "later(tick,0);later(snap,0);\n"
+ "</script></body></html>\n";
Expand Down
Loading
Loading