Migrate voice command dialog (#4150)

* Migrate voice command dialog

* Cleanup

* Correct types

* Added animation when listening and we should talk back right? :'-)

* Set recognition to english

* Comments

* Update on change of hass
This commit is contained in:
Bram Kragten 2019-10-29 22:59:35 +01:00 committed by Paulus Schoutsen
parent 12be2a9775
commit 46f5224e70
23 changed files with 542 additions and 352 deletions

View File

@ -122,6 +122,7 @@
"@types/leaflet": "^1.4.3",
"@types/memoize-one": "4.1.0",
"@types/mocha": "^5.2.6",
"@types/webspeechapi": "^0.0.29",
"babel-loader": "^8.0.5",
"chai": "^4.2.0",
"copy-webpack-plugin": "^5.0.2",

View File

@ -1,9 +1,7 @@
import { HomeAssistant } from "../../types";
/** Return if a component is loaded. */
export default function isComponentLoaded(
export const isComponentLoaded = (
hass: HomeAssistant,
component: string
): boolean {
return hass && hass.config.components.indexOf(component) !== -1;
}
): boolean => hass && hass.config.components.indexOf(component) !== -1;

View File

@ -1,56 +0,0 @@
import "@polymer/paper-icon-button/paper-icon-button";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { EventsMixin } from "../mixins/events-mixin";
import isComponentLoaded from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
/*
* @appliesMixin EventsMixin
*/
class HaStartVoiceButton extends EventsMixin(PolymerElement) {
static get template() {
return html`
<paper-icon-button
aria-label="Start conversation"
icon="hass:microphone"
hidden$="[[!canListen]]"
on-click="handleListenClick"
></paper-icon-button>
`;
}
static get properties() {
return {
hass: {
type: Object,
value: null,
},
canListen: {
type: Boolean,
computed: "computeCanListen(hass)",
notify: true,
},
};
}
computeCanListen(hass) {
return (
"webkitSpeechRecognition" in window &&
isComponentLoaded(hass, "conversation")
);
}
handleListenClick() {
fireEvent(this, "show-dialog", {
dialogImport: () =>
import(/* webpackChunkName: "voice-command-dialog" */ "../dialogs/ha-voice-command-dialog"),
dialogTag: "ha-voice-command-dialog",
});
}
}
customElements.define("ha-start-voice-button", HaStartVoiceButton);

14
src/data/conversation.ts Normal file
View File

@ -0,0 +1,14 @@
import { HomeAssistant } from "../types";
interface ProcessResults {
card: { [key: string]: { [key: string]: string } };
speech: {
[SpeechType in "plain" | "ssml"]: { extra_data: any; speech: string }
};
}
export const processText = (
hass: HomeAssistant,
text: string
): Promise<ProcessResults> =>
hass.callApi("POST", "conversation/process", { text });

View File

@ -9,7 +9,7 @@ import "./more-info/more-info-controls";
import "./more-info/more-info-settings";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import isComponentLoaded from "../common/config/is_component_loaded";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import DialogMixin from "../mixins/dialog-mixin";

View File

@ -1,266 +0,0 @@
import "@polymer/iron-icon/iron-icon";
import "@polymer/paper-dialog-behavior/paper-dialog-shared-styles";
import "@polymer/paper-icon-button/paper-icon-button";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import DialogMixin from "../mixins/dialog-mixin";
/*
* @appliesMixin DialogMixin
*/
class HaVoiceCommandDialog extends DialogMixin(PolymerElement) {
static get template() {
return html`
<style include="paper-dialog-shared-styles">
iron-icon {
margin-right: 8px;
}
.content {
width: 450px;
min-height: 80px;
font-size: 18px;
padding: 16px;
}
.messages {
max-height: 50vh;
overflow: auto;
}
.messages::after {
content: "";
clear: both;
display: block;
}
.message {
clear: both;
margin: 8px 0;
padding: 8px;
border-radius: 15px;
}
.message.user {
margin-left: 24px;
float: right;
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--light-primary-color);
color: var(--primary-text-color);
}
.message.hass {
margin-right: 24px;
float: left;
border-bottom-left-radius: 0px;
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.message.error {
background-color: var(--google-red-500);
color: var(--text-primary-color);
}
.icon {
text-align: center;
}
.icon paper-icon-button {
height: 52px;
width: 52px;
}
.interimTranscript {
color: darkgrey;
}
[hidden] {
display: none;
}
:host {
border-radius: 2px;
}
@media all and (max-width: 450px) {
:host {
margin: 0;
width: 100%;
max-height: calc(100% - 64px);
position: fixed !important;
bottom: 0px;
left: 0px;
right: 0px;
overflow: scroll;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.content {
width: auto;
}
.messages {
max-height: 68vh;
}
}
</style>
<div class="content">
<div class="messages" id="messages">
<template is="dom-repeat" items="[[_conversation]]" as="message">
<div class$="[[_computeMessageClasses(message)]]">
[[message.text]]
</div>
</template>
</div>
<template is="dom-if" if="[[results]]">
<div class="messages">
<div class="message user">
<span>{{results.final}}</span>
<span class="interimTranscript">[[results.interim]]</span>
</div>
</div>
</template>
<div class="icon" hidden$="[[results]]">
<paper-icon-button
icon="hass:text-to-speech"
on-click="startListening"
></paper-icon-button>
</div>
</div>
`;
}
static get properties() {
return {
hass: Object,
results: {
type: Object,
value: null,
observer: "_scrollMessagesBottom",
},
_conversation: {
type: Array,
value: function() {
return [{ who: "hass", text: "How can I help?" }];
},
observer: "_scrollMessagesBottom",
},
};
}
static get observers() {
return ["dialogOpenChanged(opened)"];
}
showDialog() {
this.opened = true;
}
initRecognition() {
/* eslint-disable new-cap */
this.recognition = new webkitSpeechRecognition();
/* eslint-enable new-cap */
this.recognition.onstart = function() {
this.results = {
final: "",
interim: "",
};
}.bind(this);
this.recognition.onerror = function() {
this.recognition.abort();
var text = this.results.final || this.results.interim;
this.results = null;
if (text === "") {
text = "<Home Assistant did not hear anything>";
}
this.push("_conversation", { who: "user", text: text, error: true });
}.bind(this);
this.recognition.onend = function() {
// Already handled by onerror
if (this.results == null) {
return;
}
var text = this.results.final || this.results.interim;
this.results = null;
this.push("_conversation", { who: "user", text: text });
this.hass.callApi("post", "conversation/process", { text: text }).then(
function(response) {
this.push("_conversation", {
who: "hass",
text: response.speech.plain.speech,
});
}.bind(this),
function() {
this.set(
["_conversation", this._conversation.length - 1, "error"],
true
);
}.bind(this)
);
}.bind(this);
this.recognition.onresult = function(event) {
var oldResults = this.results;
var finalTranscript = "";
var interimTranscript = "";
for (var ind = event.resultIndex; ind < event.results.length; ind++) {
if (event.results[ind].isFinal) {
finalTranscript += event.results[ind][0].transcript;
} else {
interimTranscript += event.results[ind][0].transcript;
}
}
this.results = {
interim: interimTranscript,
final: oldResults.final + finalTranscript,
};
}.bind(this);
}
startListening() {
if (!this.recognition) {
this.initRecognition();
}
this.results = {
interim: "",
final: "",
};
this.recognition.start();
}
_scrollMessagesBottom() {
setTimeout(() => {
this.$.messages.scrollTop = this.$.messages.scrollHeight;
if (this.$.messages.scrollTop !== 0) {
this.$.dialog.fire("iron-resize");
}
}, 10);
}
dialogOpenChanged(newVal) {
if (newVal) {
this.startListening();
} else if (!newVal && this.results) {
this.recognition.abort();
}
}
_computeMessageClasses(message) {
return "message " + message.who + (message.error ? " error" : "");
}
}
customElements.define("ha-voice-command-dialog", HaVoiceCommandDialog);

View File

@ -11,7 +11,7 @@ import "../../../components/ha-paper-dropdown-menu";
import HassMediaPlayerEntity from "../../../util/hass-media-player-model";
import { attributeClassNames } from "../../../common/entity/attribute_class_names";
import isComponentLoaded from "../../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { EventsMixin } from "../../../mixins/events-mixin";
import LocalizeMixin from "../../../mixins/localize-mixin";
import { computeRTLDirection } from "../../../common/util/compute_rtl";

View File

@ -13,7 +13,7 @@ import "./controls/more-info-content";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import isComponentLoaded from "../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { DOMAINS_MORE_INFO_NO_HISTORY } from "../../common/const";
import { EventsMixin } from "../../mixins/events-mixin";
import { computeRTL } from "../../common/util/compute_rtl";

View File

@ -0,0 +1,425 @@
import "@polymer/iron-icon/iron-icon";
import "@polymer/paper-dialog-behavior/paper-dialog-shared-styles";
import "@polymer/paper-icon-button/paper-icon-button";
import "../../components/dialog/ha-paper-dialog";
import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable";
import {
LitElement,
html,
property,
CSSResult,
css,
customElement,
query,
PropertyValues,
} from "lit-element";
import { HomeAssistant } from "../../types";
import { fireEvent } from "../../common/dom/fire_event";
import { processText } from "../../data/conversation";
import { classMap } from "lit-html/directives/class-map";
import { PaperInputElement } from "@polymer/paper-input/paper-input";
interface Message {
who: string;
text: string;
error?: boolean;
}
interface Results {
transcript: string;
final: boolean;
}
/* tslint:disable */
// @ts-ignore
window.SpeechRecognition =
// @ts-ignore
window.SpeechRecognition || window.webkitSpeechRecognition;
// @ts-ignore
window.SpeechGrammarList =
// @ts-ignore
window.SpeechGrammarList || window.webkitSpeechGrammarList;
// @ts-ignore
window.SpeechRecognitionEvent =
// @ts-ignore
window.SpeechRecognitionEvent || window.webkitSpeechRecognitionEvent;
/* tslint:enable */
@customElement("ha-voice-command-dialog")
export class HaVoiceCommandDialog extends LitElement {
@property() public hass!: HomeAssistant;
@property() public results: Results | null = null;
@property() private _conversation: Message[] = [
{
who: "hass",
text: "",
},
];
@property() private _opened = false;
@query("#messages") private messages!: HTMLDivElement;
private recognition?: SpeechRecognition;
public async showDialog(): Promise<void> {
this._opened = true;
if (SpeechRecognition) {
this._startListening();
}
}
protected render() {
return html`
<ha-paper-dialog
with-backdrop
.opened=${this._opened}
@opened-changed=${this._openedChanged}
>
<div class="content">
<div class="messages" id="messages">
${this._conversation.map(
(message) => html`
<div class="${this._computeMessageClasses(message)}">
${message.text}
</div>
`
)}
</div>
${this.results
? html`
<div class="messages">
<div class="message user">
<span
class=${classMap({
interimTranscript: !this.results.final,
})}
>${this.results.transcript}</span
>${!this.results.final ? "…" : ""}
</div>
</div>
`
: ""}
<paper-input
@keyup=${this._handleKeyUp}
label="${this.hass!.localize(
`ui.dialogs.voice_command.${
SpeechRecognition ? "label_voice" : "label"
}`
)}"
autofocus
>
${SpeechRecognition
? html`
<span suffix="" slot="suffix">
${this.results
? html`
<div class="bouncer">
<div class="double-bounce1"></div>
<div class="double-bounce2"></div>
</div>
`
: ""}
<paper-icon-button
.active=${Boolean(this.results)}
icon="hass:microphone"
@click=${this._toggleListening}
>
</paper-icon-button>
</span>
`
: ""}
</paper-input>
</div>
</ha-paper-dialog>
`;
}
protected firstUpdated(changedPros: PropertyValues) {
super.updated(changedPros);
this._conversation = [
{
who: "hass",
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
},
];
}
protected updated(changedPros: PropertyValues) {
super.updated(changedPros);
if (changedPros.has("_conversation")) {
this._scrollMessagesBottom();
}
}
private _addMessage(message: Message) {
this._conversation = [...this._conversation, message];
}
private _handleKeyUp(ev: KeyboardEvent) {
const input = ev.target as PaperInputElement;
if (ev.keyCode === 13 && input.value) {
if (this.recognition) {
this.recognition!.abort();
}
this._processText(input.value);
input.value = "";
}
}
private _initRecognition() {
this.recognition = new SpeechRecognition();
this.recognition.interimResults = true;
this.recognition.lang = "en-US";
this.recognition!.onstart = () => {
this.results = {
final: false,
transcript: "",
};
};
this.recognition!.onerror = (event) => {
this.recognition!.abort();
if (event.error !== "aborted") {
const text =
this.results && this.results.transcript
? this.results.transcript
: `<${this.hass.localize(
"ui.dialogs.voice_command.did_not_hear"
)}>`;
this._addMessage({ who: "user", text, error: true });
}
this.results = null;
};
this.recognition!.onend = () => {
// Already handled by onerror
if (this.results == null) {
return;
}
const text = this.results.transcript;
this.results = null;
if (text) {
this._processText(text);
} else {
this._addMessage({
who: "user",
text: `<${this.hass.localize(
"ui.dialogs.voice_command.did_not_hear"
)}>`,
error: true,
});
}
};
this.recognition!.onresult = (event) => {
const result = event.results[0];
this.results = {
transcript: result[0].transcript,
final: result.isFinal,
};
};
}
private async _processText(text: string) {
this._addMessage({ who: "user", text });
try {
const response = await processText(this.hass, text);
this._addMessage({
who: "hass",
text: response.speech.plain.speech,
});
if (speechSynthesis) {
const speech = new SpeechSynthesisUtterance(
response.speech.plain.speech
);
speech.lang = "en-US";
speechSynthesis.speak(speech);
}
} catch {
this._conversation.slice(-1).pop()!.error = true;
this.requestUpdate();
}
}
private _toggleListening() {
if (!this.results) {
this._startListening();
} else {
this.recognition!.stop();
}
}
private _startListening() {
if (!this.recognition) {
this._initRecognition();
}
if (this.results) {
return;
}
this.results = {
transcript: "",
final: false,
};
this.recognition!.start();
}
private _scrollMessagesBottom() {
this.messages.scrollTop = this.messages.scrollHeight;
if (this.messages.scrollTop === 0) {
fireEvent(this.messages, "iron-resize");
}
}
private _openedChanged(ev: CustomEvent) {
this._opened = ev.detail.value;
if (!this._opened && this.recognition) {
this.recognition.abort();
}
}
private _computeMessageClasses(message) {
return "message " + message.who + (message.error ? " error" : "");
}
static get styles(): CSSResult {
return css`
paper-icon-button {
color: var(--secondary-text-color);
}
paper-icon-button[active] {
color: var(--primary-color);
}
.content {
width: 450px;
min-height: 80px;
font-size: 18px;
padding: 16px;
}
.messages {
max-height: 50vh;
overflow: auto;
}
.messages::after {
content: "";
clear: both;
display: block;
}
.message {
clear: both;
margin: 8px 0;
padding: 8px;
border-radius: 15px;
}
.message.user {
margin-left: 24px;
float: right;
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--light-primary-color);
color: var(--primary-text-color);
}
.message.hass {
margin-right: 24px;
float: left;
border-bottom-left-radius: 0px;
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.message.error {
background-color: var(--google-red-500);
color: var(--text-primary-color);
}
.interimTranscript {
color: var(--secondary-text-color);
}
[hidden] {
display: none;
}
:host {
border-radius: 2px;
}
@media all and (max-width: 450px) {
:host {
margin: 0;
width: 100%;
max-height: calc(100% - 64px);
position: fixed !important;
bottom: 0px;
left: 0px;
right: 0px;
overflow: auto;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.messages {
max-height: 68vh;
}
}
.bouncer {
width: 40px;
height: 40px;
position: absolute;
top: 0;
}
.double-bounce1,
.double-bounce2 {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--primary-color);
opacity: 0.2;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-voice-command-dialog": HaVoiceCommandDialog;
}
}

View File

@ -0,0 +1,12 @@
import { fireEvent } from "../../common/dom/fire_event";
const loadVoiceCommandDialog = () =>
import(/* webpackChunkName: "ha-voice-command-dialog" */ "./ha-voice-command-dialog");
export const showVoiceCommandDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-voice-command-dialog",
dialogImport: loadVoiceCommandDialog,
dialogParams: {},
});
};

View File

@ -9,7 +9,7 @@ import "../../../resources/ha-style";
import "../ha-config-section";
import isComponentLoaded from "../../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import LocalizeMixin from "../../../mixins/localize-mixin";
import "./ha-config-name-form";

View File

@ -14,7 +14,7 @@ import "../../../components/ha-icon-next";
import "../ha-config-section";
import "./ha-config-navigation";
import isComponentLoaded from "../../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import LocalizeMixin from "../../../mixins/localize-mixin";
import NavigateMixin from "../../../mixins/navigate-mixin";

View File

@ -2,7 +2,7 @@ import "@polymer/iron-icon/iron-icon";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-item/paper-item";
import isComponentLoaded from "../../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";

View File

@ -1,6 +1,6 @@
import { property, PropertyValues, customElement } from "lit-element";
import "../../layouts/hass-loading-screen";
import isComponentLoaded from "../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { HomeAssistant } from "../../types";
import { CloudStatus, fetchCloudStatus } from "../../data/cloud";
import { listenMediaQuery } from "../../common/dom/media_query";

View File

@ -222,7 +222,7 @@ class DialogPersonDetail extends LitElement {
}
private _openedChanged(ev: PolymerChangedEvent<boolean>): void {
if (!(ev.detail as any).value) {
if (ev.detail.value) {
this._params = undefined;
}
}

View File

@ -9,7 +9,7 @@ import "../../../resources/ha-style";
import "../ha-config-section";
import isComponentLoaded from "../../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import LocalizeMixin from "../../../mixins/localize-mixin";
/*

View File

@ -22,7 +22,7 @@ import scrollToTarget from "../../common/dom/scroll-to-target";
import { haStyle } from "../../resources/styles";
import { HomeAssistant, Route } from "../../types";
import { navigate } from "../../common/navigate";
import isComponentLoaded from "../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@customElement("ha-panel-developer-tools")
class PanelDeveloperTools extends LitElement {

View File

@ -24,7 +24,6 @@ import "@polymer/paper-tabs/paper-tabs";
import scrollToTarget from "../../common/dom/scroll-to-target";
import "../../layouts/ha-app-layout";
import "../../components/ha-start-voice-button";
import "../../components/ha-paper-icon-button-arrow-next";
import "../../components/ha-paper-icon-button-arrow-prev";
import "../../components/ha-icon";
@ -49,9 +48,12 @@ import { afterNextRender } from "../../common/util/render-status";
import { haStyle } from "../../resources/styles";
import { computeRTLDirection } from "../../common/util/compute_rtl";
import { loadLovelaceResources } from "./common/load-resources";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import memoizeOne from "memoize-one";
class HUIRoot extends LitElement {
@property() public hass?: HomeAssistant;
@property() public hass!: HomeAssistant;
@property() public lovelace?: Lovelace;
@property() public columns?: number;
@property() public narrow?: boolean;
@ -62,6 +64,10 @@ class HUIRoot extends LitElement {
private _debouncedConfigChanged: () => void;
private _conversation = memoizeOne((_components) =>
isComponentLoaded(this.hass, "conversation")
);
constructor() {
super();
// The view can trigger a re-render when it knows that certain
@ -161,9 +167,15 @@ class HUIRoot extends LitElement {
.narrow=${this.narrow}
></ha-menu-button>
<div main-title>${this.config.title || "Home Assistant"}</div>
<ha-start-voice-button
.hass="${this.hass}"
></ha-start-voice-button>
${this._conversation(this.hass.config.components)
? html`
<paper-icon-button
aria-label="Start conversation"
icon="hass:microphone"
@click=${this._showVoiceCommandDialog}
></paper-icon-button>
`
: ""}
<paper-menu-button
no-animations
horizontal-align="right"
@ -546,6 +558,10 @@ class HUIRoot extends LitElement {
ev.target.selected = null;
}
private _showVoiceCommandDialog(): void {
showVoiceCommandDialog(this);
}
private _handleHelp(): void {
window.open("https://www.home-assistant.io/lovelace/", "_blank");
}

View File

@ -3,7 +3,7 @@ import "@polymer/iron-label/iron-label";
import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import isComponentLoaded from "../../common/config/is_component_loaded";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { pushSupported } from "../../components/ha-push-notifications-toggle";
import LocalizeMixin from "../../mixins/localize-mixin";

View File

@ -13,9 +13,10 @@ import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../components/ha-menu-button";
import "../../components/ha-start-voice-button";
import "../../components/ha-card";
import LocalizeMixin from "../../mixins/localize-mixin";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
/*
* @appliesMixin LocalizeMixin
@ -72,10 +73,14 @@ class HaPanelShoppingList extends LocalizeMixin(PolymerElement) {
narrow="[[narrow]]"
></ha-menu-button>
<div main-title>[[localize('panel.shopping_list')]]</div>
<ha-start-voice-button
hass="[[hass]]"
can-listen="{{canListen}}"
></ha-start-voice-button>
<paper-icon-button
hidden$="[[!conversation]]"
aria-label="Start conversation"
icon="hass:microphone"
on-click="_showVoiceCommandDialog"
></paper-icon-button>
<paper-menu-button
horizontal-align="right"
horizontal-offset="-5"
@ -131,7 +136,7 @@ class HaPanelShoppingList extends LocalizeMixin(PolymerElement) {
</paper-icon-item>
</template>
</ha-card>
<div class="tip" hidden$="[[!canListen]]">
<div class="tip" hidden$="[[!conversation]]">
[[localize('ui.panel.shopping-list.microphone_tip')]]
</div>
</div>
@ -143,7 +148,10 @@ class HaPanelShoppingList extends LocalizeMixin(PolymerElement) {
return {
hass: Object,
narrow: Boolean,
canListen: Boolean,
conversation: {
type: Boolean,
computed: "_computeConversation(hass)",
},
items: {
type: Array,
value: [],
@ -207,6 +215,14 @@ class HaPanelShoppingList extends LocalizeMixin(PolymerElement) {
}
}
_computeConversation(hass) {
return isComponentLoaded(hass, "conversation");
}
_showVoiceCommandDialog() {
showVoiceCommandDialog(this);
}
_saveEdit(ev) {
const { index, item } = ev.model;
const name = ev.target.value;

View File

@ -12,7 +12,6 @@ import "@polymer/paper-tabs/paper-tabs";
import "../../components/ha-cards";
import "../../components/ha-icon";
import "../../components/ha-menu-button";
import "../../components/ha-start-voice-button";
import "../../layouts/ha-app-layout";
@ -23,6 +22,8 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain";
import computeLocationName from "../../common/config/location_name";
import NavigateMixin from "../../mixins/navigate-mixin";
import { EventsMixin } from "../../mixins/events-mixin";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
const ALWAYS_SHOW_DOMAIN = ["persistent_notification", "configurator"];
@ -72,7 +73,12 @@ class PartialCards extends EventsMixin(NavigateMixin(PolymerElement)) {
<div main-title="">
[[computeTitle(views, defaultView, locationName)]]
</div>
<ha-start-voice-button hass="[[hass]]"></ha-start-voice-button>
<paper-icon-button
hidden$="[[!conversation]]"
aria-label="Start conversation"
icon="hass:microphone"
on-click="_showVoiceCommandDialog"
></paper-icon-button>
</app-toolbar>
<div sticky="" hidden$="[[areTabsHidden(views, showTabs)]]">
@ -174,6 +180,11 @@ class PartialCards extends EventsMixin(NavigateMixin(PolymerElement)) {
value: 1,
},
conversation: {
type: Boolean,
computed: "_computeConversation(hass)",
},
locationName: {
type: String,
value: "",
@ -241,6 +252,14 @@ class PartialCards extends EventsMixin(NavigateMixin(PolymerElement)) {
);
}
_computeConversation(hass) {
return isComponentLoaded(hass, "conversation");
}
_showVoiceCommandDialog() {
showVoiceCommandDialog(this);
}
areTabsHidden(views, showTabs) {
return !views || !views.length || !showTabs;
}

View File

@ -554,6 +554,12 @@
}
},
"dialogs": {
"voice_command": {
"did_not_hear": "Home Assistant did not hear anything",
"how_can_i_help": "How can I help?",
"label": "Type a question and press <Enter>",
"label_voice": "Type and press <Enter> or tap the microphone icon to speak"
},
"confirmation": {
"cancel": "Cancel",
"ok": "OK",
@ -1698,7 +1704,7 @@
"shopping-list": {
"clear_completed": "Clear completed",
"add_item": "Add item",
"microphone_tip": "Tap the microphone on the top right and say “Add candy to my shopping list”"
"microphone_tip": "Tap the microphone on the top right and say or type “Add candy to my shopping list”"
},
"page-authorize": {
"initializing": "Initializing",

View File

@ -2037,6 +2037,11 @@
dependencies:
"@types/node" "*"
"@types/webspeechapi@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/webspeechapi/-/webspeechapi-0.0.29.tgz#8f3c6b31b779df7a9bbac7f89acfce0c3bcb1972"
integrity sha512-AYEhqEJLdR08YPBOwYa73IHTiGU4DdngbKbtZdW+bzuM7s8LzKBed0Fwgl/a3oMqMY227qOT+3Lpr5A0WSmm+A==
"@types/whatwg-url@^6.4.0":
version "6.4.0"
resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"