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');