feat(direct-call): call history, recent contacts, deregister button
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m41s

Persistent JSON-backed call history for the direct-call screen so users
can see what they've placed / received / missed and dial back with one
click. Also fixes two small latent UX issues reported alongside.

Backend (Rust)
- new crate/module desktop/src-tauri/src/history.rs: thread-safe in-
  process store (OnceLock<RwLock<Vec<CallHistoryEntry>>>) backed by
  <APP_DATA_DIR>/call_history.json. Atomic writes via temp+rename. Max
  200 entries, FIFO pruning. CallDirection { Placed, Received, Missed }.
- Log hooks in the signal loop + commands:
    * place_call     → Placed entry (with target fingerprint)
    * DirectCallOffer → Missed entry up front; upgraded to Received
                        inside answer_call when accept_mode != Reject
                        via history::mark_received_if_pending(call_id).
                        If user rejects or never answers, it stays Missed.
- New Tauri commands:
    * get_call_history()     → all entries, newest first
    * get_recent_contacts()  → unique peers by fp, newest interaction first
    * clear_call_history()   → wipes JSON + in-memory
    * deregister()           → tears down signal transport + endpoint
  Backend emits `history-changed` events so the UI can live-refresh
  without polling.

Frontend (main.ts + index.html + style.css)
- Direct-call panel now has:
    * Recent contacts chip row (top 6 unique peers). Click a chip → dial.
    * Call history list (up to 50 rows). Direction icon (↗ placed, ↙
      received, ✗ missed), peer alias/fp, relative timestamp, callback
      button. Both click handlers populate target-fp and fire place_call.
    * Deregister button in the "registered" header — calls the new
      deregister command, tears down the signal transport, returns the
      UI to the pre-register state.
    * Clear-history link in the history header.
- Subscribes to `history-changed` events so the list updates the moment
  the backend logs a new entry. Also refreshed on register + after a
  clear.
- Nothing is rendered until there is data — empty sections stay hidden.

Tasks #20 + #21 (small UX items bundled in)
- Default room "general" for new installations: the html input value
  attribute is now "general" and loadSettings() defaults match. Existing
  users' localStorage still wins.
- Random alias on desktop: already latent but confirmed working — the
  startup IIFE at main.ts:374 calls get_app_info() and prefills the
  alias input from derive_alias(seed) when the input is empty. No code
  change needed, just verified it flows through the same path as the
  Android client.

Known follow-ups (deferred to step 6 polish)
- Call duration tracking (currently all entries have no duration field)
- Hangup signal from an unanswered incoming should emit history-changed
  so the missed state is visible even when the user never tapped accept
- Android UI layout fit-check on the smaller Nothing screen
This commit is contained in:
Siavash Sameni
2026-04-10 11:03:36 +04:00
parent 76a4c53e21
commit 510eae2089
5 changed files with 525 additions and 7 deletions

View File

@@ -21,7 +21,7 @@
</button>
</label>
<label>Room
<input id="room" type="text" value="android" />
<input id="room" type="text" value="general" />
</label>
<label>Alias
<input id="alias" type="text" placeholder="your name" />
@@ -48,7 +48,10 @@
<div id="direct-mode" class="hidden">
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
<div id="direct-registered" class="hidden" style="margin-top:12px">
<p style="color:var(--green);font-size:13px">&#x2705; Registered — waiting for calls</p>
<div class="direct-registered-header">
<p style="color:var(--green);font-size:13px;margin:0">&#x2705; Registered — waiting for calls</p>
<button id="deregister-btn" class="secondary-btn small">Deregister</button>
</div>
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
<p style="font-weight:bold;margin:0 0 4px 0">Incoming Call</p>
<p id="incoming-caller" style="font-size:12px;opacity:0.8;margin:0 0 8px 0">From: unknown</p>
@@ -57,6 +60,22 @@
<button id="reject-call-btn" style="flex:1;background:var(--red);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Reject</button>
</div>
</div>
<!-- Recent contacts -->
<div id="recent-contacts-section" class="hidden">
<div class="history-header">Recent contacts</div>
<div id="recent-contacts-list" class="history-list"></div>
</div>
<!-- Call history -->
<div id="call-history-section" class="hidden">
<div class="history-header">
History
<button id="clear-history-btn" class="link-btn">clear</button>
</div>
<div id="call-history-list" class="history-list"></div>
</div>
<label style="margin-top:8px">Call by fingerprint
<input id="target-fp" type="text" placeholder="xxxx:xxxx:xxxx:..." />
</label>

View File

@@ -0,0 +1,161 @@
//! Call history store.
//!
//! Keeps a rolling JSON file of the last N direct-call events so the UI can
//! show "recent contacts" + "call history with callback buttons" on the
//! direct-call screen. Storage lives in `<APP_DATA_DIR>/call_history.json`
//! alongside the identity file. The file is read lazily on first access and
//! cached in an RwLock behind a OnceLock.
//!
//! This is a v1 — no duration tracking yet, entries are logged at the
//! moment the direction is decided (placed / received / missed).
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
/// Maximum number of history entries we keep. Older ones are pruned FIFO.
const MAX_ENTRIES: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CallDirection {
/// Local user placed the call.
Placed,
/// Remote user called and local user answered.
Received,
/// Remote user called but local user did not answer (rejected or
/// missed entirely — the UI treats these identically).
Missed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallHistoryEntry {
pub call_id: String,
pub peer_fp: String,
pub peer_alias: Option<String>,
pub direction: CallDirection,
/// Seconds since UNIX epoch, UTC.
pub timestamp_unix: u64,
}
// ─── In-process store (loaded from disk once) ─────────────────────────────
static STORE: OnceLock<RwLock<Vec<CallHistoryEntry>>> = OnceLock::new();
fn store() -> &'static RwLock<Vec<CallHistoryEntry>> {
STORE.get_or_init(|| RwLock::new(load_from_disk()))
}
fn history_path() -> PathBuf {
crate::APP_DATA_DIR
.get()
.cloned()
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".wzp")
})
.join("call_history.json")
}
fn load_from_disk() -> Vec<CallHistoryEntry> {
let path = history_path();
let Ok(bytes) = std::fs::read(&path) else {
return Vec::new();
};
serde_json::from_slice::<Vec<CallHistoryEntry>>(&bytes)
.inspect_err(|e| tracing::warn!(path = %path.display(), error = %e, "call_history.json parse failed"))
.unwrap_or_default()
}
fn save_to_disk(entries: &[CallHistoryEntry]) {
let path = history_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(json) = serde_json::to_vec_pretty(entries) else { return };
// Atomic write via temp file + rename so a crash mid-write doesn't
// leave us with a half-file on disk.
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ─── Public API ───────────────────────────────────────────────────────────
/// Append a new entry to the store and persist to disk. Trims the store to
/// `MAX_ENTRIES` after insertion.
pub fn log(
call_id: String,
peer_fp: String,
peer_alias: Option<String>,
direction: CallDirection,
) {
let entry = CallHistoryEntry {
call_id,
peer_fp,
peer_alias,
direction,
timestamp_unix: now_unix(),
};
let mut guard = store().write().unwrap();
guard.push(entry);
if guard.len() > MAX_ENTRIES {
let drop_n = guard.len() - MAX_ENTRIES;
guard.drain(0..drop_n);
}
save_to_disk(&guard);
}
/// Return a copy of all entries in reverse-chronological order
/// (most recent first).
pub fn all() -> Vec<CallHistoryEntry> {
let guard = store().read().unwrap();
guard.iter().rev().cloned().collect()
}
/// Unique peer contacts sorted by most recent interaction. Each contact
/// is represented by the newest history entry for that fingerprint.
pub fn contacts() -> Vec<CallHistoryEntry> {
let guard = store().read().unwrap();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out = Vec::new();
// iterate newest → oldest
for entry in guard.iter().rev() {
if seen.insert(entry.peer_fp.clone()) {
out.push(entry.clone());
}
}
out
}
/// Clear the entire history and persist the empty file.
pub fn clear() {
let mut guard = store().write().unwrap();
guard.clear();
save_to_disk(&guard);
}
/// Find a Missed-candidate entry that matches `call_id` and hasn't been
/// answered yet. Used by the signal loop to turn "pending incoming" into
/// "Received" when the user accepts.
pub fn mark_received_if_pending(call_id: &str) -> bool {
let mut guard = store().write().unwrap();
for entry in guard.iter_mut().rev() {
if entry.call_id == call_id && entry.direction == CallDirection::Missed {
entry.direction = CallDirection::Received;
save_to_disk(&guard);
return true;
}
}
false
}

View File

@@ -21,6 +21,9 @@ mod wzp_native;
#[cfg(target_os = "android")]
mod android_audio;
// Direct-call history store (persisted JSON in app data dir).
mod history;
// CallEngine has a unified impl on both targets now — the Android branch of
// CallEngine::start() routes audio through the standalone wzp-native cdylib
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
@@ -414,6 +417,24 @@ async fn is_speakerphone_on() -> Result<bool, String> {
}
}
// ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command]
fn get_call_history() -> Vec<history::CallHistoryEntry> {
history::all()
}
#[tauri::command]
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
history::contacts()
}
#[tauri::command]
fn clear_call_history() -> Result<(), String> {
history::clear();
Ok(())
}
// ─── Signaling commands — platform independent ───────────────────────────────
struct SignalState {
@@ -479,7 +500,18 @@ async fn register_signal(
tracing::info!(%call_id, caller = %caller_fingerprint, "signal: DirectCallOffer");
let mut sig = signal_state.lock().await; sig.signal_status = "incoming".into();
sig.incoming_call_id = Some(call_id.clone()); sig.incoming_caller_fp = Some(caller_fingerprint.clone()); sig.incoming_caller_alias = caller_alias.clone();
// Log as a Missed entry up-front. If the user accepts
// the call, answer_call upgrades it to Received via
// history::mark_received_if_pending(call_id). If they
// reject or ignore, it stays Missed.
history::log(
call_id.clone(),
caller_fingerprint.clone(),
caller_alias.clone(),
history::CallDirection::Missed,
);
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
let _ = app_clone.emit("history-changed", ());
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
tracing::info!(%call_id, ?accept_mode, "signal: DirectCallAnswer (forwarded by relay)");
@@ -514,22 +546,33 @@ async fn register_signal(
}
#[tauri::command]
async fn place_call(state: tauri::State<'_, Arc<AppState>>, target_fp: String) -> Result<(), String> {
async fn place_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
target_fp: String,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or("not registered")?;
let call_id = format!("{:016x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
tracing::info!(%call_id, %target_fp, "place_call: sending DirectCallOffer");
transport.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp,
call_id, identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp.clone(),
call_id: call_id.clone(), identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
}).await.map_err(|e| format!("{e}"))?;
history::log(call_id, target_fp, None, history::CallDirection::Placed);
let _ = app.emit("history-changed", ());
Ok(())
}
#[tauri::command]
async fn answer_call(state: tauri::State<'_, Arc<AppState>>, call_id: String, mode: i32) -> Result<(), String> {
async fn answer_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
call_id: String,
mode: i32,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or_else(|| {
@@ -546,6 +589,13 @@ async fn answer_call(state: tauri::State<'_, Arc<AppState>>, call_id: String, mo
format!("{e}")
})?;
tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
// Upgrade the pending "Missed" entry to "Received" if the user
// accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
if mode != 0 {
if history::mark_received_if_pending(&call_id) {
let _ = app.emit("history-changed", ());
}
}
Ok(())
}
@@ -555,6 +605,24 @@ async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<ser
Ok(serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp}))
}
/// Tear down the signal connection so the user goes back to idle. Called
/// when the user clicks "Deregister" on the direct-call screen. The
/// spawned recv loop will break out naturally when the transport closes.
#[tauri::command]
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
let mut sig = state.signal.lock().await;
if let Some(transport) = sig.transport.take() {
tracing::info!("deregister: closing signal transport");
transport.close().await.ok();
}
sig.endpoint = None;
sig.signal_status = "idle".into();
sig.incoming_call_id = None;
sig.incoming_caller_fp = None;
sig.incoming_caller_alias = None;
Ok(())
}
// ─── App entry point ─────────────────────────────────────────────────────────
/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
@@ -616,7 +684,9 @@ pub fn run() {
ping_relay, get_identity, get_app_info,
connect, disconnect, toggle_mic, toggle_speaker, get_status,
register_signal, place_call, answer_call, get_signal_status,
deregister,
set_speakerphone, is_speakerphone_on,
get_call_history, get_recent_contacts, clear_call_history,
])
.run(tauri::generate_context!())
.expect("error while running WarzonePhone");

View File

@@ -115,7 +115,7 @@ function loadSettings(): Settings {
{ name: "Laptop", address: "172.16.81.125:4433" },
{ name: "Default", address: "193.180.213.68:4433" },
],
selectedRelay: 0, room: "android", alias: "",
selectedRelay: 0, room: "general", alias: "",
osAec: true, agc: true, quality: "auto", recentRooms: [],
};
try {
@@ -749,6 +749,7 @@ const modeDirect = document.getElementById("mode-direct")!;
const roomModeDiv = document.getElementById("room-mode")!;
const directModeDiv = document.getElementById("direct-mode")!;
const registerBtn = document.getElementById("register-btn") as HTMLButtonElement;
const deregisterBtn = document.getElementById("deregister-btn") as HTMLButtonElement;
const directRegistered = document.getElementById("direct-registered")!;
const incomingCallPanel = document.getElementById("incoming-call-panel")!;
const incomingCaller = document.getElementById("incoming-caller")!;
@@ -757,6 +758,11 @@ const rejectCallBtn = document.getElementById("reject-call-btn")!;
const targetFpInput = document.getElementById("target-fp") as HTMLInputElement;
const callBtn = document.getElementById("call-btn") as HTMLButtonElement;
const callStatusText = document.getElementById("call-status-text")!;
const recentContactsSection = document.getElementById("recent-contacts-section")!;
const recentContactsList = document.getElementById("recent-contacts-list")!;
const callHistorySection = document.getElementById("call-history-section")!;
const callHistoryList = document.getElementById("call-history-list")!;
const clearHistoryBtn = document.getElementById("clear-history-btn") as HTMLButtonElement;
let currentCallMode = "room";
@@ -781,6 +787,114 @@ modeDirect.addEventListener("click", () => {
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.add("hidden");
});
// ── Call history + recent contacts rendering ──
interface CallHistoryEntry {
call_id: string;
peer_fp: string;
peer_alias: string | null;
direction: "placed" | "received" | "missed";
timestamp_unix: number;
}
function fmtTimestamp(unix: number): string {
const d = new Date(unix * 1000);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function directionIcon(dir: string): string {
switch (dir) {
case "placed": return "↗";
case "received": return "↙";
case "missed": return "✗";
default: return "•";
}
}
function directionClass(dir: string): string {
return `dir-${dir}`;
}
function callByFingerprint(fp: string) {
targetFpInput.value = fp;
callBtn.click();
}
async function refreshHistory() {
try {
const [history, contacts] = await Promise.all([
invoke<CallHistoryEntry[]>("get_call_history"),
invoke<CallHistoryEntry[]>("get_recent_contacts"),
]);
// Recent contacts (top 6)
if (contacts.length === 0) {
recentContactsSection.classList.add("hidden");
} else {
recentContactsSection.classList.remove("hidden");
recentContactsList.innerHTML = "";
contacts.slice(0, 6).forEach((c) => {
const btn = document.createElement("button");
btn.className = "contact-chip";
const label = c.peer_alias || c.peer_fp.substring(0, 16);
btn.innerHTML = `<span class="contact-dot"></span><span class="contact-label">${label}</span>`;
btn.title = c.peer_fp;
btn.addEventListener("click", () => callByFingerprint(c.peer_fp));
recentContactsList.appendChild(btn);
});
}
// Full history
if (history.length === 0) {
callHistorySection.classList.add("hidden");
} else {
callHistorySection.classList.remove("hidden");
callHistoryList.innerHTML = "";
history.slice(0, 50).forEach((e) => {
const row = document.createElement("div");
row.className = `history-row ${directionClass(e.direction)}`;
const label = e.peer_alias || e.peer_fp.substring(0, 16);
row.innerHTML = `
<span class="history-dir">${directionIcon(e.direction)}</span>
<div class="history-meta">
<span class="history-peer">${label}</span>
<span class="history-time">${fmtTimestamp(e.timestamp_unix)}</span>
</div>
<button class="history-call-btn" title="Call back">Call</button>
`;
row.title = e.peer_fp;
const cb = row.querySelector(".history-call-btn") as HTMLButtonElement;
cb.addEventListener("click", (ev) => {
ev.stopPropagation();
callByFingerprint(e.peer_fp);
});
callHistoryList.appendChild(row);
});
}
} catch (e) {
console.error("refreshHistory failed:", e);
}
}
// Live-refresh whenever the backend logs a new entry
listen("history-changed", () => { refreshHistory(); });
clearHistoryBtn.addEventListener("click", async () => {
if (!confirm("Clear call history?")) return;
try {
await invoke("clear_call_history");
refreshHistory();
} catch (e) { console.error(e); }
});
registerBtn.addEventListener("click", async () => {
const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; }
@@ -791,6 +905,7 @@ registerBtn.addEventListener("click", async () => {
registerBtn.classList.add("hidden");
directRegistered.classList.remove("hidden");
callStatusText.textContent = `Your fingerprint: ${fp}`;
refreshHistory();
} catch (e: any) {
connectError.textContent = String(e);
registerBtn.disabled = false;
@@ -798,6 +913,20 @@ registerBtn.addEventListener("click", async () => {
}
});
deregisterBtn.addEventListener("click", async () => {
try {
await invoke("deregister");
directRegistered.classList.add("hidden");
registerBtn.classList.remove("hidden");
registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay";
callStatusText.textContent = "";
incomingCallPanel.classList.add("hidden");
} catch (e) {
console.error("deregister failed:", e);
}
});
callBtn.addEventListener("click", async () => {
const target = targetFpInput.value.trim();
if (!target) return;

View File

@@ -890,3 +890,142 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
.mode-btn:hover:not(.active) {
background: var(--surface2);
}
/* ── Direct call history + contacts ── */
.direct-registered-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.secondary-btn.small {
padding: 4px 10px;
font-size: 11px;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 12px 0 4px 0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--dim);
}
.link-btn {
background: none;
border: none;
color: var(--dim);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
text-decoration: underline;
}
.link-btn:hover { color: var(--text); }
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
}
/* Recent contacts — horizontally wrapping chips */
#recent-contacts-list {
flex-direction: row;
flex-wrap: wrap;
max-height: none;
}
.contact-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface2);
border: 1px solid var(--surface2);
color: var(--text);
border-radius: 16px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.contact-chip:hover {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.contact-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
}
.contact-chip .contact-label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Full history rows */
.history-row {
display: grid;
grid-template-columns: 20px 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--surface);
border-radius: 6px;
font-size: 12px;
}
.history-row.dir-placed .history-dir { color: var(--accent); }
.history-row.dir-received .history-dir { color: var(--green); }
.history-row.dir-missed .history-dir { color: var(--red); }
.history-dir {
text-align: center;
font-weight: 700;
}
.history-meta {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.history-peer {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
color: var(--dim);
font-size: 11px;
}
.history-call-btn {
background: var(--surface2);
border: 1px solid var(--surface2);
color: var(--text);
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.history-call-btn:hover {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Speaker routing button (non-muted earpiece state should not look red) */
#spk-btn.speaker-on .icon {
color: var(--accent);
}