fix
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m10s

This commit is contained in:
mats committed 2026-09-23 11:06:21 +02:00
1 parent ee9cffc66d
commit 143dd26c31
3 files changed
+222 -80

No files matched your search

+175 -73
View File
@@ -1073,19 +1073,22 @@ function stopScreenShare(stream, silent) {
// ---------------------------------------------------------------------------
// position sync
// ---------------------------------------------------------------------------
// Fans new joiners out from directly in front of the room's center: the
// first joiner lands straight ahead, then each next one alternates sides,
// widening by 10deg per pair (0, +10, -10, +20, -20, ...) instead of the
// old distance-maximizing grid search, which tended to dump joiners in a
// far corner rather than somewhere natural to face the group.
function chooseOpenParticipantPosition() {
const occupied = [...S.peers.values()].map((peer) => ({ x: peer.x, y: peer.y }));
const candidates = [];
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 7; col++) {
candidates.push({ x: 15 + col * 11.7, y: 15 + row * 17.5 });
}
}
if (!occupied.length) return { x: 50, y: 50 };
return candidates.sort((a, b) => {
const distance = (point) => Math.min(...occupied.map((other) => Math.hypot(point.x - other.x, point.y - other.y)));
return distance(b) - distance(a);
})[0];
const ANCHOR = { x: 50, y: 50 };
const RADIUS = 24;
const n = S.peers.size;
const step = Math.ceil(n / 2) * 10;
const angleDeg = n === 0 ? 0 : (n % 2 === 1 ? step : -step);
const rad = (angleDeg * Math.PI) / 180;
return {
x: ANCHOR.x + RADIUS * Math.sin(rad),
y: ANCHOR.y - RADIUS * Math.cos(rad),
};
}
let lastPosSend = 0;
@@ -1326,6 +1329,7 @@ function renderTiles() {
// share keys: 'me' or peer id; popped-out shares render in their own window
const poppedWindows = new Map(); // key -> {win, name}
let focusedShareKey = null; // clicked remote share, rendered large with the rest as a filmstrip
function activeShares() {
const shares = [];
@@ -1342,80 +1346,164 @@ function activeShares() {
return shares;
}
// Picks the column/row count (out of `count` tiles) that best fills a
// width x height area assuming ~16:9 tiles, so multiple streams can be
// watched at once with as little wasted space as possible.
function bestGrid(count, width, height) {
const aspect = 16 / 9;
let best = { cols: count, rows: 1, area: 0 };
for (let cols = 1; cols <= count; cols++) {
const rows = Math.ceil(count / cols);
let cellW = width / cols;
let cellH = height / rows;
if (cellW / cellH > aspect) cellW = cellH * aspect; else cellH = cellW / aspect;
const area = cellW * cellH;
if (area > best.area) best = { cols, rows, area };
}
return best;
}
// Re-measures the live grid and updates its column/row count only — avoids
// renderScreens()'s full teardown, which would restart <video> playback.
function applyGridLayout() {
const body = els.screens?.querySelector('.screens-grid');
const count = body?.children.length;
if (!body || !count) return;
const rect = body.getBoundingClientRect();
const { cols, rows } = bestGrid(count, rect.width || 800, rect.height || 450);
body.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
body.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
}
let screensResizeObserver = null;
function ensureScreensResizeObserver() {
if (screensResizeObserver || !els.screens || typeof ResizeObserver === 'undefined') return;
let raf = null;
screensResizeObserver = new ResizeObserver(() => {
if (raf) return;
raf = requestAnimationFrame(() => { raf = null; applyGridLayout(); });
});
screensResizeObserver.observe(els.screens);
}
function buildPoppedPlaceholder(share) {
const ph = document.createElement('div');
ph.className = 'screen-card popped';
ph.dataset.shareKey = share.key;
const btn = document.createElement('button');
btn.className = 'pop-btn';
btn.appendChild(mIcon('open_in_new'));
btn.appendChild(document.createTextNode(' re-embed'));
btn.onclick = () => closePopout(share.key);
const label = document.createElement('div');
label.className = 'label';
label.textContent = `${share.name} — popped out`;
ph.append(btn, label);
return ph;
}
function buildShareCard(share, focused) {
if (poppedWindows.has(share.key)) return buildPoppedPlaceholder(share);
const card = document.createElement('div');
card.className = 'screen-card' + (focused ? ' focused' : '');
card.dataset.shareKey = share.key;
card.title = focused ? 'Click to exit spotlight' : 'Click to watch this stream large';
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = share.local;
video.srcObject = share.stream;
video.play().catch(() => {});
const label = document.createElement('div');
label.className = 'label';
label.innerHTML = '<span class="live">&#9679;</span>';
label.appendChild(document.createTextNode(share.name));
const micBadge = document.createElement('span');
micBadge.className = 'tile-mic-badge';
micBadge.appendChild(mIcon('mic_off'));
label.appendChild(micBadge);
const pop = document.createElement('button');
pop.className = 'pop-btn';
pop.title = 'Pop out in new window';
pop.appendChild(mIcon('open_in_new'));
pop.onclick = (e) => { e.stopPropagation(); popOutShare(share); };
card.append(video, label, pop);
card.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
focusedShareKey = focusedShareKey === share.key ? null : share.key;
renderScreens();
});
return card;
}
function buildOwnPill(share) {
const pill = document.createElement('div');
pill.className = 'own-pill';
pill.dataset.shareKey = share.key;
const dot = document.createElement('span');
dot.className = 'live';
dot.innerHTML = '&#9679;';
const label = document.createElement('span');
label.className = 'own-pill-label';
label.textContent = 'You are sharing';
const stop = document.createElement('button');
stop.className = 'own-pill-close';
stop.title = 'Stop screen share';
stop.appendChild(mIcon('close'));
stop.onclick = () => stopScreenShare(share.stream);
pill.append(dot, label, stop);
return pill;
}
function renderScreens() {
const shares = activeShares();
// prune popouts whose share ended
const live = new Set(activeShares().map((s) => s.key));
const live = new Set(shares.map((s) => s.key));
for (const [key, p] of [...poppedWindows]) {
if (!live.has(key)) {
try { p.win.close(); } catch {}
poppedWindows.delete(key);
}
}
if (focusedShareKey && !live.has(focusedShareKey)) focusedShareKey = null;
if (!els.screens) return;
const ownShares = shares.filter((s) => s.local);
const remoteShares = shares.filter((s) => !s.local);
els.screens.innerHTML = '';
els.screens.classList.toggle('multi', activeShares().length > 1);
for (const share of activeShares()) {
if (poppedWindows.has(share.key)) {
// rendered in its own window — show a small placeholder card to re-embed
const ph = document.createElement('div');
ph.className = 'screen-card popped';
const btn = document.createElement('button');
btn.className = 'pop-btn';
btn.appendChild(mIcon('open_in_new'));
btn.appendChild(document.createTextNode(' re-embed'));
btn.onclick = () => closePopout(share.key);
const label = document.createElement('div');
label.className = 'label';
label.textContent = `${share.name} — popped out`;
ph.append(btn, label);
if (share.local) {
const stop = document.createElement('button');
stop.className = 'pop-btn stop-share-btn';
stop.title = 'Stop this screen share';
stop.appendChild(mIcon('stop_circle'));
stop.appendChild(document.createTextNode(' stop'));
stop.onclick = () => stopScreenShare(share.stream);
ph.appendChild(stop);
}
els.screens.appendChild(ph);
continue;
}
const card = document.createElement('div');
card.className = 'screen-card';
card.dataset.shareKey = share.key;
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = share.local;
video.srcObject = share.stream;
video.play().catch(() => {});
const label = document.createElement('div');
label.className = 'label';
label.innerHTML = '<span class="live">&#9679;</span>';
label.appendChild(document.createTextNode(share.name));
const micBadge = document.createElement('span');
micBadge.className = 'tile-mic-badge';
micBadge.appendChild(mIcon('mic_off'));
label.appendChild(micBadge);
const pop = document.createElement('button');
pop.className = 'pop-btn';
pop.title = 'Pop out in new window';
pop.appendChild(mIcon('open_in_new'));
pop.onclick = () => popOutShare(share);
card.append(video, label, pop);
if (share.local) {
const stop = document.createElement('button');
stop.className = 'pop-btn stop-share-btn';
stop.title = 'Stop this screen share';
stop.appendChild(mIcon('stop_circle'));
stop.appendChild(document.createTextNode(' stop'));
stop.onclick = () => stopScreenShare(share.stream);
card.appendChild(stop);
}
els.screens.appendChild(card);
if (ownShares.length) {
const pillRow = document.createElement('div');
pillRow.className = 'own-pills';
for (const share of ownShares) pillRow.appendChild(buildOwnPill(share));
els.screens.appendChild(pillRow);
}
if (remoteShares.length) {
const body = document.createElement('div');
if (focusedShareKey) {
body.className = 'screens-body spotlight';
const focused = remoteShares.find((s) => s.key === focusedShareKey);
const rest = remoteShares.filter((s) => s.key !== focusedShareKey);
body.appendChild(buildShareCard(focused, true));
if (rest.length) {
const strip = document.createElement('div');
strip.className = 'thumbs-row';
for (const s of rest) strip.appendChild(buildShareCard(s, false));
body.appendChild(strip);
}
els.screens.appendChild(body);
} else {
body.className = 'screens-body screens-grid';
for (const s of remoteShares) body.appendChild(buildShareCard(s, false));
els.screens.appendChild(body);
applyGridLayout();
ensureScreensResizeObserver();
}
}
updateScreenTileIndicators();
}
@@ -2146,6 +2234,20 @@ dragResize({
});
spatialResizer.addEventListener('mousedown', () => { spatialStartH = 0; });
// spatial pane collapse/expand
const LS_SPATIAL_COLLAPSED = 'voicepoc.spatialCollapsed';
const btnSpatialCollapse = $('#btn-spatial-collapse');
const btnSpatialExpand = $('#btn-spatial-expand');
function setSpatialCollapsed(collapsed) {
document.body.classList.toggle('spatial-collapsed', collapsed);
localStorage.setItem(LS_SPATIAL_COLLAPSED, collapsed ? '1' : '0');
if (!collapsed && typeof cy !== 'undefined') requestAnimationFrame(() => cy.resize());
applyGridLayout();
}
btnSpatialCollapse.onclick = () => setSpatialCollapsed(true);
btnSpatialExpand.onclick = () => setSpatialCollapsed(false);
if (localStorage.getItem(LS_SPATIAL_COLLAPSED) === '1') setSpatialCollapsed(true);
// dropping below the desktop breakpoint: let mobile's own CSS take over sizing
window.addEventListener('resize', () => {
if (isDesktop()) return;
+2
View File
@@ -89,6 +89,7 @@
<h2>No active screen shares</h2>
<p>When someone shares their screen, it will appear here. Click the screen button below or press <b>S</b> to share.</p>
</div>
<button id="btn-spatial-expand" title="Show spatial audio"><span class="material-symbols-outlined">keyboard_arrow_up</span></button>
</div>
<div id="spatial-resizer" title="Drag to resize"></div>
@@ -97,6 +98,7 @@
<span>SPATIAL AUDIO</span>
<span class="hint">drag your avatar to move; drag peers for local positioning &bull; scroll to zoom</span>
<button id="btn-spatial-fit" class="spatial-action-btn" title="Fit all users in view"><span class="material-symbols-outlined">fit_screen</span> Fit View</button>
<button id="btn-spatial-collapse" class="spatial-action-btn" title="Hide spatial audio pane"><span class="material-symbols-outlined">keyboard_arrow_down</span> Hide</button>
</div>
<div id="spatial"></div>
</div>
+45 -7
View File
@@ -201,9 +201,34 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; }
#stage.empty #stage-empty { display: grid; }
#stage:not(.empty) #stage-empty { display: none; }
#screens { display: flex; gap: 12px; flex-wrap: wrap; flex: 1; min-height: 0; align-items: stretch; }
#screens.multi { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); align-content: stretch; }
#screens.multi .screen-card { flex: none; }
#screens { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; }
/* own share(s): compact pill, no video preview */
.own-pills { display: flex; flex-wrap: wrap; gap: 8px; flex: none; }
.own-pill {
display: inline-flex; align-items: center; gap: 8px;
background: var(--bg-2); border: 1px solid var(--bg-4); border-radius: 999px;
padding: 5px 8px 5px 12px; font-size: 12px; font-weight: 600; color: var(--text-1);
}
.own-pill .live { color: var(--red); font-weight: 800; }
.own-pill-close {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%; border: none; cursor: pointer;
background: var(--bg-3); color: var(--text-0);
}
.own-pill-close .material-symbols-outlined { font-size: 15px; }
.own-pill-close:hover { background: var(--red); }
/* remote shares: fills remaining space, arranged in as large a grid as fits */
.screens-body { flex: 1; min-height: 0; }
.screens-grid { display: grid; gap: 12px; }
.screens-grid .screen-card { max-width: 100%; }
.screens-body.spotlight { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.screens-body.spotlight .screen-card.focused { flex: 1; min-height: 0; }
.thumbs-row { display: flex; gap: 8px; flex: none; height: 110px; overflow-x: auto; }
.thumbs-row .screen-card { flex: 0 0 180px; height: 100%; }
.screen-card.popped {
min-height: 120px; display: grid; place-items: center;
background: var(--bg-0); box-shadow: none; border: 1px dashed var(--bg-4);
@@ -216,10 +241,9 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; }
}
.pop-btn .material-symbols-outlined { font-size: 16px; }
.pop-btn:hover { background: var(--brand); }
.screen-card .stop-share-btn { right: 96px; }
.screen-card {
position: relative; background: #000; border-radius: var(--radius); overflow: hidden;
flex: 1 1 480px; max-width: 100%; box-shadow: 0 8px 24px rgba(0,0,0,.4);
box-shadow: 0 8px 24px rgba(0,0,0,.4); cursor: pointer;
}
.screen-card video { width: 100%; height: 100%; display: block; max-height: 100%; object-fit: contain; }
.screen-card .label {
@@ -351,6 +375,19 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; }
height: 34vh; min-height: 220px; background: var(--bg-1);
border-top: 1px solid rgba(0,0,0,.35); display: flex; flex-direction: column;
}
body.spatial-collapsed #spatial-wrap,
body.spatial-collapsed #spatial-resizer { display: none; }
/* floating button to bring the spatial pane back once collapsed */
#btn-spatial-expand {
display: none; position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 20;
background: var(--bg-1); border: 1px solid var(--bg-4); color: var(--text-1);
width: 32px; height: 32px; border-radius: 6px; cursor: pointer; place-items: center;
box-shadow: 0 2px 6px rgba(0,0,0,.35);
}
#btn-spatial-expand .material-symbols-outlined { font-size: 18px; }
#btn-spatial-expand:hover { background: var(--bg-3); color: var(--text-0); }
body.spatial-collapsed #btn-spatial-expand { display: grid; }
.spatial-label {
padding: 8px 16px; font-size: 11px; font-weight: 700; color: var(--text-2);
letter-spacing: .5px; display: flex; align-items: center; gap: 16px;
@@ -629,8 +666,9 @@ code { font-family: ui-monospace, monospace; font-size: 12px; }
#main { width: 100%; padding-bottom: 64px; } /* room for the fixed bottom controls */
#stage { padding: 10px; gap: 10px; }
#screens { flex-direction: column; }
#screens .screen-card, #screens.multi .screen-card { flex: 1 1 auto; width: 100%; min-height: 220px; }
.screens-grid { grid-template-columns: 1fr !important; grid-template-rows: none !important; grid-auto-rows: minmax(220px, 1fr); }
.thumbs-row { height: 84px; }
.thumbs-row .screen-card { flex-basis: 120px; }
#spatial-wrap { height: 26vh; min-height: 160px; }
.spatial-label { flex-wrap: wrap; gap: 6px; padding: 6px 10px; }