feat: relay server dropdown with status indicators and manage dialog
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s

- Relay selector as dropdown with green/yellow/red status dots
  (green < 200ms, yellow > 200ms, red = offline, gray = unknown)
- All relays pinged on startup, RTT shown next to each
- "Manage Relays..." dialog: add/remove servers, see live status
- Clicking a relay in dropdown selects it, fills connect form
- Recent room chips auto-select matching relay
- Migrates old single-relay settings format automatically
- Prevents connecting to offline relays

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-06 12:44:19 +04:00
parent dddf5d2e2d
commit 61b6e67610
3 changed files with 449 additions and 163 deletions

View File

@@ -14,9 +14,16 @@
<p class="subtitle">Encrypted Voice</p> <p class="subtitle">Encrypted Voice</p>
<div class="form"> <div class="form">
<label>Relay <label>Relay
<div class="relay-row"> <div class="relay-dropdown-wrap">
<input id="relay" type="text" value="193.180.213.68:4433" /> <button id="relay-selected" class="relay-selected" type="button">
<span id="relay-status" class="relay-status"></span> <span id="relay-dot" class="dot"></span>
<span id="relay-label">Select relay...</span>
<span class="arrow">&#9662;</span>
</button>
<div id="relay-menu" class="relay-menu hidden">
<div id="relay-list"></div>
<button id="relay-manage-btn" class="relay-manage-btn" type="button">Manage Relays...</button>
</div>
</div> </div>
</label> </label>
<label>Room <label>Room
@@ -53,14 +60,10 @@
<span id="call-timer" class="call-timer">0:00</span> <span id="call-timer" class="call-timer">0:00</span>
</div> </div>
</div> </div>
<!-- Audio level meter -->
<div class="level-meter"> <div class="level-meter">
<div id="level-bar" class="level-bar-fill"></div> <div id="level-bar" class="level-bar-fill"></div>
</div> </div>
<div id="participants" class="participants"></div> <div id="participants" class="participants"></div>
<div class="controls"> <div class="controls">
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)"> <button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
<span class="icon" id="mic-icon">Mic</span> <span class="icon" id="mic-icon">Mic</span>
@@ -72,23 +75,18 @@
<span class="icon" id="spk-icon">Spk</span> <span class="icon" id="spk-icon">Spk</span>
</button> </button>
</div> </div>
<div id="stats" class="stats"></div> <div id="stats" class="stats"></div>
</div> </div>
<!-- Settings panel (overlay) --> <!-- Settings panel -->
<div id="settings-panel" class="hidden"> <div id="settings-panel" class="hidden">
<div class="settings-card"> <div class="settings-card">
<div class="settings-header"> <div class="settings-header">
<h2>Settings</h2> <h2>Settings</h2>
<button id="settings-close" class="icon-btn">&times;</button> <button id="settings-close" class="icon-btn">&times;</button>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Connection</h3> <h3>Connection</h3>
<label>Default Relay
<input id="s-relay" type="text" />
</label>
<label>Default Room <label>Default Room
<input id="s-room" type="text" /> <input id="s-room" type="text" />
</label> </label>
@@ -96,7 +94,6 @@
<input id="s-alias" type="text" /> <input id="s-alias" type="text" />
</label> </label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Audio</h3> <h3>Audio</h3>
<label class="checkbox"> <label class="checkbox">
@@ -108,7 +105,6 @@
Automatic Gain Control Automatic Gain Control
</label> </label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Identity</h3> <h3>Identity</h3>
<div class="setting-row"> <div class="setting-row">
@@ -120,16 +116,30 @@
<span class="fp-display">~/.wzp/identity</span> <span class="fp-display">~/.wzp/identity</span>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Recent Rooms</h3> <h3>Recent Rooms</h3>
<div id="s-recent-rooms" class="recent-rooms-list"></div> <div id="s-recent-rooms" class="recent-rooms-list"></div>
<button id="s-clear-recent" class="secondary-btn">Clear History</button> <button id="s-clear-recent" class="secondary-btn">Clear History</button>
</div> </div>
<button id="settings-save" class="primary">Save</button> <button id="settings-save" class="primary">Save</button>
</div> </div>
</div> </div>
<!-- Manage Relays dialog -->
<div id="relay-dialog" class="hidden">
<div class="settings-card relay-dialog-card">
<div class="settings-header">
<h2>Manage Relays</h2>
<button id="relay-dialog-close" class="icon-btn">&times;</button>
</div>
<div id="relay-dialog-list" class="relay-dialog-list"></div>
<div class="relay-add-row">
<input id="relay-add-name" type="text" placeholder="Name (e.g. EU-1)" />
<input id="relay-add-addr" type="text" placeholder="host:port" />
<button id="relay-add-btn" class="secondary-btn">Add</button>
</div>
</div>
</div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@@ -4,7 +4,6 @@ import { listen } from "@tauri-apps/api/event";
// ── Elements ── // ── Elements ──
const connectScreen = document.getElementById("connect-screen")!; const connectScreen = document.getElementById("connect-screen")!;
const callScreen = document.getElementById("call-screen")!; const callScreen = document.getElementById("call-screen")!;
const relayInput = document.getElementById("relay") as HTMLInputElement;
const roomInput = document.getElementById("room") as HTMLInputElement; const roomInput = document.getElementById("room") as HTMLInputElement;
const aliasInput = document.getElementById("alias") as HTMLInputElement; const aliasInput = document.getElementById("alias") as HTMLInputElement;
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement; const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
@@ -24,12 +23,28 @@ const statsDiv = document.getElementById("stats")!;
const myFingerprintEl = document.getElementById("my-fingerprint")!; const myFingerprintEl = document.getElementById("my-fingerprint")!;
const recentRoomsDiv = document.getElementById("recent-rooms")!; const recentRoomsDiv = document.getElementById("recent-rooms")!;
// Relay dropdown
const relaySelected = document.getElementById("relay-selected")!;
const relayDot = document.getElementById("relay-dot")!;
const relayLabel = document.getElementById("relay-label")!;
const relayMenu = document.getElementById("relay-menu")!;
const relayList = document.getElementById("relay-list")!;
const relayManageBtn = document.getElementById("relay-manage-btn")!;
// Relay dialog
const relayDialog = document.getElementById("relay-dialog")!;
const relayDialogClose = document.getElementById("relay-dialog-close")!;
const relayDialogList = document.getElementById("relay-dialog-list")!;
const relayAddName = document.getElementById("relay-add-name") as HTMLInputElement;
const relayAddAddr = document.getElementById("relay-add-addr") as HTMLInputElement;
const relayAddBtn = document.getElementById("relay-add-btn")!;
// Settings
const settingsPanel = document.getElementById("settings-panel")!; const settingsPanel = document.getElementById("settings-panel")!;
const settingsClose = document.getElementById("settings-close")!; const settingsClose = document.getElementById("settings-close")!;
const settingsSave = document.getElementById("settings-save")!; const settingsSave = document.getElementById("settings-save")!;
const settingsBtnHome = document.getElementById("settings-btn-home")!; const settingsBtnHome = document.getElementById("settings-btn-home")!;
const settingsBtnCall = document.getElementById("settings-btn-call")!; const settingsBtnCall = document.getElementById("settings-btn-call")!;
const sRelay = document.getElementById("s-relay") as HTMLInputElement;
const sRoom = document.getElementById("s-room") as HTMLInputElement; const sRoom = document.getElementById("s-room") as HTMLInputElement;
const sAlias = document.getElementById("s-alias") as HTMLInputElement; const sAlias = document.getElementById("s-alias") as HTMLInputElement;
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement; const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
@@ -40,16 +55,23 @@ const sClearRecent = document.getElementById("s-clear-recent")!;
let statusInterval: number | null = null; let statusInterval: number | null = null;
let myFingerprint = ""; let myFingerprint = "";
let userDisconnected = false; // true when user clicks hangup (no auto-reconnect) let userDisconnected = false;
// ── Data types ──
interface RelayServer {
name: string;
address: string;
rtt?: number | null; // null = unknown, -1 = offline
}
// ── Settings persistence ──
interface RecentRoom { interface RecentRoom {
relay: string; relay: string;
room: string; room: string;
} }
interface Settings { interface Settings {
relay: string; relays: RelayServer[];
selectedRelay: number; // index into relays
room: string; room: string;
alias: string; alias: string;
osAec: boolean; osAec: boolean;
@@ -59,7 +81,8 @@ interface Settings {
function loadSettings(): Settings { function loadSettings(): Settings {
const defaults: Settings = { const defaults: Settings = {
relay: "193.180.213.68:4433", relays: [{ name: "Default", address: "193.180.213.68:4433" }],
selectedRelay: 0,
room: "android", room: "android",
alias: "", alias: "",
osAec: true, osAec: true,
@@ -70,9 +93,16 @@ function loadSettings(): Settings {
const raw = localStorage.getItem("wzp-settings"); const raw = localStorage.getItem("wzp-settings");
if (raw) { if (raw) {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
// Migrate old string[] recentRooms to RecentRoom[] // Migrate: old format had relay as string
if (parsed.recentRooms && parsed.recentRooms.length > 0 && typeof parsed.recentRooms[0] === "string") { if (parsed.relay && !parsed.relays) {
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: parsed.relay || defaults.relay, room: r })); parsed.relays = [{ name: "Default", address: parsed.relay }];
parsed.selectedRelay = 0;
delete parsed.relay;
}
// Migrate: old recentRooms as string[]
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
} }
return { ...defaults, ...parsed }; return { ...defaults, ...parsed };
} }
@@ -80,80 +110,193 @@ function loadSettings(): Settings {
return defaults; return defaults;
} }
function saveSettings() { function saveSettingsObj(s: Settings) {
const s = loadSettings();
s.relay = relayInput.value;
s.room = roomInput.value;
s.alias = aliasInput.value;
s.osAec = osAecCheckbox.checked;
// Add (relay, room) pair to recent list (dedup, max 5)
const relay = relayInput.value.trim();
const room = roomInput.value.trim();
if (room) {
const entry: RecentRoom = { relay, room };
s.recentRooms = [
entry,
...s.recentRooms.filter((r) => !(r.relay === relay && r.room === room)),
].slice(0, 5);
}
localStorage.setItem("wzp-settings", JSON.stringify(s)); localStorage.setItem("wzp-settings", JSON.stringify(s));
} }
function getSelectedRelay(): RelayServer | undefined {
const s = loadSettings();
return s.relays[s.selectedRelay];
}
// ── Apply settings to form ──
function applySettings() { function applySettings() {
const s = loadSettings(); const s = loadSettings();
relayInput.value = s.relay;
roomInput.value = s.room; roomInput.value = s.room;
aliasInput.value = s.alias; aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec; osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms); renderRecentRooms(s.recentRooms);
renderRelayDropdown();
} }
// ── Relay dropdown ──
function dotClass(rtt: number | null | undefined): string {
if (rtt === undefined || rtt === null) return "gray";
if (rtt < 0) return "red";
if (rtt > 200) return "yellow";
return "green";
}
function rttText(rtt: number | null | undefined): string {
if (rtt === undefined || rtt === null) return "";
if (rtt < 0) return "offline";
return `${rtt}ms`;
}
function renderRelayDropdown() {
const s = loadSettings();
const sel = s.relays[s.selectedRelay];
if (sel) {
relayDot.className = `dot ${dotClass(sel.rtt)}`;
relayLabel.textContent = `${sel.name} (${sel.address})`;
} else {
relayDot.className = "dot gray";
relayLabel.textContent = "No relay configured";
}
// Menu items
relayList.innerHTML = s.relays
.map((r, i) => `
<div class="relay-menu-item ${i === s.selectedRelay ? "active" : ""}" data-idx="${i}">
<span class="dot ${dotClass(r.rtt)}"></span>
<div class="relay-info">
<div class="relay-name">${escapeHtml(r.name)}</div>
<div class="relay-addr">${escapeHtml(r.address)}</div>
</div>
<span class="relay-rtt">${rttText(r.rtt)}</span>
</div>`)
.join("");
relayList.querySelectorAll(".relay-menu-item").forEach((el) => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.idx || "0");
const s = loadSettings();
s.selectedRelay = idx;
saveSettingsObj(s);
relayMenu.classList.add("hidden");
renderRelayDropdown();
});
});
}
relaySelected.addEventListener("click", (e) => {
e.stopPropagation();
relayMenu.classList.toggle("hidden");
});
document.addEventListener("click", () => {
relayMenu.classList.add("hidden");
});
relayMenu.addEventListener("click", (e) => e.stopPropagation());
relayManageBtn.addEventListener("click", () => {
relayMenu.classList.add("hidden");
openRelayDialog();
});
// ── Relay manage dialog ──
function openRelayDialog() {
renderRelayDialogList();
relayAddName.value = "";
relayAddAddr.value = "";
relayDialog.classList.remove("hidden");
}
function closeRelayDialog() {
relayDialog.classList.add("hidden");
renderRelayDropdown();
}
function renderRelayDialogList() {
const s = loadSettings();
relayDialogList.innerHTML = s.relays
.map((r, i) => `
<div class="relay-dialog-item" data-idx="${i}">
<span class="dot ${dotClass(r.rtt)}"></span>
<div class="relay-info">
<div class="relay-name">${escapeHtml(r.name)}</div>
<div class="relay-addr">${escapeHtml(r.address)}</div>
</div>
<span class="relay-rtt">${rttText(r.rtt)}</span>
<button class="remove" data-idx="${i}">&times;</button>
</div>`)
.join("");
relayDialogList.querySelectorAll(".remove").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const s = loadSettings();
s.relays.splice(idx, 1);
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
saveSettingsObj(s);
renderRelayDialogList();
});
});
}
relayAddBtn.addEventListener("click", () => {
const name = relayAddName.value.trim();
const addr = relayAddAddr.value.trim();
if (!addr) return;
const s = loadSettings();
s.relays.push({ name: name || addr, address: addr });
saveSettingsObj(s);
relayAddName.value = "";
relayAddAddr.value = "";
renderRelayDialogList();
pingAllRelays();
});
relayDialogClose.addEventListener("click", closeRelayDialog);
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
// ── Ping all relays ──
async function pingAllRelays() {
const s = loadSettings();
for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i];
try {
const rtt: number = await invoke("ping_relay", { relay: r.address });
r.rtt = rtt;
} catch {
r.rtt = -1;
}
}
saveSettingsObj(s);
renderRelayDropdown();
// Also update dialog if open
if (!relayDialog.classList.contains("hidden")) {
renderRelayDialogList();
}
}
// ── Recent rooms ──
function renderRecentRooms(rooms: RecentRoom[]) { function renderRecentRooms(rooms: RecentRoom[]) {
recentRoomsDiv.innerHTML = rooms recentRoomsDiv.innerHTML = rooms
.map( .map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
(r) =>
`<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`
)
.join(""); .join("");
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => { recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const ds = (el as HTMLElement).dataset; const ds = (el as HTMLElement).dataset;
roomInput.value = ds.room || ""; roomInput.value = ds.room || "";
relayInput.value = ds.relay || relayInput.value; // Select matching relay
const s = loadSettings();
const idx = s.relays.findIndex((r) => r.address === ds.relay);
if (idx >= 0) {
s.selectedRelay = idx;
saveSettingsObj(s);
renderRelayDropdown();
}
}); });
}); });
} }
// ── Init ──
applySettings(); applySettings();
setTimeout(pingAllRelays, 300);
// ── Relay ping ── // Load fingerprint at startup
const relayStatusEl = document.getElementById("relay-status")!;
let pingDebounce: number | null = null;
async function pingRelay(address: string) {
relayStatusEl.textContent = "...";
relayStatusEl.className = "relay-status pinging";
try {
const rtt: number = await invoke("ping_relay", { relay: address });
relayStatusEl.textContent = `${rtt}ms`;
relayStatusEl.className = "relay-status online";
connectBtn.disabled = false;
} catch {
relayStatusEl.textContent = "offline";
relayStatusEl.className = "relay-status offline";
}
}
// Ping on load and when relay input changes
relayInput.addEventListener("input", () => {
if (pingDebounce) clearTimeout(pingDebounce);
pingDebounce = window.setTimeout(() => pingRelay(relayInput.value), 500);
});
// Initial ping
setTimeout(() => pingRelay(relayInput.value), 300);
// ── Load fingerprint at startup (no connection needed) ──
(async () => { (async () => {
try { try {
const fp: string = await invoke("get_identity"); const fp: string = await invoke("get_identity");
@@ -163,38 +306,56 @@ setTimeout(() => pingRelay(relayInput.value), 300);
})(); })();
// Click fingerprint to copy // Click fingerprint to copy
myFingerprintEl.addEventListener("click", copyFingerprint); function copyFingerprint(el: HTMLElement) {
myFingerprintEl.style.cursor = "pointer";
sFingerprint.addEventListener("click", copyFingerprint);
sFingerprint.style.cursor = "pointer";
function copyFingerprint() {
if (myFingerprint) { if (myFingerprint) {
navigator.clipboard.writeText(myFingerprint).then(() => { navigator.clipboard.writeText(myFingerprint).then(() => {
const el = document.activeElement === sFingerprint ? sFingerprint : myFingerprintEl;
const orig = el.textContent; const orig = el.textContent;
el.textContent = "Copied!"; el.textContent = "Copied!";
setTimeout(() => { el.textContent = orig; }, 1000); setTimeout(() => { el.textContent = orig; }, 1000);
}); });
} }
} }
myFingerprintEl.addEventListener("click", () => copyFingerprint(myFingerprintEl));
myFingerprintEl.style.cursor = "pointer";
sFingerprint.addEventListener("click", () => copyFingerprint(sFingerprint));
sFingerprint.style.cursor = "pointer";
// ── Connect ── // ── Connect ──
connectBtn.addEventListener("click", doConnect); connectBtn.addEventListener("click", doConnect);
[relayInput, roomInput, aliasInput].forEach((el) => [roomInput, aliasInput].forEach((el) =>
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); }) el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
); );
async function doConnect() { async function doConnect() {
const relay = getSelectedRelay();
if (!relay) {
connectError.textContent = "No relay selected";
return;
}
if (relay.rtt !== undefined && relay.rtt !== null && relay.rtt < 0) {
connectError.textContent = "Relay is offline";
return;
}
connectError.textContent = ""; connectError.textContent = "";
connectBtn.disabled = true; connectBtn.disabled = true;
connectBtn.textContent = "Connecting..."; connectBtn.textContent = "Connecting...";
saveSettings();
userDisconnected = false; userDisconnected = false;
// Save recent room
const s = loadSettings();
s.room = roomInput.value;
s.alias = aliasInput.value;
s.osAec = osAecCheckbox.checked;
const room = roomInput.value.trim();
if (room) {
const entry: RecentRoom = { relay: relay.address, room };
s.recentRooms = [entry, ...s.recentRooms.filter((r) => !(r.relay === relay.address && r.room === room))].slice(0, 5);
}
saveSettingsObj(s);
try { try {
await invoke("connect", { await invoke("connect", {
relay: relayInput.value, relay: relay.address,
room: roomInput.value, room: roomInput.value,
alias: aliasInput.value, alias: aliasInput.value,
osAec: osAecCheckbox.checked, osAec: osAecCheckbox.checked,
@@ -221,13 +382,10 @@ function showConnectScreen() {
connectBtn.disabled = false; connectBtn.disabled = false;
connectBtn.textContent = "Connect"; connectBtn.textContent = "Connect";
levelBar.style.width = "0%"; levelBar.style.width = "0%";
if (statusInterval) { if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
clearInterval(statusInterval);
statusInterval = null;
}
} }
// ── Mute buttons ── // ── Mute / hangup ──
micBtn.addEventListener("click", async () => { micBtn.addEventListener("click", async () => {
try { try {
const muted: boolean = await invoke("toggle_mic"); const muted: boolean = await invoke("toggle_mic");
@@ -250,7 +408,6 @@ hangupBtn.addEventListener("click", async () => {
showConnectScreen(); showConnectScreen();
}); });
// Keyboard shortcuts (only in call, not in inputs)
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (callScreen.classList.contains("hidden")) return; if (callScreen.classList.contains("hidden")) return;
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
@@ -285,26 +442,24 @@ 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) {
// Connection dropped — try auto-reconnect unless user hung up
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) { if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++; reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
callStatus.className = "status-dot reconnecting"; callStatus.className = "status-dot reconnecting";
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`; statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`;
setTimeout(async () => { const relay = getSelectedRelay();
try { if (relay) {
await invoke("connect", { setTimeout(async () => {
relay: relayInput.value, try {
room: roomInput.value, await invoke("connect", {
alias: aliasInput.value, relay: relay.address, room: roomInput.value,
osAec: osAecCheckbox.checked, alias: aliasInput.value, osAec: osAecCheckbox.checked,
}); });
reconnectAttempts = 0; reconnectAttempts = 0;
callStatus.className = "status-dot"; callStatus.className = "status-dot";
} catch { } catch {}
// Will retry on next poll }, delay);
} }
}, delay);
return; return;
} }
reconnectAttempts = 0; reconnectAttempts = 0;
@@ -313,43 +468,36 @@ async function pollStatus() {
} }
reconnectAttempts = 0; reconnectAttempts = 0;
if (st.fingerprint) myFingerprint = st.fingerprint; if (st.fingerprint) myFingerprint = st.fingerprint;
// Mute state
micBtn.classList.toggle("muted", st.mic_muted); micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic"; micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
spkBtn.classList.toggle("muted", st.spk_muted); spkBtn.classList.toggle("muted", st.spk_muted);
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk"; spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
// Timer
callTimer.textContent = formatDuration(st.call_duration_secs); callTimer.textContent = formatDuration(st.call_duration_secs);
// Audio level
const rms = st.audio_level; const rms = st.audio_level;
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0; const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`; levelBar.style.width = `${pct}%`;
// Participants
if (st.participants.length === 0) { if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>'; participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else { } else {
participantsDiv.innerHTML = st.participants participantsDiv.innerHTML = st.participants.map((p) => {
.map((p) => { const name = p.alias || "Anonymous";
const name = p.alias || "Anonymous"; const initial = name.charAt(0).toUpperCase();
const initial = name.charAt(0).toUpperCase(); const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : "";
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : ""; const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint); return `
return ` <div class="participant">
<div class="participant"> <div class="avatar ${isMe ? "me" : ""}">${initial}</div>
<div class="avatar ${isMe ? "me" : ""}">${initial}</div> <div class="info">
<div class="info"> <div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div> <div class="fp">${escapeHtml(fp)}</div>
<div class="fp">${escapeHtml(fp)}</div> </div>
</div> </div>`;
</div>`; }).join("");
})
.join("");
} }
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`; statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
@@ -362,19 +510,15 @@ function escapeHtml(s: string): string {
return d.innerHTML; return d.innerHTML;
} }
// ── Events from backend ──
listen("call-event", (event: any) => { listen("call-event", (event: any) => {
const { kind } = event.payload; const { kind } = event.payload;
if (kind === "room-update") pollStatus(); if (kind === "room-update") pollStatus();
if (kind === "disconnected") { if (kind === "disconnected" && !userDisconnected) pollStatus();
if (!userDisconnected) pollStatus(); // triggers reconnect
}
}); });
// ── Settings panel ── // ── Settings panel ──
function openSettings() { function openSettings() {
const s = loadSettings(); const s = loadSettings();
sRelay.value = s.relay;
sRoom.value = s.room; sRoom.value = s.room;
sAlias.value = s.alias; sAlias.value = s.alias;
sOsAec.checked = s.osAec; sOsAec.checked = s.osAec;
@@ -383,30 +527,24 @@ function openSettings() {
settingsPanel.classList.remove("hidden"); settingsPanel.classList.remove("hidden");
} }
function closeSettings() { function closeSettings() { settingsPanel.classList.add("hidden"); }
settingsPanel.classList.add("hidden");
}
function renderSettingsRecentRooms(rooms: RecentRoom[]) { function renderSettingsRecentRooms(rooms: RecentRoom[]) {
if (rooms.length === 0) { if (rooms.length === 0) {
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>'; sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
return; return;
} }
sRecentRooms.innerHTML = rooms sRecentRooms.innerHTML = rooms.map((r, i) => `
.map( <div class="recent-room-item">
(r, i) => ` <span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
<div class="recent-room-item"> <button class="remove" data-idx="${i}">&times;</button>
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span> </div>`).join("");
<button class="remove" data-idx="${i}">&times;</button>
</div>`
)
.join("");
sRecentRooms.querySelectorAll(".remove").forEach((btn) => { sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const idx = parseInt((btn as HTMLElement).dataset.idx || "0"); const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const s = loadSettings(); const s = loadSettings();
s.recentRooms.splice(idx, 1); s.recentRooms.splice(idx, 1);
localStorage.setItem("wzp-settings", JSON.stringify(s)); saveSettingsObj(s);
renderSettingsRecentRooms(s.recentRooms); renderSettingsRecentRooms(s.recentRooms);
}); });
}); });
@@ -419,12 +557,10 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel)
settingsSave.addEventListener("click", () => { settingsSave.addEventListener("click", () => {
const s = loadSettings(); const s = loadSettings();
s.relay = sRelay.value;
s.room = sRoom.value; s.room = sRoom.value;
s.alias = sAlias.value; s.alias = sAlias.value;
s.osAec = sOsAec.checked; s.osAec = sOsAec.checked;
localStorage.setItem("wzp-settings", JSON.stringify(s)); saveSettingsObj(s);
relayInput.value = s.relay;
roomInput.value = s.room; roomInput.value = s.room;
aliasInput.value = s.alias; aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec; osAecCheckbox.checked = s.osAec;
@@ -435,18 +571,18 @@ settingsSave.addEventListener("click", () => {
sClearRecent.addEventListener("click", () => { sClearRecent.addEventListener("click", () => {
const s = loadSettings(); const s = loadSettings();
s.recentRooms = []; s.recentRooms = [];
localStorage.setItem("wzp-settings", JSON.stringify(s)); saveSettingsObj(s);
renderSettingsRecentRooms([]); renderSettingsRecentRooms([]);
renderRecentRooms([]); renderRecentRooms([]);
}); });
// Cmd+, / Ctrl+, opens settings, Escape closes
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === ",") { if ((e.metaKey || e.ctrlKey) && e.key === ",") {
e.preventDefault(); e.preventDefault();
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings(); settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
} }
if (e.key === "Escape" && !settingsPanel.classList.contains("hidden")) { if (e.key === "Escape") {
closeSettings(); if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
} }
}); });

View File

@@ -89,24 +89,164 @@ body {
border-color: var(--accent); border-color: var(--accent);
} }
.relay-row { /* ── Relay dropdown ── */
.relay-dropdown-wrap {
position: relative;
}
.relay-selected {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%;
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
cursor: pointer;
text-align: left;
transition: border-color 0.2s;
} }
.relay-row input { flex: 1; } .relay-selected:hover { border-color: var(--accent); }
.relay-status { .relay-selected .dot {
font-size: 11px; width: 8px;
white-space: nowrap; height: 8px;
min-width: 50px; border-radius: 50%;
text-align: right; flex-shrink: 0;
} }
.relay-status.online { color: var(--green); } .relay-selected .arrow {
.relay-status.offline { color: var(--red); } margin-left: auto;
.relay-status.pinging { color: var(--text-dim); } font-size: 10px;
color: var(--text-dim);
}
.dot.green { background: var(--green); }
.dot.yellow { background: var(--yellow); }
.dot.red { background: var(--red); }
.dot.gray { background: #555; }
.relay-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid #444;
border-radius: 8px;
overflow: hidden;
z-index: 50;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.relay-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
.relay-menu-item:hover { background: var(--surface2); }
.relay-menu-item.active { background: var(--primary); }
.relay-menu-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.relay-menu-item .relay-info { flex: 1; min-width: 0; }
.relay-menu-item .relay-name { font-weight: 500; }
.relay-menu-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; }
.relay-menu-item .relay-rtt { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
.relay-manage-btn {
display: block;
width: 100%;
padding: 10px;
background: none;
border: none;
border-top: 1px solid #333;
color: var(--accent);
font-size: 13px;
cursor: pointer;
text-align: center;
}
.relay-manage-btn:hover { background: var(--surface2); }
/* ── Relay dialog ── */
#relay-dialog {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.relay-dialog-card {
max-width: 420px;
}
.relay-dialog-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.relay-dialog-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border-radius: 8px;
padding: 8px 12px;
}
.relay-dialog-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.relay-dialog-item .relay-info { flex: 1; }
.relay-dialog-item .relay-name { font-size: 13px; font-weight: 500; }
.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; }
.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
.relay-dialog-item .remove {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
}
.relay-dialog-item .remove:hover { color: var(--red); }
.relay-add-row {
display: flex;
gap: 6px;
margin-top: 12px;
}
.relay-add-row input {
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
flex: 1;
}
.relay-add-row input:focus { border-color: var(--accent); }
.form-row { .form-row {
display: flex; display: flex;