feat(meowpanel): 通知实时化+屏幕增量帧 (agent-234) — /api/notifications 优先 IPC 读 launcher 活跃集(source:ipc,超时回退TF), dismiss/clear 联动 launcher 内存; /api/screen.jpeg?delta=1 8x8块比对+变化块外接矩形子图(近静态27KB→804B), 前端canvas叠加+开关
meowpanel→24f53b40 已部署真机验证(通知实时出现/撤下联动/增量帧带宽实测)
This commit is contained in:
1 parent
e9b6b690c1
commit
829ac3ba88
6 files changed
+421
-61
No files matched your search
@@ -243,6 +243,11 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
|
||||
border-radius:var(--radius-m); background:#000; cursor:crosshair;
|
||||
aspect-ratio:480/720; object-fit:contain; border:var(--outline-w) solid var(--outline-variant);
|
||||
}
|
||||
.screen-frame canvas{
|
||||
width:100%; max-width:480px; height:auto; display:none;
|
||||
border-radius:var(--radius-m); background:#000; cursor:crosshair;
|
||||
aspect-ratio:480/720; border:var(--outline-w) solid var(--outline-variant);
|
||||
}
|
||||
|
||||
/* 远程按键区 */
|
||||
.key-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; }
|
||||
|
||||
@@ -812,6 +812,7 @@
|
||||
let screenTimer = null, screenBusy = false, screenOn = false;
|
||||
let screenObjUrl = null, screenFps = 1;
|
||||
let screenLastMs = 0, screenMeasFps = 0; // 实测帧率(帧间间隔 EMA)
|
||||
let screenDelta = false, screenCanvasRef = null, screenCanvasReady = false; // 增量模式(canvas 叠加)
|
||||
function stopScreenPoll() {
|
||||
screenOn = false;
|
||||
if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; }
|
||||
@@ -826,20 +827,53 @@
|
||||
if (screenBusy) return;
|
||||
screenBusy = true;
|
||||
const t0 = performance.now();
|
||||
let statusMsg = '';
|
||||
try {
|
||||
const headers = {};
|
||||
const token = storageGet('meowpanel.token');
|
||||
if (token) headers['X-MeowToken'] = token;
|
||||
const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70',
|
||||
const deltaQ = screenDelta ? '&delta=1' : '';
|
||||
const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70' + deltaQ,
|
||||
Object.keys(headers).length ? { headers: headers } : undefined);
|
||||
if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; return; }
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = $('#screen-img');
|
||||
if (!img) { URL.revokeObjectURL(url); return; }
|
||||
img.src = url;
|
||||
if (screenObjUrl) URL.revokeObjectURL(screenObjUrl);
|
||||
screenObjUrl = url;
|
||||
const hd = (k) => res.headers.get(k);
|
||||
if (screenDelta) {
|
||||
const isDelta = hd('X-Meow-Delta') === '1';
|
||||
const full = hd('X-Meow-Full') === '1';
|
||||
const changed = parseInt(hd('X-Meow-Changed') || '-1', 10);
|
||||
const fw = parseInt(hd('X-Meow-W') || '480', 10);
|
||||
const fh = parseInt(hd('X-Meow-H') || '720', 10);
|
||||
const blob = await res.blob();
|
||||
if (!isDelta || full) { // 整帧(或旧后端无 delta 头)→ 播种/替换 canvas
|
||||
const img = await createImgFromBlob(blob);
|
||||
const cv = ensureScreenCanvas(fw, fh);
|
||||
if (cv) { cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; }
|
||||
statusMsg = '已更新' + (changed >= 0 ? '(整帧)' : '');
|
||||
} else if (!screenCanvasReady) { // 尚未播种:取一整帧再继续
|
||||
await seedScreenCanvas(fw, fh);
|
||||
if (screenCanvasReady) statusMsg = '已更新(播种)';
|
||||
} else if (changed === 0) { // 无变化:跳过叠加
|
||||
statusMsg = '无变化';
|
||||
} else { // 子图叠加
|
||||
const subX = parseInt(hd('X-Meow-Sub-X') || '0', 10);
|
||||
const subY = parseInt(hd('X-Meow-Sub-Y') || '0', 10);
|
||||
const subW = parseInt(hd('X-Meow-Sub-W') || fw, 10);
|
||||
const subH = parseInt(hd('X-Meow-Sub-H') || fh, 10);
|
||||
const img = await createImgFromBlob(blob);
|
||||
const cv = ensureScreenCanvas(fw, fh);
|
||||
if (cv) cv.getContext('2d').drawImage(img, subX, subY, subW, subH);
|
||||
statusMsg = '已更新(增量 ' + changed + ' 块)';
|
||||
}
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = $('#screen-img');
|
||||
if (!img) { URL.revokeObjectURL(url); return; }
|
||||
img.src = url;
|
||||
if (screenObjUrl) URL.revokeObjectURL(screenObjUrl);
|
||||
screenObjUrl = url;
|
||||
statusMsg = '已更新';
|
||||
}
|
||||
// 实测帧率:上一帧到本帧间隔的 EMA(首帧无间隔)
|
||||
const now = performance.now();
|
||||
if (screenLastMs) {
|
||||
@@ -847,7 +881,7 @@
|
||||
screenMeasFps = screenMeasFps ? screenMeasFps * 0.7 + (1 / dt) * 0.3 : 1 / dt;
|
||||
}
|
||||
screenLastMs = now;
|
||||
$('#screen-status').textContent = '已更新' + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString();
|
||||
$('#screen-status').textContent = statusMsg + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
$('#screen-status').textContent = '后端未连接';
|
||||
}
|
||||
@@ -859,9 +893,48 @@
|
||||
screenTimer = setTimeout(fetchScreen, wait);
|
||||
}
|
||||
}
|
||||
// 增量模式助手:blob → Image(一次性 objectURL,加载后即回收)
|
||||
async function createImgFromBlob(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
|
||||
return img;
|
||||
} finally { URL.revokeObjectURL(url); }
|
||||
}
|
||||
function ensureScreenCanvas(fw, fh) {
|
||||
let cv = screenCanvasRef;
|
||||
if (!cv) cv = screenCanvasRef = $('#screen-canvas');
|
||||
if (!cv) return null;
|
||||
if (cv.width !== fw || cv.height !== fh) { cv.width = fw; cv.height = fh; }
|
||||
return cv;
|
||||
}
|
||||
async function seedScreenCanvas(fw, fh) {
|
||||
const headers = {};
|
||||
const token = storageGet('meowpanel.token');
|
||||
if (token) headers['X-MeowToken'] = token;
|
||||
const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70',
|
||||
Object.keys(headers).length ? { headers: headers } : undefined);
|
||||
if (!res.ok) return false;
|
||||
const img = await createImgFromBlob(await res.blob());
|
||||
const cv = ensureScreenCanvas(fw, fh);
|
||||
if (!cv) return false;
|
||||
cv.getContext('2d').drawImage(img, 0, 0, fw, fh);
|
||||
screenCanvasReady = true;
|
||||
return true;
|
||||
}
|
||||
function applyScreenDeltaDom() {
|
||||
const img = $('#screen-img'), cv = $('#screen-canvas');
|
||||
if (!img || !cv) return;
|
||||
if (screenDelta) { img.style.display = 'none'; cv.style.display = 'block'; }
|
||||
else { img.style.display = 'block'; cv.style.display = 'none'; }
|
||||
}
|
||||
async function screenTap(e) {
|
||||
const img = $('#screen-img'); if (!img) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
const img = $('#screen-img'), cv = $('#screen-canvas');
|
||||
const useCanvas = screenDelta && cv && cv.style.display !== 'none';
|
||||
const el = useCanvas ? cv : img;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return;
|
||||
// 前端按 480x720(设备原生分辨率)换算
|
||||
const nx = Math.round((e.clientX - r.left) / r.width * 480);
|
||||
@@ -882,15 +955,20 @@
|
||||
'<p class="muted">点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。</p>' +
|
||||
'<div class="screen-frame">' +
|
||||
'<img id="screen-img" alt="设备实时画面">' +
|
||||
'<canvas id="screen-canvas" style="display:none"></canvas>' +
|
||||
'</div>' +
|
||||
'<div class="field-row" style="margin-top:12px">' +
|
||||
'<label class="muted" for="screen-fps">刷新率</label>' +
|
||||
'<input type="range" id="screen-fps" min="0.2" max="2" step="0.1" value="' + screenFps + '">' +
|
||||
'<span class="muted" id="screen-fps-lbl" style="min-width:56px">' + screenFps.toFixed(1) + ' fps</span>' +
|
||||
'</div>' +
|
||||
'<div class="field-row" style="margin-top:6px">' +
|
||||
'<label class="muted"><input type="checkbox" id="screen-delta"' + (screenDelta ? ' checked' : '') + '> 增量传输(省流量)</label>' +
|
||||
'</div>' +
|
||||
'<p class="muted" id="screen-status">载入中…</p>' +
|
||||
'</div></div>';
|
||||
$('#content').innerHTML = h;
|
||||
screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态
|
||||
const slider = $('#screen-fps');
|
||||
slider.addEventListener('input', function () {
|
||||
const v = parseFloat(slider.value);
|
||||
@@ -898,7 +976,16 @@
|
||||
$('#screen-fps-lbl').textContent = v.toFixed(1) + ' fps';
|
||||
restartScreenPoll();
|
||||
});
|
||||
const deltaBox = $('#screen-delta');
|
||||
deltaBox.addEventListener('change', function () {
|
||||
screenDelta = deltaBox.checked;
|
||||
screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种
|
||||
applyScreenDeltaDom();
|
||||
restartScreenPoll();
|
||||
});
|
||||
$('#screen-img').addEventListener('click', screenTap);
|
||||
$('#screen-canvas').addEventListener('click', screenTap);
|
||||
applyScreenDeltaDom();
|
||||
// restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发
|
||||
restartScreenPoll();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user