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. -->
@@ -302,16 +302,26 @@
Toca para empezar
-
-
-
- Detener
-
+
+
+
+
+
+
+
+
+ Silenciar micrófono
+
+
+
+ Detener
+
+
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;