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 { ...@@ -44,6 +44,11 @@ export class BrowserProxy {
clearTimeout(id) { clearTimeout(id) {
window.clearTimeout(id); window.clearTimeout(id);
} }
/** @return {number} */
random() {
return Math.random();
}
} }
addSingletonGetter(BrowserProxy); 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 @@ ...@@ -66,6 +66,8 @@
file="icons/header.svg" type="BINDATA" compress="gzip" /> file="icons/header.svg" type="BINDATA" compress="gzip" />
<include name="IDR_NEW_TAB_PAGE_GOOGLE_MIC_SVG" <include name="IDR_NEW_TAB_PAGE_GOOGLE_MIC_SVG"
file="icons/googlemic_clr_24px.svg" type="BINDATA" compress="gzip" /> 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" <include name="IDR_NEW_TAB_PAGE_MINI_PAGE_JS"
file="${root_gen_dir}/chrome/browser/resources/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" /> use_base_dir="false" type="BINDATA" compress="gzip" />
......
<style> <style>
:host {
--receiving-audio-color: var(--google-red-refresh-500);
}
#dialog { #dialog {
align-items: center; align-items: center;
background-color: var(--ntp-background-override-color); background-color: var(--ntp-background-override-color);
...@@ -45,11 +49,17 @@ ...@@ -45,11 +49,17 @@
left: 16px; left: 16px;
} }
#content {
align-items: center;
display: flex;
flex-direction: row;
width: 660px;
}
#texts { #texts {
color: var(--ntp-secondary-text-color); color: var(--ntp-secondary-text-color);
flex-grow: 1;
font-size: 32px; font-size: 32px;
max-width: 660px;
min-width: 622px;
text-align: start; text-align: start;
} }
...@@ -69,43 +79,124 @@ ...@@ -69,43 +79,124 @@
margin-inline-start: 0.25em; margin-inline-start: 0.25em;
text-decoration: none; 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> </style>
<dialog id="dialog" on-close="onOverlayClose_" on-click="onOverlayClick_"> <dialog id="dialog" on-close="onOverlayClose_" on-click="onOverlayClick_">
<button id="closeButton" tabindex="0" title="$i18n{close}">×</button> <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 animations. -->
<!-- TODO(crbug.com/1042534): Add Listening state. --> <!-- TODO(crbug.com/1042534): Add Listening state. -->
<iron-pages id="texts" selected="[[getText_(state_)]]" <div id="content">
attr-for-selected="text" fallback-selection="none" aria-live="polite"> <iron-pages id="texts" selected="[[getText_(state_)]]"
<div text="none"></div> attr-for-selected="text" fallback-selection="none" aria-live="polite">
<div text="waiting">$i18n{waiting}</div> <div text="none"></div>
<div text="speak">$i18n{speak}</div> <div text="waiting">$i18n{waiting}</div>
<div text="result"> <div text="speak">$i18n{speak}</div>
<span id="finalResult">[[finalResult_]]</span> <div text="result">
<span>[[interimResult_]]</span> <span id="finalResult">[[finalResult_]]</span>
</div> <span>[[interimResult_]]</span>
<div text="error"> </div>
<iron-pages id="errors" selected="[[error_]]" attr-for-selected="error" <div text="error">
fallback-selection="7"> <iron-pages id="errors" selected="[[error_]]" attr-for-selected="error"
<span error="1">$i18n{noVoice}</span> fallback-selection="7">
<span error="2">$i18n{audioError}</span> <span error="1">$i18n{noVoice}</span>
<span error="3">$i18n{networkError}</span> <span error="2">$i18n{audioError}</span>
<span error="4">$i18n{permissionError}</span> <span error="3">$i18n{networkError}</span>
<span error="5">$i18n{languageError}</span> <span error="4">$i18n{permissionError}</span>
<span error="6">$i18n{noTranslation}</span> <span error="5">$i18n{languageError}</span>
<span error="7">$i18n{otherError}</span> <span error="6">$i18n{noTranslation}</span>
</iron-pages> <span error="7">$i18n{otherError}</span>
<iron-pages id="errorLinks" selected="[[getErrorLink_(error_)]]" </iron-pages>
attr-for-selected="link" fallback-selection="none"> <iron-pages id="errorLinks" selected="[[getErrorLink_(error_)]]"
<span link="none"></span> attr-for-selected="link" fallback-selection="none">
<a link="learn-more" target="_blank" href="[[helpUrl_]]"><!-- <span link="none"></span>
-->$i18n{learnMore} <a link="learn-more" target="_blank" href="[[helpUrl_]]"><!--
</a> -->$i18n{learnMore}
<a link="details" target="_blank" href="[[helpUrl_]]"><!-- </a>
-->$i18n{details} <a link="details" target="_blank" href="[[helpUrl_]]"><!--
</a> -->$i18n{details}
<a link="try-again" href="#">$i18n{tryAgain}</a> </a>
</iron-pages> <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> </div>
</iron-pages> </div>
</dialog> </dialog>
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'chrome://resources/polymer/v3_0/iron-pages/iron-pages.js'; 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 {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 {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BrowserProxy} from './browser_proxy.js'; import {BrowserProxy} from './browser_proxy.js';
/** /**
...@@ -46,6 +46,18 @@ const ERROR_TIMEOUT_SHORT_MS = 3000; ...@@ -46,6 +46,18 @@ const ERROR_TIMEOUT_SHORT_MS = 3000;
*/ */
const ERROR_TIMEOUT_LONG_MS = 8000; 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. * The set of controller states.
* @enum {number} * @enum {number}
...@@ -171,6 +183,18 @@ class VoiceSearchOverlayElement extends PolymerElement { ...@@ -171,6 +183,18 @@ class VoiceSearchOverlayElement extends PolymerElement {
value: `https://support.google.com/chrome/?` + value: `https://support.google.com/chrome/?` +
`p=ui_voice_search&hl=${window.navigator.language}`, `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 { ...@@ -256,6 +280,7 @@ class VoiceSearchOverlayElement extends PolymerElement {
onSpeechStart_() { onSpeechStart_() {
this.resetIdleTimer_(); this.resetIdleTimer_();
this.state_ = State.SPEECH_RECEIVED; this.state_ = State.SPEECH_RECEIVED;
this.animateVolume_();
} }
/** /**
...@@ -370,6 +395,23 @@ class VoiceSearchOverlayElement extends PolymerElement { ...@@ -370,6 +395,23 @@ class VoiceSearchOverlayElement extends PolymerElement {
this.resetErrorTimer_(getErrorTimeout(error)); 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} * @return {string}
* @private * @private
...@@ -408,6 +450,22 @@ class VoiceSearchOverlayElement extends PolymerElement { ...@@ -408,6 +450,22 @@ class VoiceSearchOverlayElement extends PolymerElement {
return 'none'; 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); customElements.define(VoiceSearchOverlayElement.is, VoiceSearchOverlayElement);
...@@ -7,7 +7,7 @@ import 'chrome://new-tab-page/voice_search_overlay.js'; ...@@ -7,7 +7,7 @@ import 'chrome://new-tab-page/voice_search_overlay.js';
import {BrowserProxy} from 'chrome://new-tab-page/browser_proxy.js'; import {BrowserProxy} from 'chrome://new-tab-page/browser_proxy.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js'; import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {flushTasks} from 'chrome://test/test_util.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 { class MockSpeechRecognition {
constructor() { constructor() {
...@@ -66,6 +66,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => { ...@@ -66,6 +66,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
// Assert. // Assert.
assertTrue(isVisible( assertTrue(isVisible(
voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=waiting]'))); voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=waiting]')));
assertEquals(voiceSearchOverlay.$.micContainer.className, '');
assertStyle(voiceSearchOverlay.$.micVolume, '--mic-volume-level', '0');
}); });
test('on audio received shows speak text', () => { test('on audio received shows speak text', () => {
...@@ -75,9 +77,28 @@ suite('NewTabPageVoiceSearchOverlayTest', () => { ...@@ -75,9 +77,28 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
// Assert. // Assert.
assertTrue(isVisible( assertTrue(isVisible(
voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=speak]'))); 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', () => { test('on result received shows recognized text', () => {
// Arrange.
testProxy.setResultFor('random', 0.5);
// Act. // Act.
mockSpeechRecognition.onresult({ mockSpeechRecognition.onresult({
results: [ results: [
...@@ -107,12 +128,16 @@ suite('NewTabPageVoiceSearchOverlayTest', () => { ...@@ -107,12 +128,16 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
assertTrue(isVisible(finalResult)); assertTrue(isVisible(finalResult));
assertEquals(intermediateResult.innerText, 'hello'); assertEquals(intermediateResult.innerText, 'hello');
assertEquals(finalResult.innerText, 'world'); 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 () => { test('on final result received queries google', async () => {
// Arrange. // Arrange.
const googleBaseUrl = 'https://google.com'; const googleBaseUrl = 'https://google.com';
loadTimeData.data = {googleBaseUrl: googleBaseUrl}; loadTimeData.data = {googleBaseUrl: googleBaseUrl};
testProxy.setResultFor('random', 0);
// Act. // Act.
mockSpeechRecognition.onresult({ mockSpeechRecognition.onresult({
...@@ -131,6 +156,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => { ...@@ -131,6 +156,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
// Assert. // Assert.
const href = await testProxy.whenCalled('navigate'); const href = await testProxy.whenCalled('navigate');
assertEquals(href, `${googleBaseUrl}/search?q=hello+world&gs_ivs=1`); 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', () => { test('on error received shows error text', () => {
...@@ -142,6 +169,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => { ...@@ -142,6 +169,8 @@ suite('NewTabPageVoiceSearchOverlayTest', () => {
voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=error]'))); voiceSearchOverlay.shadowRoot.querySelector('#texts *[text=error]')));
assertTrue(isVisible( assertTrue(isVisible(
voiceSearchOverlay.shadowRoot.querySelector('#errors *[error="2"]'))); 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', () => { test('on end received shows error text if no final result', () => {
......
...@@ -55,6 +55,8 @@ ...@@ -55,6 +55,8 @@
--google-red-refresh-300-rgb: 242, 139, 130; /* #f28b82 */ --google-red-refresh-300-rgb: 242, 139, 130; /* #f28b82 */
--google-red-refresh-300: rgb(var(--google-red-refresh-300-rgb)); --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: 253, 214, 51; /* #fdd633 */
--google-yellow-refresh-300: rgb(var(--google-yellow-refresh-300-rgb)); --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