4 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
4 changed files with 239 additions and 164 deletions

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>
</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">
IN-CALL — voice active (room or direct) <div class="vd-info">
═══════════════════════════════════════════════════════ --> <span id="vd-status" class="vd-status-dot"></span>
<div id="call-screen" class="hidden"> <span id="vd-room" class="vd-room">general</span>
<div class="call-header"> <span id="vd-timer" class="vd-timer">0:00</span>
<div class="call-header-row"> <span id="vd-badge" class="vd-badge hidden"></span>
<button id="back-to-lobby-btn" class="icon-btn small" title="Back to lobby">&#x2190;</button> </div>
<div id="room-name" class="room-name"></div> <div class="vd-level">
<button id="settings-btn-call" class="icon-btn small" title="Settings">&#9881;</button> <div id="vd-level-bar" class="vd-level-fill"></div>
</div>
<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="vd-spk-btn" class="vd-btn" title="Speaker (s)">
<span id="vd-spk-icon">Spk</span>
</button>
<button id="vd-end-btn" class="vd-btn vd-end" title="Leave voice (q)">
<span>End</span>
</button>
</div>
</div> </div>
<div class="call-meta"> <!-- Direct call info (shown during P2P calls) -->
<span id="call-status" class="status-dot"></span> <div id="vd-direct-info" class="vd-direct-info hidden">
<span id="call-timer" class="call-timer">0:00</span> <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>
<div id="vd-stats" class="vd-stats"></div>
</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 id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
<span class="icon">End</span>
</button>
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
<span class="icon" id="spk-icon">Spk</span>
</button>
</div>
<div id="stats" class="stats"></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,23 +67,23 @@ 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")!;
@@ -216,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);
@@ -269,115 +271,109 @@ 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 const relay = getRelay();
try { await invoke("disconnect"); } catch {} const s = loadSettings();
inVoice = false; if (!relay) return;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>'; try {
joinVoiceBtn.classList.remove("active"); await invoke("connect", {
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } relay: relay.address,
showLobby(); room: s.room || "general",
} else { alias: s.alias || "",
// Join voice osAec: s.osAec,
const relay = getRelay(); quality: s.quality || "auto",
const s = loadSettings(); });
if (!relay) return; enterVoice(false);
try { } catch (e: any) {
await invoke("connect", { console.error("connect failed:", e);
relay: relay.address,
room: s.room || "general",
alias: s.alias || "",
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);
} catch (e: any) {
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 {}
}); });
@@ -385,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) {
@@ -424,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 {}
} }
@@ -490,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);
} }
@@ -501,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;
} }
@@ -676,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 {}
}); });
@@ -734,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)";
@@ -754,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;