feat(ui): add microphone mute control

This commit is contained in:
2026-10-05 11:43:34 -03:00
parent a149f4ef8b
commit c5ddb3f333
3 changed files with 116 additions and 21 deletions
+12 -2
View File
@@ -256,7 +256,7 @@
- Blue mic circular button (116 px, deep blue, white mic) - Blue mic circular button (116 px, deep blue, white mic)
- Two waveform banks left + right (audio-reactive CSS bars) - Two waveform banks left + right (audio-reactive CSS bars)
- Listening status text - Listening status text
- Stop pill button below. - Mute and stop controls below.
Mirrors the screenshot center UI exactly. --> Mirrors the screenshot center UI exactly. -->
<div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz"> <div class="white-mic-stage" hidden data-theme-only="white" aria-label="Control de voz">
<!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars --> <!-- Waveform left side: 7 bars mirror right side, heights driven by JS vars -->
@@ -302,7 +302,16 @@
<p id="white-mic-caption" class="white-mic-caption" role="status">Toca para empezar</p> <p id="white-mic-caption" class="white-mic-caption" role="status">Toca para empezar</p>
<p id="white-mic-subcaption" class="white-mic-subcaption" hidden></p> <p id="white-mic-subcaption" class="white-mic-subcaption" hidden></p>
<!-- Secondary stop pill below caption, with a dark stop-square icon. --> <div id="white-session-controls" class="white-session-controls" role="group" aria-label="Controles de la conversación" hidden>
<button id="white-mute-btn" class="white-mute-btn" type="button" hidden disabled aria-label="Silenciar micrófono" title="Silenciar micrófono">
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/>
</svg>
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="2" y1="2" x2="22" y2="22"/><path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 10v1a3 3 0 0 0 5.1 2.1"/><path d="M19 10a7 7 0 0 1-1.24 3.97"/><path d="M5 10a7 7 0 0 0 11 5.67"/><line x1="12" y1="19" x2="12" y2="22"/>
</svg>
<span id="white-mute-label">Silenciar micrófono</span>
</button>
<button id="white-stop-btn" <button id="white-stop-btn"
class="white-stop-btn" class="white-stop-btn"
type="button" type="button"
@@ -313,6 +322,7 @@
<span>Detener</span> <span>Detener</span>
</button> </button>
</div> </div>
</div>
<p id="circle-caption" class="circle-caption" role="status">Toca para empezar</p> <p id="circle-caption" class="circle-caption" role="status">Toca para empezar</p>
<p id="circle-subcaption" class="circle-subcaption" hidden></p> <p id="circle-subcaption" class="circle-subcaption" hidden></p>
+38 -10
View File
@@ -670,6 +670,12 @@ const stopBtn = $("#stop-btn");
const whiteMicBtn = $("#white-mic-circle"); const whiteMicBtn = $("#white-mic-circle");
/** @type {HTMLButtonElement} */ /** @type {HTMLButtonElement} */
const whiteStopBtn = $("#white-stop-btn"); const whiteStopBtn = $("#white-stop-btn");
/** @type {HTMLButtonElement} */
const whiteMuteBtn = $("#white-mute-btn");
/** @type {HTMLElement} */
const whiteMuteLabel = $("#white-mute-label");
/** @type {HTMLElement} */
const whiteSessionControls = $("#white-session-controls");
/** @type {HTMLParagraphElement} */ /** @type {HTMLParagraphElement} */
const whiteMicCaption = $("#white-mic-caption"); const whiteMicCaption = $("#white-mic-caption");
/** @type {HTMLParagraphElement} */ /** @type {HTMLParagraphElement} */
@@ -1917,12 +1923,28 @@ let micMuted = false;
/** @type {Promise<void> | null} */ /** @type {Promise<void> | null} */
let teardownPromise = null; let teardownPromise = null;
function syncMicControls() {
const live = LIVE_STATES.has(currentState);
const canMute = live && !!micStream && !!client && !teardownPromise;
const label = micMuted ? "Activar micrófono" : "Silenciar micrófono";
for (const button of [micBtn, whiteMuteBtn]) {
button.disabled = !canMute;
button.classList.toggle("muted", micMuted);
button.setAttribute("aria-label", label);
button.title = label;
}
whiteMuteBtn.hidden = !live || s2sTheme !== "white";
whiteMuteLabel.textContent = label;
}
function syncVoiceControls() { function syncVoiceControls() {
const view = STATE_VIEWS[currentState]; const view = STATE_VIEWS[currentState];
const live = LIVE_STATES.has(currentState); const live = LIVE_STATES.has(currentState);
const disabled = view.disabled || !!teardownPromise; const disabled = view.disabled || !!teardownPromise;
const mutedWhileListening = micMuted && (currentState === "listening" || currentState === "user-speaking");
const label = currentState === "error" ? "Reintentar conversación de voz" const label = currentState === "error" ? "Reintentar conversación de voz"
: currentState === "idle" ? "Iniciar conversación de voz" : view.caption; : currentState === "idle" ? "Iniciar conversación de voz"
: mutedWhileListening ? "Micrófono silenciado" : view.caption;
circleBtn.disabled = disabled; circleBtn.disabled = disabled;
circleBtn.setAttribute("aria-label", label); circleBtn.setAttribute("aria-label", label);
whiteMicBtn.disabled = disabled; whiteMicBtn.disabled = disabled;
@@ -1930,6 +1952,8 @@ function syncVoiceControls() {
whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`; whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`;
whiteStopBtn.hidden = !live || s2sTheme !== "white"; whiteStopBtn.hidden = !live || s2sTheme !== "white";
whiteStopBtn.disabled = !live || !!teardownPromise; whiteStopBtn.disabled = !live || !!teardownPromise;
whiteSessionControls.hidden = !live || s2sTheme !== "white";
syncMicControls();
const waiting = currentState === "queued" || currentState === "your-turn"; const waiting = currentState === "queued" || currentState === "your-turn";
chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error" chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error"
@@ -2017,7 +2041,8 @@ function updateRestartAvailability() {
* @param {"" | "error" | "muted"} [kind] * @param {"" | "error" | "muted"} [kind]
*/ */
function setCaption(text, kind = "") { function setCaption(text, kind = "") {
const trimmed = text.trim(); const mutedWhileListening = micMuted && (currentState === "listening" || currentState === "user-speaking") && kind !== "error";
const trimmed = mutedWhileListening ? "Micrófono silenciado" : text.trim();
circleCaption.textContent = trimmed; circleCaption.textContent = trimmed;
// .idle-cta is the JS-based fallback (adds gradient CTA style) for browsers // .idle-cta is the JS-based fallback (adds gradient CTA style) for browsers
// that don't support :has(). The primary selector is .stage:has(.state-idle) .circle-caption. // that don't support :has(). The primary selector is .stage:has(.state-idle) .circle-caption.
@@ -2028,7 +2053,7 @@ function setCaption(text, kind = "") {
if (addIdleCta) parts.push("idle-cta"); if (addIdleCta) parts.push("idle-cta");
circleCaption.className = parts.join(" "); circleCaption.className = parts.join(" ");
whiteMicCaption.textContent = trimmed; whiteMicCaption.textContent = trimmed;
whiteMicCaption.classList.toggle("muted", kind === "muted"); whiteMicCaption.classList.toggle("muted", kind === "muted" || mutedWhileListening);
whiteMicCaption.classList.toggle("error", kind === "error"); whiteMicCaption.classList.toggle("error", kind === "error");
} }
@@ -3012,17 +3037,21 @@ async function handleStartError(err) {
onFatalError(err); onFatalError(err);
} }
micBtn.addEventListener("click", () => { function toggleMicMute() {
if (!micStream || !client) return; if (!micStream || !client || !LIVE_STATES.has(currentState) || teardownPromise) return;
micMuted = !micMuted; micMuted = !micMuted;
for (const track of micStream.getAudioTracks()) { for (const track of micStream.getAudioTracks()) {
track.enabled = !micMuted; track.enabled = !micMuted;
} }
client.setMuted(micMuted); client.setMuted(micMuted);
micBtn.classList.toggle("muted", micMuted); syncVoiceControls();
micBtn.setAttribute("aria-label", micMuted ? "Activar micrófono" : "Silenciar micrófono"); if ((currentState === "listening" || currentState === "user-speaking") && !whiteMicCaption.classList.contains("error")) {
micBtn.title = micMuted ? "Activar micrófono" : "Silenciar micrófono"; setCaption(STATE_VIEWS[currentState].caption);
}); }
}
micBtn.addEventListener("click", toggleMicMute);
whiteMuteBtn.addEventListener("click", toggleMicMute);
stopBtn.addEventListener("click", async () => { stopBtn.addEventListener("click", async () => {
await teardown(); await teardown();
@@ -3440,7 +3469,6 @@ async function finishTeardown(closingClient, finalState) {
micStream = null; micStream = null;
} }
micMuted = false; micMuted = false;
micBtn.classList.remove("muted");
setState(finalState); setState(finalState);
// Refresh the chip's remaining-today after the budget moved. // Refresh the chip's remaining-today after the budget moved.
if (limiterOn) void account.refresh(); if (limiterOn) void account.refresh();
+57
View File
@@ -542,6 +542,8 @@ a:hover {
0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent); 0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent);
} }
#ident-user-name[hidden], #ident-user-name[hidden],
#white-session-controls[hidden],
#white-mute-btn[hidden],
#white-stop-btn[hidden], #white-stop-btn[hidden],
#white-mic-subcaption[hidden] { #white-mic-subcaption[hidden] {
display: none !important; display: none !important;
@@ -4623,6 +4625,7 @@ html[data-theme="white"] .white-mic-circle:disabled {
cursor: default; cursor: default;
} }
html[data-theme="white"] .white-mic-circle:focus-visible, html[data-theme="white"] .white-mic-circle:focus-visible,
html[data-theme="white"] .white-mute-btn:focus-visible,
html[data-theme="white"] .white-stop-btn:focus-visible { html[data-theme="white"] .white-stop-btn:focus-visible {
outline: 3px solid #1d4ed8; outline: 3px solid #1d4ed8;
outline-offset: 4px; outline-offset: 4px;
@@ -4706,6 +4709,60 @@ html[data-theme="white"] .white-mic-subcaption {
text-align: center; text-align: center;
} }
/* ── Active conversation controls ──────────────────────────────────── */
html[data-theme="white"] .white-session-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 100%;
}
html[data-theme="white"] .white-mute-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 10px 16px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #ffffff;
color: #1d4ed8;
font: inherit;
font-size: clamp(14px, 3.4vw, 16px);
font-weight: 600;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease;
}
html[data-theme="white"] .white-mute-btn:hover:not(:disabled) {
background: #eff6ff;
border-color: #93c5fd;
}
html[data-theme="white"] .white-mute-btn.muted {
background: #fffbeb;
border-color: #fcd34d;
color: #92400e;
}
html[data-theme="white"] .white-mute-btn.muted:hover:not(:disabled) {
background: #fef3c7;
}
html[data-theme="white"] .white-mute-btn:disabled {
cursor: default;
}
html[data-theme="white"] .white-mute-btn svg {
flex: none;
width: 18px;
height: 18px;
}
html[data-theme="white"] .white-mute-btn .mic-off,
html[data-theme="white"] .white-mute-btn.muted .mic-on {
display: none;
}
html[data-theme="white"] .white-mute-btn.muted .mic-off {
display: block;
}
/* ── "Detener" pill ─────────────────────────────────────────────────── */ /* ── "Detener" pill ─────────────────────────────────────────────────── */
html[data-theme="white"] .white-stop-btn { html[data-theme="white"] .white-stop-btn {
display: inline-flex; display: inline-flex;