Compare commits
10 Commits
7e7391fdbb
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217567383d | ||
|
|
98ed981805 | ||
|
|
01a3133544 | ||
|
|
25471c694f | ||
|
|
a058a83c91 | ||
|
|
9b8013ba7f | ||
|
|
defd8eab07 | ||
|
|
cc23e829b2 | ||
|
|
18c204c1ff | ||
|
|
1120c7b579 |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -65,47 +65,40 @@
|
||||
<button id="reject-call-btn" class="btn-reject">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">←</button>
|
||||
<div id="room-name" class="room-name"></div>
|
||||
<button id="settings-btn-call" class="icon-btn small" title="Settings">⚙</button>
|
||||
<!-- ═════ 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 class="vd-level">
|
||||
<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 class="call-meta">
|
||||
<span id="call-status" class="status-dot"></span>
|
||||
<span id="call-timer" class="call-timer">0:00</span>
|
||||
<!-- 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 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>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,115 +271,109 @@ 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">🎧</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 s = loadSettings();
|
||||
if (!relay) return;
|
||||
try {
|
||||
await invoke("connect", {
|
||||
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">🎧</span><span class="fab-label">Leave Voice</span>';
|
||||
joinVoiceBtn.classList.add("active");
|
||||
showCallScreen(false);
|
||||
} catch (e: any) {
|
||||
console.error("connect failed:", e);
|
||||
}
|
||||
if (inVoice) return;
|
||||
const relay = getRelay();
|
||||
const s = loadSettings();
|
||||
if (!relay) return;
|
||||
try {
|
||||
await invoke("connect", {
|
||||
relay: relay.address,
|
||||
room: s.room || "general",
|
||||
alias: s.alias || "",
|
||||
osAec: s.osAec,
|
||||
quality: s.quality || "auto",
|
||||
});
|
||||
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">🎧</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">🎧</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">×</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(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user