remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
perf: instant cursor prediction + lighter frames in live view
The window stream is damage-driven, so an idle window barely repaints —
and moving the mouse never repaints it (the cursor is a compositor
overlay, not in the window buffer), so the cyan marker was the only
thing tracking the finger and it updated just 4x/s from the pl poll:
pointer motion felt extremely laggy. Now the marker is predicted
client-side from the finger delta (same accel as the sent move) for
instant tracking, reconciled by the poll (now 120ms). Frames also
lighten: MAX_EDGE 700->560, JPEG q70->60 — 67KB->29KB/frame, ~57% less
over wifi, faster encode and phone-side decode, plenty sharp at phone
size.
Co-Authored-By: Claude Fable 5 <[email protected]>
index.html | 28 +++++++++++++++++++++++-----
src/screencopy.rs | 4 ++--
2 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/index.html b/index.html
index a4e3609..56442d1 100644
--- a/index.html
+++ b/index.html
@@ -186,9 +186,9 @@ function clampPan() {
// view mode draws its own marker: poll pointer-location over the WS and
// place the ring via the inverse of the tap mapping.
const vcursor = document.getElementById("vcursor");
-function updateCursorMarker(coords) {
+let ptr = null; // last known pointer position in layout px (from ploc + prediction)
+function placeMarker(lx, ly) {
if (!viewMode || !winRect || !screenEl.naturalWidth) { vcursor.classList.remove("on"); return; }
- const [lx, ly] = coords.split(" ").map(Number);
const u = (lx - winRect.x) / winRect.w, v = (ly - winRect.y) / winRect.h;
if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) { vcursor.classList.remove("on"); return; }
const box = screenEl.getBoundingClientRect();
@@ -199,6 +199,21 @@ function updateCursorMarker(coords) {
vcursor.style.top = (box.top - padBox.top + (box.height - dh) / 2 + v * dh) + "px";
vcursor.classList.add("on");
}
+// Server truth (poll): reconcile the predicted position.
+function updateCursorMarker(coords) {
+ const [lx, ly] = coords.split(" ").map(Number);
+ ptr = { x: lx, y: ly };
+ placeMarker(lx, ly);
+}
+// Client prediction: move the marker with the finger immediately, so pointer
+// motion feels instant even though the window frame is damage-gated and won't
+// repaint just because the cursor moved. Reconciled by the next ploc poll.
+function predictMarker(dLx, dLy) {
+ if (!ptr || !winRect) return;
+ ptr.x = Math.min(Math.max(ptr.x + dLx, winRect.x), winRect.x + winRect.w);
+ ptr.y = Math.min(Math.max(ptr.y + dLy, winRect.y), winRect.y + winRect.h);
+ placeMarker(ptr.x, ptr.y);
+}
// touch point (viewport px) → layout px inside the focused window,
// accounting for the contain-fit letterbox. null when off the image.
@@ -252,8 +267,11 @@ pad.addEventListener("touchmove", e => {
const t = e.changedTouches[0], p = touches.get(t.identifier);
if (!p) return;
// one-finger drag = relative pointer motion, in BOTH modes
- pendDx += t.clientX - p.x; pendDy += t.clientY - p.y; queueFlush();
- moved += Math.abs(t.clientX - p.x) + Math.abs(t.clientY - p.y);
+ const rdx = t.clientX - p.x, rdy = t.clientY - p.y;
+ pendDx += rdx; pendDy += rdy; queueFlush();
+ // view mode: predict the cursor marker instantly (same accel as the send)
+ if (viewMode) predictMarker(rdx * ACCEL, rdy * ACCEL);
+ moved += Math.abs(rdx) + Math.abs(rdy);
touches.set(t.identifier, { x: t.clientX, y: t.clientY });
} else if (touches.size === 2) {
let dy = 0, n = 0;
@@ -329,7 +347,7 @@ function setViewMode(on) {
streamStart();
send("wl"); // prime winRect
rectTimer = setInterval(() => send("wl"), 2000);
- cursorTimer = setInterval(() => send("pl"), 250);
+ cursorTimer = setInterval(() => send("pl"), 120);
// Reconnect if no frame has loaded for a while (the compositor keepalives
// every ≤15s, so ~20s of silence means the connection is dead, not idle).
watchdog = setInterval(() => {
diff --git a/src/screencopy.rs b/src/screencopy.rs
index 2a5b513..515675c 100644
--- a/src/screencopy.rs
+++ b/src/screencopy.rs
@@ -27,8 +27,8 @@ use wayland_protocols_wlr::screencopy::v1::client::{
/// Longest edge of the encoded frame, in px — the downscale factor is chosen
/// per frame to stay under this.
-const MAX_EDGE: u32 = 700;
-const JPEG_QUALITY: u8 = 70;
+const MAX_EDGE: u32 = 560;
+const JPEG_QUALITY: u8 = 60;
#[derive(Default)]
struct CapState {