7 Commits

Author SHA1 Message Date
Siavash Sameni
217567383d fix(ui): timestamps in logs, proper call debounce, no cross-calling
- Copy/Share log now includes HH:MM:SS timestamps
- callInProgress stays true until call resolves (setup or hangup),
  preventing multiple taps from firing multiple place_call offers
- Block place_call when there's a pending incoming call
- leaveVoice clears all call state (callInProgress, pendingCallId)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:16:20 +04:00
Siavash Sameni
98ed981805 fix(ui): self-call prevention, debounce, codec in stats
- Filter self from lobby list (double-check in renderLobbyUsers)
- Disable "Direct Call" button when tapping own user
- Debounce call button (callInProgress flag prevents double-tap)
- Block calling own fingerprint
- Stats line shows codec names + fps + audio level

The direct call to the other phone failing is likely because
both phones share the same reflexive addr:port on the same NAT,
making determine_role return None (equal addrs). This is an
existing edge case in reflect.rs — not a UI bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:10:31 +04:00
Siavash Sameni
01a3133544 fix(ui): drawer buttons, stats fields, nicknames
- Buttons: use text labels (Mic/Spk/End) instead of emoji HTML
  entities that rendered as raw text on Android WebView
- Stats: match Rust CallStatus fields (tx_codec, rx_codec,
  encode_fps, recv_fps, audio_level, spk_muted)
- Nicknames: register_signal sends derive_alias() as the alias
  so other users see "Brave Falcon" instead of "a525:e9b2:..."
- Lobby header shows alias from get_app_info instead of raw fp
- pollStatus uses correct field names from Rust struct

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:00:09 +04:00
Siavash Sameni
25471c694f feat(ui): voice drawer replaces full-screen call UI
Discord-style bottom drawer for voice instead of navigating away:

- "Join Voice" hides the FAB, slides up a persistent bottom bar
- Drawer shows: room name, timer, P2P/Relay badge, level meter
- Controls: mic, speaker, end call — all in the drawer
- Direct call info (identicon, name, P2P badge) shown inline
- Lobby stays visible above the drawer at all times
- Stats line shows codec/packet/FEC info
- Leave voice = drawer slides away, FAB returns

Removed: full-screen call-screen, back button, old participant
list, old mic/speaker/hangup buttons. All voice interaction
happens in the 15% bottom drawer while the lobby stays live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:47:40 +04:00
Siavash Sameni
a058a83c91 feat(ui): relay list management in settings
Settings now shows relay list with:
- Visual list of all configured relays
- Active relay highlighted in green with "ACTIVE" badge
- Tap a relay to switch (deregisters + reconnects automatically)
- X button to remove a relay (keeps at least 1)
- Add relay with name + address inputs
- Reconnect flow: deregister → clear lobby → auto-connect to new relay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:37:58 +04:00
Siavash Sameni
9b8013ba7f merge main: PresenceList direct send fix 2026-04-14 18:36:01 +04:00
Siavash Sameni
defd8eab07 fix(signal): send PresenceList directly to new client after ack
Some checks failed
Mirror to GitHub / mirror (push) Failing after 24s
Build Release Binaries / build-amd64 (push) Failing after 3m50s
The broadcast alone wasn't reaching the first client because its
recv loop hadn't started yet when the second client registered.
Now the relay sends PresenceList directly to the new client (right
after RegisterPresenceAck) AND broadcasts to all others.

This guarantees every client gets the full user list:
- New client: via direct send (queued before recv loop starts)
- Existing clients: via broadcast

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:20:37 +04:00
5 changed files with 329 additions and 165 deletions

View File

@@ -1016,10 +1016,16 @@ async fn main() -> anyhow::Result<()> {
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered"); info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
// Broadcast updated presence to all signal clients // Send the full presence list directly to the new
// client (guaranteed delivery — their recv loop is
// about to start). Then broadcast to all OTHER
// clients so they learn about the new user.
{ {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let presence = hub.presence_list(); let presence = hub.presence_list();
// Direct send to new client (arrives right after ack)
let _ = transport.send_signal(&presence).await;
// Broadcast to everyone else
hub.broadcast(&presence).await; hub.broadcast(&presence).await;
} }

View File

@@ -65,47 +65,40 @@
<button id="reject-call-btn" class="btn-reject">Reject</button> <button id="reject-call-btn" class="btn-reject">Reject</button>
</div> </div>
</div> </div>
<!-- ═════ Voice Drawer (bottom bar, stays on lobby) ═════ -->
<div id="voice-drawer" class="voice-drawer hidden">
<div class="voice-drawer-bar" id="voice-drawer-bar">
<div class="vd-info">
<span id="vd-status" class="vd-status-dot"></span>
<span id="vd-room" class="vd-room">general</span>
<span id="vd-timer" class="vd-timer">0:00</span>
<span id="vd-badge" class="vd-badge hidden"></span>
</div> </div>
<div class="vd-level">
<!-- ═══════════════════════════════════════════════════════ <div id="vd-level-bar" class="vd-level-fill"></div>
IN-CALL — voice active (room or direct)
═══════════════════════════════════════════════════════ -->
<div id="call-screen" class="hidden">
<div class="call-header">
<div class="call-header-row">
<button id="back-to-lobby-btn" class="icon-btn small" title="Back to lobby">&#x2190;</button>
<div id="room-name" class="room-name"></div>
<button id="settings-btn-call" class="icon-btn small" title="Settings">&#9881;</button>
</div> </div>
<div class="call-meta"> <div class="vd-controls">
<span id="call-status" class="status-dot"></span> <button id="vd-mic-btn" class="vd-btn" title="Mic (m)">
<span id="call-timer" class="call-timer">0:00</span> <span id="vd-mic-icon">Mic</span>
</div>
</div>
<div class="level-meter">
<div id="level-bar" class="level-bar-fill"></div>
</div>
<!-- Direct-call phone layout -->
<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>
<!-- Room participants -->
<div id="participants" class="participants"></div>
<div class="controls">
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
<span class="icon" id="mic-icon">Mic</span>
</button> </button>
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)"> <button id="vd-spk-btn" class="vd-btn" title="Speaker (s)">
<span class="icon">End</span> <span id="vd-spk-icon">Spk</span>
</button> </button>
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)"> <button id="vd-end-btn" class="vd-btn vd-end" title="Leave voice (q)">
<span class="icon" id="spk-icon">Spk</span> <span>End</span>
</button> </button>
</div> </div>
<div id="stats" class="stats"></div> </div>
<!-- Direct call info (shown during P2P calls) -->
<div id="vd-direct-info" class="vd-direct-info hidden">
<span id="vd-dc-identicon" class="vd-dc-identicon"></span>
<div class="vd-dc-details">
<div id="vd-dc-name" class="vd-dc-name">Unknown</div>
<div id="vd-dc-badge" class="vd-dc-badge">Connecting...</div>
</div>
</div>
<div id="vd-stats" class="vd-stats"></div>
</div>
</div> </div>
<!-- ═══════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════

View File

@@ -1215,8 +1215,11 @@ fn do_register_signal(
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn)); let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
emit_call_debug(&app, "register_signal:quic_connected", serde_json::json!({ "relay": relay })); emit_call_debug(&app, "register_signal:quic_connected", serde_json::json!({ "relay": relay }));
// Send alias from seed-derived adjective+noun so other
// users see a friendly name in the lobby.
let alias = derive_alias(&seed);
transport.send_signal(&SignalMessage::RegisterPresence { transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub, signature: vec![], alias: None, identity_pub, signature: vec![], alias: Some(alias),
}).await.map_err(|e| format!("{e}"))?; }).await.map_err(|e| format!("{e}"))?;
emit_call_debug(&app, "register_signal:register_presence_sent", serde_json::json!({})); emit_call_debug(&app, "register_signal:register_presence_sent", serde_json::json!({}));

View File

@@ -67,29 +67,35 @@ const incomingCallerName = document.getElementById("incoming-caller-name")!;
const incomingIdenticon = document.getElementById("incoming-identicon")!; const incomingIdenticon = document.getElementById("incoming-identicon")!;
const acceptCallBtn = document.getElementById("accept-call-btn")!; const acceptCallBtn = document.getElementById("accept-call-btn")!;
const rejectCallBtn = document.getElementById("reject-call-btn")!; const rejectCallBtn = document.getElementById("reject-call-btn")!;
const backToLobbyBtn = document.getElementById("back-to-lobby-btn")!; // Voice drawer elements
const roomName = document.getElementById("room-name")!; const voiceDrawer = document.getElementById("voice-drawer")!;
const callTimer = document.getElementById("call-timer")!; const vdRoom = document.getElementById("vd-room")!;
const callStatus = document.getElementById("call-status")!; const vdTimer = document.getElementById("vd-timer")!;
const levelBar = document.getElementById("level-bar")!; const vdStatus = document.getElementById("vd-status")!;
const participantsDiv = document.getElementById("participants")!; const vdBadge = document.getElementById("vd-badge")!;
const directCallView = document.getElementById("direct-call-view")!; const vdLevelBar = document.getElementById("vd-level-bar")!;
const dcIdenticon = document.getElementById("dc-identicon")!; const vdMicBtn = document.getElementById("vd-mic-btn")!;
const dcName = document.getElementById("dc-name")!; const vdMicIcon = document.getElementById("vd-mic-icon")!;
const dcFp = document.getElementById("dc-fp")!; const vdSpkBtn = document.getElementById("vd-spk-btn")!;
const dcBadge = document.getElementById("dc-badge")!; const vdSpkIcon = document.getElementById("vd-spk-icon")!;
const micBtn = document.getElementById("mic-btn")!; const vdEndBtn = document.getElementById("vd-end-btn")!;
const micIcon = document.getElementById("mic-icon")!; const vdDirectInfo = document.getElementById("vd-direct-info")!;
const spkBtn = document.getElementById("spk-btn")!; const vdDcIdenticon = document.getElementById("vd-dc-identicon")!;
const spkIcon = document.getElementById("spk-icon")!; const vdDcName = document.getElementById("vd-dc-name")!;
const hangupBtn = document.getElementById("hangup-btn")!; const vdDcBadge = document.getElementById("vd-dc-badge")!;
const statsDiv = document.getElementById("stats")!; const vdStats = document.getElementById("vd-stats")!;
const ctxMenu = document.getElementById("user-context-menu")!; const ctxMenu = document.getElementById("user-context-menu")!;
const ctxIdenticon = document.getElementById("ctx-identicon")!; const ctxIdenticon = document.getElementById("ctx-identicon")!;
const ctxName = document.getElementById("ctx-name")!; const ctxName = document.getElementById("ctx-name")!;
const ctxFp = document.getElementById("ctx-fp")!; const ctxFp = document.getElementById("ctx-fp")!;
const ctxCallBtn = document.getElementById("ctx-call-btn")!; const ctxCallBtn = document.getElementById("ctx-call-btn")!;
const ctxCloseBtn = document.getElementById("ctx-close-btn")!; const ctxCloseBtn = document.getElementById("ctx-close-btn")!;
// Relay management
const sRelayList = document.getElementById("s-relay-list")!;
const sRelayName = document.getElementById("s-relay-name") as HTMLInputElement;
const sRelayAddr = document.getElementById("s-relay-addr") as HTMLInputElement;
const sRelayAdd = document.getElementById("s-relay-add")!;
// Settings // Settings
const settingsPanel = document.getElementById("settings-panel")!; const settingsPanel = document.getElementById("settings-panel")!;
const settingsBtn = document.getElementById("settings-btn")!; const settingsBtn = document.getElementById("settings-btn")!;
@@ -210,7 +216,9 @@ sQuality?.addEventListener("input", () => updateQualityUI(parseInt(sQuality.valu
// ── Lobby rendering ─────────────────────────────────────────────── // ── Lobby rendering ───────────────────────────────────────────────
function renderLobbyUsers() { function renderLobbyUsers() {
lobbyUserList.innerHTML = ""; lobbyUserList.innerHTML = "";
const users = Array.from(lobbyUsers.values()).sort((a, b) => { const users = Array.from(lobbyUsers.values())
.filter((u) => u.fingerprint !== myFingerprint) // always exclude self
.sort((a, b) => {
// Voice users first, then alphabetical // Voice users first, then alphabetical
if (a.inVoice !== b.inVoice) return a.inVoice ? -1 : 1; if (a.inVoice !== b.inVoice) return a.inVoice ? -1 : 1;
return (a.alias || a.fingerprint).localeCompare(b.alias || b.fingerprint); return (a.alias || a.fingerprint).localeCompare(b.alias || b.fingerprint);
@@ -263,36 +271,45 @@ function openContextMenu(user: LobbyUser) {
ctxIdenticon.appendChild(createIdenticonEl(user.fingerprint, 40, true)); ctxIdenticon.appendChild(createIdenticonEl(user.fingerprint, 40, true));
ctxName.textContent = user.alias || user.fingerprint.substring(0, 16); ctxName.textContent = user.alias || user.fingerprint.substring(0, 16);
ctxFp.textContent = user.fingerprint; ctxFp.textContent = user.fingerprint;
// Hide call button for self
const isSelf = user.fingerprint === myFingerprint;
(ctxCallBtn as HTMLButtonElement).disabled = isSelf;
(ctxCallBtn as HTMLElement).style.opacity = isSelf ? "0.3" : "1";
ctxMenu.classList.remove("hidden"); ctxMenu.classList.remove("hidden");
} }
ctxCloseBtn.addEventListener("click", () => ctxMenu.classList.add("hidden")); ctxCloseBtn.addEventListener("click", () => ctxMenu.classList.add("hidden"));
ctxMenu.addEventListener("click", (e) => { if (e.target === ctxMenu) ctxMenu.classList.add("hidden"); }); ctxMenu.addEventListener("click", (e) => { if (e.target === ctxMenu) ctxMenu.classList.add("hidden"); });
let callInProgress = false;
ctxCallBtn.addEventListener("click", async () => { ctxCallBtn.addEventListener("click", async () => {
if (!contextUser) return; if (!contextUser || callInProgress) return;
if (contextUser.fingerprint === myFingerprint) {
ctxMenu.classList.add("hidden");
return;
}
// Don't place a call if there's already a pending incoming call
if (pendingCallId) {
ctxMenu.classList.add("hidden");
return;
}
callInProgress = true;
ctxMenu.classList.add("hidden"); ctxMenu.classList.add("hidden");
directCallPeer = { fingerprint: contextUser.fingerprint, alias: contextUser.alias }; directCallPeer = { fingerprint: contextUser.fingerprint, alias: contextUser.alias };
try { try {
await invoke("place_call", { targetFp: contextUser.fingerprint }); await invoke("place_call", { targetFp: contextUser.fingerprint });
// Keep callInProgress true until the call resolves (setup/hangup)
// — it's cleared in leaveVoice() or when the call connects
} catch (e: any) { } catch (e: any) {
console.error("place_call failed:", e); console.error("place_call failed:", e);
directCallPeer = null; directCallPeer = null;
callInProgress = false;
} }
}); });
// ── Voice join/leave ────────────────────────────────────────────── // ── Voice join/leave (drawer-based) ───────────────────────────────
joinVoiceBtn.addEventListener("click", async () => { joinVoiceBtn.addEventListener("click", async () => {
if (inVoice) { if (inVoice) return;
// Leave voice
try { await invoke("disconnect"); } catch {}
inVoice = false;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>';
joinVoiceBtn.classList.remove("active");
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby();
} else {
// Join voice
const relay = getRelay(); const relay = getRelay();
const s = loadSettings(); const s = loadSettings();
if (!relay) return; if (!relay) return;
@@ -304,74 +321,59 @@ joinVoiceBtn.addEventListener("click", async () => {
osAec: s.osAec, osAec: s.osAec,
quality: s.quality || "auto", quality: s.quality || "auto",
}); });
inVoice = true; enterVoice(false);
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Leave Voice</span>';
joinVoiceBtn.classList.add("active");
showCallScreen(false);
} catch (e: any) { } catch (e: any) {
console.error("connect failed:", e); console.error("connect failed:", e);
} }
}
}); });
// ── Screen transitions ──────────────────────────────────────────── function enterVoice(isDirect: boolean) {
function showLobby() { inVoice = true;
callScreen.classList.add("hidden"); const s = loadSettings();
lobbyScreen.classList.remove("hidden"); joinVoiceBtn.classList.add("hidden");
directCallPeer = null; voiceDrawer.classList.remove("hidden");
levelBar.style.width = "0%"; vdRoom.textContent = isDirect && directCallPeer
} ? (directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16))
: (s.room || "general");
function showCallScreen(isDirect: boolean) { vdTimer.textContent = "0:00";
lobbyScreen.classList.add("hidden"); vdBadge.classList.add("hidden");
callScreen.classList.remove("hidden"); vdBadge.textContent = "";
if (isDirect && directCallPeer) { if (isDirect && directCallPeer) {
roomName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16); vdDirectInfo.classList.remove("hidden");
dcName.textContent = directCallPeer.alias || "Unknown"; vdDcIdenticon.innerHTML = "";
dcFp.textContent = directCallPeer.fingerprint; vdDcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 32, true));
dcIdenticon.innerHTML = ""; vdDcName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16);
dcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 96, true)); vdDcBadge.textContent = "Connecting...";
dcBadge.textContent = "Connecting..."; vdDcBadge.className = "vd-dc-badge connecting";
dcBadge.className = "dc-badge connecting";
directCallView.classList.remove("hidden");
participantsDiv.classList.add("hidden");
} else { } else {
const s = loadSettings(); vdDirectInfo.classList.add("hidden");
roomName.textContent = s.room || "general";
directCallView.classList.add("hidden");
participantsDiv.classList.remove("hidden");
} }
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250); statusInterval = window.setInterval(pollStatus, 250);
} }
// Back button from call to lobby function leaveVoice() {
backToLobbyBtn.addEventListener("click", async () => {
try { await invoke("disconnect"); } catch {}
inVoice = false; inVoice = false;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>'; callInProgress = false;
joinVoiceBtn.classList.remove("active"); directCallPeer = null;
pendingCallId = null;
voiceDrawer.classList.add("hidden");
joinVoiceBtn.classList.remove("hidden");
vdLevelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby(); }
});
// Hangup // Drawer controls
hangupBtn.addEventListener("click", async () => { vdEndBtn.addEventListener("click", async () => {
try { await invoke("hangup_call"); } catch {} try { await invoke("hangup_call"); } catch {}
try { await invoke("disconnect"); } catch {} try { await invoke("disconnect"); } catch {}
inVoice = false; leaveVoice();
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>';
joinVoiceBtn.classList.remove("active");
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby();
}); });
vdMicBtn.addEventListener("click", async () => {
// Mic/speaker toggles
micBtn.addEventListener("click", async () => {
try { await invoke("toggle_mic"); } catch {} try { await invoke("toggle_mic"); } catch {}
}); });
spkBtn.addEventListener("click", async () => { vdSpkBtn.addEventListener("click", async () => {
try { await invoke("toggle_speaker"); } catch {} try { await invoke("toggle_speaker"); } catch {}
}); });
@@ -379,38 +381,40 @@ spkBtn.addEventListener("click", async () => {
interface CallStatusI { interface CallStatusI {
active: boolean; active: boolean;
mic_muted: boolean; mic_muted: boolean;
speaker_muted: boolean; spk_muted: boolean;
send_rms: number; participants: any[];
recv_rms: number; encode_fps: number;
codec_tx: string; recv_fps: number;
codec_rx: string; audio_level: number;
fec_ratio: number;
send_packets: number;
recv_packets: number;
call_duration_secs: number; call_duration_secs: number;
fingerprint: string; fingerprint: string;
tx_codec: string;
rx_codec: string;
} }
async function pollStatus() { async function pollStatus() {
try { try {
const st: CallStatusI = await invoke("get_status"); const st: CallStatusI = await invoke("get_status");
if (!st.active) { if (!st.active) {
showLobby(); leaveVoice();
return; return;
} }
if (st.fingerprint) myFingerprint = st.fingerprint; if (st.fingerprint) myFingerprint = st.fingerprint;
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
spkBtn.classList.toggle("muted", st.speaker_muted);
spkIcon.textContent = st.speaker_muted ? "Spk Off" : "Spk";
const pct = Math.min(100, (st.send_rms / 10000) * 100); // Update drawer controls
levelBar.style.width = `${pct}%`; vdMicBtn.classList.toggle("muted", st.mic_muted);
vdMicIcon.textContent = st.mic_muted ? "Muted" : "Mic";
vdSpkBtn.classList.toggle("muted", st.spk_muted);
vdSpkIcon.textContent = st.spk_muted ? "Off" : "Spk";
// Level meter
const pct = Math.min(100, (st.audio_level / 10000) * 100);
vdLevelBar.style.width = `${pct}%`;
// Duration // Duration
const m = Math.floor(st.call_duration_secs / 60); const m = Math.floor(st.call_duration_secs / 60);
const s = Math.floor(st.call_duration_secs % 60); const s = Math.floor(st.call_duration_secs % 60);
callTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`; vdTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`;
// P2P badge for direct calls // P2P badge for direct calls
if (directCallPeer) { if (directCallPeer) {
@@ -418,16 +422,23 @@ async function pollStatus() {
const engineOk = [...callDebugBuffer].reverse().find((e) => e.step === "connect:call_engine_started"); const engineOk = [...callDebugBuffer].reverse().find((e) => e.step === "connect:call_engine_started");
if (engineOk) { if (engineOk) {
if (pathNeg?.details?.use_direct === true) { if (pathNeg?.details?.use_direct === true) {
dcBadge.textContent = "P2P Direct"; vdDcBadge.textContent = "P2P Direct";
dcBadge.className = "dc-badge"; vdDcBadge.className = "vd-dc-badge direct";
vdBadge.textContent = "P2P";
vdBadge.className = "vd-badge direct";
vdBadge.classList.remove("hidden");
} else { } else {
dcBadge.textContent = "Via Relay"; vdDcBadge.textContent = "Via Relay";
dcBadge.className = "dc-badge relay"; vdDcBadge.className = "vd-dc-badge relay";
vdBadge.textContent = "Relay";
vdBadge.className = "vd-badge relay";
vdBadge.classList.remove("hidden");
} }
} }
} }
statsDiv.textContent = `TX: ${st.codec_tx} ${st.send_packets}pkt | RX: ${st.codec_rx} ${st.recv_packets}pkt | FEC: ${(st.fec_ratio * 100).toFixed(0)}%`; // Stats with codec
vdStats.textContent = `TX: ${st.tx_codec || "?"} ${st.encode_fps || 0}fps | RX: ${st.rx_codec || "?"} ${st.recv_fps || 0}fps | Level: ${st.audio_level || 0}`;
} catch {} } catch {}
} }
@@ -484,7 +495,7 @@ listen("signal-event", (event: any) => {
directOnly: s.directOnly || false, directOnly: s.directOnly || false,
birthdayAttack: s.birthdayAttack || false, birthdayAttack: s.birthdayAttack || false,
}); });
showCallScreen(true); enterVoice(true);
} catch (e: any) { } catch (e: any) {
console.error("connect failed:", e); console.error("connect failed:", e);
} }
@@ -495,7 +506,7 @@ listen("signal-event", (event: any) => {
incomingBanner.classList.add("hidden"); incomingBanner.classList.add("hidden");
(async () => { (async () => {
try { await invoke("disconnect"); } catch {} try { await invoke("disconnect"); } catch {}
showLobby(); leaveVoice();
})(); })();
break; break;
} }
@@ -547,6 +558,80 @@ listen("call-event", (event: any) => {
}); });
// ── Settings ────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────
// ── Relay list management ──────────────────────────────────────
function renderRelayList() {
const s = loadSettings();
sRelayList.innerHTML = "";
for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i];
const isActive = i === s.selectedRelay;
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:6px;padding:8px;border-radius:6px;margin-bottom:4px;cursor:pointer;" +
(isActive ? "background:rgba(74,222,128,0.12);border:1px solid var(--green);" : "background:var(--surface);border:1px solid transparent;");
row.innerHTML = `
<span style="flex:1;font-size:13px;font-weight:${isActive ? '600' : '400'}">
<span style="color:${isActive ? 'var(--green)' : 'var(--text)'}">${r.name}</span>
<span style="color:var(--text-dim);font-size:11px;margin-left:4px">${r.address}</span>
</span>
${isActive ? '<span style="color:var(--green);font-size:11px">ACTIVE</span>' : ''}
<button class="relay-rm-btn" data-idx="${i}" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:16px;padding:2px 6px">&times;</button>
`;
// Click to select (not on the X button)
row.addEventListener("click", (e) => {
if ((e.target as HTMLElement).classList.contains("relay-rm-btn")) return;
const settings = loadSettings();
if (i !== settings.selectedRelay) {
settings.selectedRelay = i;
saveSettings(settings);
renderRelayList();
// Reconnect to new relay
reconnectSignal();
}
});
sRelayList.appendChild(row);
}
// Wire remove buttons
sRelayList.querySelectorAll(".relay-rm-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const settings = loadSettings();
if (settings.relays.length <= 1) return; // keep at least one
settings.relays.splice(idx, 1);
if (settings.selectedRelay >= settings.relays.length) {
settings.selectedRelay = 0;
}
saveSettings(settings);
renderRelayList();
reconnectSignal();
});
});
}
sRelayAdd.addEventListener("click", () => {
const name = sRelayName.value.trim();
const addr = sRelayAddr.value.trim();
if (!name || !addr) return;
if (!addr.includes(":")) return; // must be host:port
const s = loadSettings();
s.relays.push({ name, address: addr });
saveSettings(s);
sRelayName.value = "";
sRelayAddr.value = "";
renderRelayList();
});
async function reconnectSignal() {
// Deregister from current relay, then auto-connect to new one
try { await invoke("deregister"); } catch {}
lobbyUsers.clear();
renderLobbyUsers();
lobbyDot.style.background = "var(--yellow)";
lobbyRelayLabel.textContent = "Reconnecting...";
// Short delay to let deregister complete
setTimeout(() => autoConnect(), 500);
}
function openSettings() { function openSettings() {
const s = loadSettings(); const s = loadSettings();
sRoom.value = s.room; sRoom.value = s.room;
@@ -562,6 +647,7 @@ function openSettings() {
sQuality.value = String(qi); sQuality.value = String(qi);
updateQualityUI(qi); updateQualityUI(qi);
sFingerprint.textContent = myFingerprint || "(loading...)"; sFingerprint.textContent = myFingerprint || "(loading...)";
renderRelayList();
settingsPanel.classList.remove("hidden"); settingsPanel.classList.remove("hidden");
} }
@@ -584,6 +670,8 @@ settingsSave.addEventListener("click", () => {
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {}); invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {}); invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {});
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none"; sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
// Update lobby room label
lobbyRoomLabel.textContent = s.room || "general";
settingsPanel.classList.add("hidden"); settingsPanel.classList.add("hidden");
}); });
@@ -593,11 +681,17 @@ sCallDebugClearBtn?.addEventListener("click", () => {
sCallDebugLogEl.textContent = ""; sCallDebugLogEl.textContent = "";
}); });
sCallDebugCopyBtn?.addEventListener("click", () => { sCallDebugCopyBtn?.addEventListener("click", () => {
const text = callDebugBuffer.map((e) => `${e.step} ${JSON.stringify(e.details)}`).join("\n"); const text = callDebugBuffer.map((e) => {
const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
return `${t} ${e.step} ${JSON.stringify(e.details)}`;
}).join("\n");
navigator.clipboard?.writeText(text).catch(() => {}); navigator.clipboard?.writeText(text).catch(() => {});
}); });
sCallDebugShareBtn?.addEventListener("click", async () => { sCallDebugShareBtn?.addEventListener("click", async () => {
const text = callDebugBuffer.map((e) => `${e.step} ${JSON.stringify(e.details)}`).join("\n"); const text = callDebugBuffer.map((e) => {
const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
return `${t} ${e.step} ${JSON.stringify(e.details)}`;
}).join("\n");
try { await (navigator as any).share({ text }); } catch {} try { await (navigator as any).share({ text }); } catch {}
}); });
@@ -651,13 +745,13 @@ async function autoConnect() {
lobbyDot.style.background = "var(--green)"; lobbyDot.style.background = "var(--green)";
lobbyRelayLabel.textContent = `${relay.name} — connected`; lobbyRelayLabel.textContent = `${relay.name} — connected`;
// Get identity // Get identity + alias
const fp: string = await invoke("get_identity"); const appInfo: any = await invoke("get_app_info");
if (fp) { if (appInfo?.fingerprint) {
myFingerprint = fp; myFingerprint = appInfo.fingerprint;
lobbyFp.textContent = fp; lobbyFp.textContent = appInfo.alias || appInfo.fingerprint;
lobbyIdenticon.innerHTML = ""; lobbyIdenticon.innerHTML = "";
lobbyIdenticon.appendChild(createIdenticonEl(fp, 20, true)); lobbyIdenticon.appendChild(createIdenticonEl(appInfo.fingerprint, 20, true));
} }
} catch (e: any) { } catch (e: any) {
lobbyDot.style.background = "var(--red)"; lobbyDot.style.background = "var(--red)";
@@ -671,9 +765,9 @@ invoke("set_call_debug_logs", { enabled: !!loadSettings().callDebugLogs }).catch
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "m") micBtn.click(); if (e.key === "m") vdMicBtn.click();
if (e.key === "q") hangupBtn.click(); if (e.key === "q") vdEndBtn.click();
if (e.key === "s") spkBtn.click(); if (e.key === "s") vdSpkBtn.click();
if (e.key === "," && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openSettings(); } if (e.key === "," && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openSettings(); }
}); });

View File

@@ -238,6 +238,74 @@ body {
.fab-icon { font-size: 18px; } .fab-icon { font-size: 18px; }
/* ── Voice Drawer (bottom bar) ── */
.voice-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--surface2);
padding: 0 16px;
padding-bottom: env(safe-area-inset-bottom, 8px);
z-index: 50;
animation: drawerUp 0.25s ease-out;
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
}
@keyframes drawerUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.voice-drawer-bar {
display: flex; flex-direction: column; gap: 6px; padding: 10px 0 6px;
}
.vd-info {
display: flex; align-items: center; gap: 8px; font-size: 13px;
}
.vd-status-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0;
}
.vd-room { color: var(--green); font-weight: 600; }
.vd-timer { color: var(--text-dim); font-family: ui-monospace, monospace; font-size: 12px; }
.vd-badge {
font-size: 10px; padding: 1px 6px; border-radius: 6px; font-weight: 500;
}
.vd-badge.direct { background: rgba(74,222,128,0.15); color: var(--green); }
.vd-badge.relay { background: rgba(96,165,250,0.15); color: #60a5fa; }
.vd-level { height: 3px; background: var(--surface2); border-radius: 2px; overflow: hidden; }
.vd-level-fill {
height: 100%; width: 0%; background: var(--green); border-radius: 2px; transition: width 0.1s;
}
.vd-controls {
display: flex; align-items: center; justify-content: center; gap: 12px; padding: 4px 0;
}
.vd-btn {
width: 44px; height: 44px; border-radius: 50%; border: none;
background: var(--surface2); color: var(--text); font-size: 18px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.vd-btn:hover { background: var(--primary); }
.vd-btn.muted { background: var(--red); color: white; }
.vd-end { background: var(--red); color: white; }
.vd-end:hover { background: #dc2626; }
.vd-direct-info {
display: flex; align-items: center; gap: 10px; padding: 8px 0 4px;
border-top: 1px solid var(--surface2); margin-top: 4px;
}
.vd-dc-identicon { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; }
.vd-dc-name { font-size: 13px; font-weight: 600; }
.vd-dc-badge {
font-size: 10px; padding: 1px 6px; border-radius: 6px;
}
.vd-dc-badge.direct { background: rgba(74,222,128,0.15); color: var(--green); }
.vd-dc-badge.relay { background: rgba(96,165,250,0.15); color: #60a5fa; }
.vd-dc-badge.connecting { background: rgba(250,204,21,0.15); color: var(--yellow); }
.vd-stats {
font-size: 10px; color: var(--text-dim); font-family: ui-monospace, monospace;
padding: 2px 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Incoming call banner */ /* Incoming call banner */
.incoming-banner { .incoming-banner {
position: fixed; position: fixed;