diff --git a/package-lock.json b/package-lock.json index 7bf2134..b6db40a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f1664b2..d08c43a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/public/app.js b/public/app.js index 9cd05c5..8019b31 100644 --- a/public/app.js +++ b/public/app.js @@ -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 {} }); diff --git a/public/index.html b/public/index.html index 582d797..42da382 100644 --- a/public/index.html +++ b/public/index.html @@ -17,6 +17,17 @@ + +
+ + gibbz voice + + + Disconnected + +
+ +
+ +
+ + + +
+