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
+38 -10
View File
@@ -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<void> | 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();