fix
This commit is contained in:
1 parent
ee9cffc66d
commit
143dd26c31
3 files changed
+222
-80
No files matched your search
+175
-73
@@ -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">●</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 = '●';
|
||||
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">●</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;
|
||||
|
||||
@@ -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 • 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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user