zebra-spaces: speakers can toggle camera (1280x720 @ 1.5Mbps), separate publish
Camera is a third SFU publisher alongside mic and screen — same multi- track pipeline, kind=camera suffix on streamID, own #cameras grid in the timeline column (multi-column auto-fill so multiple face cams fit without dwarfing a screen share above them). - new UI: share-camera / stop-camera buttons + device selector in the same share section. Camera-select restart triggers a clean unpub + re-pub because deviceId change needs renegotiation anyway. - new state: sfuCameraPC/Stream/PeerID + cameraStreams/cameraVideos maps - subscribe ontrack: generic colon-split routes pubkey:screen/camera to the right tile renderer; back-compat for the existing :screen path - renderScreenTile + removeScreenTile refactored to a kind-parametric renderVideoTile via a TILE_KINDS map; the old names stay as thin shims so callers don't change - role demotion + leave + peer-left + boot all clean up cameras too - contentHint='motion' for face-cam (vs 'detail' on screen) - camera bitrate capped at 1.5Mbps so screen-share headroom isn't cannibalised when both are publishing simultaneously Paired with SFU change zebra-spaces-sfu#017c94b which allowlists kind=camera alongside screen.
This commit is contained in:
parent
3f43379e00
commit
7ddedc9a09
1 changed files with 186 additions and 34 deletions
|
|
@ -86,6 +86,30 @@
|
||||||
/* sec-screens visible → push everything else in the timeline column down or hide */
|
/* sec-screens visible → push everything else in the timeline column down or hide */
|
||||||
.timeline:has(> #sec-screens:not(.hidden)) > .game-tabs,
|
.timeline:has(> #sec-screens:not(.hidden)) > .game-tabs,
|
||||||
.timeline:has(> #sec-screens:not(.hidden)) > #game-frame { display: none; }
|
.timeline:has(> #sec-screens:not(.hidden)) > #game-frame { display: none; }
|
||||||
|
|
||||||
|
/* camera tiles: smaller, multi-column grid so several face-cams fit
|
||||||
|
* side-by-side without dwarfing the screen share above them */
|
||||||
|
#cameras { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
|
||||||
|
.camera-tile {
|
||||||
|
border: 1px solid #000; background: #000; padding: 0;
|
||||||
|
display: flex; flex-direction: column; position: relative;
|
||||||
|
}
|
||||||
|
.camera-tile video {
|
||||||
|
width: 100%; height: auto; max-height: 40vh; display: block; background: #000;
|
||||||
|
aspect-ratio: 16 / 9; object-fit: cover;
|
||||||
|
}
|
||||||
|
.camera-tile .screen-meta {
|
||||||
|
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.2rem 0.4rem;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
}
|
||||||
|
/* tap-to-play overlay applies to camera tiles too */
|
||||||
|
.camera-tile .tap-play {
|
||||||
|
position: absolute; left: 0; right: 0; top: 0; bottom: 1.2rem;
|
||||||
|
display: none; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
|
||||||
|
font-family: monospace; font-size: 0.9rem; user-select: none;
|
||||||
|
}
|
||||||
|
.camera-tile.needs-tap .tap-play { display: flex; }
|
||||||
h1 {
|
h1 {
|
||||||
font-family: 'chunkfiveregular', serif;
|
font-family: 'chunkfiveregular', serif;
|
||||||
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
|
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
|
||||||
|
|
@ -203,6 +227,11 @@
|
||||||
<div id="screens"></div>
|
<div id="screens"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="sec-cameras" class="hidden">
|
||||||
|
<h2>cameras</h2>
|
||||||
|
<div id="cameras"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- placeholder content until the v0.3 event timeline lands: a game switcher
|
<!-- placeholder content until the v0.3 event timeline lands: a game switcher
|
||||||
framing an external playable. Iframes load unsandboxed so the game JS can
|
framing an external playable. Iframes load unsandboxed so the game JS can
|
||||||
run; if a target refuses framing (X-Frame-Options / CSP frame-ancestors)
|
run; if a target refuses framing (X-Frame-Options / CSP frame-ancestors)
|
||||||
|
|
@ -325,6 +354,11 @@
|
||||||
<button id="btn-screen-stop" class="hidden">stop sharing</button>
|
<button id="btn-screen-stop" class="hidden">stop sharing</button>
|
||||||
<span class="note">window or tab; tick "share audio" if offered.</span>
|
<span class="note">window or tab; tick "share audio" if offered.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin-top:0.4rem">
|
||||||
|
<button id="btn-camera-share" class="invert">share camera</button>
|
||||||
|
<button id="btn-camera-stop" class="hidden">stop camera</button>
|
||||||
|
<select id="camera-select" title="camera device — applies on next start"><option value="">default camera</option></select>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="sec-listener-actions" class="hidden">
|
<section id="sec-listener-actions" class="hidden">
|
||||||
|
|
@ -558,10 +592,13 @@ const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
|
||||||
let sfuPubPC = null, sfuPubPeerID = null;
|
let sfuPubPC = null, sfuPubPeerID = null;
|
||||||
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
|
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
|
||||||
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
|
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
|
||||||
/* incoming screen streams keyed by publisher pubkey hex (== streamID).
|
let sfuCameraPC = null, sfuCameraPeerID = null, sfuCameraStream = null, cameraDeviceId = '';
|
||||||
|
/* incoming screen/camera streams keyed by publisher pubkey hex (== streamID).
|
||||||
* Cleared when the corresponding publisher leaves (peer-left or boot). */
|
* Cleared when the corresponding publisher leaves (peer-left or boot). */
|
||||||
const screenStreams = new Map(); // pubHex -> MediaStream
|
const screenStreams = new Map(); // pubHex -> MediaStream
|
||||||
const screenVideos = new Map(); // pubHex -> { tile, video }
|
const screenVideos = new Map(); // pubHex -> { tile, video }
|
||||||
|
const cameraStreams = new Map(); // pubHex -> MediaStream
|
||||||
|
const cameraVideos = new Map(); // pubHex -> { tile, video }
|
||||||
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
|
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
|
||||||
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
|
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
|
||||||
* we tear their audio element, but Pion often REUSES the transceiver on
|
* we tear their audio element, but Pion often REUSES the transceiver on
|
||||||
|
|
@ -606,10 +643,16 @@ function flushSfuStreams(){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* render one screen-share tile (or update its srcObject if it already
|
/* shared video-tile renderer for screens + cameras. The two kinds share
|
||||||
* exists). Removed on peer-left / boot / SFU track removal via
|
* the autoplay + tap-to-play + unmute machinery; only the DOM target
|
||||||
* removeScreenTile. */
|
* (container + tile class), label prefix, and backing Map differ.
|
||||||
function renderScreenTile(pubHex, stream, opts){
|
* Removed on peer-left / boot / SFU track removal via removeVideoTile. */
|
||||||
|
const TILE_KINDS = {
|
||||||
|
screen: { container:'screens', section:'sec-screens', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
|
||||||
|
camera: { container:'cameras', section:'sec-cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
|
||||||
|
};
|
||||||
|
function renderVideoTile(kind, pubHex, stream, opts){
|
||||||
|
const k = TILE_KINDS[kind]; if (!k) return;
|
||||||
const local = !!(opts && opts.local);
|
const local = !!(opts && opts.local);
|
||||||
/* find the matching member for the title; self isn't in members map */
|
/* find the matching member for the title; self isn't in members map */
|
||||||
let label = shortHex(pubHex);
|
let label = shortHex(pubHex);
|
||||||
|
|
@ -620,14 +663,13 @@ function renderScreenTile(pubHex, stream, opts){
|
||||||
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ label = mm.handle || label; break; } } catch(_){}
|
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ label = mm.handle || label; break; } } catch(_){}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let entry = screenVideos.get(pubHex);
|
let entry = k.store.get(pubHex);
|
||||||
if (!entry){
|
if (!entry){
|
||||||
const tile = document.createElement('div'); tile.className = 'screen-tile';
|
const tile = document.createElement('div'); tile.className = k.tileClass;
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
/* set BOTH the HTML attributes and the IDL properties — Firefox Android
|
/* set BOTH the HTML attributes and the IDL properties — Firefox Android
|
||||||
* checks the attribute when deciding autoplay eligibility, not just the
|
* checks the attribute when deciding autoplay eligibility, not just the
|
||||||
* .muted property. Without the attribute, MediaStream-backed video
|
* .muted property. */
|
||||||
* stays paused even though it's logically muted. */
|
|
||||||
video.setAttribute('autoplay', '');
|
video.setAttribute('autoplay', '');
|
||||||
video.setAttribute('playsinline', '');
|
video.setAttribute('playsinline', '');
|
||||||
video.setAttribute('muted', '');
|
video.setAttribute('muted', '');
|
||||||
|
|
@ -639,7 +681,7 @@ function renderScreenTile(pubHex, stream, opts){
|
||||||
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
|
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
|
||||||
};
|
};
|
||||||
const meta = document.createElement('div'); meta.className = 'screen-meta';
|
const meta = document.createElement('div'); meta.className = 'screen-meta';
|
||||||
const who = document.createElement('span'); who.textContent = 'screen: '+label;
|
const who = document.createElement('span'); who.textContent = k.labelPrefix+': '+label;
|
||||||
const ctl = document.createElement('span');
|
const ctl = document.createElement('span');
|
||||||
/* local preview never offers unmute — playing our own captured audio
|
/* local preview never offers unmute — playing our own captured audio
|
||||||
* back would feed straight into our mic */
|
* back would feed straight into our mic */
|
||||||
|
|
@ -647,8 +689,7 @@ function renderScreenTile(pubHex, stream, opts){
|
||||||
const unmute = document.createElement('button'); unmute.className = 'small';
|
const unmute = document.createElement('button'); unmute.className = 'small';
|
||||||
unmute.textContent = 'unmute audio';
|
unmute.textContent = 'unmute audio';
|
||||||
unmute.onclick = () => {
|
unmute.onclick = () => {
|
||||||
const turnOn = video.muted;
|
video.muted = !video.muted;
|
||||||
video.muted = !turnOn;
|
|
||||||
if (video.muted) video.setAttribute('muted', ''); else video.removeAttribute('muted');
|
if (video.muted) video.setAttribute('muted', ''); else video.removeAttribute('muted');
|
||||||
unmute.textContent = video.muted ? 'unmute audio' : 'mute audio';
|
unmute.textContent = video.muted ? 'unmute audio' : 'mute audio';
|
||||||
try { video.play().catch(e => logLine('err','play after unmute: '+e.message)); } catch(_){}
|
try { video.play().catch(e => logLine('err','play after unmute: '+e.message)); } catch(_){}
|
||||||
|
|
@ -660,9 +701,9 @@ function renderScreenTile(pubHex, stream, opts){
|
||||||
ctl.appendChild(pop);
|
ctl.appendChild(pop);
|
||||||
meta.appendChild(who); meta.appendChild(ctl);
|
meta.appendChild(who); meta.appendChild(ctl);
|
||||||
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
|
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
|
||||||
$('screens').appendChild(tile);
|
$(k.container).appendChild(tile);
|
||||||
entry = { tile, video, label };
|
entry = { tile, video, label };
|
||||||
screenVideos.set(pubHex, entry);
|
k.store.set(pubHex, entry);
|
||||||
}
|
}
|
||||||
entry.video.srcObject = stream;
|
entry.video.srcObject = stream;
|
||||||
try {
|
try {
|
||||||
|
|
@ -678,17 +719,24 @@ function renderScreenTile(pubHex, stream, opts){
|
||||||
logLine('err','play threw: '+e.message);
|
logLine('err','play threw: '+e.message);
|
||||||
entry.tile.classList.add('needs-tap');
|
entry.tile.classList.add('needs-tap');
|
||||||
}
|
}
|
||||||
$('sec-screens').classList.remove('hidden');
|
$(k.section).classList.remove('hidden');
|
||||||
logLine('', 'screen share: '+(local?'local preview ':'receiving ')+label);
|
logLine('', k.labelPrefix+' share: '+(local?'local preview ':'receiving ')+label);
|
||||||
}
|
}
|
||||||
function removeScreenTile(pubHex){
|
function removeVideoTile(kind, pubHex){
|
||||||
const entry = screenVideos.get(pubHex);
|
const k = TILE_KINDS[kind]; if (!k) return;
|
||||||
|
const entry = k.store.get(pubHex);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
|
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
|
||||||
screenVideos.delete(pubHex);
|
k.store.delete(pubHex);
|
||||||
screenStreams.delete(pubHex);
|
k.streams.delete(pubHex);
|
||||||
if (screenVideos.size === 0) $('sec-screens').classList.add('hidden');
|
if (k.store.size === 0) $(k.section).classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
/* back-compat shims — keep the old names callable so the rest of the
|
||||||
|
* file doesn't have to be rewritten in one shot */
|
||||||
|
function renderScreenTile(pubHex, stream, opts){ return renderVideoTile('screen', pubHex, stream, opts); }
|
||||||
|
function removeScreenTile(pubHex){ return removeVideoTile('screen', pubHex); }
|
||||||
|
function renderCameraTile(pubHex, stream, opts){ return renderVideoTile('camera', pubHex, stream, opts); }
|
||||||
|
function removeCameraTile(pubHex){ return removeVideoTile('camera', pubHex); }
|
||||||
|
|
||||||
async function sfuPublish(){
|
async function sfuPublish(){
|
||||||
if (sfuPubPC){ logLine('','sfu publish: already publishing'); return; }
|
if (sfuPubPC){ logLine('','sfu publish: already publishing'); return; }
|
||||||
|
|
@ -800,6 +848,62 @@ async function sfuUnpublishScreen(){
|
||||||
logLine('', 'screen share stopped');
|
logLine('', 'screen share stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- camera publish (kind=camera) ----- */
|
||||||
|
async function sfuPublishCamera(){
|
||||||
|
if (sfuCameraPC || !myKeys || !roomID) return;
|
||||||
|
let stream;
|
||||||
|
const videoConstraints = { width:{ideal:1280}, height:{ideal:720}, frameRate:{ideal:30} };
|
||||||
|
if (cameraDeviceId) videoConstraints.deviceId = { exact: cameraDeviceId };
|
||||||
|
try {
|
||||||
|
/* camera only — mic comes through the separate sfuPublish path so a
|
||||||
|
* speaker can choose camera-on while still using a different audio
|
||||||
|
* input (monitor source, music mode, etc) */
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
||||||
|
} catch(e){ logLine('err','camera open cancelled: '+e.message); return; }
|
||||||
|
sfuCameraStream = stream;
|
||||||
|
const pc = new RTCPeerConnection(rtcConfig);
|
||||||
|
for (const tr of stream.getTracks()){
|
||||||
|
if (tr.kind === 'video') tr.contentHint = 'motion'; /* face/scene cam = motion over detail */
|
||||||
|
pc.addTrack(tr, stream);
|
||||||
|
}
|
||||||
|
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishCamera(); });
|
||||||
|
await pc.setLocalDescription(await pc.createOffer());
|
||||||
|
await waitForIceGathering(pc);
|
||||||
|
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
||||||
|
+ '&pub=' + myKeys.pubHex + '&kind=camera';
|
||||||
|
let res;
|
||||||
|
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
||||||
|
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw e; }
|
||||||
|
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; handleBlocked('publish-camera'); return; }
|
||||||
|
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw new Error('sfu publish-camera http '+res.status); }
|
||||||
|
const ans = await res.json();
|
||||||
|
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||||
|
sfuCameraPC = pc; sfuCameraPeerID = ans.peer_id;
|
||||||
|
/* 1.5 Mbps is plenty for 720p30 face cam — keeps the screen-share
|
||||||
|
* headroom intact when both are live */
|
||||||
|
for (const s of pc.getSenders()){
|
||||||
|
if (s.track && s.track.kind === 'video') setSenderMaxBitrate(s, 1500000);
|
||||||
|
}
|
||||||
|
logLine('', 'sfu: camera on as '+shortHex(sfuCameraPeerID));
|
||||||
|
renderCameraTile(myKeys.pubHex, stream, { local: true });
|
||||||
|
$('btn-camera-share').classList.add('hidden');
|
||||||
|
$('btn-camera-stop').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
async function sfuUnpublishCamera(){
|
||||||
|
if (!sfuCameraPC && !sfuCameraStream) return;
|
||||||
|
const pid = sfuCameraPeerID;
|
||||||
|
if (myKeys) removeCameraTile(myKeys.pubHex);
|
||||||
|
if (sfuCameraStream){ sfuCameraStream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; }
|
||||||
|
if (sfuCameraPC){ try { sfuCameraPC.close(); } catch(_){} sfuCameraPC = null; sfuCameraPeerID = null; }
|
||||||
|
if (pid && roomID){
|
||||||
|
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
||||||
|
}
|
||||||
|
$('btn-camera-share').classList.remove('hidden');
|
||||||
|
$('btn-camera-stop').classList.add('hidden');
|
||||||
|
logLine('', 'camera off');
|
||||||
|
}
|
||||||
|
|
||||||
async function sfuUnpublish(){
|
async function sfuUnpublish(){
|
||||||
if (!sfuPubPC) return;
|
if (!sfuPubPC) return;
|
||||||
const pid = sfuPubPeerID;
|
const pid = sfuPubPeerID;
|
||||||
|
|
@ -816,14 +920,25 @@ async function sfuSubscribe(){
|
||||||
pc.ontrack = (ev) => {
|
pc.ontrack = (ev) => {
|
||||||
const sid = ev.streams[0] ? ev.streams[0].id : '';
|
const sid = ev.streams[0] ? ev.streams[0].id : '';
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
/* streamID format: PUBKEY (mic) | PUBKEY:screen (screen share).
|
/* streamID format: PUBKEY (mic) | PUBKEY:screen | PUBKEY:camera.
|
||||||
* Route screens to the video tile renderer; mics to the audio path. */
|
* Route video kinds to the tile renderer; mics to the audio path. */
|
||||||
if (sid.endsWith(':screen')){
|
const colon = sid.indexOf(':');
|
||||||
const pubHex = sid.slice(0, -':screen'.length);
|
if (colon > 0){
|
||||||
/* skip echo of our own screen — we already render a local preview */
|
const pubHex = sid.slice(0, colon);
|
||||||
|
const kind = sid.slice(colon + 1);
|
||||||
|
/* skip echo of our own publish — we already render a local preview */
|
||||||
if (myKeys && pubHex === myKeys.pubHex) return;
|
if (myKeys && pubHex === myKeys.pubHex) return;
|
||||||
screenStreams.set(pubHex, ev.streams[0]);
|
if (kind === 'screen'){
|
||||||
renderScreenTile(pubHex, ev.streams[0]);
|
screenStreams.set(pubHex, ev.streams[0]);
|
||||||
|
renderScreenTile(pubHex, ev.streams[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'camera'){
|
||||||
|
cameraStreams.set(pubHex, ev.streams[0]);
|
||||||
|
renderCameraTile(pubHex, ev.streams[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pubHex = sid;
|
const pubHex = sid;
|
||||||
|
|
@ -1186,9 +1301,9 @@ async function handleSignal(raw){
|
||||||
{
|
{
|
||||||
const left = members.get(m.uuid);
|
const left = members.get(m.uuid);
|
||||||
if (left) logLine('', left.handle+' left');
|
if (left) logLine('', left.handle+' left');
|
||||||
/* if they were sharing a screen, drop the tile */
|
/* if they were sharing screen or camera, drop the tiles */
|
||||||
if (left && left.pubkey){
|
if (left && left.pubkey){
|
||||||
try { removeScreenTile(hex(unb64(left.pubkey))); } catch(_){}
|
try { const h = hex(unb64(left.pubkey)); removeScreenTile(h); removeCameraTile(h); } catch(_){}
|
||||||
}
|
}
|
||||||
members.delete(m.uuid);
|
members.delete(m.uuid);
|
||||||
handraise.delete(m.uuid);
|
handraise.delete(m.uuid);
|
||||||
|
|
@ -1280,6 +1395,7 @@ async function handleSignal(raw){
|
||||||
for (const u of [...peers.keys()]) tearPeer(u);
|
for (const u of [...peers.keys()]) tearPeer(u);
|
||||||
sfuUnpublish().catch(()=>{});
|
sfuUnpublish().catch(()=>{});
|
||||||
sfuUnpublishScreen().catch(()=>{});
|
sfuUnpublishScreen().catch(()=>{});
|
||||||
|
sfuUnpublishCamera().catch(()=>{});
|
||||||
sfuUnsubscribe().catch(()=>{});
|
sfuUnsubscribe().catch(()=>{});
|
||||||
dropMic();
|
dropMic();
|
||||||
}
|
}
|
||||||
|
|
@ -1380,6 +1496,10 @@ function updateScreenShareUI(){
|
||||||
* publishing a screen via the SFU requires being a speaker anyway */
|
* publishing a screen via the SFU requires being a speaker anyway */
|
||||||
$('sec-screen-share').classList.toggle('hidden', !canSpeak(myRole));
|
$('sec-screen-share').classList.toggle('hidden', !canSpeak(myRole));
|
||||||
if (!canSpeak(myRole) && (sfuScreenPC || sfuScreenStream)) sfuUnpublishScreen();
|
if (!canSpeak(myRole) && (sfuScreenPC || sfuScreenStream)) sfuUnpublishScreen();
|
||||||
|
/* camera lives in the same section as screen-share — visibility is
|
||||||
|
* driven by canSpeak() above. If a role demotion lands while the
|
||||||
|
* camera is live, force it off the same way the screen share is. */
|
||||||
|
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRoleUI(){
|
function updateRoleUI(){
|
||||||
|
|
@ -1573,6 +1693,36 @@ $('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); })
|
||||||
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
|
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
|
||||||
$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
|
$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
|
||||||
$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
|
$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
|
||||||
|
$('btn-camera-share').addEventListener('click', () => { sfuPublishCamera().catch(e => logLine('err','camera share: '+e.message)); });
|
||||||
|
$('btn-camera-stop').addEventListener('click', () => { sfuUnpublishCamera(); });
|
||||||
|
$('camera-select').addEventListener('change', async (e) => {
|
||||||
|
cameraDeviceId = e.target.value;
|
||||||
|
if (sfuCameraPC){
|
||||||
|
/* restart with the new device — getUserMedia must be re-called with
|
||||||
|
* the new deviceId; replaceTrack on a sender from a different device
|
||||||
|
* needs renegotiation anyway, so a clean restart is simpler */
|
||||||
|
await sfuUnpublishCamera();
|
||||||
|
sfuPublishCamera().catch(err => logLine('err','camera restart: '+err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function refreshCameraList(){
|
||||||
|
try {
|
||||||
|
const devs = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const cams = devs.filter(d => d.kind === 'videoinput');
|
||||||
|
const sel = $('camera-select'); if (!sel) return;
|
||||||
|
sel.innerHTML = '';
|
||||||
|
if (!cams.length){ sel.innerHTML = '<option value="">default camera</option>'; return; }
|
||||||
|
cams.forEach((c, i) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = c.deviceId; o.textContent = c.label || ('camera '+(i+1));
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
if (cameraDeviceId && cams.some(c => c.deviceId === cameraDeviceId)) sel.value = cameraDeviceId;
|
||||||
|
else cameraDeviceId = sel.value;
|
||||||
|
} catch(e){ logLine('err','could not list cameras: '+e.message); }
|
||||||
|
}
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', refreshCameraList);
|
||||||
|
refreshCameraList();
|
||||||
/* notice banner — visible callouts for events that affect you directly
|
/* notice banner — visible callouts for events that affect you directly
|
||||||
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
|
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
|
||||||
let noticeTimer = null;
|
let noticeTimer = null;
|
||||||
|
|
@ -1608,6 +1758,7 @@ function handleBlocked(source){
|
||||||
for (const u of [...peers.keys()]) tearPeer(u);
|
for (const u of [...peers.keys()]) tearPeer(u);
|
||||||
sfuUnpublish().catch(()=>{});
|
sfuUnpublish().catch(()=>{});
|
||||||
sfuUnpublishScreen().catch(()=>{});
|
sfuUnpublishScreen().catch(()=>{});
|
||||||
|
sfuUnpublishCamera().catch(()=>{});
|
||||||
sfuUnsubscribe().catch(()=>{});
|
sfuUnsubscribe().catch(()=>{});
|
||||||
dropMic();
|
dropMic();
|
||||||
$('btn-leave').disabled = true;
|
$('btn-leave').disabled = true;
|
||||||
|
|
@ -1699,11 +1850,12 @@ $('btn-leave').addEventListener('click', async () => {
|
||||||
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
||||||
for (const u of [...peers.keys()]) tearPeer(u);
|
for (const u of [...peers.keys()]) tearPeer(u);
|
||||||
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
||||||
await sfuUnpublish(); await sfuUnpublishScreen(); await sfuUnsubscribe();
|
await sfuUnpublish(); await sfuUnpublishScreen(); await sfuUnpublishCamera(); await sfuUnsubscribe();
|
||||||
dropMic();
|
dropMic();
|
||||||
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
|
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
|
||||||
/* tear all screen tiles regardless of source — fresh slate next time */
|
/* tear all video tiles regardless of source — fresh slate next time */
|
||||||
for (const k of [...screenVideos.keys()]) removeScreenTile(k);
|
for (const k of [...screenVideos.keys()]) removeScreenTile(k);
|
||||||
|
for (const k of [...cameraVideos.keys()]) removeCameraTile(k);
|
||||||
$('sec-room').classList.add('hidden');
|
$('sec-room').classList.add('hidden');
|
||||||
$('sec-invite').classList.add('hidden');
|
$('sec-invite').classList.add('hidden');
|
||||||
$('sec-listener-actions').classList.add('hidden');
|
$('sec-listener-actions').classList.add('hidden');
|
||||||
|
|
@ -1726,8 +1878,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
||||||
|
|
||||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-02</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-02</span><br>
|
||||||
md5 <span class="stamp-md5">4004c2acd2095e3db1654df18fb745cc</span><br>
|
md5 <span class="stamp-md5">d91602a3c30333c3c88c8fb9d1ce83d6</span><br>
|
||||||
sha256 <span class="stamp-sha">0485fb4623fab71d8e956a80eac72d3c85d7326d8bae94f629121b105cd9944d</span><br>
|
sha256 <span class="stamp-sha">d57f2da4b2958764af64527fdbc7f41a8420c216975f1ec9d55f259d5832c6ed</span><br>
|
||||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue