diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 1a453e1..6787d04 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -2252,6 +2252,68 @@ async function sfuUnpublishCamera(){
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
}
+/* On mobile the camera sensor's natural read-out orientation is locked
+ * at getUserMedia() time. Rotating the phone does NOT update the encoded
+ * frames' orientation — listeners see whatever was captured initially,
+ * regardless of how the publisher is holding the device now.
+ *
+ * Fix: when the device orientation changes while we're publishing, re-
+ * acquire the camera so the sensor reads out in the new orientation.
+ * We swap the track in the existing publisher's RTCRtpSender via
+ * replaceTrack — no SDP renegotiation, no SFU-side supplant, no
+ * subscriber renegotiation. Subscribers just start receiving frames
+ * in the new orientation a few hundred ms later.
+ *
+ * Debounced 350ms so a fast orientation flick doesn't trigger two
+ * back-to-back captures. */
+let cameraOrientationDebounce = null;
+async function reacquireCameraForOrientation(){
+ if (!sfuCameraPC || !sfuCameraStream || !myKeys) return;
+ const constraints = {
+ width: { ideal: 1280 }, height: { ideal: 720 },
+ aspectRatio: { ideal: 16/9 },
+ frameRate: { ideal: 30 },
+ };
+ if (cameraDeviceId) constraints.deviceId = { exact: cameraDeviceId };
+ let fresh;
+ try { fresh = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false }); }
+ catch(e){ logLine('err','camera re-acquire failed: '+e.message); return; }
+ const newVideo = fresh.getVideoTracks()[0];
+ if (!newVideo){ fresh.getTracks().forEach(t=>t.stop()); return; }
+ newVideo.contentHint = 'motion';
+ newVideo.addEventListener('ended', () => { sfuUnpublishCamera(); });
+ /* swap the sender's track */
+ let swapped = false;
+ for (const s of sfuCameraPC.getSenders()){
+ if (s.track && s.track.kind === 'video'){
+ try { await s.replaceTrack(newVideo); swapped = true; break; }
+ catch(e){ logLine('err','replaceTrack: '+e.message); }
+ }
+ }
+ if (!swapped){ fresh.getTracks().forEach(t=>t.stop()); return; }
+ /* stop the old stream's tracks AFTER swap so the encoder has the
+ * new source ready before the old one ends */
+ const old = sfuCameraStream;
+ sfuCameraStream = fresh;
+ if (old) old.getTracks().forEach(t=>t.stop());
+ renderCameraTile(myKeys.pubHex, fresh, { local: true });
+ const w = newVideo.getSettings ? (newVideo.getSettings().width||'?') : '?';
+ const h = newVideo.getSettings ? (newVideo.getSettings().height||'?') : '?';
+ logLine('', 'camera re-captured for new orientation ('+w+'x'+h+')');
+}
+function onCameraOrientationChange(){
+ if (cameraOrientationDebounce) clearTimeout(cameraOrientationDebounce);
+ cameraOrientationDebounce = setTimeout(() => {
+ cameraOrientationDebounce = null;
+ reacquireCameraForOrientation();
+ }, 350);
+}
+if (window.screen && window.screen.orientation && window.screen.orientation.addEventListener){
+ window.screen.orientation.addEventListener('change', onCameraOrientationChange);
+} else {
+ window.addEventListener('orientationchange', onCameraOrientationChange);
+}
+
async function sfuUnpublish(){
if (!sfuPubPC) return;
const pid = sfuPubPeerID;
@@ -3709,8 +3771,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');