feat(ui): phone-style layout for direct calls

The call screen now shows two different layouts depending on
whether the call is a 1:1 direct call or a room/group call:

**Direct call (directCallPeer set):**
- Large centered identicon (96px circular with glow)
- Peer name (22px bold) + fingerprint (11px mono)
- Connection badge: "P2P Direct" (green), "Via Relay" (blue),
  or "Connecting..." (yellow) — auto-detected from the
  call-debug buffer's dual_path_race_won event
- Room name header shows the peer's alias/fp instead of "general"
- Group participant list is hidden

**Room/group call (directCallPeer null):**
- Existing group participant list layout — unchanged

The badge updates live from pollStatus by scanning the debug
buffer for the connect:dual_path_race_won event. If the path
was "Direct" → green P2P badge; if "Relay" → blue relay badge.
Before the race resolves, shows yellow "Connecting...".

directCallView is cleared on showConnectScreen (call end).

CSS in style.css: .direct-call-view, .dc-identicon, .dc-name,
.dc-fp, .dc-badge with .relay and .connecting modifiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 08:47:13 +04:00
parent 0cb8d34b21
commit 50e6a50de4
3 changed files with 119 additions and 13 deletions

View File

@@ -111,6 +111,16 @@
<div class="level-meter">
<div id="level-bar" class="level-bar-fill"></div>
</div>
<!-- Direct-call phone layout — shown instead of the group
participant list when directCallPeer is set. Centered
identicon, name, fp, connection badge. Hidden for
room calls (directCallPeer == null). -->
<div id="direct-call-view" class="direct-call-view hidden">
<div id="dc-identicon" class="dc-identicon"></div>
<div id="dc-name" class="dc-name">Unknown</div>
<div id="dc-fp" class="dc-fp"></div>
<div id="dc-badge" class="dc-badge">Connecting...</div>
</div>
<div id="participants" class="participants"></div>
<div class="controls">
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">

View File

@@ -169,6 +169,11 @@ const callTimer = document.getElementById("call-timer")!;
const callStatus = document.getElementById("call-status")!;
const levelBar = document.getElementById("level-bar")!;
const participantsDiv = document.getElementById("participants")!;
const directCallView = document.getElementById("direct-call-view")!;
const dcIdenticon = document.getElementById("dc-identicon")!;
const dcName = document.getElementById("dc-name")!;
const dcFp = document.getElementById("dc-fp")!;
const dcBadge = document.getElementById("dc-badge")!;
const micBtn = document.getElementById("mic-btn")!;
const micIcon = document.getElementById("mic-icon")!;
const spkBtn = document.getElementById("spk-btn")!;
@@ -846,7 +851,25 @@ let directCallPeer: { fingerprint: string; alias: string | null } | null = null;
function showCallScreen() {
connectScreen.classList.add("hidden");
callScreen.classList.remove("hidden");
roomName.textContent = roomInput.value;
// Direct call → phone-style layout; room call → group layout.
if (directCallPeer) {
const fp = directCallPeer.fingerprint || "";
const alias = directCallPeer.alias;
roomName.textContent = alias || fp.substring(0, 16) || "Direct Call";
dcName.textContent = alias || "Unknown";
dcFp.textContent = fp;
dcIdenticon.innerHTML = "";
dcIdenticon.appendChild(createIdenticonEl(fp || "?", 96, true));
dcBadge.textContent = "Connecting...";
dcBadge.className = "dc-badge connecting";
directCallView.classList.remove("hidden");
participantsDiv.classList.add("hidden");
} else {
roomName.textContent = roomInput.value;
directCallView.classList.add("hidden");
participantsDiv.classList.remove("hidden");
}
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on
@@ -984,23 +1007,38 @@ async function pollStatus() {
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`;
// Participants grouped by relay. For direct P2P calls the
// relay never sends a RoomUpdate (neither peer joins the
// relay's media room) so st.participants is empty. If we
// have a directCallPeer from the signal plane, inject a
// synthetic entry so the UI shows who we're talking to.
const displayParticipants =
st.participants.length === 0 && directCallPeer
? [{ ...directCallPeer, relay_label: "P2P Direct" }]
: st.participants;
// Direct-call phone-style layout: update the connection
// badge from the call-debug buffer or from participants.
if (directCallPeer) {
// Check the debug buffer for the race result to label
// the connection type (P2P Direct vs Relay).
const raceWon = callDebugBuffer.find((e) => e.step === "connect:dual_path_race_won");
const engineOk = callDebugBuffer.find((e) => e.step === "connect:call_engine_started");
if (engineOk) {
if (raceWon?.details?.path === "Direct") {
dcBadge.textContent = "P2P Direct";
dcBadge.className = "dc-badge";
} else {
dcBadge.textContent = "Via Relay";
dcBadge.className = "dc-badge relay";
}
}
// Skip the group participant rendering — direct-call
// view is already visible and showing the peer.
}
if (displayParticipants.length === 0) {
// Participants grouped by relay (group/room calls only).
// Hidden when directCallPeer is set — the phone-style
// layout above handles the 1:1 display.
if (directCallPeer) {
// no-op: direct call view handles it
} else if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else {
participantsDiv.innerHTML = "";
// Group by relay_label (null = this relay)
const groups: Record<string, typeof st.participants> = {};
displayParticipants.forEach((p: any) => {
st.participants.forEach((p: any) => {
const relay = p.relay_label || "This Relay";
if (!groups[relay]) groups[relay] = [];
groups[relay].push(p);

View File

@@ -371,7 +371,65 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
transition: width 0.1s ease-out;
}
/* ── Participants ── */
/* ── Direct call phone-style layout ── */
.direct-call-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 32px 16px;
gap: 8px;
}
.dc-identicon {
width: 96px;
height: 96px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 12px;
box-shadow: 0 0 24px rgba(74, 222, 128, 0.15);
}
.dc-identicon canvas,
.dc-identicon svg,
.dc-identicon img {
width: 100% !important;
height: 100% !important;
display: block;
}
.dc-name {
font-size: 22px;
font-weight: 600;
color: var(--text);
text-align: center;
}
.dc-fp {
font-size: 11px;
font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace;
color: var(--text-dim);
text-align: center;
word-break: break-all;
max-width: 280px;
}
.dc-badge {
display: inline-block;
margin-top: 8px;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: rgba(74, 222, 128, 0.12);
color: var(--green);
}
.dc-badge.relay {
background: rgba(96, 165, 250, 0.12);
color: #60a5fa;
}
.dc-badge.connecting {
background: rgba(250, 204, 21, 0.12);
color: var(--yellow);
}
/* ── Participants (group call layout) ── */
.participants {
background: var(--surface);
border-radius: var(--radius);