Show who's signed in on each controller, and an Invite QR code

When the PS4 reports a user signed in on a controller, the service asks for
the user's name and adds it to the status as `user`. The PS4 may not have
finished signing the user in by then, so a failed look-up is retried every
half second for five seconds. The tile shows the name in place of
"Controller N" (the ring keeps the number), and the controller screen reads
"Controller 1 · Alex". Names are kept out of the log, since people post logs
in bug reports, and they go out cleaned: quotes and backslashes escaped, and
control characters or anything that isn't valid UTF-8 replaced, because a
browser drops the whole WebSocket on a text frame that isn't valid UTF-8.

An Invite panel, opened from a button next to the settings or from the menu,
shows the page's address as a QR code for friends to scan. The console makes
the code with the QR encoder the app already ships, from the address the page
reached it on, and sends the module grid; the page draws it as SVG, so it still
needs no libraries.

The gamepad picker's tooltip had the same "Controller 1 · Controller 1" doubling
the controller screen had; it now names the user too.
This commit is contained in:
MoHadiShibli committed 2026-10-06 01:05:33 +03:00
1 parent be950bf7b5
commit 200e2bfc0b
18 files changed
+308 -19

No files matched your search

+86 -6
View File
@@ -924,6 +924,18 @@
.options-menu .actions { display: grid; gap: 10px; margin-top: 16px; }
.menu-close { position: absolute; top: max(14px, env(safe-area-inset-top)); right: 14px; }
/* Invite: the page's address as a QR code, in a panel opened on demand */
.invite-qr {
width: min(280px, 100%);
aspect-ratio: 1;
margin: 20px auto 14px;
border-radius: 14px;
background: #fff;
overflow: hidden;
}
.invite-qr svg { display: block; width: 100%; height: 100%; }
.invite-address { text-align: center; font-size: 18px; letter-spacing: .02em; word-break: break-all; user-select: all; }
/* ---------- Notification ---------- */
.toast {
@@ -987,6 +999,9 @@
<span class="conn" id="conn" data-conn="connecting" role="status" aria-live="polite"><span class="dot"></span><span id="connText">Connecting</span><span class="conn-detail" id="connDetail"></span></span>
</div>
<div class="tb-group">
<button class="icon-btn" type="button" id="openInvite" aria-label="Invite someone" title="Invite someone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="14" y="3.5" width="6.5" height="6.5" rx="1.2"/><rect x="3.5" y="14" width="6.5" height="6.5" rx="1.2"/><path d="M14 14h3v3M20.5 14v.01M14 20.5h3M20.5 17.5v3"/></svg>
</button>
<button class="icon-btn" type="button" id="openSettings" aria-label="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
@@ -1146,12 +1161,23 @@
<div class="rows" id="menuRows"></div>
<div class="actions">
<button class="btn" type="button" id="menuLayout">Edit the button layout</button>
<button class="btn" type="button" id="menuInvite">Invite someone</button>
<button class="btn" type="button" id="menuSettings">All settings</button>
<button class="btn danger" type="button" id="leaveBtn">Disconnect controller</button>
<button class="btn danger" type="button" id="stopBtn">Stop Control4Free</button>
</div>
</aside>
<aside class="options-menu" id="inviteSheet" aria-label="Invite someone">
<button class="icon-btn menu-close" type="button" id="closeInvite" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
<h2>Invite someone</h2>
<p class="hint">Scan this with a phone's camera to open the controller page. The phone has to be on the same network as the PS4.</p>
<div class="invite-qr" id="inviteQr"></div>
<p class="invite-address" id="inviteAddress"></p>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite">
<svg viewBox="56 38 888 566" aria-hidden="true"><path fill="#fff" d="M500 78C410 78 330 74 262 62C232 46 188 42 146 62C108 80 86 120 78 170C70 230 58 320 60 400C62 470 80 535 118 568C152 598 196 592 222 560C250 525 272 470 300 430C360 418 430 440 500 440C570 440 640 418 700 430C728 470 750 525 778 560C804 592 848 598 882 568C920 535 938 470 940 400C942 320 930 230 922 170C914 120 892 80 854 62C812 42 768 46 738 62C670 74 590 78 500 78ZM366 98L634 98C648.359 98 660 109.641 660 124L660 226C660 240.359 648.359 252 634 252L366 252C351.641 252 340 240.359 340 226L340 124C340 109.641 351.641 98 366 98ZM173 148L219 148L219 189L260 189L260 235L219 235L219 276L173 276L173 235L132 235L132 189L173 189L173 148ZM804 121C818.912 121 831 133.088 831 148C831 162.912 818.912 175 804 175C789.088 175 777 162.912 777 148C777 133.088 789.088 121 804 121ZM804 249C818.912 249 831 261.088 831 276C831 290.912 818.912 303 804 303C789.088 303 777 290.912 777 276C777 261.088 789.088 249 804 249ZM740 185C754.912 185 767 197.088 767 212C767 226.912 754.912 239 740 239C725.088 239 713 226.912 713 212C713 197.088 725.088 185 740 185ZM868 185C882.912 185 895 197.088 895 212C895 226.912 882.912 239 868 239C853.088 239 841 226.912 841 212C841 197.088 853.088 185 868 185ZM338 276C374.451 276 404 305.549 404 342C404 378.451 374.451 408 338 408C301.549 408 272 378.451 272 342C272 305.549 301.549 276 338 276ZM338 298C313.699 298 294 317.699 294 342C294 366.301 313.699 386 338 386C362.301 386 382 366.301 382 342C382 317.699 362.301 298 338 298ZM662 276C698.451 276 728 305.549 728 342C728 378.451 698.451 408 662 408C625.549 408 596 378.451 596 342C596 305.549 625.549 276 662 276ZM662 298C637.699 298 618 317.699 618 342C618 366.301 637.699 386 662 386C686.301 386 706 366.301 706 342C706 317.699 686.301 298 662 298ZM500 319C509.389 319 517 326.611 517 336C517 345.389 509.389 353 500 353C490.611 353 483 345.389 483 336C483 326.611 490.611 319 500 319Z"/></svg>
<span id="toastText"></span>
@@ -1239,7 +1265,7 @@
const status = Array.from({ length: PADS }, (_, i) => ({
pad: i, open: false, enabled: true, real: false, logged: false, joined: false, connected: false,
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1),
full: false, shared: false, player: 0, uid: '', cfg: '', name: 'Controller ' + (i + 1), user: '',
color: FALLBACK_COLORS[i], clients: 0, known: false,
}));
@@ -2336,7 +2362,8 @@
head.appendChild(el('span', 'activity'));
tile.appendChild(head);
const body = el('span');
body.appendChild(el('div', 'tile-name', s.known ? s.name : 'Controller ' + (i + 1)));
// Who's signed in on it, once the PS4 says; the ring keeps the number.
body.appendChild(el('div', 'tile-name', s.known && s.user ? s.user : 'Controller ' + (i + 1)));
body.appendChild(el('div', 'tile-meta', padMeta(s) || ''));
tile.appendChild(body);
const states = el('span', 'tile-state');
@@ -2400,7 +2427,7 @@
b.setAttribute('aria-checked', String(src.pad === pad));
if (pad >= 0) {
b.style.setProperty('--pc', css(status[pad].color));
b.title = 'Controller ' + (pad + 1) + ' · ' + status[pad].name;
b.title = 'Controller ' + (pad + 1) + (status[pad].user ? ' · ' + status[pad].user : '');
}
b.addEventListener('click', () => setGamepadPad(index, pad));
seg.appendChild(b);
@@ -2427,9 +2454,8 @@
const s = status[primaryPad];
document.documentElement.style.setProperty('--accent', css(s.color));
const label = $('#padLabel');
// The console's name for it, when it says more than the number does.
const title = 'Controller ' + (primaryPad + 1);
label.textContent = title + (s.known && s.name && s.name !== title ? ' · ' + s.name : '');
// And who's signed in on it, once the PS4 says.
label.textContent = 'Controller ' + (primaryPad + 1) + (s.known && s.user ? ' · ' + s.user : '');
const state = padState(s);
const extra = conn.state !== 'open' ? $('#connText').textContent + $('#connDetail').textContent
: state === 'ready' ? 'Signed in on the PS4'
@@ -2573,10 +2599,64 @@
}
function closeMenu() {
$('#inviteSheet').classList.remove('open');
document.body.classList.remove('menu-open');
$('#optionsMenu').classList.remove('open');
}
// ---------------------------------------------------------------- invite
// The console sends the QR code as a grid, one hex string per row, so the page
// needs no QR library: each run of dark modules becomes one rectangle.
function qrPath(size, rows, quiet) {
const dark = (row, x) => x < size && ((parseInt(row[x >> 2], 16) >> (3 - (x & 3))) & 1) === 1;
let d = '';
for (let y = 0; y < size; y++) {
const row = rows[y] || '';
for (let x = 0; x < size;) {
if (!dark(row, x)) { x++; continue; }
let end = x;
while (dark(row, end)) end++;
d += 'M' + (x + quiet) + ' ' + (y + quiet) + 'h' + (end - x) + 'v1h-' + (end - x) + 'z';
x = end;
}
}
return d;
}
// The address this page reached the console on, which is the one that works
// from this network; the console falls back to its own when there's none.
function inviteParams() {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?::(\d{1,5}))?$/.exec(conn.address || '');
return m ? [+m[1], +m[2], +m[3], +m[4], +(m[5] || 4264)] : [];
}
function openInvite() {
closeMenu();
document.body.classList.add('menu-open');
$('#inviteSheet').classList.add('open');
const box = $('#inviteQr'), address = $('#inviteAddress');
box.textContent = '';
address.textContent = '';
call('invite', inviteParams()).then(r => {
const quiet = 4, n = r.size + quiet * 2, ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 ' + n + ' ' + n);
svg.setAttribute('shape-rendering', 'crispEdges');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'QR code for ' + r.text);
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', qrPath(r.size, r.rows, quiet));
path.setAttribute('fill', '#000');
svg.appendChild(path);
box.appendChild(svg);
address.textContent = r.text.replace(/\/$/, '');
}).catch(e => { address.textContent = e.message || 'Couldn\'t make the QR code'; });
}
$('#openInvite').addEventListener('click', openInvite);
$('#menuInvite').addEventListener('click', openInvite);
$('#closeInvite').addEventListener('click', closeMenu);
$('#padMenuBtn').addEventListener('click', () => { if (!editor.active) openMenu(); });
$('#menuLayout').addEventListener('click', () => { closeMenu(); editor.open(); });
$('#closeOptions').addEventListener('click', closeMenu);