git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

commit474e6634ce0ad3a0a88d38a4dcecccd2961eb09b
parent85322d2825
authorLucas Galante <[email protected]>
date2026-07-21 22:43
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 {