From c5ddb3f333bc930fc824db2a27b37e22869dce09 Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Mon, 5 Oct 2026 11:43:34 -0300 Subject: [PATCH] feat(ui): add microphone mute control --- s2s-client/index.html | 32 +++++++++++++++--------- s2s-client/main.js | 48 ++++++++++++++++++++++++++++-------- s2s-client/style.css | 57 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/s2s-client/index.html b/s2s-client/index.html index 86ed6f7..19120f7 100644 --- a/s2s-client/index.html +++ b/s2s-client/index.html @@ -256,7 +256,7 @@ - Blue mic circular button (116 px, deep blue, white mic) - Two waveform banks left + right (audio-reactive CSS bars) - Listening status text - - Stop pill button below. + - Mute and stop controls below. Mirrors the screenshot center UI exactly. -->

Toca para empezar

diff --git a/s2s-client/main.js b/s2s-client/main.js index 0d8f61b..d5c299f 100644 --- a/s2s-client/main.js +++ b/s2s-client/main.js @@ -670,6 +670,12 @@ const stopBtn = $("#stop-btn"); const whiteMicBtn = $("#white-mic-circle"); /** @type {HTMLButtonElement} */ 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} */ const whiteMicCaption = $("#white-mic-caption"); /** @type {HTMLParagraphElement} */ @@ -1917,12 +1923,28 @@ let micMuted = false; /** @type {Promise | 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() { const view = STATE_VIEWS[currentState]; const live = LIVE_STATES.has(currentState); const disabled = view.disabled || !!teardownPromise; + const mutedWhileListening = micMuted && (currentState === "listening" || currentState === "user-speaking"); 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.setAttribute("aria-label", label); whiteMicBtn.disabled = disabled; @@ -1930,6 +1952,8 @@ function syncVoiceControls() { whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`; whiteStopBtn.hidden = !live || s2sTheme !== "white"; whiteStopBtn.disabled = !live || !!teardownPromise; + whiteSessionControls.hidden = !live || s2sTheme !== "white"; + syncMicControls(); const waiting = currentState === "queued" || currentState === "your-turn"; chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error" @@ -2017,7 +2041,8 @@ function updateRestartAvailability() { * @param {"" | "error" | "muted"} [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; // .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. @@ -2028,7 +2053,7 @@ function setCaption(text, kind = "") { if (addIdleCta) parts.push("idle-cta"); circleCaption.className = parts.join(" "); whiteMicCaption.textContent = trimmed; - whiteMicCaption.classList.toggle("muted", kind === "muted"); + whiteMicCaption.classList.toggle("muted", kind === "muted" || mutedWhileListening); whiteMicCaption.classList.toggle("error", kind === "error"); } @@ -3012,17 +3037,21 @@ async function handleStartError(err) { onFatalError(err); } -micBtn.addEventListener("click", () => { - if (!micStream || !client) return; +function toggleMicMute() { + if (!micStream || !client || !LIVE_STATES.has(currentState) || teardownPromise) return; micMuted = !micMuted; for (const track of micStream.getAudioTracks()) { track.enabled = !micMuted; } client.setMuted(micMuted); - micBtn.classList.toggle("muted", micMuted); - micBtn.setAttribute("aria-label", micMuted ? "Activar micrófono" : "Silenciar micrófono"); - micBtn.title = micMuted ? "Activar micrófono" : "Silenciar micrófono"; -}); + syncVoiceControls(); + if ((currentState === "listening" || currentState === "user-speaking") && !whiteMicCaption.classList.contains("error")) { + setCaption(STATE_VIEWS[currentState].caption); + } +} + +micBtn.addEventListener("click", toggleMicMute); +whiteMuteBtn.addEventListener("click", toggleMicMute); stopBtn.addEventListener("click", async () => { await teardown(); @@ -3440,7 +3469,6 @@ async function finishTeardown(closingClient, finalState) { micStream = null; } micMuted = false; - micBtn.classList.remove("muted"); setState(finalState); // Refresh the chip's remaining-today after the budget moved. if (limiterOn) void account.refresh(); diff --git a/s2s-client/style.css b/s2s-client/style.css index a55e432..30b42dc 100644 --- a/s2s-client/style.css +++ b/s2s-client/style.css @@ -542,6 +542,8 @@ a:hover { 0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent); } #ident-user-name[hidden], +#white-session-controls[hidden], +#white-mute-btn[hidden], #white-stop-btn[hidden], #white-mic-subcaption[hidden] { display: none !important; @@ -4623,6 +4625,7 @@ html[data-theme="white"] .white-mic-circle:disabled { cursor: default; } 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 { outline: 3px solid #1d4ed8; outline-offset: 4px; @@ -4706,6 +4709,60 @@ html[data-theme="white"] .white-mic-subcaption { 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 ─────────────────────────────────────────────────── */ html[data-theme="white"] .white-stop-btn { display: inline-flex;