Add a mobile-responsive layout and mirrored call controls

- Sidebar (channel list + nickname) becomes an off-canvas drawer below
  860px, opened via a new fixed top bar (hamburger + live status) and
  closed by tapping the backdrop or picking a channel.
- Mic/deafen/share get an always-visible bottom control bar so they're
  reachable mid-call without opening the drawer; updateMicButton/
  updateDeafenButton and their click wiring now target both the
  sidebar and mirror buttons via shared NodeLists instead of single
  elements.
- Per-user volume popup (previously hover-only) now also toggles on
  tap, and repositions below the row instead of to the side, which
  would overflow a narrow drawer.
- Screen-share tiles stack full-width, touch targets are enlarged,
  and the spatial map panel shrinks to leave room for the stage.

Also includes the working tree's prior swap of the spatial view from
Konva to Cytoscape.js (drag, scroll-to-zoom, fit-to-view, distance
edges) — not authored in this change, carried along since it was
already uncommitted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5 committed 2026-09-22 11:39:05 +02:00
1 parent 664417c6da
commit 7795cf84a3
5 files changed
+467 -185

No files matched your search

+337 -144
View File
@@ -1,5 +1,5 @@
import * as matrixcs from 'matrix-js-sdk';
import Konva from 'konva';
import cytoscape from 'cytoscape';
// ---------------------------------------------------------------------------
// state
@@ -41,7 +41,14 @@ const els = {
reconnectBanner: $('#reconnect-banner'), reconnectText: $('#reconnect-text'),
mxPanel: $('#mx-panel'), mxMessages: $('#mx-messages'), mxInput: $('#mx-input'),
welcomeBackdrop: $('#welcome-backdrop'), welcomeNick: $('#welcome-nick'), welcomeNickError: $('#welcome-nick-error'),
sidebar: $('#sidebar'), sidebarBackdrop: $('#sidebar-backdrop'), btnMenu: $('#btn-menu'),
};
// buttons that appear twice (sidebar + mobile bottom bar) — keep both in sync
const micButtons = [...document.querySelectorAll('#btn-mic, .mirror-mic')];
const deafenButtons = [...document.querySelectorAll('#btn-deafen, .mirror-deafen')];
const shareButtons = [...document.querySelectorAll('#btn-share, .mirror-share')];
const statusTextEls = [...document.querySelectorAll('#voice-status, #mobile-status-text')];
const statusDotEls = [...document.querySelectorAll('#status-dot, #mobile-status-dot')];
// ---------------------------------------------------------------------------
// helpers
@@ -88,6 +95,15 @@ async function joinVoice(room) {
await S.audioCtx.resume().catch(() => {});
orientListener();
// Attach a one-time gesture listener in case browser autoplay policy keeps AudioContext suspended
const resumeCtx = () => {
if (S.audioCtx && S.audioCtx.state === 'suspended') {
S.audioCtx.resume().catch(() => {});
}
};
window.addEventListener('click', resumeCtx, { passive: true });
window.addEventListener('keydown', resumeCtx, { passive: true });
try {
S.micStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
@@ -96,10 +112,9 @@ async function joinVoice(room) {
S.micStream = null;
toast('No microphone available — joining in listen-only mode', 'warn');
}
els.mic.disabled = !S.micStream;
S.muted = false;
updateMicButton();
$('#btn-share').disabled = false;
for (const btn of shareButtons) btn.disabled = false;
try {
S.iceServers = await fetchIce();
@@ -142,8 +157,7 @@ function leaveVoice(reason) {
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; }
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; }
els.mic.disabled = false;
// Keep btn-share enabled so clicking it prompts the user to join a channel
// Keep share buttons enabled so clicking one prompts the user to join a channel
updateMicButton();
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
S.room = null;
@@ -157,20 +171,16 @@ function leaveVoice(reason) {
}
function setStatus(text, state = '') {
if (els.status) {
// Concise display text
let shortText = text;
if (text.startsWith('voice connected')) {
shortText = S.room ? `#${S.room}` : 'Connected';
} else if (text.startsWith('connecting to')) {
shortText = 'Connecting…';
}
els.status.textContent = shortText;
// Concise display text
let shortText = text;
if (text.startsWith('voice connected')) {
shortText = S.room ? `#${S.room}` : 'Connected';
} else if (text.startsWith('connecting to')) {
shortText = 'Connecting…';
}
for (const el of statusTextEls) el.textContent = shortText;
if (els.statusRow) els.statusRow.title = text;
if (els.statusDot) {
els.statusDot.className = 'status-indicator' + (state ? ` ${state}` : '');
}
for (const el of statusDotEls) el.className = 'status-indicator' + (state ? ` ${state}` : '');
}
// ---------------------------------------------------------------------------
@@ -442,6 +452,10 @@ async function onCandidate(peer, candidate) {
function destroyPeer(peer) {
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
detachSpatial(peer);
if (peer.audioEl) {
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
peer.audioEl = null;
}
peer.micStream = null;
peer.screenStream = null;
peer.els = {};
@@ -452,19 +466,31 @@ function destroyPeer(peer) {
// remote media: mic -> spatial chain, screen -> big video + direct audio
// ---------------------------------------------------------------------------
function onRemoteTrack(peer, ev) {
const stream = ev.streams[0];
if (!stream) return;
if (stream.getVideoTracks().length > 0) {
const track = ev.track;
const stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([track]);
if (track.kind === 'video') {
peer.screenStream = stream;
stream.getVideoTracks().forEach((t) => {
t.addEventListener('ended', () => {
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
});
track.addEventListener('ended', () => {
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
});
renderScreens();
console.log('remote screen share from', peer.name);
} else {
} else if (track.kind === 'audio') {
peer.micStream = stream;
// Ensure AudioContext is active (resumes on Firefox/Safari if suspended)
if (S.audioCtx && S.audioCtx.state === 'suspended') {
S.audioCtx.resume().catch(() => {});
}
// Chrome/Safari benefit from having an Audio element attached to pump the stream
if (!peer.audioEl) {
peer.audioEl = new Audio();
peer.audioEl.srcObject = stream;
peer.audioEl.muted = true;
peer.audioEl.play().catch((e) => console.warn('peer audioEl autoplay', e));
}
attachSpatial(peer, stream);
console.log('remote mic from', peer.name);
}
@@ -571,7 +597,7 @@ async function startScreenShare() {
}
renderScreens();
renderTiles();
$('#btn-share').classList.add('active');
for (const btn of shareButtons) btn.classList.add('active');
toast('You are sharing your screen', 'ok');
}
@@ -596,7 +622,7 @@ function stopScreenShare(silent) {
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: false } });
renderScreens();
renderTiles();
$('#btn-share').classList.remove('active');
for (const btn of shareButtons) btn.classList.remove('active');
if (!silent) toast('Screen share stopped');
}
@@ -753,6 +779,12 @@ function renderTiles() {
};
volPop.append(volLabel, volInput);
// Touch has no :hover — tap the row to reveal the volume popup instead.
item.addEventListener('click', (e) => {
if (e.target === volInput) return;
item.classList.toggle('show-vol');
});
item.append(av, name, badges, volPop);
roster.appendChild(item);
}
@@ -906,132 +938,272 @@ function closePopout(key) {
}
// ---------------------------------------------------------------------------
// spatial canvas (Konva) — the "me" node is draggable and stays visually
// pinned to the stage center: every world-unit it moves, the layer is
// re-centered by the same amount in the opposite direction, so the world
// pans underneath it instead of the dot sliding away from center.
// spatial canvas (Cytoscape.js) — graph visualization with auto-zoom-to-bounds,
// smooth node dragging, distance indicators, and speaking/hover highlights.
// ---------------------------------------------------------------------------
const pad = 30;
let sx = 1, sy = 1; // px per world unit, recomputed on resize
const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 });
const worldLayer = new Konva.Layer();
spatialStage.add(worldLayer);
// grid lives inside worldLayer so it pans with the camera along with everyone else
const gridGroup = new Konva.Group({ listening: false });
worldLayer.add(gridGroup);
const rangeCircle = new Konva.Circle({
radius: 0, stroke: 'rgba(88,101,242,.35)', strokeWidth: 1, dash: [5, 5], listening: false,
const cy = cytoscape({
container: els.spatial,
boxSelectionEnabled: false,
autounselectify: false,
userZoomingEnabled: true,
userPanningEnabled: true,
minZoom: 0.2,
maxZoom: 3.5,
style: [
{
selector: 'node',
style: {
'shape': 'ellipse',
'width': 34,
'height': 34,
'background-color': 'data(color)',
'label': 'data(label)',
'text-valign': 'bottom',
'text-margin-y': 6,
'font-size': '11px',
'font-family': 'system-ui, -apple-system, sans-serif',
'color': '#80848e',
'text-background-opacity': 0.7,
'text-background-color': '#1e1f22',
'text-background-padding': '2px',
'text-background-shape': 'roundrectangle',
'border-width': 2,
'border-color': 'rgba(255,255,255,0.15)',
'transition-property': 'border-color, border-width, width, height',
'transition-duration': '0.15s',
},
},
{
selector: 'node[?isMe]',
style: {
'width': 40,
'height': 40,
'color': '#b5bac1',
'font-weight': 'bold',
'border-width': 3,
'border-color': 'rgba(255,255,255,0.4)',
},
},
{
selector: 'node.speaking',
style: {
'border-color': '#23a55a',
'border-width': 4,
},
},
{
selector: 'node.muted',
style: {
'border-color': '#f23f43',
'border-width': 3,
},
},
{
selector: 'node:selected',
style: {
'border-color': '#5865f2',
'border-width': 4,
},
},
{
selector: 'edge',
style: {
'width': 1,
'line-color': 'rgba(88, 101, 242, 0.25)',
'line-style': 'dashed',
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': '9px',
'color': '#80848e',
'text-background-opacity': 0.6,
'text-background-color': '#111214',
'text-background-padding': '2px',
},
},
],
});
worldLayer.add(rangeCircle);
const avatarNodes = new Map(); // id -> {group, circle, initial, nameText}
function avatarNode(id, isMe) {
let node = avatarNodes.get(id);
if (node) return node;
const r = isMe ? 16 : 13;
const group = new Konva.Group({ draggable: isMe });
const circle = new Konva.Circle({ radius: r, fill: '#5865f2' });
const initial = new Konva.Text({
text: '', fontStyle: 'bold', fontSize: Math.round(r), fill: '#fff',
width: r * 2, height: r * 2, offsetX: r, offsetY: r, align: 'center', verticalAlign: 'middle',
});
const nameText = new Konva.Text({
text: '', fontSize: 11, fill: isMe ? '#b5bac1' : '#80848e', y: r + 4, align: 'center',
});
group.add(circle, initial, nameText);
worldLayer.add(group);
if (isMe) {
group.dragBoundFunc(function (pos) {
// pos is in worldLayer's parent (stage) coordinates; clamp the
// resulting world position to the room bounds, not the screen.
const worldX = Math.min(ROOM_UNITS, Math.max(0, (pos.x - worldLayer.x()) / sx));
const worldY = Math.min(ROOM_UNITS, Math.max(0, (pos.y - worldLayer.y()) / sy));
return { x: worldLayer.x() + worldX * sx, y: worldLayer.y() + worldY * sy };
});
group.on('dragmove', () => {
S.me.x = (group.x() - worldLayer.x()) / sx;
S.me.y = (group.y() - worldLayer.y()) / sy;
recenterCamera();
sendMyPos();
});
group.on('dragstart', () => els.spatial.classList.add('dragging'));
group.on('dragend', () => {
els.spatial.classList.remove('dragging');
sendMyPos(true);
});
}
node = { group, circle, initial, nameText };
avatarNodes.set(id, node);
return node;
let isDraggingNode = false;
// Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center)
const SPATIAL_PX_SCALE = 6;
function roomToCyPos(x, y) {
return { x: (x - 50) * SPATIAL_PX_SCALE, y: (y - 50) * SPATIAL_PX_SCALE };
}
function cyToRoomPos(pos) {
const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50));
const y = Math.min(ROOM_UNITS, Math.max(0, pos.y / SPATIAL_PX_SCALE + 50));
return { x, y };
}
function recenterCamera() {
const cx = spatialStage.width() / 2, cy = spatialStage.height() / 2;
worldLayer.position({ x: cx - S.me.x * sx, y: cy - S.me.y * sy });
}
function layoutSpatialGrid() {
gridGroup.destroyChildren();
for (let i = 0; i <= ROOM_UNITS; i += 10) {
gridGroup.add(new Konva.Line({
points: [i * sx, 0, i * sx, ROOM_UNITS * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
}));
gridGroup.add(new Konva.Line({
points: [0, i * sy, ROOM_UNITS * sx, i * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
}));
// Fit / zoom viewport to all active users with comfortable padding
function zoomToBounds(animate = true) {
const nodes = cy.nodes();
if (nodes.length === 0) return;
if (animate) {
cy.stop();
cy.animate({
fit: { eles: nodes, padding: 60 },
duration: 350,
easing: 'ease-out-cubic',
});
} else {
cy.fit(nodes, 60);
}
}
function resizeSpatial() {
const r = els.spatial.getBoundingClientRect();
spatialStage.width(r.width);
spatialStage.height(r.height);
sx = (r.width - 2 * pad) / ROOM_UNITS;
sy = (r.height - 2 * pad) / ROOM_UNITS;
layoutSpatialGrid();
recenterCamera();
}
new ResizeObserver(resizeSpatial).observe(els.spatial);
resizeSpatial();
// Handle dragging nodes
cy.on('grab', 'node', (evt) => {
isDraggingNode = true;
els.spatial.classList.add('dragging');
});
function updateAvatarNode(node, id, name, muted, speaking, isMe, worldX, worldY) {
// Safe to set even for "me" while dragging: dragmove already derived
// S.me from the node's own current position, so this is a no-op then.
node.group.position({ x: worldX * sx, y: worldY * sy });
node.circle.stroke(muted ? '#f23f43' : speaking ? '#23a55a' : '');
node.circle.strokeWidth(3);
node.circle.fill(colorFor(id));
node.initial.text((name || '?').slice(0, 1).toUpperCase());
node.nameText.text(name + (isMe ? ' (you)' : ''));
node.nameText.offsetX(node.nameText.width() / 2);
}
cy.on('drag', 'node', (evt) => {
const node = evt.target;
const pos = cyToRoomPos(node.position());
if (node.data('isMe')) {
S.me.x = pos.x;
S.me.y = pos.y;
sendMyPos();
} else {
// Peer dragged: update local audio position relative to me
const peer = S.peers.get(node.id());
if (peer) {
peer.x = pos.x;
peer.y = pos.y;
updatePanner(peer);
}
}
updateSpatialEdges();
});
function drawSpatial() {
// range circle stays centered on "me", which the camera keeps at center
rangeCircle.position({ x: S.me.x * sx, y: S.me.y * sy });
rangeCircle.radius(Math.max(0, Math.min(56 * sx, spatialStage.height() / 2 - pad)));
cy.on('free', 'node', (evt) => {
isDraggingNode = false;
els.spatial.classList.remove('dragging');
const node = evt.target;
if (node.data('isMe')) {
sendMyPos(true);
}
});
const seen = new Set(['me']);
const meId = S.myId || 'me';
const meNode = avatarNode('me', true);
updateAvatarNode(meNode, meId, S.name || 'me', S.muted, els.meAvatar.classList.contains('speaking'), true, S.me.x, S.me.y);
meNode.group.moveToTop();
// Update connecting audio distance edges between 'me' and peers
function updateSpatialEdges() {
const meNode = cy.getElementById('me');
if (!meNode || meNode.empty()) return;
const mePos = meNode.position();
for (const peer of S.peers.values()) {
seen.add(peer.id);
const node = avatarNode(peer.id, false);
updateAvatarNode(node, peer.id, peer.name, peer.state.muted, peer.speaking, false, peer.x, peer.y);
}
for (const [id, node] of avatarNodes) {
if (!seen.has(id)) { node.group.destroy(); avatarNodes.delete(id); }
const peerNode = cy.getElementById(peer.id);
if (!peerNode || peerNode.empty()) continue;
const edgeId = `edge-me-${peer.id}`;
let edge = cy.getElementById(edgeId);
// Calculate 2D distance
const dist = Math.hypot(peer.x - S.me.x, peer.y - S.me.y);
const distText = `${Math.round(dist)}m`;
if (edge.empty()) {
cy.add({
group: 'edges',
data: { id: edgeId, source: 'me', target: peer.id, label: distText },
});
} else {
edge.data('label', distText);
}
}
worldLayer.batchDraw();
// Remove orphan edges
cy.edges().forEach((edge) => {
const peerId = edge.data('target');
if (!S.peers.has(peerId)) {
edge.remove();
}
});
}
requestAnimationFrame(function loop() { drawSpatial(); requestAnimationFrame(loop); });
let lastPeerCount = -1;
function syncSpatialGraph() {
const meId = S.myId || 'me';
const meSpeaking = els.meAvatar.classList.contains('speaking');
// Upsert 'me' node
let meNode = cy.getElementById('me');
if (meNode.empty()) {
meNode = cy.add({
group: 'nodes',
data: {
id: 'me',
label: `${S.name || 'me'} (you)`,
color: colorFor(meId),
isMe: true,
},
position: roomToCyPos(S.me.x, S.me.y),
});
} else {
meNode.data('label', `${S.name || 'me'} (you)`);
meNode.data('color', colorFor(meId));
if (!isDraggingNode) {
meNode.position(roomToCyPos(S.me.x, S.me.y));
}
}
meNode.toggleClass('speaking', meSpeaking);
meNode.toggleClass('muted', !!S.muted);
// Upsert peers
const currentPeerIds = new Set();
for (const peer of S.peers.values()) {
currentPeerIds.add(peer.id);
let peerNode = cy.getElementById(peer.id);
if (peerNode.empty()) {
peerNode = cy.add({
group: 'nodes',
data: {
id: peer.id,
label: peer.name,
color: colorFor(peer.id),
isMe: false,
},
position: roomToCyPos(peer.x, peer.y),
});
} else {
peerNode.data('label', peer.name);
peerNode.data('color', colorFor(peer.id));
if (!isDraggingNode || cy.$(':grabbed').id() !== peer.id) {
peerNode.position(roomToCyPos(peer.x, peer.y));
}
}
peerNode.toggleClass('speaking', !!peer.speaking);
peerNode.toggleClass('muted', !!peer.state?.muted);
}
// Remove nodes that have left
cy.nodes().forEach((node) => {
const id = node.id();
if (id !== 'me' && !currentPeerIds.has(id)) {
node.remove();
}
});
updateSpatialEdges();
// Auto-fit / zoom to bounds whenever participants count changes
const totalCount = S.peers.size + 1;
if (totalCount !== lastPeerCount) {
lastPeerCount = totalCount;
setTimeout(() => zoomToBounds(true), 50);
}
}
// Auto resize on container size change
new ResizeObserver(() => {
cy.resize();
zoomToBounds(false);
}).observe(els.spatial);
$('#btn-spatial-fit')?.addEventListener('click', () => zoomToBounds(true));
setInterval(syncSpatialGraph, 100);
// ---------------------------------------------------------------------------
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
@@ -1244,11 +1416,14 @@ els.mxInput?.addEventListener('keydown', (ev) => {
// ---------------------------------------------------------------------------
function updateMicButton() {
const off = S.muted || !S.micStream;
els.mic.classList.toggle('off', off);
els.mic.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
for (const btn of micButtons) {
btn.classList.toggle('off', off);
btn.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
btn.disabled = !S.micStream;
}
}
function updateDeafenButton() {
els.deafen.classList.toggle('off', S.deafened);
for (const btn of deafenButtons) btn.classList.toggle('off', S.deafened);
}
function sendState() {
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
@@ -1274,9 +1449,9 @@ function toggleDeafen() {
renderTiles();
}
els.mic.onclick = toggleMic;
els.deafen.onclick = toggleDeafen;
$('#btn-share').onclick = () => (S.sharing ? stopScreenShare() : startScreenShare());
for (const btn of micButtons) btn.onclick = toggleMic;
for (const btn of deafenButtons) btn.onclick = toggleDeafen;
for (const btn of shareButtons) btn.onclick = () => (S.sharing ? stopScreenShare() : startScreenShare());
$('#btn-settings').onclick = openSettings;
els.nick.value = S.name;
@@ -1292,6 +1467,7 @@ setAvatar(els.meAvatar, S.name, 'me');
document.querySelectorAll('.channel.voice').forEach((el) => {
el.onclick = () => {
closeSidebar();
if (S.room === el.dataset.room) return;
cancelReconnect();
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
@@ -1304,6 +1480,23 @@ document.querySelectorAll('.channel.voice').forEach((el) => {
};
});
// ---------------------------------------------------------------------------
// mobile channel drawer — the sidebar becomes an off-canvas panel below the
// #mobile-topbar breakpoint (see style.css); on desktop these are no-ops
// since the sidebar is never given the "open" class there.
// ---------------------------------------------------------------------------
function openSidebar() {
els.sidebar.classList.add('open');
els.sidebarBackdrop.classList.add('show');
}
function closeSidebar() {
els.sidebar.classList.remove('open');
els.sidebarBackdrop.classList.remove('show');
}
els.btnMenu.onclick = () => els.sidebar.classList.contains('open') ? closeSidebar() : openSidebar();
els.sidebarBackdrop.onclick = closeSidebar;
document.querySelectorAll('.channel.text').forEach((el) => el.addEventListener('click', closeSidebar));
// Initial roster fetch and lightweight periodic sync (especially for unjoined clients)
updateMicButton();
updateDeafenButton();
@@ -1328,7 +1521,7 @@ window.addEventListener('keydown', (ev) => {
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return;
if (ev.key === 'm') toggleMic();
if (ev.key === 'd') toggleDeafen();
if (ev.key === 's') $('#btn-share').click();
if (ev.key === 's') (S.sharing ? stopScreenShare() : startScreenShare());
});
window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} });