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

commit4fffed47faa85705696397f4f8ac3fb80a0dbe57
parentcc30856e60
authorLucas Galante <[email protected]>
date2026-07-21 22:30
fix: black square in live view — move zoom transform off the <img>

iOS Safari stops repainting a GPU-promoted layer when its
multipart/x-mixed-replace <img> updates, so the persistent inline
transform + will-change I added for pinch-zoom turned the live view
black. The zoom/pan transform now lives on a #screenwrap div; the <img>
stays in the normal paint path. Tap mapping and the cursor marker are
unaffected (getBoundingClientRect reflects the ancestor transform).

Co-Authored-By: Claude Fable 5 <[email protected]>

 index.html | 16 +++++++++++-----
 1 file changed, 11 insertions(+), 5 deletions(-)

diff --git a/index.html b/index.html
index 74015f5..ccd4b2c 100644
--- a/index.html
+++ b/index.html
@@ -28,9 +28,14 @@
                    border-radius: 10px; }
   #clicks button:active, #bar button:active { background: #2c5f69; }
   #kb { position: absolute; opacity: 0.02; width: 1px; height: 1px; left: -10px; top: -10px; }
-  #screen { display: none; position: absolute; inset: 0; width: 100%; height: 100%;
-            object-fit: contain; background: #0c0d12; border-radius: 12px; z-index: 2;
-            transform-origin: 0 0; will-change: transform; }
+  /* Zoom/pan transform lives on the WRAPPER, never the <img>: iOS Safari
+     stops repainting a GPU-promoted layer when its multipart-MJPEG <img>
+     updates (black square). The img stays in the normal paint path. */
+  #screenwrap { display: none; position: absolute; inset: 0; z-index: 2;
+                transform-origin: 0 0; border-radius: 12px; overflow: hidden; }
+  #pad.view #screenwrap { display: block; }
+  #screen { display: block; width: 100%; height: 100%;
+            object-fit: contain; background: #0c0d12; }
   #vcursor { display: none; position: absolute; width: 14px; height: 14px; z-index: 3;
              pointer-events: none; border: 2px solid #7dffff; border-radius: 50%;
              box-shadow: 0 0 4px #000; margin: -7px 0 0 -7px; }
@@ -76,7 +81,7 @@
 </div>
 <div id="preview"></div>
 <div id="pad"><div class="hint">drag = move · tap = click · 2-finger drag = scroll<br>2-finger tap = right click · hold = drag</div>
-  <img id="screen" draggable="false">
+  <div id="screenwrap"><img id="screen" draggable="false"></div>
   <div id="vcursor"></div>
   <div id="switcher"></div>
   <div id="menu"></div>
@@ -153,8 +158,9 @@ function updateRect(wins) {
 // transform of the streamed image. Uniform scale+translate means
 // getBoundingClientRect reflects it, so mapToWindow stays correct zoomed.
 let vZoom = 1, vPanX = 0, vPanY = 0, pinch = null;
+const screenWrap = document.getElementById("screenwrap");
 function applyViewTransform() {
-  screenEl.style.transform = "translate(" + vPanX + "px," + vPanY + "px) scale(" + vZoom + ")";
+  screenWrap.style.transform = "translate(" + vPanX + "px," + vPanY + "px) scale(" + vZoom + ")";
 }
 function resetViewTransform() { vZoom = 1; vPanX = 0; vPanY = 0; pinch = null; applyViewTransform(); }
 function clampPan() {