Commit bd903048 authored by Tibor Goldschwendt's avatar Tibor Goldschwendt Committed by Commit Bot

[webui][ntp] Add microphone button to voice search overlay

Bug: 1042534
Change-Id: I7fd3603d7ee547ef2f70aec3576572f4e7f23f99
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2028303
Commit-Queue: Tibor Goldschwendt <tiborg@chromium.org>
Reviewed-by: default avatarEsmael Elmoslimany <aee@chromium.org>
Reviewed-by: default avatarMoe Ahmadi <mahmadi@chromium.org>
Auto-Submit: Tibor Goldschwendt <tiborg@chromium.org>
Cr-Commit-Position: refs/heads/master@{#737544}
parent c85c6027
......@@ -44,6 +44,11 @@ export class BrowserProxy {
clearTimeout(id) {
window.clearTimeout(id);
}
/** @return {number} */
random() {
return Math.random();
}
}
addSingletonGetter(BrowserProxy);
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M17 11.998c0 2.76-2.23 5-4.99 5l-.002.002a4.994 4.994 0 0 1-4.979-5h-2c0 3.52 2.59 6.433 5.98 6.92v3.078h.01V22h2v-3.08h-.01A6.982 6.982 0 0 0 19 11.998z"/><path fill="none" d="M0 0h24v24H0z"/><path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v7c0 1.66 1.34 3 3 3z"/></svg>
\ No newline at end of file
......@@ -66,6 +66,8 @@
file="icons/header.svg" type="BINDATA" compress="gzip" />
<include name="IDR_NEW_TAB_PAGE_GOOGLE_MIC_SVG"
file="icons/googlemic_clr_24px.svg" type="BINDATA" compress="gzip" />
<include name="IDR_NEW_TAB_PAGE_MIC_SVG"
file="icons/mic.svg" type="BINDATA" compress="gzip" />
<include name="IDR_NEW_TAB_PAGE_MINI_PAGE_JS"
file="${root_gen_dir}/chrome/browser/resources/new_tab_page/mini_page.js"
use_base_dir="false" type="BINDATA" compress="gzip" />
......
<style>
:host {
--receiving-audio-color: var(--google-red-refresh-500);
}
#dialog {
align-items: center;
background-color: var(--ntp-background-override-color);
......@@ -45,11 +49,17 @@
left: 16px;
}
#content {
align-items: center;
display: flex;
flex-direction: row;
width: 660px;
}
#texts {
color: var(--ntp-secondary-text-color);
flex-grow: 1;
font-size: 32px;
max-width: 660px;
min-width: 622px;
text-align: start;
}
......@@ -69,43 +79,124 @@
margin-inline-start: 0.25em;
text-decoration: none;
}
#micContainer {
--mic-button-size: 165px;
--mic-container-size: 300px;
align-items: center;
display: grid;
flex-shrink: 0;
height: var(--mic-container-size);
justify-items: center;
width: var(--mic-container-size);
}
#micContainer > * {
grid-column-start: 1;
grid-row-start: 1;
}
#micVolume {
--mic-volume-size: calc(var(--mic-button-size) +
var(--mic-volume-level) * (var(--mic-container-size) -
var(--mic-button-size)));
align-items: center;
background-color: var(--ntp-border-color);
border-radius: 50%;
display: flex;
height: var(--mic-volume-size);
justify-content: center;
transition-duration: var(--mic-volume-duration);
transition-property: height, width;
transition-timing-function: ease-in-out;
width: var(--mic-volume-size);
}
#micVolumeCutout {
background-color: var(--ntp-background-override-color);
border-radius: 50%;
height: var(--mic-button-size);
width: var(--mic-button-size);
}
#micButton {
border-radius: 50%;
height: var(--mic-button-size);
transition: background-color 200ms ease-in-out;
width: var(--mic-button-size);
}
.receiving #micButton {
background-color: var(--receiving-audio-color);
border-color: var(--receiving-audio-color);
}
#micIcon {
-webkit-mask-image: url(icons/mic.svg);
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: 100%;
background-color: var(--ntp-border-color);
height: 80px;
transition: background-color 200ms ease-in-out;
width: 80px;
}
.listening #micIcon {
background-color: var(--receiving-audio-color);
}
.receiving #micIcon {
background-color: white;
}
</style>
<dialog id="dialog" on-close="onOverlayClose_" on-click="onOverlayClick_">
<button id="closeButton" tabindex="0" title="$i18n{close}">×</button>
<!-- TODO(crbug.com/1042534): Add microphone button. -->
<!-- TODO(crbug.com/1042534): Add animations. -->
<!-- TODO(crbug.com/1042534): Add Listening state. -->
<iron-pages id="texts" selected="[[getText_(state_)]]"
attr-for-selected="text" fallback-selection="none" aria-live="polite">
<div text="none"></div>
<div text="waiting">$i18n{waiting}</div>
<div text="speak">$i18n{speak}</div>
<div text="result">
<span id="finalResult">[[finalResult_]]</span>
<span>[[interimResult_]]</span>
</div>
<div text="error">
<iron-pages id="errors" selected="[[error_]]" attr-for-selected="error"
fallback-selection="7">
<span error="1">$i18n{noVoice}</span>
<span error="2">$i18n{audioError}</span>
<span error="3">$i18n{networkError}</span>
<span error="4">$i18n{permissionError}</span>
<span error="5">$i18n{languageError}</span>
<span error="6">$i18n{noTranslation}</span>
<span error="7">$i18n{otherError}</span>
</iron-pages>
<iron-pages id="errorLinks" selected="[[getErrorLink_(error_)]]"
attr-for-selected="link" fallback-selection="none">
<span link="none"></span>
<a link="learn-more" target="_blank" href="[[helpUrl_]]"><!--
-->$i18n{learnMore}
</a>
<a link="details" target="_blank" href="[[helpUrl_]]"><!--
-->$i18n{details}
</a>
<a link="try-again" href="#">$i18n{tryAgain}</a>
</iron-pages>
<div id="content">
<iron-pages id="texts" selected="[[getText_(state_)]]"
attr-for-selected="text" fallback-selection="none" aria-live="polite">
<div text="none"></div>
<div text="waiting">$i18n{waiting}</div>
<div text="speak">$i18n{speak}</div>
<div text="result">
<span id="finalResult">[[finalResult_]]</span>
<span>[[interimResult_]]</span>
</div>
<div text="error">
<iron-pages id="errors" selected="[[error_]]" attr-for-selected="error"
fallback-selection="7">
<span error="1">$i18n{noVoice}</span>
<span error="2">$i18n{audioError}</span>
<span error="3">$i18n{networkError}</span>
<span error="4">$i18n{permissionError}</span>
<span error="5">$i18n{languageError}</span>
<span error="6">$i18n{noTranslation}</span>
<span error="7">$i18n{otherError}</span>
</iron-pages>
<iron-pages id="errorLinks" selected="[[getErrorLink_(error_)]]"
attr-for-selected="link" fallback-selection="none">
<span link="none"></span>
<a link="learn-more" target="_blank" href="[[helpUrl_]]"><!--
-->$i18n{learnMore}
</a>
<a link="details" target="_blank" href="[[helpUrl_]]"><!--
-->$i18n{details}
</a>
<a link="try-again" href="#">$i18n{tryAgain}</a>
</iron-pages>
</div>
</iron-pages>
<div id="micContainer" class$="[[getMicClass_(state_)]]">
<div id="micVolume"
style="--mic-volume-level: [[micVolumeLevel_]];
--mic-volume-duration: [[micVolumeDuration_]]ms;">
<div id="micVolumeCutout">
</div>
</div>
<cr-button id="micButton">
<div id="micIcon"></div>
</cr-button>
</div>
</iron-pages>
</div>
</dialog>
......@@ -3,10 +3,10 @@
// found in the LICENSE file.
import 'chrome://resources/polymer/v3_0/iron-pages/iron-pages.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BrowserProxy} from './browser_proxy.js';
/**
......@@ -46,6 +46,18 @@ const ERROR_TIMEOUT_SHORT_MS = 3000;
*/
const ERROR_TIMEOUT_LONG_MS = 8000;
/**
* The minimum transition time for the volume rings.
* @private
*/
const VOLUME_ANIMATION_DURATION_MIN_MS = 170;
/**
* The range of the transition time for the volume rings.
* @private
*/
const VOLUME_ANIMATION_DURATION_RANGE_MS = 10;
/**
* The set of controller states.
* @enum {number}
......@@ -171,6 +183,18 @@ class VoiceSearchOverlayElement extends PolymerElement {
value: `https://support.google.com/chrome/?` +
`p=ui_voice_search&hl=${window.navigator.language}`,
},
/** @private */
micVolumeLevel_: {
type: Number,
value: 0,
},
/** @private */
micVolumeDuration_: {
type: Number,
value: VOLUME_ANIMATION_DURATION_MIN_MS,
},
};
}
......@@ -256,6 +280,7 @@ class VoiceSearchOverlayElement extends PolymerElement {
onSpeechStart_() {
this.resetIdleTimer_();
this.state_ = State.SPEECH_RECEIVED;
this.animateVolume_();
}
/**
......@@ -370,6 +395,23 @@ class VoiceSearchOverlayElement extends PolymerElement {
this.resetErrorTimer_(getErrorTimeout(error));
}
/** @private */
animateVolume_() {
this.micVolumeLevel_ = 0;
this.micVolumeDuration_ = VOLUME_ANIMATION_DURATION_MIN_MS;
if (this.state_ !== State.SPEECH_RECEIVED &&
this.state_ !== State.RESULT_RECEIVED) {
return;
}
this.micVolumeLevel_ = BrowserProxy.getInstance().random();
this.micVolumeDuration_ = Math.round(
VOLUME_ANIMATION_DURATION_MIN_MS +
BrowserProxy.getInstance().random() *
VOLUME_ANIMATION_DURATION_RANGE_MS);
BrowserProxy.getInstance().setTimeout(
this.animateVolume_.bind(this), this.micVolumeDuration_);
}
/**
* @return {string}
* @private
......@@ -408,6 +450,22 @@ class VoiceSearchOverlayElement extends PolymerElement {
return 'none';
}
}
/**
* @return {string}
* @private
*/
getMicClass_() {
switch (this.state_) {
case State.AUDIO_RECEIVED:
return 'listening';
case State.SPEECH_RECEIVED:
case State.RESULT_RECEIVED:
return 'receiving';
default:
return '';
}
}
}
customElements.define(VoiceSearchOverlayElement.is, VoiceSearchOverlayElement);
......@@ -7,7 +7,7 @@ import 'chrome://new-tab-page/voice_search_overlay.js';
import {BrowserProxy} from 'chrome://new-tab-page/browser_proxy.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {flushTasks} from 'chrome://test/test_util.m.js';
import {createTestProxy, isVisible} from './test_support.js';
import {assertStyle, createTestProxy, isVisible} from './test_support.js';
class MockSpeechRecognition {
constructor() {
......@@ -66,6 +66,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
// Assert.
assertTrue(isVisible(
voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=waiting]')));
assertEquals(voiceSearchOverlay.$.micContainer.className, '');
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0');
});
test('on audio received shows speak text', () => {
......@@ -75,9 +77,28 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
// Assert.
assertTrue(isVisible(
voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=speak]')));
assertTrue(
voiceSearchOverlay.$.micContainer.classList.contains('listening'));
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0');
});
test('on speech received starts volume animation', () => {
// Arrange.
testProxy.setResultFor('random', 0.5);
// Act.
mockSpeechRecognition.onspeechstart();
// Assert.
assertTrue(
voiceSearchOverlay.$.micContainer.classList.contains('receiving'));
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0.5');
});
test('on result received shows recognized text', () => {
// Arrange.
testProxy.setResultFor('random', 0.5);
// Act.
mockSpeechRecognition.onresult({
results: [
......@@ -107,12 +128,16 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
assertTrue(isVisible(finalResult));
assertEquals(intermediateResult.innerText, 'hello');
assertEquals(finalResult.innerText, 'world');
assertTrue(
voiceSearchOverlay.$.micContainer.classList.contains('receiving'));
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0.5');
});
test('on final result received queries google', async () => {
// Arrange.
const googleBaseUrl = 'https://google.com';
loadTimeData.data = {googleBaseUrl: googleBaseUrl};
testProxy.setResultFor('random', 0);
// Act.
mockSpeechRecognition.onresult({
......@@ -131,6 +156,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
// Assert.
const href = await testProxy.whenCalled('navigate');
assertEquals(href, `${googleBaseUrl}/search?q=hello+world&gs_ivs=1`);
assertEquals(voiceSearchOverlay.$.micContainer.className, '');
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0');
});
test('on error received shows error text', () => {
......@@ -142,6 +169,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=error]')));
assertTrue(isVisible(
voiceSearchOverlay.shadowRoot.querySelector('#errors *[error="2"]')));
assertEquals(voiceSearchOverlay.$.micContainer.className, '');
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0');
});
test('on end received shows error text if no final result', () => {
......
......@@ -55,6 +55,8 @@
--google-red-refresh-300-rgb: 242, 139, 130; /* #f28b82 */
--google-red-refresh-300: rgb(var(--google-red-refresh-300-rgb));
--google-red-refresh-500-rgb: 234, 67, 53; /* #ea4335 */
--google-red-refresh-500: rgb(var(--google-red-refresh-500-rgb));
--google-yellow-refresh-300-rgb: 253, 214, 51; /* #fdd633 */
--google-yellow-refresh-300: rgb(var(--google-yellow-refresh-300-rgb));
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment