Files
UniXNU/scripts/debug_screen_viewer.html

337 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>UniXNU · USB 投屏</title>
<style>
:root{font:14px/1.5 system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#e8edf5;background:#0b0e13;--line:#2a313e;--muted:#929dac;--panel:#141922;--accent:#9bbdff}
*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,select,input{border:1px solid #343e4d;background:#1a212d;color:inherit;border-radius:7px}button{padding:7px 11px;cursor:pointer;white-space:nowrap}button:hover:not(:disabled){background:#283345;border-color:#6d83a5}button:disabled,input:disabled,select:disabled{opacity:.4;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button.primary{background:#abc8ff;color:#11213e;border-color:#abc8ff;font-weight:600}button.primary:hover:not(:disabled){background:#c2d7ff}a{color:var(--accent);text-underline-offset:3px}select,input[type=number]{padding:7px 9px;min-width:0;width:100%}input[type=checkbox]{accent-color:#abc8ff;width:16px;height:16px;margin:0}.app{height:100dvh;min-height:440px;display:flex;flex-direction:column;background:#0b0e13}.toolbar{display:flex;gap:8px;align-items:center;padding:12px 18px;border-bottom:1px solid var(--line);background:var(--panel);flex-wrap:wrap}.brand{display:flex;align-items:center;gap:10px;margin-right:14px}.brand-icon{display:grid;place-items:center;width:30px;height:34px;border:2px solid #abc8ff;border-radius:7px;color:#abc8ff;font-size:13px}.brand strong{font-size:16px}.brand small{color:var(--muted);display:block;font-size:11px;letter-spacing:.06em}.spacer{flex:1}.toggle{display:flex;align-items:center;gap:8px;border:1px solid #343e4d;border-radius:7px;padding:7px 10px;white-space:nowrap}.toggle.enabled{border-color:#8cb3f8;background:#20314e;color:#c5d9ff}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 264px;flex:1;min-height:0}.stage{position:relative;display:grid;place-items:center;overflow:auto;min-width:0;min-height:0;padding:24px;outline:none;background:radial-gradient(ellipse at 50% 40%,#151c29 0%,#0c1017 65%)}.stage:focus-visible{box-shadow:inset 0 0 0 2px #8daee1}.display{position:relative;flex-shrink:0;background:#000;box-shadow:0 8px 42px #0008;touch-action:none;user-select:none;-webkit-user-select:none}.display[hidden]{display:none}.display img{position:absolute;left:50%;top:50%;max-width:none;display:block;pointer-events:none;user-select:none;-webkit-user-drag:none}.display.interactive{cursor:crosshair}.empty{max-width:330px;text-align:center;color:var(--muted);padding:24px}.empty .outline-phone{width:54px;height:88px;border:2px solid #455168;border-radius:11px;margin:0 auto 23px;position:relative}.outline-phone:after{content:"";position:absolute;bottom:8px;left:18px;right:18px;border-top:2px solid #455168}.empty h1{font-size:18px;color:#d3deed;font-weight:500;margin:0 0 9px}.empty p{margin:0;font-size:13px}.inspector{background:#10151e;border-left:1px solid var(--line);padding:21px 18px;overflow:auto}.section-title{font-size:12px;letter-spacing:.08em;color:#c0ccdc;margin:0 0 16px;font-weight:600}.setting{margin-bottom:15px}.setting label{display:block;color:#b9c3d2;font-size:12px;margin-bottom:6px}.custom{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:8px}.custom[hidden]{display:none}.hint{font-size:12px;color:var(--muted);margin:9px 0 0;line-height:1.7}.divider{border:0;border-top:1px solid var(--line);margin:23px 0}.device-keys{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.device-keys button{padding:8px 4px;font-size:12px}.facts{margin:0;display:grid;grid-template-columns:auto 1fr;gap:9px 12px;font-size:12px}.facts dt{color:var(--muted)}.facts dd{margin:0;text-align:right;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.shortcuts{display:grid;grid-template-columns:1fr auto;gap:8px;font-size:12px;color:var(--muted)}kbd{font:11px ui-monospace,Consolas,monospace;color:#cbd8e9;background:#1c2430;border:1px solid #354050;border-radius:4px;padding:1px 5px;white-space:nowrap}.footer{display:flex;gap:12px;align-items:center;padding:9px 18px;background:var(--panel);border-top:1px solid var(--line);font-size:12px;color:#b4bfce;min-height:39px}.connection{display:flex;gap:7px;align-items:center;white-space:nowrap}.dot{height:7px;width:7px;border-radius:50%;background:#8e99a9}.dot.online{background:#76d2a0}.dot.error{background:#ef9b91}.message{flex:1;min-width:0;overflow-wrap:anywhere}.message.error{color:#ffb4a9}.download{font-size:12px}.rotation{color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}.toolbar .zoom{width:111px;font-size:12px}.toast{position:fixed;bottom:54px;left:50%;transform:translateX(-50%);max-width:min(600px,90vw);background:#25344c;border:1px solid #50698f;border-radius:8px;padding:11px 16px;box-shadow:0 5px 30px #0008;z-index:10;font-size:13px}.toast[hidden]{display:none}
.settings-toggle{display:none}
@media(max-width:840px){.workspace{grid-template-columns:minmax(0,1fr) 226px}.toolbar{padding:10px;gap:6px}.brand{margin-right:0}.brand small{display:none}.toolbar button{padding:7px 9px}.inspector{padding:17px 12px}.spacer{display:none}.footer{padding:9px 10px}.rotation{display:none}}
@media(max-width:620px){.workspace{grid-template-columns:minmax(0,1fr)}.inspector{display:none}.inspector.open{display:block;position:absolute;top:var(--toolbar-height,110px);bottom:39px;right:0;width:264px;z-index:5;box-shadow:-12px 0 35px #0009}.settings-toggle{display:block}.brand strong{font-size:14px}.toolbar{gap:5px}.toolbar button{font-size:12px}.toolbar .zoom{display:none}.brand-icon{display:none}.download{display:none}.stage{padding:14px}.toggle{font-size:12px;padding:7px}.footer{font-size:11px}.app{min-height:320px}}
</style>
</head>
<body>
<main class="app" id="app">
<header class="toolbar">
<div class="brand"><span class="brand-icon" aria-hidden="true">↗</span><div><strong>UniXNU 投屏</strong><small>USB DISPLAY & CONTROL</small></div></div>
<button id="start" class="primary" title="开始或恢复投屏">开始投屏</button>
<button id="stop" title="暂停传输并保留最后一帧">暂停</button>
<label class="toggle" id="controlLabel" title="开启后可在画面中使用鼠标和物理键盘"><input type="checkbox" id="control" disabled>电脑控制</label>
<span class="spacer"></span>
<button id="snapshot" title="请求手机截图并保存 PNG">截图</button>
<button id="rotate" title="顺时针旋转预览;Alt + →">旋转</button>
<span class="rotation" id="rotation">0°</span>
<select id="zoom" class="zoom" aria-label="显示缩放"><option value="fit">适应窗口</option><option value="1">100%</option><option value="0.5">50%</option><option value="1.5">150%</option></select>
<button id="fullscreen" title="全屏;Alt + F">全屏</button>
<button id="settings" class="settings-toggle" aria-controls="inspector" aria-expanded="false">设置</button>
</header>
<div class="workspace">
<div class="stage" id="stage" tabindex="0" aria-label="手机画面;开启电脑控制后可用鼠标和键盘操作">
<div class="empty" id="empty"><div class="outline-phone" aria-hidden="true"></div><h1>等待手机画面</h1><p>收到真实屏幕帧后显示。首次连接或切换分辨率时,传输可能需要几秒。</p></div>
<div class="display" id="display" hidden><img id="screen" alt="手机实际屏幕画面" draggable="false"></div>
</div>
<aside class="inspector" id="inspector" aria-label="投屏设置">
<h2 class="section-title">画面设置</h2>
<div class="setting"><label for="size">分辨率 · 最长边</label><select id="size"><option value="480">480 px · 节省带宽</option><option value="695" selected>695 px · 默认</option><option value="960">960 px</option><option value="1280">1280 px</option><option value="1920">1920 px</option><option value="2780">2780 px · 原生上限</option><option value="custom">自定义…</option></select><div class="custom" id="customSize" hidden><input id="maxSize" type="number" min="160" max="2780" value="695" step="1" aria-label="最长边像素"><span style="align-self:center;color:var(--muted)">px</span></div></div>
<div class="setting"><label for="fps">目标帧率上限</label><select id="fps"><option value="1">1 FPS · 默认</option><option value="2">2 FPS</option><option value="5">5 FPS</option><option value="10">10 FPS</option><option value="15">15 FPS</option><option value="30">30 FPS</option></select></div>
<button id="apply" style="width:100%">应用设置</button>
<p class="hint">保持手机画面比例。USB 原始像素传输的实际帧率取决于分辨率和带宽,目标值不是实测帧率。</p>
<hr class="divider">
<h2 class="section-title">设备操作</h2>
<div class="device-keys"><button data-key="back" disabled title="右键 / Alt + B">返回</button><button data-key="home" disabled title="中键 / Alt + H">主屏幕</button><button data-key="power" disabled title="Alt + P">电源</button><button data-key="volumedown" disabled title="Alt + ↓">音量 −</button><button data-key="volumeup" disabled title="Alt + ↑">音量 +</button><button id="release" disabled title="释放所有按下的鼠标和键盘按键">释放按键</button></div>
<p class="hint" id="inputStatus">正在查询设备控制能力…</p>
<hr class="divider">
<h2 class="section-title">传输状态</h2>
<dl class="facts"><dt>收到的分辨率</dt><dd id="dimensions">—</dd><dt>实际接收帧率</dt><dd id="actualFps">—</dd><dt>累计接收</dt><dd id="frameCount">0 帧</dd><dt>最近一帧</dt><dd id="age">—</dd></dl>
<hr class="divider">
<div class="shortcuts"><span>点击 / 长按 / 拖动</span><kbd>鼠标左键</kbd><span>上下滑动</span><kbd>滚轮</kbd><span>全屏</span><kbd>Alt F</kbd><span>向左 / 向右旋转</span><kbd>Alt ← / →</kbd></div>
<p class="hint">点击画面后使用物理键盘。输入法组字和剪贴板文本同步暂不支持。旋转仅改变电脑预览。</p>
</aside>
</div>
<footer class="footer"><span class="connection"><span class="dot" id="dot"></span><span id="connection">正在连接</span></span><span class="message" id="message" role="status" aria-live="polite">等待本地投屏服务…</span><a class="download" href="/frame.png" download="unixnu-screen.png" id="download" hidden>保存最新 PNG</a></footer>
</main>
<div class="toast" id="toast" hidden role="status"></div>
<script>
'use strict';
const TOKEN = '__VIEWER_TOKEN__';
const $ = id => document.getElementById(id);
const screen = $('screen'), stage = $('stage'), display = $('display');
let state = {}, frame = 0, rotation = 0, online = false, enabled = false, busyControl = false, controlFailed = false;
let width = 0, height = 0, receivedAt = 0, paused = false, settingsDirty = false;
let activePointer = null, pendingMove = null, moveTimer = null, lastMoveAt = 0;
let pendingScroll = null, scrollTimer = null;
let lastPoint = {x:32768,y:32768}, heldKeys = new Set(), requestQueue = [], sending = false;
let toastTimer = null, sampleAt = performance.now(), sampleFrames = 0, sampled = false;
const keyboardCodes=new Set(['Enter','Escape','Backspace','Tab','Space','Minus','Equal','BracketLeft','BracketRight','Backslash','Semicolon','Quote','Backquote','Comma','Period','Slash','CapsLock','PrintScreen','ScrollLock','Pause','Insert','Home','PageUp','Delete','End','PageDown','ArrowRight','ArrowLeft','ArrowDown','ArrowUp','NumLock','NumpadDivide','NumpadMultiply','NumpadSubtract','NumpadAdd','NumpadEnter','NumpadDecimal','ContextMenu','ControlLeft','ControlRight','ShiftLeft','ShiftRight','AltLeft','AltRight','MetaLeft','MetaRight',...Array.from({length:26},(_,i)=>'Key'+String.fromCharCode(65+i)),...Array.from({length:10},(_,i)=>'Digit'+i),...Array.from({length:10},(_,i)=>'Numpad'+i),...Array.from({length:12},(_,i)=>'F'+(i+1))]);
function message(text, error=false) { $('message').textContent=text; $('message').classList.toggle('error',error); }
function toast(text) { $('toast').textContent=text; $('toast').hidden=false; clearTimeout(toastTimer); toastTimer=setTimeout(() => $('toast').hidden=true,3200); }
function canControl() { return enabled && online && state.input_available === true && state.connection === 'diagnostic' && !paused && frame > 0; }
function hasCapability(name) { return state.input_capabilities?.[name] === true; }
function canTouch() { return canControl() && hasCapability('touch'); }
function modifiers(event) {
let mask=0;
for(const [flag,left,right,bit] of [['ctrlKey','ControlLeft','ControlRight',1],['shiftKey','ShiftLeft','ShiftRight',2],['altKey','AltLeft','AltRight',4],['metaKey','MetaLeft','MetaRight',8]]) {
if(heldKeys.has(left)) mask|=bit;
if(heldKeys.has(right)) mask|=bit<<4;
if(event[flag]&&!heldKeys.has(left)&&!heldKeys.has(right)) mask|=bit;
}
return mask;
}
function send(payload) {
return new Promise((resolve,reject) => {
if (requestQueue.length >= 96) { reject(new Error('操作积压,请等待设备响应')); return; }
requestQueue.push({payload,resolve,reject});
pump();
});
}
async function pump() {
if(sending) return;
sending=true;
while(requestQueue.length) {
const item=requestQueue.shift();
try {
const response=await fetch('/control',{method:'POST',headers:{'Content-Type':'application/json','X-Screen-Token':TOKEN},body:JSON.stringify(item.payload),signal:AbortSignal.timeout(5000)});
const result=await response.json();
if(!response.ok) throw new Error(result.error||'设备未接受操作');
item.resolve(result);
} catch(error) {
item.reject(error);
if(['touch','key','scroll'].includes(item.payload.action)) {
enabled=false;controlFailed=true;$('control').checked=false;activePointer=null;heldKeys.clear();cancelMove();cancelScroll();
requestQueue=requestQueue.filter(queued=>{
if(['touch','key','scroll','heartbeat'].includes(queued.payload.action)) {queued.resolve(null);return false;}
return true;
});
emergencyRelease(true);refreshControls();
}
}
}
sending=false;
}
function operate(payload) { return send(payload).catch(error => { message(error.message,true);toast(error.message);return null; }); }
function emergencyRelease(disable=false) {
fetch('/control',{method:'POST',headers:{'Content-Type':'application/json','X-Screen-Token':TOKEN},body:JSON.stringify(disable?{action:'control',enabled:false}:{action:'release'}),keepalive:true}).catch(()=>{});
}
function cancelMove() { clearTimeout(moveTimer); moveTimer=null; pendingMove=null; }
function cancelScroll() { clearTimeout(scrollTimer);scrollTimer=null;pendingScroll=null; }
function release() {
cancelMove();cancelScroll();
activePointer=null; heldKeys.clear();
requestQueue=requestQueue.filter(item => {
if(['touch','key','scroll'].includes(item.payload.action)) { item.resolve({discarded:true}); return false; }
return true;
});
return operate({action:'release'});
}
function refreshControls() {
const diagnostic=online && state.connection==='diagnostic';
$('control').disabled=!diagnostic || !state.input_available || busyControl;
$('control').checked=enabled;
$('controlLabel').classList.toggle('enabled',enabled);
display.classList.toggle('interactive',canTouch());
for(const button of document.querySelectorAll('[data-key]')) button.disabled=!canControl()||!hasCapability(button.dataset.key==='back'?'keyboard':'buttons');
$('release').disabled=!canControl();
for(const id of ['start','stop','snapshot','apply']) $(id).disabled=!diagnostic;
$('download').hidden=!frame;
$('inputStatus').textContent=state.connection==='shell'?'Shell 会话中,电脑控制已暂停。':state.input_available?(enabled?'电脑控制已开启;窗口失焦时自动释放按键。':'设备支持控制,开启「电脑控制」后操作。'):(state.input_status||'设备尚未报告控制能力;需要包含远程输入支持的新内核。');
}
function layout() {
$('app').style.setProperty('--toolbar-height',document.querySelector('.toolbar').getBoundingClientRect().height+'px');
if(!width || !height) return;
const sideways=rotation%180!==0, rw=sideways?height:width, rh=sideways?width:height;
const padding=innerWidth<=620?28:48;
const scale=$('zoom').value==='fit'?Math.min(Math.max(1,stage.clientWidth-padding)/rw,Math.max(1,stage.clientHeight-padding)/rh):Number($('zoom').value);
display.style.width=(rw*scale)+'px'; display.style.height=(rh*scale)+'px';
screen.style.width=(width*scale)+'px'; screen.style.height=(height*scale)+'px';
screen.style.transform=`translate(-50%,-50%) rotate(${rotation}deg)`;
}
function point(event,clamp=false) {
const bounds=display.getBoundingClientRect();
if(!bounds.width || !bounds.height) return null;
let x=(event.clientX-bounds.left)/bounds.width, y=(event.clientY-bounds.top)/bounds.height;
if(!clamp && (x<0 || x>1 || y<0 || y>1)) return null;
x=Math.max(0,Math.min(1,x)); y=Math.max(0,Math.min(1,y));
let u=x,v=y;
if(rotation===90) {u=y;v=1-x;}
else if(rotation===180) {u=1-x;v=1-y;}
else if(rotation===270) {u=1-y;v=x;}
return {x:Math.round(u*65535),y:Math.round(v*65535)};
}
function flushMove() {
clearTimeout(moveTimer); moveTimer=null;
if(pendingMove) { const payload=pendingMove; pendingMove=null; lastMoveAt=performance.now(); operate(payload); }
}
function flushScroll() {
scrollTimer=null;
if(!pendingScroll) return;
const payload=pendingScroll;pendingScroll=null;
payload.delta_x=Math.round(payload.delta_x);payload.delta_y=Math.round(payload.delta_y);
if(payload.delta_x||payload.delta_y) operate(payload);
}
function rotate(delta) { release(); rotation=(rotation+delta+360)%360; $('rotation').textContent=rotation+'°'; layout(); }
async function fullscreen() {
try { if(document.fullscreenElement) await document.exitFullscreen(); else await $('app').requestFullscreen(); }
catch(error) { message('无法进入全屏:'+error.message,true); }
}
async function buttonPress(key) {
if(!canControl() || !hasCapability(key==='back'?'keyboard':'buttons')) return;
if(!await operate({action:'key',key,pressed:true})) return;
await operate({action:'key',key,pressed:false});
}
display.addEventListener('pointerdown',event => {
stage.focus({preventScroll:true});
if(!canControl() || activePointer!==null) return;
if(event.button===2) {event.preventDefault();buttonPress('back');return;}
if(event.button===1) {event.preventDefault();buttonPress('home');return;}
if(event.button!==0 || !canTouch()) return;
const at=point(event); if(!at) return;
cancelScroll();
event.preventDefault(); activePointer=event.pointerId; lastPoint=at;
display.setPointerCapture(event.pointerId);
operate({action:'touch',phase:'down',...at});
});
display.addEventListener('pointermove',event => {
if(event.pointerId!==activePointer || !canTouch()) return;
event.preventDefault(); lastPoint=point(event,true);
pendingMove={action:'touch',phase:'move',...lastPoint};
if(!moveTimer) moveTimer=setTimeout(flushMove,Math.max(0,34-(performance.now()-lastMoveAt)));
});
function endPointer(event,cancel=false) {
if(event.pointerId!==activePointer) return;
event.preventDefault(); flushMove();
const at=point(event,true)||lastPoint;
activePointer=null;
if(display.hasPointerCapture(event.pointerId)) display.releasePointerCapture(event.pointerId);
operate({action:'touch',phase:cancel?'cancel':'up',...at});
}
display.addEventListener('pointerup',event=>endPointer(event));
display.addEventListener('pointercancel',event=>endPointer(event,true));
display.addEventListener('lostpointercapture',()=>{if(activePointer!==null) release();});
display.addEventListener('contextmenu',event=>{if(canControl()) event.preventDefault();});
display.addEventListener('auxclick',event=>{if(canControl()) event.preventDefault();});
display.addEventListener('wheel',event=>{
if(!canTouch() || activePointer!==null) return;
const at=point(event); if(!at) return;
event.preventDefault();
const unit=event.deltaMode===1?16:event.deltaMode===2?stage.clientHeight:1;
let dx=Math.max(-1000,Math.min(1000,event.deltaX*unit)),dy=Math.max(-1000,Math.min(1000,event.deltaY*unit));
if(rotation===90) [dx,dy]=[dy,-dx];
else if(rotation===180) [dx,dy]=[-dx,-dy];
else if(rotation===270) [dx,dy]=[-dy,dx];
pendingScroll={action:'scroll',...at,delta_x:Math.max(-1000,Math.min(1000,(pendingScroll?.delta_x||0)+dx)),delta_y:Math.max(-1000,Math.min(1000,(pendingScroll?.delta_y||0)+dy))};
if(!scrollTimer) scrollTimer=setTimeout(flushScroll,100);
},{passive:false});
stage.addEventListener('keydown',event=>{
if(event.altKey && !event.ctrlKey && !event.metaKey) {
const shortcuts={KeyF:fullscreen,ArrowLeft:()=>rotate(-90),ArrowRight:()=>rotate(90),KeyH:()=>buttonPress('home'),KeyB:()=>buttonPress('back'),KeyP:()=>buttonPress('power'),ArrowUp:()=>buttonPress('volumeup'),ArrowDown:()=>buttonPress('volumedown')};
if(shortcuts[event.code]) {event.preventDefault();if(!event.repeat) {release();shortcuts[event.code]();}return;}
}
if(!canControl() || !hasCapability('keyboard') || event.isComposing || !keyboardCodes.has(event.code) || event.key==='Unidentified') return;
event.preventDefault();
if(event.repeat || heldKeys.has(event.code)) return;
heldKeys.add(event.code);
operate({action:'key',code:event.code,pressed:true,modifiers:modifiers(event)});
});
stage.addEventListener('keyup',event=>{
if(!heldKeys.delete(event.code)) return;
event.preventDefault();operate({action:'key',code:event.code,pressed:false,modifiers:modifiers(event)});
});
stage.addEventListener('compositionstart',()=>{release();toast('暂不支持输入法组字,请切换到物理键盘输入。');});
stage.addEventListener('blur',()=>{if(enabled) release();});
window.addEventListener('blur',()=>{if(enabled) release();});
window.addEventListener('pagehide',()=>{cancelMove();cancelScroll();emergencyRelease();});
document.addEventListener('visibilitychange',()=>{if(document.hidden && enabled) release();});
$('control').addEventListener('change',async()=>{
const requested=$('control').checked; busyControl=true;refreshControls();
if(!requested) await release();
const result=await operate({action:'control',enabled:requested});
if(result!==null) controlFailed=false;
enabled=result!==null && requested; busyControl=false;refreshControls();
if(enabled) {stage.focus({preventScroll:true});toast('电脑控制已开启;左键触控,右键返回,中键主屏幕。');}
});
$('start').onclick=async()=>{if(await operate({action:'start'})) {paused=false;message('投屏请求已排队,等待手机返回画面。');refreshControls();}};
$('stop').onclick=async()=>{await release();if(await operate({action:'stop'})) {paused=true;message('暂停请求已排队,保留最后一帧。');refreshControls();}};
$('snapshot').onclick=async()=>{if(await operate({action:'snapshot'})) toast('截图请求已排队,完整画面收到后保存 PNG。');};
$('rotate').onclick=()=>rotate(90); $('fullscreen').onclick=fullscreen; $('zoom').onchange=layout; $('release').onclick=release;
$('settings').onclick=()=>{$('settings').setAttribute('aria-expanded',String($('inspector').classList.toggle('open')));};
document.addEventListener('fullscreenchange',()=>{$('fullscreen').textContent=document.fullscreenElement?'退出全屏':'全屏';layout();});
for(const button of document.querySelectorAll('[data-key]')) button.onclick=()=>buttonPress(button.dataset.key);
$('size').onchange=()=>{$('customSize').hidden=$('size').value!=='custom';settingsDirty=true;};
$('maxSize').oninput=$('fps').onchange=()=>{settingsDirty=true;};
$('apply').onclick=async()=>{
const max_size=Number($('size').value==='custom'?$('maxSize').value:$('size').value),max_fps=Number($('fps').value);
if(!Number.isInteger(max_size)||max_size<160||max_size>2780) {message('最长边请填写 160–2780 之间的整数。',true);$('maxSize').focus();return;}
await release();
const result=await operate({action:'configure',max_size,max_fps});
if(result) {settingsDirty=false;toast('设置请求已排队,收到新帧后显示新的分辨率。');}
};
new ResizeObserver(layout).observe(stage);
function updateAge() {
if(!receivedAt) return;
const seconds=Math.max(0,(Date.now()-receivedAt)/1000);
$('age').textContent=seconds<1?'刚刚':seconds<60?Math.floor(seconds)+' 秒前':Math.floor(seconds/60)+' 分钟前';
}
async function update() {
try {
const response=await fetch('/status',{cache:'no-store',signal:AbortSignal.timeout(4000)});
if(!response.ok) throw new Error('读取投屏状态失败');
const next=await response.json();
const hadControl=canControl(); state=next; online=next.connection!=='disconnected';
if(next.connection!=='diagnostic'||!next.input_available) enabled=false;
else if(typeof next.settings?.control==='boolean'&&!busyControl) enabled=next.settings.control&&!controlFailed;
if(hadControl && !canControl()) release();
if(next.frames && next.frames!==frame) {
const incoming=next.frames;
screen.onload=()=>{
width=screen.naturalWidth;height=screen.naturalHeight;
display.hidden=false;$('empty').hidden=true;layout();
};
screen.src='/frame.png?v='+incoming;frame=incoming;
if(!sampled) {sampleAt=performance.now();sampleFrames=frame;sampled=true;}
receivedAt=Date.parse(next.received_utc)||0;
$('dimensions').textContent=next.width+' × '+next.height;
$('frameCount').textContent=frame+' 帧';
if(!next.state?.includes('stopped')) paused=false;
}
const now=performance.now();
if(now-sampleAt>=2000) {$('actualFps').textContent=((frame-sampleFrames)*1000/(now-sampleAt)).toFixed(1)+' FPS';sampleAt=now;sampleFrames=frame;}
if(next.state?.includes('stopped')) paused=true;
if(!settingsDirty && next.settings) {
const max=String(next.settings.max_size);
const preset=[...$('size').options].some(option=>option.value===max);
$('size').value=preset?max:'custom';$('customSize').hidden=preset;$('maxSize').value=max;
const fpsValue=String(next.settings.max_fps);
if(![...$('fps').options].some(option=>option.value===fpsValue)) {
const option=document.createElement('option');option.value=fpsValue;option.textContent=fpsValue+' FPS';$('fps').append(option);
}
$('fps').value=fpsValue;
}
$('dot').className='dot '+(online?'online':'error');
$('connection').textContent=!online?'连接已断开':next.connection==='shell'?'Shell 会话':'USB 已连接';
if(next.connection==='shell') message('Shell 使用中,投屏与电脑控制暂时停用。');
else if(!online) message('设备已断开;当前显示的是最后收到的画面。',true);
else if(paused) message('投屏已暂停,保留最后一帧。');
else if(frame) message('真实 framebuffer · '+(next.received_utc?new Date(next.received_utc).toLocaleTimeString():'等待更新'));
else message(next.state||'已连接,等待手机返回真实屏幕帧。');
refreshControls();updateAge();
} catch(error) {
if(online||enabled) {enabled=false;cancelMove();cancelScroll();activePointer=null;heldKeys.clear();emergencyRelease();}
online=false;$('dot').className='dot error';$('connection').textContent='服务已断开';
message('本地投屏服务不可用;请检查电脑终端是否仍在运行。',true);refreshControls();updateAge();
}
setTimeout(update,300);
}
setInterval(()=>{
if(enabled && online && document.hasFocus() && !document.hidden) operate({action:'heartbeat'});
},1000);
update();
</script>
</body>
</html>