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:
MeowOS committed 2026-08-31 18:50:12 +08:00
1 parent e9b6b690c1
commit 829ac3ba88
6 files changed
+421 -61

No files matched your search

+5
View File
@@ -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; }
+98 -11
View File
@@ -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();
}