feat(ui): add microphone mute control
This commit is contained in:
+21
-11
@@ -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,16 +302,26 @@
|
|||||||
<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-stop-btn"
|
<button id="white-mute-btn" class="white-mute-btn" type="button" hidden disabled aria-label="Silenciar micrófono" title="Silenciar micrófono">
|
||||||
class="white-stop-btn"
|
<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">
|
||||||
type="button"
|
<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"/>
|
||||||
hidden
|
</svg>
|
||||||
aria-label="Detener"
|
<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">
|
||||||
title="Detener sesión de voz">
|
<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 viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
</svg>
|
||||||
<span>Detener</span>
|
<span id="white-mute-label">Silenciar micrófono</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="white-stop-btn"
|
||||||
|
class="white-stop-btn"
|
||||||
|
type="button"
|
||||||
|
hidden
|
||||||
|
aria-label="Detener"
|
||||||
|
title="Detener sesión de voz">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
|
<span>Detener</span>
|
||||||
|
</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>
|
||||||
|
|||||||
+38
-10
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user