10 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
Siavash Sameni
cc23e829b2 feat(ui): handle PresenceList in lobby — show online users
The lobby now populates from PresenceList signal events:
- Relay broadcasts user list on register/deregister
- JS receives "presence_list" signal-event
- Updates lobbyUsers map (excluding self)
- Renders user rows with identicon, name, fingerprint

Users appear in the lobby as soon as they register their
signal channel — no need to join voice first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:13:45 +04:00
Siavash Sameni
18c204c1ff merge main: PresenceList signal for lobby 2026-04-14 18:13:15 +04:00
Siavash Sameni
1120c7b579 feat(signal): PresenceList broadcast for lobby user discovery
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 7m21s
Mirror to GitHub / mirror (push) Failing after 27s
New signal infrastructure for the lobby-first UI:

- PresenceUser struct: { fingerprint, alias }
- SignalMessage::PresenceList: relay broadcasts full user list
  to all signal clients on every register/deregister
- SignalHub::presence_list(): builds the list from connected clients
- SignalHub::broadcast(): sends to ALL signal clients
- Relay calls broadcast on register + unregister
- Desktop emits "presence_list" signal-event to JS frontend

This gives clients real-time visibility of who's online via the
signal channel, without needing to join a voice room first.

603 tests pass, 0 regressions.

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

View File

@@ -138,6 +138,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
| SignalMessage::UpgradeResponse { .. }
| SignalMessage::UpgradeConfirm { .. }
| SignalMessage::QualityCapability { .. } => CallSignalType::Offer, // quality negotiation
SignalMessage::PresenceList { .. } => CallSignalType::Offer, // lobby presence
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
}
}

View File

@@ -27,7 +27,7 @@ pub use codec_id::{CodecId, QualityProfile};
pub use error::*;
pub use packet::{
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
PresenceUser, QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
FRAME_TYPE_MINI,
};
pub use bandwidth::{BandwidthEstimator, CongestionState};

View File

@@ -156,6 +156,14 @@ impl MediaHeader {
}
}
/// A user visible in the signal presence list.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PresenceUser {
pub fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
}
/// Quality report appended to a media packet when Q flag is set (4 bytes).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QualityReport {
@@ -1020,6 +1028,16 @@ pub enum SignalMessage {
reason: Option<String>,
},
// ── Signal presence ───────────────────────────────────────────
/// Relay broadcasts the list of currently registered signal
/// users to all connected clients. Sent on every register/
/// deregister so clients can maintain a live lobby user list.
PresenceList {
/// List of online users. Each entry is { fingerprint, alias }.
users: Vec<PresenceUser>,
},
// ── Quality upgrade negotiation (#28, #29) ──────────────────
/// Peer proposes upgrading to a higher quality profile.

View File

@@ -1016,6 +1016,19 @@ async fn main() -> anyhow::Result<()> {
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
// 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 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;
}
// Signal recv loop
loop {
match transport.recv_signal().await {
@@ -1560,6 +1573,9 @@ async fn main() -> anyhow::Result<()> {
{
let mut hub = signal_hub.lock().await;
hub.unregister(&client_fp);
// Broadcast updated presence to remaining clients
let presence_msg = hub.presence_list();
hub.broadcast(&presence_msg).await;
}
{
let mut reg = presence.lock().await;

View File

@@ -86,6 +86,26 @@ impl SignalHub {
pub fn alias(&self, fp: &str) -> Option<&str> {
self.clients.get(fp).and_then(|c| c.alias.as_deref())
}
/// Build a PresenceList message with all online users.
pub fn presence_list(&self) -> SignalMessage {
let users: Vec<wzp_proto::PresenceUser> = self
.clients
.values()
.map(|c| wzp_proto::PresenceUser {
fingerprint: c.fingerprint.clone(),
alias: c.alias.clone(),
})
.collect();
SignalMessage::PresenceList { users }
}
/// Broadcast a message to ALL connected signal clients.
pub async fn broadcast(&self, msg: &SignalMessage) {
for client in self.clients.values() {
let _ = client.transport.send_signal(msg).await;
}
}
}
#[cfg(test)]

View File

@@ -65,47 +65,40 @@
<button id="reject-call-btn" class="btn-reject">Reject</button>
</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>
<!-- ═══════════════════════════════════════════════════════
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 class="vd-level">
<div id="vd-level-bar" class="vd-level-fill"></div>
</div>
<div class="call-meta">
<span id="call-status" class="status-dot"></span>
<span id="call-timer" class="call-timer">0:00</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>
<div class="vd-controls">
<button id="vd-mic-btn" class="vd-btn" title="Mic (m)">
<span id="vd-mic-icon">Mic</span>
</button>
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
<span class="icon">End</span>
<button id="vd-spk-btn" class="vd-btn" title="Speaker (s)">
<span id="vd-spk-icon">Spk</span>
</button>
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
<span class="icon" id="spk-icon">Spk</span>
<button id="vd-end-btn" class="vd-btn vd-end" title="Leave voice (q)">
<span>End</span>
</button>
</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>
<!-- ═══════════════════════════════════════════════════════

View File

@@ -1215,8 +1215,11 @@ fn do_register_signal(
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
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 {
identity_pub, signature: vec![], alias: None,
identity_pub, signature: vec![], alias: Some(alias),
}).await.map_err(|e| format!("{e}"))?;
emit_call_debug(&app, "register_signal:register_presence_sent", serde_json::json!({}));
@@ -1451,6 +1454,20 @@ fn do_register_signal(
});
}
}
Ok(Some(SignalMessage::PresenceList { users })) => {
tracing::info!(count = users.len(), "signal: PresenceList received");
// Emit to JS frontend for lobby user list
let user_list: Vec<serde_json::Value> = users.iter().map(|u| {
serde_json::json!({
"fingerprint": u.fingerprint,
"alias": u.alias,
})
}).collect();
let _ = app_clone.emit("signal-event", serde_json::json!({
"type": "presence_list",
"users": user_list,
}));
}
Ok(Some(SignalMessage::UpgradeProposal { call_id, proposal_id, proposed_profile, local_loss_pct, local_rtt_ms })) => {
tracing::info!(%call_id, %proposal_id, ?proposed_profile, "signal: UpgradeProposal from peer");
emit_call_debug(&app_clone, "recv:UpgradeProposal", serde_json::json!({

View File

@@ -67,29 +67,35 @@ const incomingCallerName = document.getElementById("incoming-caller-name")!;
const incomingIdenticon = document.getElementById("incoming-identicon")!;
const acceptCallBtn = document.getElementById("accept-call-btn")!;
const rejectCallBtn = document.getElementById("reject-call-btn")!;
const backToLobbyBtn = document.getElementById("back-to-lobby-btn")!;
const roomName = document.getElementById("room-name")!;
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")!;
const spkIcon = document.getElementById("spk-icon")!;
const hangupBtn = document.getElementById("hangup-btn")!;
const statsDiv = document.getElementById("stats")!;
// Voice drawer elements
const voiceDrawer = document.getElementById("voice-drawer")!;
const vdRoom = document.getElementById("vd-room")!;
const vdTimer = document.getElementById("vd-timer")!;
const vdStatus = document.getElementById("vd-status")!;
const vdBadge = document.getElementById("vd-badge")!;
const vdLevelBar = document.getElementById("vd-level-bar")!;
const vdMicBtn = document.getElementById("vd-mic-btn")!;
const vdMicIcon = document.getElementById("vd-mic-icon")!;
const vdSpkBtn = document.getElementById("vd-spk-btn")!;
const vdSpkIcon = document.getElementById("vd-spk-icon")!;
const vdEndBtn = document.getElementById("vd-end-btn")!;
const vdDirectInfo = document.getElementById("vd-direct-info")!;
const vdDcIdenticon = document.getElementById("vd-dc-identicon")!;
const vdDcName = document.getElementById("vd-dc-name")!;
const vdDcBadge = document.getElementById("vd-dc-badge")!;
const vdStats = document.getElementById("vd-stats")!;
const ctxMenu = document.getElementById("user-context-menu")!;
const ctxIdenticon = document.getElementById("ctx-identicon")!;
const ctxName = document.getElementById("ctx-name")!;
const ctxFp = document.getElementById("ctx-fp")!;
const ctxCallBtn = document.getElementById("ctx-call-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
const settingsPanel = document.getElementById("settings-panel")!;
const settingsBtn = document.getElementById("settings-btn")!;
@@ -210,7 +216,9 @@ sQuality?.addEventListener("input", () => updateQualityUI(parseInt(sQuality.valu
// ── Lobby rendering ───────────────────────────────────────────────
function renderLobbyUsers() {
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
if (a.inVoice !== b.inVoice) return a.inVoice ? -1 : 1;
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));
ctxName.textContent = user.alias || user.fingerprint.substring(0, 16);
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");
}
ctxCloseBtn.addEventListener("click", () => ctxMenu.classList.add("hidden"));
ctxMenu.addEventListener("click", (e) => { if (e.target === ctxMenu) ctxMenu.classList.add("hidden"); });
let callInProgress = false;
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");
directCallPeer = { fingerprint: contextUser.fingerprint, alias: contextUser.alias };
try {
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) {
console.error("place_call failed:", e);
directCallPeer = null;
callInProgress = false;
}
});
// ── Voice join/leave ──────────────────────────────────────────────
// ── Voice join/leave (drawer-based) ───────────────────────────────
joinVoiceBtn.addEventListener("click", async () => {
if (inVoice) {
// 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
if (inVoice) return;
const relay = getRelay();
const s = loadSettings();
if (!relay) return;
@@ -304,74 +321,59 @@ joinVoiceBtn.addEventListener("click", async () => {
osAec: s.osAec,
quality: s.quality || "auto",
});
inVoice = true;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Leave Voice</span>';
joinVoiceBtn.classList.add("active");
showCallScreen(false);
enterVoice(false);
} catch (e: any) {
console.error("connect failed:", e);
}
}
});
// ── Screen transitions ────────────────────────────────────────────
function showLobby() {
callScreen.classList.add("hidden");
lobbyScreen.classList.remove("hidden");
directCallPeer = null;
levelBar.style.width = "0%";
}
function showCallScreen(isDirect: boolean) {
lobbyScreen.classList.add("hidden");
callScreen.classList.remove("hidden");
function enterVoice(isDirect: boolean) {
inVoice = true;
const s = loadSettings();
joinVoiceBtn.classList.add("hidden");
voiceDrawer.classList.remove("hidden");
vdRoom.textContent = isDirect && directCallPeer
? (directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16))
: (s.room || "general");
vdTimer.textContent = "0:00";
vdBadge.classList.add("hidden");
vdBadge.textContent = "";
if (isDirect && directCallPeer) {
roomName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16);
dcName.textContent = directCallPeer.alias || "Unknown";
dcFp.textContent = directCallPeer.fingerprint;
dcIdenticon.innerHTML = "";
dcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 96, true));
dcBadge.textContent = "Connecting...";
dcBadge.className = "dc-badge connecting";
directCallView.classList.remove("hidden");
participantsDiv.classList.add("hidden");
vdDirectInfo.classList.remove("hidden");
vdDcIdenticon.innerHTML = "";
vdDcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 32, true));
vdDcName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16);
vdDcBadge.textContent = "Connecting...";
vdDcBadge.className = "vd-dc-badge connecting";
} else {
const s = loadSettings();
roomName.textContent = s.room || "general";
directCallView.classList.add("hidden");
participantsDiv.classList.remove("hidden");
vdDirectInfo.classList.add("hidden");
}
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
}
// Back button from call to lobby
backToLobbyBtn.addEventListener("click", async () => {
try { await invoke("disconnect"); } catch {}
function leaveVoice() {
inVoice = false;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>';
joinVoiceBtn.classList.remove("active");
callInProgress = false;
directCallPeer = null;
pendingCallId = null;
voiceDrawer.classList.add("hidden");
joinVoiceBtn.classList.remove("hidden");
vdLevelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby();
});
}
// Hangup
hangupBtn.addEventListener("click", async () => {
// Drawer controls
vdEndBtn.addEventListener("click", async () => {
try { await invoke("hangup_call"); } catch {}
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();
leaveVoice();
});
// Mic/speaker toggles
micBtn.addEventListener("click", async () => {
vdMicBtn.addEventListener("click", async () => {
try { await invoke("toggle_mic"); } catch {}
});
spkBtn.addEventListener("click", async () => {
vdSpkBtn.addEventListener("click", async () => {
try { await invoke("toggle_speaker"); } catch {}
});
@@ -379,38 +381,40 @@ spkBtn.addEventListener("click", async () => {
interface CallStatusI {
active: boolean;
mic_muted: boolean;
speaker_muted: boolean;
send_rms: number;
recv_rms: number;
codec_tx: string;
codec_rx: string;
fec_ratio: number;
send_packets: number;
recv_packets: number;
spk_muted: boolean;
participants: any[];
encode_fps: number;
recv_fps: number;
audio_level: number;
call_duration_secs: number;
fingerprint: string;
tx_codec: string;
rx_codec: string;
}
async function pollStatus() {
try {
const st: CallStatusI = await invoke("get_status");
if (!st.active) {
showLobby();
leaveVoice();
return;
}
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);
levelBar.style.width = `${pct}%`;
// Update drawer controls
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
const m = 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
if (directCallPeer) {
@@ -418,16 +422,23 @@ async function pollStatus() {
const engineOk = [...callDebugBuffer].reverse().find((e) => e.step === "connect:call_engine_started");
if (engineOk) {
if (pathNeg?.details?.use_direct === true) {
dcBadge.textContent = "P2P Direct";
dcBadge.className = "dc-badge";
vdDcBadge.textContent = "P2P Direct";
vdDcBadge.className = "vd-dc-badge direct";
vdBadge.textContent = "P2P";
vdBadge.className = "vd-badge direct";
vdBadge.classList.remove("hidden");
} else {
dcBadge.textContent = "Via Relay";
dcBadge.className = "dc-badge relay";
vdDcBadge.textContent = "Via 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 {}
}
@@ -435,6 +446,20 @@ async function pollStatus() {
listen("signal-event", (event: any) => {
const data = event.payload;
switch (data.type) {
case "presence_list":
// Relay sent updated user list
lobbyUsers.clear();
for (const u of data.users || []) {
if (u.fingerprint === myFingerprint) continue; // don't show self
lobbyUsers.set(u.fingerprint, {
fingerprint: u.fingerprint,
alias: u.alias || null,
inVoice: false,
speaking: false,
});
}
renderLobbyUsers();
break;
case "ringing":
// We placed a call, it's ringing
break;
@@ -470,7 +495,7 @@ listen("signal-event", (event: any) => {
directOnly: s.directOnly || false,
birthdayAttack: s.birthdayAttack || false,
});
showCallScreen(true);
enterVoice(true);
} catch (e: any) {
console.error("connect failed:", e);
}
@@ -481,7 +506,7 @@ listen("signal-event", (event: any) => {
incomingBanner.classList.add("hidden");
(async () => {
try { await invoke("disconnect"); } catch {}
showLobby();
leaveVoice();
})();
break;
}
@@ -533,6 +558,80 @@ listen("call-event", (event: any) => {
});
// ── 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() {
const s = loadSettings();
sRoom.value = s.room;
@@ -548,6 +647,7 @@ function openSettings() {
sQuality.value = String(qi);
updateQualityUI(qi);
sFingerprint.textContent = myFingerprint || "(loading...)";
renderRelayList();
settingsPanel.classList.remove("hidden");
}
@@ -570,6 +670,8 @@ settingsSave.addEventListener("click", () => {
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {});
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
// Update lobby room label
lobbyRoomLabel.textContent = s.room || "general";
settingsPanel.classList.add("hidden");
});
@@ -579,11 +681,17 @@ sCallDebugClearBtn?.addEventListener("click", () => {
sCallDebugLogEl.textContent = "";
});
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(() => {});
});
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 {}
});
@@ -637,13 +745,13 @@ async function autoConnect() {
lobbyDot.style.background = "var(--green)";
lobbyRelayLabel.textContent = `${relay.name} — connected`;
// Get identity
const fp: string = await invoke("get_identity");
if (fp) {
myFingerprint = fp;
lobbyFp.textContent = fp;
// Get identity + alias
const appInfo: any = await invoke("get_app_info");
if (appInfo?.fingerprint) {
myFingerprint = appInfo.fingerprint;
lobbyFp.textContent = appInfo.alias || appInfo.fingerprint;
lobbyIdenticon.innerHTML = "";
lobbyIdenticon.appendChild(createIdenticonEl(fp, 20, true));
lobbyIdenticon.appendChild(createIdenticonEl(appInfo.fingerprint, 20, true));
}
} catch (e: any) {
lobbyDot.style.background = "var(--red)";
@@ -657,9 +765,9 @@ invoke("set_call_debug_logs", { enabled: !!loadSettings().callDebugLogs }).catch
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "m") micBtn.click();
if (e.key === "q") hangupBtn.click();
if (e.key === "s") spkBtn.click();
if (e.key === "m") vdMicBtn.click();
if (e.key === "q") vdEndBtn.click();
if (e.key === "s") vdSpkBtn.click();
if (e.key === "," && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openSettings(); }
});

View File

@@ -238,6 +238,74 @@ body {
.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-banner {
position: fixed;