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:
1 parent
664417c6da
commit
7795cf84a3
5 files changed
+467
-185
No files matched your search
Generated
+10
-33
@@ -16,7 +16,7 @@
|
||||
"@opentelemetry/sdk-logs": "^0.222.0",
|
||||
"@opentelemetry/sdk-node": "^0.222.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.43.0",
|
||||
"konva": "^10.6.0",
|
||||
"cytoscape": "^3.34.3",
|
||||
"matrix-js-sdk": "^42.4.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
@@ -3050,6 +3050,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.34.3",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz",
|
||||
"integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@@ -3941,38 +3950,6 @@
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/konva": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-10.6.0.tgz",
|
||||
"integrity": "sha512-6dyI91lZTx2mDu9DRo5zrT2Pg+Q3TFeyLfGQSAPJMhfvKjelbjPnTJimYQISam/EW+g0+pjLk/yztJKpSuxFPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/lavrton"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/konva"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/lavrton"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0",
|
||||
"skia-canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
},
|
||||
"skia-canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"@opentelemetry/sdk-logs": "^0.222.0",
|
||||
"@opentelemetry/sdk-node": "^0.222.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.43.0",
|
||||
"konva": "^10.6.0",
|
||||
"cytoscape": "^3.34.3",
|
||||
"matrix-js-sdk": "^42.4.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
||||
+337
-144
@@ -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 {} });
|
||||
|
||||
|
||||
+20
-1
@@ -17,6 +17,17 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- mobile top bar: hamburger opens the channel drawer, shows live status -->
|
||||
<header id="mobile-topbar">
|
||||
<button id="btn-menu" title="Channels">☰</button>
|
||||
<span id="mobile-title">gibbz voice</span>
|
||||
<span class="status-row" id="mobile-status-row">
|
||||
<span class="status-indicator" id="mobile-status-dot"></span>
|
||||
<span id="mobile-status-text">Disconnected</span>
|
||||
</span>
|
||||
</header>
|
||||
<div id="sidebar-backdrop"></div>
|
||||
|
||||
<div id="app">
|
||||
<!-- server rail -->
|
||||
<nav id="rail">
|
||||
@@ -71,13 +82,21 @@
|
||||
<div id="spatial-wrap">
|
||||
<div class="spatial-label">
|
||||
<span>SPATIAL AUDIO</span>
|
||||
<span class="hint">drag <b>your</b> avatar — others hear you shift; you hear them relative to position</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">⛶ Fit View</button>
|
||||
</div>
|
||||
<div id="spatial"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- mobile bottom bar: always-reachable call controls, mirror the sidebar's -->
|
||||
<div id="mobile-controls">
|
||||
<button class="ctl mirror-mic" title="Toggle mic (M)">🎤</button>
|
||||
<button class="ctl mirror-deafen" title="Toggle deafen (D)">👀</button>
|
||||
<button class="ctl mirror-share" title="Share your screen (S)">🖥️</button>
|
||||
</div>
|
||||
|
||||
<!-- settings modal -->
|
||||
<div id="modal-backdrop" class="hidden">
|
||||
<div id="settings-modal" role="dialog" aria-modal="true">
|
||||
|
||||
+99
-6
@@ -218,7 +218,8 @@ body {
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.channel-user-item:hover .volume-popup {
|
||||
.channel-user-item:hover .volume-popup,
|
||||
.channel-user-item.show-vol .volume-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -245,12 +246,36 @@ body {
|
||||
border-top: 1px solid rgba(0,0,0,.35); display: flex; flex-direction: column;
|
||||
}
|
||||
.spatial-label {
|
||||
padding: 8px 16px 0; font-size: 11px; font-weight: 700; color: var(--text-2);
|
||||
letter-spacing: .5px; display: flex; gap: 16px;
|
||||
padding: 8px 16px; font-size: 11px; font-weight: 700; color: var(--text-2);
|
||||
letter-spacing: .5px; display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
.spatial-label .hint { font-weight: 400; letter-spacing: 0; text-transform: none; flex: 1; }
|
||||
.spatial-action-btn {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--bg-4);
|
||||
color: var(--text-1);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.spatial-action-btn:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--text-0);
|
||||
}
|
||||
#spatial {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.spatial-label .hint { font-weight: 400; letter-spacing: 0; text-transform: none; }
|
||||
#spatial { flex: 1; width: 100%; cursor: grab; touch-action: none; }
|
||||
#spatial.dragging { cursor: grabbing; }
|
||||
|
||||
/* modal */
|
||||
#modal-backdrop {
|
||||
@@ -391,3 +416,71 @@ body {
|
||||
|
||||
.hint { color: var(--text-2); font-size: 12px; }
|
||||
code { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
|
||||
/* mobile chrome — hidden on desktop, shown/positioned in the breakpoint below */
|
||||
#mobile-topbar, #sidebar-backdrop, #mobile-controls { display: none; }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
mobile layout
|
||||
- rail is dropped (decorative only in this POC)
|
||||
- sidebar becomes an off-canvas drawer opened from #mobile-topbar
|
||||
- mic/deafen/share get an always-visible mirror bar pinned to the bottom
|
||||
--------------------------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
#app { padding-top: 48px; } /* room for the fixed topbar */
|
||||
#rail { display: none; }
|
||||
|
||||
#mobile-topbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
position: fixed; top: 0; left: 0; right: 0; height: 48px; z-index: 55;
|
||||
background: var(--bg-0); padding: 0 12px; box-shadow: 0 1px 0 rgba(0,0,0,.3);
|
||||
}
|
||||
#mobile-topbar button {
|
||||
background: transparent; border: none; color: var(--text-0); font-size: 20px;
|
||||
width: 36px; height: 36px; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
#mobile-topbar button:active { background: var(--bg-3); }
|
||||
#mobile-title { font-weight: 700; font-size: 14px; }
|
||||
#mobile-status-row {
|
||||
display: flex; align-items: center; gap: 5px; margin-left: auto;
|
||||
font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#sidebar-backdrop.show {
|
||||
display: block; position: fixed; inset: 0; top: 48px; z-index: 65; background: rgba(0,0,0,.55);
|
||||
}
|
||||
#sidebar {
|
||||
position: fixed; top: 48px; bottom: 0; left: 0; z-index: 66; width: 82vw; max-width: 320px;
|
||||
transform: translateX(-100%); transition: transform .2s ease; box-shadow: 8px 0 24px rgba(0,0,0,.5);
|
||||
}
|
||||
#sidebar.open { transform: translateX(0); }
|
||||
|
||||
#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; }
|
||||
|
||||
#spatial-wrap { height: 26vh; min-height: 160px; }
|
||||
.spatial-label { flex-wrap: wrap; gap: 6px; padding: 6px 10px; }
|
||||
.spatial-label .hint { display: none; } /* the drag/zoom hint doesn't fit — controls are still discoverable by touch */
|
||||
|
||||
/* bigger touch targets everywhere controls are tapped directly */
|
||||
.ctl { width: 40px; height: 40px; font-size: 18px; }
|
||||
|
||||
#mobile-controls {
|
||||
display: flex; justify-content: center; gap: 10px;
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 55;
|
||||
background: var(--bg-0); padding: 8px; box-shadow: 0 -1px 0 rgba(0,0,0,.3);
|
||||
}
|
||||
#mobile-controls .ctl { width: 48px; height: 48px; font-size: 20px; background: var(--bg-2); }
|
||||
|
||||
/* volume popup: side placement would run off a narrow drawer */
|
||||
.volume-popup { left: 0; right: 0; top: 100%; transform: none; width: auto; margin-top: 4px; }
|
||||
|
||||
/* modals already clamp to 92vw; just tighten padding a bit */
|
||||
#settings-modal, #welcome-modal { padding: 16px; }
|
||||
|
||||
#mx-panel { width: 100vw; }
|
||||
|
||||
#toasts { left: 12px; right: 12px; max-width: none; }
|
||||
}
|
||||
Reference in new issue
Block a user