feat: relay server dropdown with status indicators and manage dialog
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s
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:
@@ -14,9 +14,16 @@
|
||||
<p class="subtitle">Encrypted Voice</p>
|
||||
<div class="form">
|
||||
<label>Relay
|
||||
<div class="relay-row">
|
||||
<input id="relay" type="text" value="193.180.213.68:4433" />
|
||||
<span id="relay-status" class="relay-status"></span>
|
||||
<div class="relay-dropdown-wrap">
|
||||
<button id="relay-selected" class="relay-selected" type="button">
|
||||
<span id="relay-dot" class="dot"></span>
|
||||
<span id="relay-label">Select relay...</span>
|
||||
<span class="arrow">▾</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>
|
||||
</label>
|
||||
<label>Room
|
||||
@@ -53,14 +60,10 @@
|
||||
<span id="call-timer" class="call-timer">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio level meter -->
|
||||
<div class="level-meter">
|
||||
<div id="level-bar" class="level-bar-fill"></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -72,23 +75,18 @@
|
||||
<span class="icon" id="spk-icon">Spk</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel (overlay) -->
|
||||
<!-- Settings panel -->
|
||||
<div id="settings-panel" class="hidden">
|
||||
<div class="settings-card">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="settings-close" class="icon-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Connection</h3>
|
||||
<label>Default Relay
|
||||
<input id="s-relay" type="text" />
|
||||
</label>
|
||||
<label>Default Room
|
||||
<input id="s-room" type="text" />
|
||||
</label>
|
||||
@@ -96,7 +94,6 @@
|
||||
<input id="s-alias" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Audio</h3>
|
||||
<label class="checkbox">
|
||||
@@ -108,7 +105,6 @@
|
||||
Automatic Gain Control
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Identity</h3>
|
||||
<div class="setting-row">
|
||||
@@ -120,16 +116,30 @@
|
||||
<span class="fp-display">~/.wzp/identity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Recent Rooms</h3>
|
||||
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
||||
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
|
||||
</div>
|
||||
|
||||
<button id="settings-save" class="primary">Save</button>
|
||||
</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">×</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>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { listen } from "@tauri-apps/api/event";
|
||||
// ── Elements ──
|
||||
const connectScreen = document.getElementById("connect-screen")!;
|
||||
const callScreen = document.getElementById("call-screen")!;
|
||||
const relayInput = document.getElementById("relay") as HTMLInputElement;
|
||||
const roomInput = document.getElementById("room") as HTMLInputElement;
|
||||
const aliasInput = document.getElementById("alias") 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 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 settingsClose = document.getElementById("settings-close")!;
|
||||
const settingsSave = document.getElementById("settings-save")!;
|
||||
const settingsBtnHome = document.getElementById("settings-btn-home")!;
|
||||
const settingsBtnCall = document.getElementById("settings-btn-call")!;
|
||||
const sRelay = document.getElementById("s-relay") as HTMLInputElement;
|
||||
const sRoom = document.getElementById("s-room") as HTMLInputElement;
|
||||
const sAlias = document.getElementById("s-alias") 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 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 {
|
||||
relay: string;
|
||||
room: string;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
relay: string;
|
||||
relays: RelayServer[];
|
||||
selectedRelay: number; // index into relays
|
||||
room: string;
|
||||
alias: string;
|
||||
osAec: boolean;
|
||||
@@ -59,7 +81,8 @@ interface Settings {
|
||||
|
||||
function loadSettings(): Settings {
|
||||
const defaults: Settings = {
|
||||
relay: "193.180.213.68:4433",
|
||||
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
|
||||
selectedRelay: 0,
|
||||
room: "android",
|
||||
alias: "",
|
||||
osAec: true,
|
||||
@@ -70,9 +93,16 @@ function loadSettings(): Settings {
|
||||
const raw = localStorage.getItem("wzp-settings");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
// Migrate old string[] recentRooms to RecentRoom[]
|
||||
if (parsed.recentRooms && parsed.recentRooms.length > 0 && typeof parsed.recentRooms[0] === "string") {
|
||||
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: parsed.relay || defaults.relay, room: r }));
|
||||
// Migrate: old format had relay as string
|
||||
if (parsed.relay && !parsed.relays) {
|
||||
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 };
|
||||
}
|
||||
@@ -80,80 +110,193 @@ function loadSettings(): Settings {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
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);
|
||||
}
|
||||
function saveSettingsObj(s: Settings) {
|
||||
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() {
|
||||
const s = loadSettings();
|
||||
relayInput.value = s.relay;
|
||||
roomInput.value = s.room;
|
||||
aliasInput.value = s.alias;
|
||||
osAecCheckbox.checked = s.osAec;
|
||||
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}">×</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[]) {
|
||||
recentRoomsDiv.innerHTML = rooms
|
||||
.map(
|
||||
(r) =>
|
||||
`<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`
|
||||
)
|
||||
.map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
|
||||
.join("");
|
||||
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const ds = (el as HTMLElement).dataset;
|
||||
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();
|
||||
setTimeout(pingAllRelays, 300);
|
||||
|
||||
// ── Relay ping ──
|
||||
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) ──
|
||||
// Load fingerprint at startup
|
||||
(async () => {
|
||||
try {
|
||||
const fp: string = await invoke("get_identity");
|
||||
@@ -163,38 +306,56 @@ setTimeout(() => pingRelay(relayInput.value), 300);
|
||||
})();
|
||||
|
||||
// Click fingerprint to copy
|
||||
myFingerprintEl.addEventListener("click", copyFingerprint);
|
||||
myFingerprintEl.style.cursor = "pointer";
|
||||
sFingerprint.addEventListener("click", copyFingerprint);
|
||||
sFingerprint.style.cursor = "pointer";
|
||||
|
||||
function copyFingerprint() {
|
||||
function copyFingerprint(el: HTMLElement) {
|
||||
if (myFingerprint) {
|
||||
navigator.clipboard.writeText(myFingerprint).then(() => {
|
||||
const el = document.activeElement === sFingerprint ? sFingerprint : myFingerprintEl;
|
||||
const orig = el.textContent;
|
||||
el.textContent = "Copied!";
|
||||
setTimeout(() => { el.textContent = orig; }, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
myFingerprintEl.addEventListener("click", () => copyFingerprint(myFingerprintEl));
|
||||
myFingerprintEl.style.cursor = "pointer";
|
||||
sFingerprint.addEventListener("click", () => copyFingerprint(sFingerprint));
|
||||
sFingerprint.style.cursor = "pointer";
|
||||
|
||||
// ── Connect ──
|
||||
connectBtn.addEventListener("click", doConnect);
|
||||
[relayInput, roomInput, aliasInput].forEach((el) =>
|
||||
[roomInput, aliasInput].forEach((el) =>
|
||||
el.addEventListener("keydown", (e) => { if (e.key === "Enter") 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 = "";
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = "Connecting...";
|
||||
saveSettings();
|
||||
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 {
|
||||
await invoke("connect", {
|
||||
relay: relayInput.value,
|
||||
relay: relay.address,
|
||||
room: roomInput.value,
|
||||
alias: aliasInput.value,
|
||||
osAec: osAecCheckbox.checked,
|
||||
@@ -221,13 +382,10 @@ function showConnectScreen() {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = "Connect";
|
||||
levelBar.style.width = "0%";
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||
}
|
||||
|
||||
// ── Mute buttons ──
|
||||
// ── Mute / hangup ──
|
||||
micBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const muted: boolean = await invoke("toggle_mic");
|
||||
@@ -250,7 +408,6 @@ hangupBtn.addEventListener("click", async () => {
|
||||
showConnectScreen();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts (only in call, not in inputs)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (callScreen.classList.contains("hidden")) return;
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
@@ -285,26 +442,24 @@ async function pollStatus() {
|
||||
try {
|
||||
const st: CallStatusI = await invoke("get_status");
|
||||
if (!st.active) {
|
||||
// Connection dropped — try auto-reconnect unless user hung up
|
||||
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) {
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
||||
callStatus.className = "status-dot reconnecting";
|
||||
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`;
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await invoke("connect", {
|
||||
relay: relayInput.value,
|
||||
room: roomInput.value,
|
||||
alias: aliasInput.value,
|
||||
osAec: osAecCheckbox.checked,
|
||||
});
|
||||
reconnectAttempts = 0;
|
||||
callStatus.className = "status-dot";
|
||||
} catch {
|
||||
// Will retry on next poll
|
||||
}
|
||||
}, delay);
|
||||
const relay = getSelectedRelay();
|
||||
if (relay) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await invoke("connect", {
|
||||
relay: relay.address, room: roomInput.value,
|
||||
alias: aliasInput.value, osAec: osAecCheckbox.checked,
|
||||
});
|
||||
reconnectAttempts = 0;
|
||||
callStatus.className = "status-dot";
|
||||
} catch {}
|
||||
}, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reconnectAttempts = 0;
|
||||
@@ -313,43 +468,36 @@ async function pollStatus() {
|
||||
}
|
||||
|
||||
reconnectAttempts = 0;
|
||||
|
||||
if (st.fingerprint) myFingerprint = st.fingerprint;
|
||||
|
||||
// Mute state
|
||||
micBtn.classList.toggle("muted", st.mic_muted);
|
||||
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
||||
spkBtn.classList.toggle("muted", st.spk_muted);
|
||||
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
||||
|
||||
// Timer
|
||||
callTimer.textContent = formatDuration(st.call_duration_secs);
|
||||
|
||||
// Audio level
|
||||
const rms = st.audio_level;
|
||||
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
||||
levelBar.style.width = `${pct}%`;
|
||||
|
||||
// Participants
|
||||
if (st.participants.length === 0) {
|
||||
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||
} else {
|
||||
participantsDiv.innerHTML = st.participants
|
||||
.map((p) => {
|
||||
const name = p.alias || "Anonymous";
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : "";
|
||||
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
|
||||
return `
|
||||
<div class="participant">
|
||||
<div class="avatar ${isMe ? "me" : ""}">${initial}</div>
|
||||
<div class="info">
|
||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||
<div class="fp">${escapeHtml(fp)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
participantsDiv.innerHTML = st.participants.map((p) => {
|
||||
const name = p.alias || "Anonymous";
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : "";
|
||||
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
|
||||
return `
|
||||
<div class="participant">
|
||||
<div class="avatar ${isMe ? "me" : ""}">${initial}</div>
|
||||
<div class="info">
|
||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||
<div class="fp">${escapeHtml(fp)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
||||
@@ -362,19 +510,15 @@ function escapeHtml(s: string): string {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Events from backend ──
|
||||
listen("call-event", (event: any) => {
|
||||
const { kind } = event.payload;
|
||||
if (kind === "room-update") pollStatus();
|
||||
if (kind === "disconnected") {
|
||||
if (!userDisconnected) pollStatus(); // triggers reconnect
|
||||
}
|
||||
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
||||
});
|
||||
|
||||
// ── Settings panel ──
|
||||
function openSettings() {
|
||||
const s = loadSettings();
|
||||
sRelay.value = s.relay;
|
||||
sRoom.value = s.room;
|
||||
sAlias.value = s.alias;
|
||||
sOsAec.checked = s.osAec;
|
||||
@@ -383,30 +527,24 @@ function openSettings() {
|
||||
settingsPanel.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
settingsPanel.classList.add("hidden");
|
||||
}
|
||||
function closeSettings() { settingsPanel.classList.add("hidden"); }
|
||||
|
||||
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
||||
if (rooms.length === 0) {
|
||||
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
|
||||
return;
|
||||
}
|
||||
sRecentRooms.innerHTML = rooms
|
||||
.map(
|
||||
(r, i) => `
|
||||
<div class="recent-room-item">
|
||||
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
||||
<button class="remove" data-idx="${i}">×</button>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
sRecentRooms.innerHTML = rooms.map((r, i) => `
|
||||
<div class="recent-room-item">
|
||||
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
||||
<button class="remove" data-idx="${i}">×</button>
|
||||
</div>`).join("");
|
||||
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
|
||||
const s = loadSettings();
|
||||
s.recentRooms.splice(idx, 1);
|
||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
||||
saveSettingsObj(s);
|
||||
renderSettingsRecentRooms(s.recentRooms);
|
||||
});
|
||||
});
|
||||
@@ -419,12 +557,10 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel)
|
||||
|
||||
settingsSave.addEventListener("click", () => {
|
||||
const s = loadSettings();
|
||||
s.relay = sRelay.value;
|
||||
s.room = sRoom.value;
|
||||
s.alias = sAlias.value;
|
||||
s.osAec = sOsAec.checked;
|
||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
||||
relayInput.value = s.relay;
|
||||
saveSettingsObj(s);
|
||||
roomInput.value = s.room;
|
||||
aliasInput.value = s.alias;
|
||||
osAecCheckbox.checked = s.osAec;
|
||||
@@ -435,18 +571,18 @@ settingsSave.addEventListener("click", () => {
|
||||
sClearRecent.addEventListener("click", () => {
|
||||
const s = loadSettings();
|
||||
s.recentRooms = [];
|
||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
||||
saveSettingsObj(s);
|
||||
renderSettingsRecentRooms([]);
|
||||
renderRecentRooms([]);
|
||||
});
|
||||
|
||||
// Cmd+, / Ctrl+, opens settings, Escape closes
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
|
||||
e.preventDefault();
|
||||
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
|
||||
}
|
||||
if (e.key === "Escape" && !settingsPanel.classList.contains("hidden")) {
|
||||
closeSettings();
|
||||
if (e.key === "Escape") {
|
||||
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
|
||||
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,24 +89,164 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.relay-row {
|
||||
/* ── Relay dropdown ── */
|
||||
.relay-dropdown-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.relay-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
.relay-selected .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.relay-status.online { color: var(--green); }
|
||||
.relay-status.offline { color: var(--red); }
|
||||
.relay-status.pinging { color: var(--text-dim); }
|
||||
.relay-selected .arrow {
|
||||
margin-left: auto;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user