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-logs": "^0.222.0",
|
||||||
"@opentelemetry/sdk-node": "^0.222.0",
|
"@opentelemetry/sdk-node": "^0.222.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.43.0",
|
"@opentelemetry/semantic-conventions": "^1.43.0",
|
||||||
"konva": "^10.6.0",
|
"cytoscape": "^3.34.3",
|
||||||
"matrix-js-sdk": "^42.4.0",
|
"matrix-js-sdk": "^42.4.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
@@ -3050,6 +3050,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"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-logs": "^0.222.0",
|
||||||
"@opentelemetry/sdk-node": "^0.222.0",
|
"@opentelemetry/sdk-node": "^0.222.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.43.0",
|
"@opentelemetry/semantic-conventions": "^1.43.0",
|
||||||
"konva": "^10.6.0",
|
"cytoscape": "^3.34.3",
|
||||||
"matrix-js-sdk": "^42.4.0",
|
"matrix-js-sdk": "^42.4.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+337
-144
@@ -1,5 +1,5 @@
|
|||||||
import * as matrixcs from 'matrix-js-sdk';
|
import * as matrixcs from 'matrix-js-sdk';
|
||||||
import Konva from 'konva';
|
import cytoscape from 'cytoscape';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// state
|
// state
|
||||||
@@ -41,7 +41,14 @@ const els = {
|
|||||||
reconnectBanner: $('#reconnect-banner'), reconnectText: $('#reconnect-text'),
|
reconnectBanner: $('#reconnect-banner'), reconnectText: $('#reconnect-text'),
|
||||||
mxPanel: $('#mx-panel'), mxMessages: $('#mx-messages'), mxInput: $('#mx-input'),
|
mxPanel: $('#mx-panel'), mxMessages: $('#mx-messages'), mxInput: $('#mx-input'),
|
||||||
welcomeBackdrop: $('#welcome-backdrop'), welcomeNick: $('#welcome-nick'), welcomeNickError: $('#welcome-nick-error'),
|
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
|
// helpers
|
||||||
@@ -88,6 +95,15 @@ async function joinVoice(room) {
|
|||||||
await S.audioCtx.resume().catch(() => {});
|
await S.audioCtx.resume().catch(() => {});
|
||||||
orientListener();
|
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 {
|
try {
|
||||||
S.micStream = await navigator.mediaDevices.getUserMedia({
|
S.micStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||||
@@ -96,10 +112,9 @@ async function joinVoice(room) {
|
|||||||
S.micStream = null;
|
S.micStream = null;
|
||||||
toast('No microphone available — joining in listen-only mode', 'warn');
|
toast('No microphone available — joining in listen-only mode', 'warn');
|
||||||
}
|
}
|
||||||
els.mic.disabled = !S.micStream;
|
|
||||||
S.muted = false;
|
S.muted = false;
|
||||||
updateMicButton();
|
updateMicButton();
|
||||||
$('#btn-share').disabled = false;
|
for (const btn of shareButtons) btn.disabled = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
S.iceServers = await fetchIce();
|
S.iceServers = await fetchIce();
|
||||||
@@ -142,8 +157,7 @@ function leaveVoice(reason) {
|
|||||||
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
||||||
if (S.ws) { try { S.ws.close(); } catch {} S.ws = 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; }
|
if (S.micStream) { S.micStream.getTracks().forEach((t) => t.stop()); S.micStream = null; }
|
||||||
els.mic.disabled = false;
|
// Keep share buttons enabled so clicking one prompts the user to join a channel
|
||||||
// Keep btn-share enabled so clicking it prompts the user to join a channel
|
|
||||||
updateMicButton();
|
updateMicButton();
|
||||||
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
||||||
S.room = null;
|
S.room = null;
|
||||||
@@ -157,20 +171,16 @@ function leaveVoice(reason) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(text, state = '') {
|
function setStatus(text, state = '') {
|
||||||
if (els.status) {
|
// Concise display text
|
||||||
// Concise display text
|
let shortText = text;
|
||||||
let shortText = text;
|
if (text.startsWith('voice connected')) {
|
||||||
if (text.startsWith('voice connected')) {
|
shortText = S.room ? `#${S.room}` : 'Connected';
|
||||||
shortText = S.room ? `#${S.room}` : 'Connected';
|
} else if (text.startsWith('connecting to')) {
|
||||||
} else if (text.startsWith('connecting to')) {
|
shortText = 'Connecting…';
|
||||||
shortText = 'Connecting…';
|
|
||||||
}
|
|
||||||
els.status.textContent = shortText;
|
|
||||||
}
|
}
|
||||||
|
for (const el of statusTextEls) el.textContent = shortText;
|
||||||
if (els.statusRow) els.statusRow.title = text;
|
if (els.statusRow) els.statusRow.title = text;
|
||||||
if (els.statusDot) {
|
for (const el of statusDotEls) el.className = 'status-indicator' + (state ? ` ${state}` : '');
|
||||||
els.statusDot.className = 'status-indicator' + (state ? ` ${state}` : '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -442,6 +452,10 @@ async function onCandidate(peer, candidate) {
|
|||||||
function destroyPeer(peer) {
|
function destroyPeer(peer) {
|
||||||
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
if (peer.pc) { try { peer.pc.close(); } catch {} peer.pc = null; }
|
||||||
detachSpatial(peer);
|
detachSpatial(peer);
|
||||||
|
if (peer.audioEl) {
|
||||||
|
try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {}
|
||||||
|
peer.audioEl = null;
|
||||||
|
}
|
||||||
peer.micStream = null;
|
peer.micStream = null;
|
||||||
peer.screenStream = null;
|
peer.screenStream = null;
|
||||||
peer.els = {};
|
peer.els = {};
|
||||||
@@ -452,19 +466,31 @@ function destroyPeer(peer) {
|
|||||||
// remote media: mic -> spatial chain, screen -> big video + direct audio
|
// remote media: mic -> spatial chain, screen -> big video + direct audio
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function onRemoteTrack(peer, ev) {
|
function onRemoteTrack(peer, ev) {
|
||||||
const stream = ev.streams[0];
|
const track = ev.track;
|
||||||
if (!stream) return;
|
const stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([track]);
|
||||||
if (stream.getVideoTracks().length > 0) {
|
|
||||||
|
if (track.kind === 'video') {
|
||||||
peer.screenStream = stream;
|
peer.screenStream = stream;
|
||||||
stream.getVideoTracks().forEach((t) => {
|
track.addEventListener('ended', () => {
|
||||||
t.addEventListener('ended', () => {
|
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
|
||||||
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
renderScreens();
|
renderScreens();
|
||||||
console.log('remote screen share from', peer.name);
|
console.log('remote screen share from', peer.name);
|
||||||
} else {
|
} else if (track.kind === 'audio') {
|
||||||
peer.micStream = stream;
|
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);
|
attachSpatial(peer, stream);
|
||||||
console.log('remote mic from', peer.name);
|
console.log('remote mic from', peer.name);
|
||||||
}
|
}
|
||||||
@@ -571,7 +597,7 @@ async function startScreenShare() {
|
|||||||
}
|
}
|
||||||
renderScreens();
|
renderScreens();
|
||||||
renderTiles();
|
renderTiles();
|
||||||
$('#btn-share').classList.add('active');
|
for (const btn of shareButtons) btn.classList.add('active');
|
||||||
toast('You are sharing your screen', 'ok');
|
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 } });
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: false } });
|
||||||
renderScreens();
|
renderScreens();
|
||||||
renderTiles();
|
renderTiles();
|
||||||
$('#btn-share').classList.remove('active');
|
for (const btn of shareButtons) btn.classList.remove('active');
|
||||||
if (!silent) toast('Screen share stopped');
|
if (!silent) toast('Screen share stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,6 +779,12 @@ function renderTiles() {
|
|||||||
};
|
};
|
||||||
volPop.append(volLabel, volInput);
|
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);
|
item.append(av, name, badges, volPop);
|
||||||
roster.appendChild(item);
|
roster.appendChild(item);
|
||||||
}
|
}
|
||||||
@@ -906,132 +938,272 @@ function closePopout(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// spatial canvas (Konva) — the "me" node is draggable and stays visually
|
// spatial canvas (Cytoscape.js) — graph visualization with auto-zoom-to-bounds,
|
||||||
// pinned to the stage center: every world-unit it moves, the layer is
|
// smooth node dragging, distance indicators, and speaking/hover highlights.
|
||||||
// re-centered by the same amount in the opposite direction, so the world
|
|
||||||
// pans underneath it instead of the dot sliding away from center.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const pad = 30;
|
const cy = cytoscape({
|
||||||
let sx = 1, sy = 1; // px per world unit, recomputed on resize
|
container: els.spatial,
|
||||||
|
boxSelectionEnabled: false,
|
||||||
const spatialStage = new Konva.Stage({ container: 'spatial', width: 1, height: 1 });
|
autounselectify: false,
|
||||||
const worldLayer = new Konva.Layer();
|
userZoomingEnabled: true,
|
||||||
spatialStage.add(worldLayer);
|
userPanningEnabled: true,
|
||||||
|
minZoom: 0.2,
|
||||||
// grid lives inside worldLayer so it pans with the camera along with everyone else
|
maxZoom: 3.5,
|
||||||
const gridGroup = new Konva.Group({ listening: false });
|
style: [
|
||||||
worldLayer.add(gridGroup);
|
{
|
||||||
|
selector: 'node',
|
||||||
const rangeCircle = new Konva.Circle({
|
style: {
|
||||||
radius: 0, stroke: 'rgba(88,101,242,.35)', strokeWidth: 1, dash: [5, 5], listening: false,
|
'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}
|
let isDraggingNode = false;
|
||||||
function avatarNode(id, isMe) {
|
|
||||||
let node = avatarNodes.get(id);
|
// Convert spatial room units (0..100) to Cytoscape viewport pixels (scaled around center)
|
||||||
if (node) return node;
|
const SPATIAL_PX_SCALE = 6;
|
||||||
const r = isMe ? 16 : 13;
|
function roomToCyPos(x, y) {
|
||||||
const group = new Konva.Group({ draggable: isMe });
|
return { x: (x - 50) * SPATIAL_PX_SCALE, y: (y - 50) * SPATIAL_PX_SCALE };
|
||||||
const circle = new Konva.Circle({ radius: r, fill: '#5865f2' });
|
}
|
||||||
const initial = new Konva.Text({
|
function cyToRoomPos(pos) {
|
||||||
text: '', fontStyle: 'bold', fontSize: Math.round(r), fill: '#fff',
|
const x = Math.min(ROOM_UNITS, Math.max(0, pos.x / SPATIAL_PX_SCALE + 50));
|
||||||
width: r * 2, height: r * 2, offsetX: r, offsetY: r, align: 'center', verticalAlign: 'middle',
|
const y = Math.min(ROOM_UNITS, Math.max(0, pos.y / SPATIAL_PX_SCALE + 50));
|
||||||
});
|
return { x, y };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function recenterCamera() {
|
// Fit / zoom viewport to all active users with comfortable padding
|
||||||
const cx = spatialStage.width() / 2, cy = spatialStage.height() / 2;
|
function zoomToBounds(animate = true) {
|
||||||
worldLayer.position({ x: cx - S.me.x * sx, y: cy - S.me.y * sy });
|
const nodes = cy.nodes();
|
||||||
}
|
if (nodes.length === 0) return;
|
||||||
|
if (animate) {
|
||||||
function layoutSpatialGrid() {
|
cy.stop();
|
||||||
gridGroup.destroyChildren();
|
cy.animate({
|
||||||
for (let i = 0; i <= ROOM_UNITS; i += 10) {
|
fit: { eles: nodes, padding: 60 },
|
||||||
gridGroup.add(new Konva.Line({
|
duration: 350,
|
||||||
points: [i * sx, 0, i * sx, ROOM_UNITS * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
|
easing: 'ease-out-cubic',
|
||||||
}));
|
});
|
||||||
gridGroup.add(new Konva.Line({
|
} else {
|
||||||
points: [0, i * sy, ROOM_UNITS * sx, i * sy], stroke: 'rgba(255,255,255,.05)', strokeWidth: 1,
|
cy.fit(nodes, 60);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeSpatial() {
|
// Handle dragging nodes
|
||||||
const r = els.spatial.getBoundingClientRect();
|
cy.on('grab', 'node', (evt) => {
|
||||||
spatialStage.width(r.width);
|
isDraggingNode = true;
|
||||||
spatialStage.height(r.height);
|
els.spatial.classList.add('dragging');
|
||||||
sx = (r.width - 2 * pad) / ROOM_UNITS;
|
});
|
||||||
sy = (r.height - 2 * pad) / ROOM_UNITS;
|
|
||||||
layoutSpatialGrid();
|
|
||||||
recenterCamera();
|
|
||||||
}
|
|
||||||
new ResizeObserver(resizeSpatial).observe(els.spatial);
|
|
||||||
resizeSpatial();
|
|
||||||
|
|
||||||
function updateAvatarNode(node, id, name, muted, speaking, isMe, worldX, worldY) {
|
cy.on('drag', 'node', (evt) => {
|
||||||
// Safe to set even for "me" while dragging: dragmove already derived
|
const node = evt.target;
|
||||||
// S.me from the node's own current position, so this is a no-op then.
|
const pos = cyToRoomPos(node.position());
|
||||||
node.group.position({ x: worldX * sx, y: worldY * sy });
|
if (node.data('isMe')) {
|
||||||
node.circle.stroke(muted ? '#f23f43' : speaking ? '#23a55a' : '');
|
S.me.x = pos.x;
|
||||||
node.circle.strokeWidth(3);
|
S.me.y = pos.y;
|
||||||
node.circle.fill(colorFor(id));
|
sendMyPos();
|
||||||
node.initial.text((name || '?').slice(0, 1).toUpperCase());
|
} else {
|
||||||
node.nameText.text(name + (isMe ? ' (you)' : ''));
|
// Peer dragged: update local audio position relative to me
|
||||||
node.nameText.offsetX(node.nameText.width() / 2);
|
const peer = S.peers.get(node.id());
|
||||||
}
|
if (peer) {
|
||||||
|
peer.x = pos.x;
|
||||||
|
peer.y = pos.y;
|
||||||
|
updatePanner(peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSpatialEdges();
|
||||||
|
});
|
||||||
|
|
||||||
function drawSpatial() {
|
cy.on('free', 'node', (evt) => {
|
||||||
// range circle stays centered on "me", which the camera keeps at center
|
isDraggingNode = false;
|
||||||
rangeCircle.position({ x: S.me.x * sx, y: S.me.y * sy });
|
els.spatial.classList.remove('dragging');
|
||||||
rangeCircle.radius(Math.max(0, Math.min(56 * sx, spatialStage.height() / 2 - pad)));
|
const node = evt.target;
|
||||||
|
if (node.data('isMe')) {
|
||||||
|
sendMyPos(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const seen = new Set(['me']);
|
// Update connecting audio distance edges between 'me' and peers
|
||||||
const meId = S.myId || 'me';
|
function updateSpatialEdges() {
|
||||||
const meNode = avatarNode('me', true);
|
const meNode = cy.getElementById('me');
|
||||||
updateAvatarNode(meNode, meId, S.name || 'me', S.muted, els.meAvatar.classList.contains('speaking'), true, S.me.x, S.me.y);
|
if (!meNode || meNode.empty()) return;
|
||||||
meNode.group.moveToTop();
|
const mePos = meNode.position();
|
||||||
|
|
||||||
for (const peer of S.peers.values()) {
|
for (const peer of S.peers.values()) {
|
||||||
seen.add(peer.id);
|
const peerNode = cy.getElementById(peer.id);
|
||||||
const node = avatarNode(peer.id, false);
|
if (!peerNode || peerNode.empty()) continue;
|
||||||
updateAvatarNode(node, peer.id, peer.name, peer.state.muted, peer.speaking, false, peer.x, peer.y);
|
const edgeId = `edge-me-${peer.id}`;
|
||||||
}
|
let edge = cy.getElementById(edgeId);
|
||||||
for (const [id, node] of avatarNodes) {
|
|
||||||
if (!seen.has(id)) { node.group.destroy(); avatarNodes.delete(id); }
|
// 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
|
// Matrix text chat (experimental) — native OAuth2/OIDC auth against the
|
||||||
@@ -1244,11 +1416,14 @@ els.mxInput?.addEventListener('keydown', (ev) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function updateMicButton() {
|
function updateMicButton() {
|
||||||
const off = S.muted || !S.micStream;
|
const off = S.muted || !S.micStream;
|
||||||
els.mic.classList.toggle('off', off);
|
for (const btn of micButtons) {
|
||||||
els.mic.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
|
btn.classList.toggle('off', off);
|
||||||
|
btn.textContent = off ? '\u{1F507}' : '\u{1F3A4}'; // 🔇 muted/no-mic vs 🎤 live
|
||||||
|
btn.disabled = !S.micStream;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function updateDeafenButton() {
|
function updateDeafenButton() {
|
||||||
els.deafen.classList.toggle('off', S.deafened);
|
for (const btn of deafenButtons) btn.classList.toggle('off', S.deafened);
|
||||||
}
|
}
|
||||||
function sendState() {
|
function sendState() {
|
||||||
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
|
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } });
|
||||||
@@ -1274,9 +1449,9 @@ function toggleDeafen() {
|
|||||||
renderTiles();
|
renderTiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
els.mic.onclick = toggleMic;
|
for (const btn of micButtons) btn.onclick = toggleMic;
|
||||||
els.deafen.onclick = toggleDeafen;
|
for (const btn of deafenButtons) btn.onclick = toggleDeafen;
|
||||||
$('#btn-share').onclick = () => (S.sharing ? stopScreenShare() : startScreenShare());
|
for (const btn of shareButtons) btn.onclick = () => (S.sharing ? stopScreenShare() : startScreenShare());
|
||||||
$('#btn-settings').onclick = openSettings;
|
$('#btn-settings').onclick = openSettings;
|
||||||
|
|
||||||
els.nick.value = S.name;
|
els.nick.value = S.name;
|
||||||
@@ -1292,6 +1467,7 @@ setAvatar(els.meAvatar, S.name, 'me');
|
|||||||
|
|
||||||
document.querySelectorAll('.channel.voice').forEach((el) => {
|
document.querySelectorAll('.channel.voice').forEach((el) => {
|
||||||
el.onclick = () => {
|
el.onclick = () => {
|
||||||
|
closeSidebar();
|
||||||
if (S.room === el.dataset.room) return;
|
if (S.room === el.dataset.room) return;
|
||||||
cancelReconnect();
|
cancelReconnect();
|
||||||
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
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)
|
// Initial roster fetch and lightweight periodic sync (especially for unjoined clients)
|
||||||
updateMicButton();
|
updateMicButton();
|
||||||
updateDeafenButton();
|
updateDeafenButton();
|
||||||
@@ -1328,7 +1521,7 @@ window.addEventListener('keydown', (ev) => {
|
|||||||
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return;
|
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return;
|
||||||
if (ev.key === 'm') toggleMic();
|
if (ev.key === 'm') toggleMic();
|
||||||
if (ev.key === 'd') toggleDeafen();
|
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 {} });
|
window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} });
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -17,6 +17,17 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<div id="app">
|
||||||
<!-- server rail -->
|
<!-- server rail -->
|
||||||
<nav id="rail">
|
<nav id="rail">
|
||||||
@@ -71,13 +82,21 @@
|
|||||||
<div id="spatial-wrap">
|
<div id="spatial-wrap">
|
||||||
<div class="spatial-label">
|
<div class="spatial-label">
|
||||||
<span>SPATIAL AUDIO</span>
|
<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>
|
||||||
<div id="spatial"></div>
|
<div id="spatial"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 -->
|
<!-- settings modal -->
|
||||||
<div id="modal-backdrop" class="hidden">
|
<div id="modal-backdrop" class="hidden">
|
||||||
<div id="settings-modal" role="dialog" aria-modal="true">
|
<div id="settings-modal" role="dialog" aria-modal="true">
|
||||||
|
|||||||
+99
-6
@@ -218,7 +218,8 @@ body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.channel-user-item:hover .volume-popup {
|
.channel-user-item:hover .volume-popup,
|
||||||
|
.channel-user-item.show-vol .volume-popup {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -245,12 +246,36 @@ body {
|
|||||||
border-top: 1px solid rgba(0,0,0,.35); display: flex; flex-direction: column;
|
border-top: 1px solid rgba(0,0,0,.35); display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
.spatial-label {
|
.spatial-label {
|
||||||
padding: 8px 16px 0; font-size: 11px; font-weight: 700; color: var(--text-2);
|
padding: 8px 16px; font-size: 11px; font-weight: 700; color: var(--text-2);
|
||||||
letter-spacing: .5px; display: flex; gap: 16px;
|
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 */
|
||||||
#modal-backdrop {
|
#modal-backdrop {
|
||||||
@@ -391,3 +416,71 @@ body {
|
|||||||
|
|
||||||
.hint { color: var(--text-2); font-size: 12px; }
|
.hint { color: var(--text-2); font-size: 12px; }
|
||||||
code { font-family: ui-monospace, monospace; 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