diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8948f51df5..4a67ddd5a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,7 +30,7 @@ jobs: env: CI: true - name: Build resources - run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-demos + run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages - name: Run eslint run: yarn run lint:eslint - name: Run tsc diff --git a/.github/workflows/netflify.yml b/.github/workflows/netflify.yml index 47f13fef80..51989057ba 100644 --- a/.github/workflows/netflify.yml +++ b/.github/workflows/netflify.yml @@ -15,5 +15,5 @@ jobs: - name: Trigger Demo build run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_DEMO_DEV_BUILD_HOOK }} - - name: Trigger Gallery build - run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_GALLERY_DEV_BUILD_HOOK }} + - name: Trigger Design build + run: curl -X POST -d "NIGHTLY" https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_GALLERY_DEV_BUILD_HOOK }} diff --git a/build-scripts/gulp/clean.js b/build-scripts/gulp/clean.js index 2b184d5e63..7bc0ed9e83 100644 --- a/build-scripts/gulp/clean.js +++ b/build-scripts/gulp/clean.js @@ -31,6 +31,6 @@ gulp.task("clean-hassio", () => gulp.task( "clean-gallery", gulp.parallel("clean-translations", () => - del([paths.gallery_output_root, paths.build_dir]) + del([paths.gallery_output_root, paths.gallery_build, paths.build_dir]) ) ); diff --git a/build-scripts/gulp/gallery.js b/build-scripts/gulp/gallery.js index 2823ad70b1..fcca2fc26e 100644 --- a/build-scripts/gulp/gallery.js +++ b/build-scripts/gulp/gallery.js @@ -1,7 +1,11 @@ +/* eslint-disable */ // Run demo develop mode const gulp = require("gulp"); const fs = require("fs"); const path = require("path"); +const marked = require("marked"); +const glob = require("glob"); +const yaml = require("js-yaml"); const env = require("../env"); const paths = require("../paths"); @@ -15,26 +19,129 @@ require("./service-worker.js"); require("./entry-html.js"); require("./rollup.js"); -gulp.task("gather-gallery-demos", async function gatherDemos() { - const files = await fs.promises.readdir( - path.resolve(paths.gallery_dir, "src/demos") - ); - - let content = "export const DEMOS = {\n"; - - for (const file of files) { - const demoId = path.basename(file, ".ts"); - const demoPath = "../src/demos/" + demoId; - content += ` "${demoId}": () => import("${demoPath}"),\n`; - } - - content += "};"; +gulp.task("gather-gallery-pages", async function gatherPages() { + const pageDir = path.resolve(paths.gallery_dir, "src/pages"); + const files = glob.sync(path.resolve(pageDir, "**/*")); const galleryBuild = path.resolve(paths.gallery_dir, "build"); - fs.mkdirSync(galleryBuild, { recursive: true }); + + let content = "export const PAGES = {\n"; + + const processed = new Set(); + + for (const file of files) { + if (fs.lstatSync(file).isDirectory()) { + continue; + } + const pageId = file.substring(pageDir.length + 1, file.lastIndexOf(".")); + + if (processed.has(pageId)) { + continue; + } + processed.add(pageId); + + const [category, name] = pageId.split("/", 2); + + const demoFile = path.resolve(pageDir, `${pageId}.ts`); + const descriptionFile = path.resolve(pageDir, `${pageId}.markdown`); + const hasDemo = fs.existsSync(demoFile); + let hasDescription = fs.existsSync(descriptionFile); + let metadata = {}; + if (hasDescription) { + let descriptionContent = fs.readFileSync(descriptionFile, "utf-8"); + + if (descriptionContent.startsWith("---")) { + const metadataEnd = descriptionContent.indexOf("---", 3); + metadata = yaml.load(descriptionContent.substring(3, metadataEnd)); + descriptionContent = descriptionContent + .substring(metadataEnd + 3) + .trim(); + } + + // If description is just metadata + if (descriptionContent === "") { + hasDescription = false; + } else { + descriptionContent = marked(descriptionContent).replace(/`/g, "\\`"); + fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true }); + fs.writeFileSync( + path.resolve(galleryBuild, `${pageId}-description.ts`), + ` + import {html} from "lit"; + export default html\`${descriptionContent}\` + ` + ); + } + } + content += ` "${pageId}": { + metadata: ${JSON.stringify(metadata)}, + ${ + hasDescription + ? `description: () => import("./${pageId}-description").then(m => m.default),` + : "" + } + ${hasDemo ? `demo: () => import("../src/pages/${pageId}")` : ""} + + },\n`; + } + + content += "};\n"; + + // Generate sidebar + const sidebarPath = path.resolve(paths.gallery_dir, "sidebar.js"); + // To make watch work during development + delete require.cache[sidebarPath]; + const sidebar = require(sidebarPath); + + const pagesToProcess = {}; + for (const key of processed) { + const [category, page] = key.split("/", 2); + if (!(category in pagesToProcess)) { + pagesToProcess[category] = new Set(); + } + pagesToProcess[category].add(page); + } + + for (const group of Object.values(sidebar)) { + const toProcess = pagesToProcess[group.category]; + delete pagesToProcess[group.category]; + + if (!toProcess) { + console.error("Unknown category", group.category); + if (!group.pages) { + group.pages = []; + } + continue; + } + + // Any pre-defined groups will not be sorted. + if (group.pages) { + for (const page of group.pages) { + if (!toProcess.delete(page)) { + console.error("Found unreferenced demo", page); + } + } + } else { + group.pages = []; + } + for (const page of Array.from(toProcess).sort()) { + group.pages.push(page); + } + } + + for (const [category, pages] of Object.entries(pagesToProcess)) { + sidebar.push({ + category, + header: category, + pages: Array.from(pages).sort(), + }); + } + + content += `export const SIDEBAR = ${JSON.stringify(sidebar, null, 2)};\n`; + fs.writeFileSync( - path.resolve(galleryBuild, "import-demos.ts"), + path.resolve(galleryBuild, "import-pages.ts"), content, "utf-8" ); @@ -52,11 +159,24 @@ gulp.task( "gen-icons-json", "build-translations", "build-locale-data", - "gather-gallery-demos" + "gather-gallery-pages" ), "copy-static-gallery", "gen-index-gallery-dev", - env.useRollup() ? "rollup-dev-server-gallery" : "webpack-dev-server-gallery" + gulp.parallel( + env.useRollup() + ? "rollup-dev-server-gallery" + : "webpack-dev-server-gallery", + async function watchMarkdownFiles() { + gulp.watch( + [ + path.resolve(paths.gallery_dir, "src/pages/**/*.markdown"), + path.resolve(paths.gallery_dir, "sidebar.js"), + ], + gulp.series("gather-gallery-pages") + ); + } + ) ) ); @@ -72,7 +192,7 @@ gulp.task( "gen-icons-json", "build-translations", "build-locale-data", - "gather-gallery-demos" + "gather-gallery-pages" ), "copy-static-gallery", env.useRollup() ? "rollup-prod-gallery" : "webpack-prod-gallery", diff --git a/build-scripts/paths.js b/build-scripts/paths.js index d89f5f582a..241c7669dd 100644 --- a/build-scripts/paths.js +++ b/build-scripts/paths.js @@ -26,6 +26,7 @@ module.exports = { cast_output_es5: path.resolve(__dirname, "../cast/dist/frontend_es5"), gallery_dir: path.resolve(__dirname, "../gallery"), + gallery_build: path.resolve(__dirname, "../gallery/build"), gallery_output_root: path.resolve(__dirname, "../gallery/dist"), gallery_output_latest: path.resolve( __dirname, diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts index a43eac5c78..06544bcc37 100644 --- a/demo/src/stubs/energy.ts +++ b/demo/src/stubs/energy.ts @@ -83,7 +83,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => { })); hass.mockWS("energy/info", () => ({ cost_sensors: [] })); hass.mockWS("energy/fossil_energy_consumption", ({ period }) => ({ - start: period === "month" ? 500 : period === "day" ? 20 : 5, + start: period === "month" ? 250 : period === "day" ? 10 : 2, })); const todayString = format(startOfToday(), "yyyy-MM-dd"); const tomorrowString = format(startOfTomorrow(), "yyyy-MM-dd"); diff --git a/gallery/script/netlify_build_gallery b/gallery/script/netlify_build_gallery index 173b77d73f..a5732d4c83 100755 --- a/gallery/script/netlify_build_gallery +++ b/gallery/script/netlify_build_gallery @@ -1,6 +1,6 @@ #!/bin/bash -TARGET_LABEL="Needs gallery preview" +TARGET_LABEL="needs design preview" if [[ "$NETLIFY" != "true" ]]; then echo "This script can only be run on Netlify" @@ -13,16 +13,14 @@ function createStatus() { target_url="$3" curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $GITHUB_TOKEN" \ "https://api.github.com/repos/home-assistant/frontend/statuses/$COMMIT_REF" \ - -d '{"state": "'"${state}"'", "context": "Netlify/Gallery Preview Build", "description": "'"$description"'", "target_url": "'"$target_url"'"}' + -d '{"state": "'"${state}"'", "context": "Netlify/Design Preview Build", "description": "'"$description"'", "target_url": "'"$target_url"'"}' } -if [[ "${PULL_REQUEST}" == "false" ]]; then - gulp build-gallery -else +if [[ "${PULL_REQUEST}" == "true" ]]; then if [[ "$(curl -sSLf -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $GITHUB_TOKEN" \ "https://api.github.com/repos/home-assistant/frontend/pulls/${REVIEW_ID}" | jq '.labels[].name' -r)" =~ "$TARGET_LABEL" ]]; then - createStatus "pending" "Building gallery preview" "https://app.netlify.com/sites/home-assistant-gallery/deploys/$BUILD_ID" + createStatus "pending" "Building design preview" "https://app.netlify.com/sites/home-assistant-gallery/deploys/$BUILD_ID" gulp build-gallery if [ $? -eq 0 ]; then createStatus "success" "Build complete" "$DEPLOY_URL" @@ -32,4 +30,6 @@ else else createStatus "success" "Build was not requested by PR label" fi +elif [[ "$INCOMING_HOOK_BODY" == "NIGHTLY" ]]; then + gulp build-gallery fi diff --git a/gallery/sidebar.js b/gallery/sidebar.js new file mode 100644 index 0000000000..84bd8f8eff --- /dev/null +++ b/gallery/sidebar.js @@ -0,0 +1,48 @@ +module.exports = [ + { + // This section has no header and so all page links are shown directly in the sidebar + category: "concepts", + pages: ["home"], + }, + + { + category: "lovelace", + // Label for in the sidebar + header: "Lovelace", + // Specify order of pages. Any pages in the category folder but not listed here will + // automatically be added after the pages listed here. + pages: ["introduction"], + }, + { + category: "automation", + header: "Automation", + pages: [ + "editor-trigger", + "editor-condition", + "editor-action", + "selectors", + "trace", + "trace-timeline", + ], + }, + { + category: "components", + header: "Components", + }, + { + category: "more-info", + header: "More Info dialogs", + }, + { + category: "misc", + header: "Miscelaneous", + }, + { + category: "user-test", + header: "User Tests", + }, + { + category: "design.home-assistant.io", + header: "Design Documentation", + }, +]; diff --git a/gallery/src/components/demo-card.js b/gallery/src/components/demo-card.js deleted file mode 100644 index 4d9ce53899..0000000000 --- a/gallery/src/components/demo-card.js +++ /dev/null @@ -1,129 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { load } from "js-yaml"; -import { createCardElement } from "../../../src/panels/lovelace/create-element/create-card-element"; - -class DemoCard extends PolymerElement { - static get template() { - return html` - -

- [[config.heading]] - -

-
-
- -
- `; - } - - static get properties() { - return { - hass: { - type: Object, - observer: "_hassChanged", - }, - config: { - type: Object, - observer: "_configChanged", - }, - showConfig: Boolean, - _size: { - type: Number, - }, - }; - } - - ready() { - super.ready(); - } - - _configChanged(config) { - const card = this.$.card; - while (card.lastChild) { - card.removeChild(card.lastChild); - } - - const el = this._createCardElement(load(config.config)[0]); - card.appendChild(el); - this._getSize(el); - } - - async _getSize(el) { - await customElements.whenDefined(el.localName); - - if (!("getCardSize" in el)) { - this._size = undefined; - return; - } - this._size = await el.getCardSize(); - } - - _createCardElement(cardConfig) { - const element = createCardElement(cardConfig); - if (this.hass) { - element.hass = this.hass; - } - element.addEventListener( - "ll-rebuild", - (ev) => { - ev.stopPropagation(); - this._rebuildCard(element, cardConfig); - }, - { once: true } - ); - return element; - } - - _rebuildCard(cardElToReplace, config) { - const newCardEl = this._createCardElement(config); - cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace); - } - - _hassChanged(hass) { - const card = this.$.card.lastChild; - if (card) card.hass = hass; - } - - _trim(config) { - return config.trim(); - } -} - -customElements.define("demo-card", DemoCard); diff --git a/gallery/src/components/demo-card.ts b/gallery/src/components/demo-card.ts new file mode 100644 index 0000000000..1c08792952 --- /dev/null +++ b/gallery/src/components/demo-card.ts @@ -0,0 +1,129 @@ +import { load } from "js-yaml"; +import { html, css, LitElement, PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { createCardElement } from "../../../src/panels/lovelace/create-element/create-card-element"; +import { HomeAssistant } from "../../../src/types"; + +export interface DemoCardConfig { + heading: string; + config: string; +} + +@customElement("demo-card") +class DemoCard extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public config!: DemoCardConfig; + + @property() public showConfig = false; + + @state() private _size?: number; + + @query("#card") private _card!: HTMLElement; + + render() { + return html` +

+ ${this.config.heading} + ${this._size !== undefined + ? html`(size ${this._size})` + : ""} +

+
+
+ ${this.showConfig ? html`
${this.config.config.trim()}
` : ""} +
+ `; + } + + updated(changedProps: PropertyValues) { + super.updated(changedProps); + + if (changedProps.has("config")) { + const card = this._card; + while (card.lastChild) { + card.removeChild(card.lastChild); + } + + const el = this._createCardElement((load(this.config.config) as any)[0]); + card.appendChild(el); + this._getSize(el); + } + + if (changedProps.has("hass")) { + const card = this._card.lastChild; + if (card) { + (card as any).hass = this.hass; + } + } + } + + async _getSize(el) { + await customElements.whenDefined(el.localName); + + if (!("getCardSize" in el)) { + this._size = undefined; + return; + } + this._size = await el.getCardSize(); + } + + _createCardElement(cardConfig) { + const element = createCardElement(cardConfig); + if (this.hass) { + element.hass = this.hass; + } + element.addEventListener( + "ll-rebuild", + (ev) => { + ev.stopPropagation(); + this._rebuildCard(element, cardConfig); + }, + { once: true } + ); + return element; + } + + _rebuildCard(cardElToReplace, config) { + const newCardEl = this._createCardElement(config); + cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace); + } + + static styles = css` + .root { + display: flex; + } + h2 { + margin: 0 0 20px; + color: var(--primary-color); + } + h2 small { + font-size: 0.5em; + color: var(--primary-text-color); + } + #card { + max-width: 400px; + width: 100vw; + } + pre { + width: 400px; + margin: 0 16px; + overflow: auto; + color: var(--primary-text-color); + } + @media only screen and (max-width: 800px) { + .root { + flex-direction: column; + } + pre { + margin: 16px 0; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "demo-card": DemoCard; + } +} diff --git a/gallery/src/components/demo-cards.js b/gallery/src/components/demo-cards.js deleted file mode 100644 index 59faa671a6..0000000000 --- a/gallery/src/components/demo-cards.js +++ /dev/null @@ -1,83 +0,0 @@ -import "@polymer/app-layout/app-toolbar/app-toolbar"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element"; -import "../../../src/components/ha-formfield"; -import "../../../src/components/ha-switch"; -import "./demo-card"; - -class DemoCards extends PolymerElement { - static get template() { - return html` - - -
- - - - - - - -
-
-
-
- -
-
- `; - } - - static get properties() { - return { - configs: Object, - hass: Object, - _showConfig: { - type: Boolean, - value: false, - }, - }; - } - - _showConfigToggled(ev) { - this._showConfig = ev.target.checked; - } - - _darkThemeToggled(ev) { - applyThemesOnElement(this.$.container, { themes: {} }, "default", { - dark: ev.target.checked, - }); - } -} - -customElements.define("demo-cards", DemoCards); diff --git a/gallery/src/components/demo-cards.ts b/gallery/src/components/demo-cards.ts new file mode 100644 index 0000000000..fa91ca2097 --- /dev/null +++ b/gallery/src/components/demo-cards.ts @@ -0,0 +1,88 @@ +import "@polymer/app-layout/app-toolbar/app-toolbar"; +import { html, css, LitElement } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element"; +import "../../../src/components/ha-formfield"; +import "../../../src/components/ha-switch"; +import { HomeAssistant } from "../../../src/types"; +import "./demo-card"; +import type { DemoCardConfig } from "./demo-card"; + +@customElement("demo-cards") +class DemoCards extends LitElement { + @property() public configs!: DemoCardConfig[]; + + @property() public hass!: HomeAssistant; + + @state() private _showConfig = false; + + @query("#container") private _container!: HTMLElement; + + render() { + return html` + +
+ + + + + + + +
+
+
+
+ ${this.configs.map( + (config) => html` + + ` + )} +
+
+ `; + } + + _showConfigToggled(ev) { + this._showConfig = ev.target.checked; + } + + _darkThemeToggled(ev) { + applyThemesOnElement(this._container, { themes: {} } as any, "default", { + dark: ev.target.checked, + }); + } + + static styles = css` + .cards { + display: flex; + flex-wrap: wrap; + justify-content: center; + } + demo-card { + margin: 16px 16px 32px; + } + app-toolbar { + background-color: var(--light-primary-color); + } + .filters { + margin-left: 60px; + } + ha-formfield { + margin-right: 16px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "demo-cards": DemoCards; + } +} diff --git a/gallery/src/components/page-description.ts b/gallery/src/components/page-description.ts new file mode 100644 index 0000000000..8e449195a6 --- /dev/null +++ b/gallery/src/components/page-description.ts @@ -0,0 +1,46 @@ +import { html, css } from "lit"; +import { customElement, property } from "lit/decorators"; +import { until } from "lit/directives/until"; +import { HaMarkdown } from "../../../src/components/ha-markdown"; +import { PAGES } from "../../build/import-pages"; + +@customElement("page-description") +class PageDescription extends HaMarkdown { + @property() public page!: string; + + render() { + if (!PAGES[this.page].description) { + return html``; + } + return html` + ${until( + PAGES[this.page] + .description() + .then((content) => html`
${content}
`), + "" + )} + `; + } + + static styles = [ + HaMarkdown.styles, + css` + .root { + max-width: 800px; + margin: 0 auto; + } + .root > *:first-child { + margin-top: 0; + } + .root > *:last-child { + margin-bottom: 0; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "page-description": PageDescription; + } +} diff --git a/gallery/src/ha-gallery.js b/gallery/src/ha-gallery.js deleted file mode 100644 index 671cbe9077..0000000000 --- a/gallery/src/ha-gallery.js +++ /dev/null @@ -1,225 +0,0 @@ -import "@polymer/app-layout/app-header-layout/app-header-layout"; -import "@polymer/app-layout/app-header/app-header"; -import "@polymer/app-layout/app-toolbar/app-toolbar"; -import "@polymer/paper-item/paper-item"; -import "@polymer/paper-item/paper-item-body"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import "../../src/components/ha-card"; -import "../../src/components/ha-icon"; -import "../../src/components/ha-icon-button"; -import "../../src/managers/notification-manager"; -import "../../src/styles/polymer-ha-style"; -// eslint-disable-next-line import/extensions -import { DEMOS } from "../build/import-demos"; - -class HaGallery extends PolymerElement { - static get template() { - return html` - - - - - - - - -
- [[_withDefault(_demo, "Home Assistant Gallery")]] -
-
-
- -
-
- -
-
- - `; - } - - static get properties() { - return { - _fakeHass: { - type: Object, - // Just enough for computeRTL - value: { - language: "en", - translationMetadata: { - translations: {}, - }, - }, - }, - _demo: { - type: String, - value: document.location.hash.substr(1), - observer: "_demoChanged", - }, - _demos: { - type: Array, - value: Object.keys(DEMOS), - }, - _lovelaceDemos: { - type: Array, - computed: "_computeLovelace(_demos)", - }, - _restDemos: { - type: Array, - computed: "_computeRest(_demos)", - }, - }; - } - - ready() { - super.ready(); - - this.addEventListener("show-notification", (ev) => - this.$.notifications.showDialog({ message: ev.detail.message }) - ); - - this.addEventListener("alert-dismissed-clicked", () => - this.$.notifications.showDialog({ message: "Alert dismissed clicked" }) - ); - this.addEventListener("hass-more-info", (ev) => { - if (ev.detail.entityId) { - this.$.notifications.showDialog({ - message: `Showing more info for ${ev.detail.entityId}`, - }); - } - }); - - window.addEventListener("hashchange", () => { - this._demo = document.location.hash.substr(1); - }); - } - - _withDefault(value, def) { - return value || def; - } - - _demoChanged(demo) { - const root = this.$.demo; - - while (root.lastChild) root.removeChild(root.lastChild); - - if (demo) { - DEMOS[demo](); - const el = document.createElement(demo); - root.appendChild(el); - } - } - - _computeHeaderButtonClass(demo) { - return demo ? "" : "invisible"; - } - - _backTapped() { - document.location.hash = ""; - } - - _computeLovelace(demos) { - return demos.filter((demo) => demo.includes("hui")); - } - - _computeRest(demos) { - return demos.filter((demo) => !demo.includes("hui")); - } -} - -customElements.define("ha-gallery", HaGallery); diff --git a/gallery/src/ha-gallery.ts b/gallery/src/ha-gallery.ts new file mode 100644 index 0000000000..86e64e8d35 --- /dev/null +++ b/gallery/src/ha-gallery.ts @@ -0,0 +1,257 @@ +import { mdiMenu } from "@mdi/js"; +import "@material/mwc-drawer"; +import "@material/mwc-top-app-bar-fixed"; +import { html, css, LitElement, PropertyValues } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import "../../src/components/ha-icon-button"; +import "../../src/managers/notification-manager"; +import { haStyle } from "../../src/resources/styles"; +import { PAGES, SIDEBAR } from "../build/import-pages"; +import { dynamicElement } from "../../src/common/dom/dynamic-element-directive"; +import "./components/page-description"; + +const GITHUB_DEMO_URL = + "https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/"; + +const FAKE_HASS = { + // Just enough for computeRTL for notification-manager + language: "en", + translationMetadata: { + translations: {}, + }, +}; + +@customElement("ha-gallery") +class HaGallery extends LitElement { + @property() private _page = + document.location.hash.substring(1) || + `${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`; + + @query("notification-manager") + private _notifications!: HTMLElementTagNameMap["notification-manager"]; + + @query("mwc-drawer") + private _drawer!: HTMLElementTagNameMap["mwc-drawer"]; + + private _narrow = window.matchMedia("(max-width: 600px)").matches; + + render() { + const sidebar: unknown[] = []; + + for (const group of SIDEBAR) { + const links: unknown[] = []; + + for (const page of group.pages!) { + const key = `${group.category}/${page}`; + const active = this._page === key; + const title = PAGES[key].metadata.title || page; + links.push(html` + ${title} + `); + } + + sidebar.push( + group.header + ? html` +
+ ${group.header} + ${links} +
+ ` + : links + ); + } + + return html` + + Home Assistant Design + + +
+ + + +
+ ${PAGES[this._page].metadata.title || this._page.split("/")[1]} +
+
+
+ ${PAGES[this._page].description + ? html` + + ` + : ""} + ${dynamicElement(`demo-${this._page.replace("/", "-")}`)} +
+ +
+
+ + `; + } + + firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + + this.addEventListener("show-notification", (ev) => + this._notifications.showDialog({ message: ev.detail.message }) + ); + this.addEventListener("alert-dismissed-clicked", () => + this._notifications.showDialog({ message: "Alert dismissed clicked" }) + ); + this.addEventListener("hass-more-info", (ev) => { + if (ev.detail.entityId) { + this._notifications.showDialog({ + message: `Showing more info for ${ev.detail.entityId}`, + }); + } + }); + + document.location.hash = this._page; + + window.addEventListener("hashchange", () => { + this._page = document.location.hash.substring(1); + if (this._narrow) { + this._drawer.open = false; + } + }); + } + + updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!changedProps.has("_page")) { + return; + } + + if (PAGES[this._page].demo) { + PAGES[this._page].demo(); + } + + const menuItem = this.shadowRoot!.querySelector( + `a[href="#${this._page}"]` + )!; + // Make sure section is expanded + if (menuItem.parentElement instanceof HTMLDetailsElement) { + menuItem.parentElement.open = true; + } + } + + _menuTapped() { + this._drawer.open = !this._drawer.open; + } + + static styles = [ + haStyle, + css` + :host { + -ms-user-select: initial; + -webkit-user-select: initial; + -moz-user-select: initial; + } + + .sidebar { + padding: 4px; + } + + .sidebar details { + margin-top: 1em; + margin-left: 1em; + } + + .sidebar summary { + cursor: pointer; + font-weight: bold; + margin-bottom: 8px; + } + + .sidebar a { + color: var(--primary-text-color); + display: block; + padding: 4px 12px; + text-decoration: none; + position: relative; + } + + .sidebar a[active]::before { + border-radius: 4px; + position: absolute; + top: 0; + right: 2px; + bottom: 0; + left: 2px; + pointer-events: none; + content: ""; + transition: opacity 15ms linear; + will-change: opacity; + background-color: var(--sidebar-selected-icon-color); + opacity: 0.12; + } + + div[slot="appContent"] { + display: flex; + flex-direction: column; + min-height: 100vh; + background: var(--primary-background-color); + } + + .content { + flex: 1; + } + + page-description { + margin: 16px; + } + + .page-footer { + text-align: center; + margin: 16px 0; + padding-top: 16px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + + .page-footer a { + display: inline-block; + margin: 0 8px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-gallery": HaGallery; + } +} diff --git a/gallery/src/html/index.html.template b/gallery/src/html/index.html.template index 3240e59bb0..05d6a4bb36 100644 --- a/gallery/src/html/index.html.template +++ b/gallery/src/html/index.html.template @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1, shrink-to-fit=no" /> - HAGallery + Home Assistant Design - - - -
[[value]] [[units]]
-
-
- - - -
-
- - - -
-
- `; - } - - static get properties() { - return { - value: { - type: Number, - observer: "valueChanged", - }, - units: { - type: String, - }, - min: { - type: Number, - }, - max: { - type: Number, - }, - step: { - type: Number, - value: 1, - }, - }; - } - - temperatureStateInFlux(inFlux) { - this.$.target_temperature.classList.toggle("in-flux", inFlux); - } - - _round(val) { - // round value to precision derived from step - // insired by https://github.com/soundar24/roundSlider/blob/master/src/roundslider.js - const s = this.step.toString().split("."); - return s[1] ? parseFloat(val.toFixed(s[1].length)) : Math.round(val); - } - - incrementValue() { - const newval = this._round(this.value + this.step); - if (this.value < this.max) { - this.last_changed = Date.now(); - this.temperatureStateInFlux(true); - } - if (newval <= this.max) { - // If no initial target_temp - // this forces control to start - // from the min configured instead of 0 - if (newval <= this.min) { - this.value = this.min; - } else { - this.value = newval; - } - } else { - this.value = this.max; - } - } - - decrementValue() { - const newval = this._round(this.value - this.step); - if (this.value > this.min) { - this.last_changed = Date.now(); - this.temperatureStateInFlux(true); - } - if (newval >= this.min) { - this.value = newval; - } else { - this.value = this.min; - } - } - - valueChanged() { - // when the last_changed timestamp is changed, - // trigger a potential event fire in - // the future, as long as last changed is far enough in the - // past. - if (this.last_changed) { - window.setTimeout(() => { - const now = Date.now(); - if (now - this.last_changed >= 2000) { - this.fire("change"); - this.temperatureStateInFlux(false); - this.last_changed = null; - } - }, 2010); - } - } -} - -customElements.define("ha-climate-control", HaClimateControl); diff --git a/src/components/ha-climate-control.ts b/src/components/ha-climate-control.ts new file mode 100644 index 0000000000..b57cbb5053 --- /dev/null +++ b/src/components/ha-climate-control.ts @@ -0,0 +1,138 @@ +import { mdiChevronDown, mdiChevronUp } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { conditionalClamp } from "../common/number/clamp"; +import { HomeAssistant } from "../types"; +import "./ha-icon"; +import "./ha-icon-button"; + +@customElement("ha-climate-control") +class HaClimateControl extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public value!: number; + + @property() public unit = ""; + + @property() public min?: number; + + @property() public max?: number; + + @property() public step = 1; + + private _lastChanged?: number; + + @query("#target_temperature") private _targetTemperature!: HTMLElement; + + protected render(): TemplateResult { + return html` +
${this.value} ${this.unit}
+
+
+ + +
+
+ + +
+
+ `; + } + + protected updated(changedProperties) { + if (changedProperties.has("value")) { + this._valueChanged(); + } + } + + private _temperatureStateInFlux(inFlux) { + this._targetTemperature.classList.toggle("in-flux", inFlux); + } + + private _round(value) { + // Round value to precision derived from step. + // Inspired by https://github.com/soundar24/roundSlider/blob/master/src/roundslider.js + const s = this.step.toString().split("."); + return s[1] ? parseFloat(value.toFixed(s[1].length)) : Math.round(value); + } + + private _incrementValue() { + const newValue = this._round(this.value + this.step); + this._processNewValue(newValue); + } + + private _decrementValue() { + const newValue = this._round(this.value - this.step); + this._processNewValue(newValue); + } + + private _processNewValue(value) { + const newValue = conditionalClamp(value, this.min, this.max); + + if (this.value !== newValue) { + this.value = newValue; + this._lastChanged = Date.now(); + this._temperatureStateInFlux(true); + } + } + + private _valueChanged() { + // When the last_changed timestamp is changed, + // trigger a potential event fire in the future, + // as long as last_changed is far enough in the past. + if (this._lastChanged) { + window.setTimeout(() => { + const now = Date.now(); + if (now - this._lastChanged! >= 2000) { + fireEvent(this, "change"); + this._temperatureStateInFlux(false); + this._lastChanged = undefined; + } + }, 2010); + } + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex; + justify-content: space-between; + } + .in-flux { + color: var(--error-color); + } + #target_temperature { + align-self: center; + font-size: 28px; + direction: ltr; + } + .control-buttons { + font-size: 24px; + text-align: right; + } + ha-icon-button { + --mdc-icon-size: 32px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-climate-control": HaClimateControl; + } +} diff --git a/src/components/ha-form/ha-form-string.ts b/src/components/ha-form/ha-form-string.ts index 5b40a1cfb7..fc787e4e63 100644 --- a/src/components/ha-form/ha-form-string.ts +++ b/src/components/ha-form/ha-form-string.ts @@ -68,7 +68,6 @@ export class HaFormString extends LitElement implements HaFormElement { toggles .label=${`${this._unmaskedPassword ? "Hide" : "Show"} password`} @click=${this._toggleUnmaskedPassword} - tabindex="-1" .path=${this._unmaskedPassword ? mdiEyeOff : mdiEye} >` : ""} diff --git a/src/components/ha-gauge.ts b/src/components/ha-gauge.ts index 9ce1259669..c3374d6b97 100644 --- a/src/components/ha-gauge.ts +++ b/src/components/ha-gauge.ts @@ -1,16 +1,10 @@ import { css, LitElement, PropertyValues, svg, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { formatNumber } from "../common/number/format_number"; import { afterNextRender } from "../common/util/render-status"; import { FrontendLocaleData } from "../data/translation"; import { getValueInPercentage, normalize } from "../util/calculate"; -import { isSafari } from "../util/is_safari"; - -// Safari version 15.2 and up behaves differently than other Safari versions. -// https://github.com/home-assistant/frontend/issues/10766 -const isSafari152 = isSafari && /Version\/15\.[^0-1]/.test(navigator.userAgent); const getAngle = (value: number, min: number, max: number) => { const percentage = getValueInPercentage(normalize(value, min, max), min, max); @@ -65,12 +59,12 @@ export class Gauge extends LitElement { protected render() { return svg` - + ${ !this.needle || !this.levels ? svg`` : "" } @@ -87,9 +81,9 @@ export class Gauge extends LitElement { stroke="var(--info-color)" class="level" d="M - ${50 - 40 * Math.cos((angle * Math.PI) / 180)} - ${50 - 40 * Math.sin((angle * Math.PI) / 180)} - A 40 40 0 0 1 90 50 + ${0 - 40 * Math.cos((angle * Math.PI) / 180)} + ${0 - 40 * Math.sin((angle * Math.PI) / 180)} + A 40 40 0 0 1 40 0 " >`; } @@ -98,9 +92,9 @@ export class Gauge extends LitElement { stroke="${level.stroke}" class="level" d="M - ${50 - 40 * Math.cos((angle * Math.PI) / 180)} - ${50 - 40 * Math.sin((angle * Math.PI) / 180)} - A 40 40 0 0 1 90 50 + ${0 - 40 * Math.cos((angle * Math.PI) / 180)} + ${0 - 40 * Math.sin((angle * Math.PI) / 180)} + A 40 40 0 0 1 40 0 " >`; }) @@ -110,46 +104,16 @@ export class Gauge extends LitElement { this.needle ? svg` ` : svg`` } - ${ - // Workaround for https://github.com/home-assistant/frontend/issues/6467 - isSafari - ? svg`` - : "" - } @@ -187,12 +151,10 @@ export class Gauge extends LitElement { fill: none; stroke-width: 15; stroke: var(--gauge-color); - transform-origin: 50% 100%; transition: all 1s ease 0s; } .needle { fill: var(--primary-text-color); - transform-origin: 50% 100%; transition: all 1s ease 0s; } .level { diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts index dbabf84b20..fd16491efa 100644 --- a/src/components/ha-hls-player.ts +++ b/src/components/ha-hls-player.ts @@ -9,7 +9,6 @@ import { } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { nextRender } from "../common/util/render-status"; -import { getExternalConfig } from "../external_app/external_config"; import type { HomeAssistant } from "../types"; import "./ha-alert"; @@ -48,8 +47,11 @@ class HaHLSPlayer extends LitElement { private _exoPlayer = false; + private static streamCount = 0; + public connectedCallback() { super.connectedCallback(); + HaHLSPlayer.streamCount += 1; if (this.hasUpdated) { this._startHls(); } @@ -57,6 +59,7 @@ class HaHLSPlayer extends LitElement { public disconnectedCallback() { super.disconnectedCallback(); + HaHLSPlayer.streamCount -= 1; this._cleanUp(); } @@ -87,19 +90,9 @@ class HaHLSPlayer extends LitElement { this._startHls(); } - private async _getUseExoPlayer(): Promise { - if (!this.hass!.auth.external || !this.allowExoPlayer) { - return false; - } - const externalConfig = await getExternalConfig(this.hass!.auth.external); - return externalConfig && externalConfig.hasExoPlayer; - } - private async _startHls(): Promise { this._error = undefined; - const videoEl = this._videoEl; - const useExoPlayerPromise = this._getUseExoPlayer(); const masterPlaylistPromise = fetch(this.url); const Hls: typeof HlsType = (await import("hls.js/dist/hls.light.min")) @@ -113,7 +106,7 @@ class HaHLSPlayer extends LitElement { if (!hlsSupported) { hlsSupported = - videoEl.canPlayType("application/vnd.apple.mpegurl") !== ""; + this._videoEl.canPlayType("application/vnd.apple.mpegurl") !== ""; } if (!hlsSupported) { @@ -123,7 +116,8 @@ class HaHLSPlayer extends LitElement { return; } - const useExoPlayer = await useExoPlayerPromise; + const useExoPlayer = + this.allowExoPlayer && this.hass.auth.external?.config.hasExoPlayer; const masterPlaylist = await (await masterPlaylistPromise).text(); if (!this.isConnected) { @@ -151,9 +145,9 @@ class HaHLSPlayer extends LitElement { if (useExoPlayer && match !== null && match[1] !== undefined) { this._renderHLSExoPlayer(playlist_url); } else if (Hls.isSupported()) { - this._renderHLSPolyfill(videoEl, Hls, playlist_url); + this._renderHLSPolyfill(this._videoEl, Hls, playlist_url); } else { - this._renderHLSNative(videoEl, playlist_url); + this._renderHLSNative(this._videoEl, playlist_url); } } @@ -187,6 +181,28 @@ class HaHLSPlayer extends LitElement { }); }; + private _isLLHLSSupported(): boolean { + // LL-HLS keeps multiple requests in flight, which can run into browser limitations without + // an http/2 proxy to pipeline requests. However, a small number of streams active at + // once should be OK. + // The stream count may be incremented multiple times before this function is called to check + // the count e.g. when loading a page with many streams on it. The race can work in our favor + // so we now have a better idea on if we'll use too many browser connections later. + if (HaHLSPlayer.streamCount <= 2) { + return true; + } + if ( + !("performance" in window) || + performance.getEntriesByType("resource").length === 0 + ) { + return false; + } + const perfEntry = performance.getEntriesByType( + "resource" + )[0] as PerformanceResourceTiming; + return "nextHopProtocol" in perfEntry && perfEntry.nextHopProtocol === "h2"; + } + private async _renderHLSPolyfill( videoEl: HTMLVideoElement, Hls: typeof HlsType, @@ -198,6 +214,7 @@ class HaHLSPlayer extends LitElement { manifestLoadingTimeOut: 30000, levelLoadingTimeOut: 30000, maxLiveSyncPlaybackRate: 2, + lowLatencyMode: this._isLLHLSSupported(), }); this._hlsPolyfillInstance = hls; hls.attachMedia(videoEl); @@ -261,9 +278,10 @@ class HaHLSPlayer extends LitElement { this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" }); this._exoPlayer = false; } - const videoEl = this._videoEl; - videoEl.removeAttribute("src"); - videoEl.load(); + if (this._videoEl) { + this._videoEl.removeAttribute("src"); + this._videoEl.load(); + } } static get styles(): CSSResultGroup { diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index 0f192bac32..2b628c1e04 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators"; import "./ha-markdown-element"; @customElement("ha-markdown") -class HaMarkdown extends LitElement { +export class HaMarkdown extends LitElement { @property() public content?; @property({ type: Boolean }) public allowSvg = false; @@ -38,35 +38,43 @@ class HaMarkdown extends LitElement { ha-markdown-element > *:last-child { margin-bottom: 0; } - ha-markdown-element a { + a { color: var(--primary-color); } - ha-markdown-element img { + img { max-width: 100%; } - ha-markdown-element code, + code, pre { background-color: var(--markdown-code-background-color, none); border-radius: 3px; } - ha-markdown-element svg { + svg { background-color: var(--markdown-svg-background-color, none); color: var(--markdown-svg-color, none); } - ha-markdown-element code { + code { font-size: 85%; padding: 0.2em 0.4em; } - ha-markdown-element pre code { + pre code { padding: 0; } - ha-markdown-element pre { + pre { padding: 16px; overflow: auto; line-height: 1.45; font-family: var(--code-font-family, monospace); } - ha-markdown-element h2 { + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: initial; + } + h2 { font-size: 1.5em; font-weight: bold; } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index b9cfa06920..8b2b189af9 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -130,6 +130,33 @@ export class HaServiceControl extends LitElement { this._value = this.value; } + if (oldValue?.service !== this.value?.service) { + let updatedDefaultValue = false; + if (this._value && serviceData) { + // Set mandatory bools without a default value to false + this._value.data ??= {}; + serviceData.fields.forEach((field) => { + if ( + field.selector && + field.required && + field.default === undefined && + "boolean" in field.selector && + this._value!.data![field.key] === undefined + ) { + updatedDefaultValue = true; + this._value!.data![field.key] = false; + } + }); + } + if (updatedDefaultValue) { + fireEvent(this, "value-changed", { + value: { + ...this._value, + }, + }); + } + } + if (this._value?.data) { const yamlEditor = this._yamlEditor; if (yamlEditor && yamlEditor.value !== this._value.data) { diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 6d19184f88..70ee6ed471 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -44,10 +44,6 @@ import { PersistentNotification, subscribeNotifications, } from "../data/persistent_notification"; -import { - ExternalConfig, - getExternalConfig, -} from "../external_app/external_config"; import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; import { haStyleScrollbar } from "../resources/styles"; import type { HomeAssistant, PanelInfo, Route } from "../types"; @@ -192,8 +188,6 @@ class HaSidebar extends LitElement { @property({ type: Boolean }) public editMode = false; - @state() private _externalConfig?: ExternalConfig; - @state() private _notifications?: PersistentNotification[]; @state() private _renderEmptySortable = false; @@ -270,13 +264,6 @@ class HaSidebar extends LitElement { protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - - if (this.hass && this.hass.auth.external) { - getExternalConfig(this.hass.auth.external).then((conf) => { - this._externalConfig = conf; - }); - } - subscribeNotifications(this.hass.connection, (notifications) => { this._notifications = notifications; }); @@ -559,8 +546,7 @@ class HaSidebar extends LitElement { private _renderExternalConfiguration() { return html`${!this.hass.user?.is_admin && - this._externalConfig && - this._externalConfig.hasSettingsScreen + this.hass.auth.external?.config.hasSettingsScreen ? html` + focusable="false" + role="img" + aria-hidden="true" + > ${this.path ? svg`` : ""} diff --git a/src/components/ha-textfield.ts b/src/components/ha-textfield.ts new file mode 100644 index 0000000000..55daec0349 --- /dev/null +++ b/src/components/ha-textfield.ts @@ -0,0 +1,25 @@ +import { TextField } from "@material/mwc-textfield"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators"; + +@customElement("ha-textfield") +export class HaTextField extends TextField { + override renderIcon(_icon: string, isTrailingIcon = false): TemplateResult { + const type = isTrailingIcon ? "trailing" : "leading"; + + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-textfield": HaTextField; + } +} diff --git a/src/components/ha-water_heater-state.js b/src/components/ha-water_heater-state.js index 8e6759ea6d..c9bd1147ac 100644 --- a/src/components/ha-water_heater-state.js +++ b/src/components/ha-water_heater-state.js @@ -64,7 +64,7 @@ class HaWaterHeaterState extends LocalizeMixin(PolymerElement) { return `${formatNumber( stateObj.attributes.target_temp_low, this.hass.locale - )} - ${formatNumber( + )} – ${formatNumber( stateObj.attributes.target_temp_high, this.hass.locale )} ${hass.config.unit_system.temperature}`; diff --git a/src/components/ha-web-rtc-player.ts b/src/components/ha-web-rtc-player.ts index b2f64a0645..e65017265e 100644 --- a/src/components/ha-web-rtc-player.ts +++ b/src/components/ha-web-rtc-player.ts @@ -136,9 +136,8 @@ class HaWebRtcPlayer extends LitElement { this._remoteStream = undefined; } if (this._videoEl) { - const videoEl = this._videoEl; - videoEl.removeAttribute("src"); - videoEl.load(); + this._videoEl.removeAttribute("src"); + this._videoEl.load(); } if (this._peerConnection) { this._peerConnection.close(); diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index 4e1a4e5853..03fa830724 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -60,6 +60,7 @@ export class HaYamlEditor extends LitElement { mode="yaml" .error=${this.isValid === false} @value-changed=${this._onChange} + dir="ltr" > `; } diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 6e5a54d973..8e737dc0ed 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -1,45 +1,50 @@ +import "../ha-header-bar"; +import { mdiArrowLeft, mdiClose } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event"; +import { computeRTLDirection } from "../../common/util/compute_rtl"; import type { MediaPickedEvent, MediaPlayerBrowseAction, + MediaPlayerItem, } from "../../data/media-player"; import { haStyleDialog } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; import "../ha-dialog"; import "./ha-media-player-browse"; +import type { MediaPlayerItemId } from "./ha-media-player-browse"; import { MediaPlayerBrowseDialogParams } from "./show-media-browser-dialog"; @customElement("dialog-media-player-browse") class DialogMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private _entityId!: string; + @state() private _currentItem?: MediaPlayerItem; - @state() private _mediaContentId?: string; - - @state() private _mediaContentType?: string; - - @state() private _action?: MediaPlayerBrowseAction; + @state() private _navigateIds?: MediaPlayerItemId[]; @state() private _params?: MediaPlayerBrowseDialogParams; public showDialog(params: MediaPlayerBrowseDialogParams): void { this._params = params; - this._entityId = this._params.entityId; - this._mediaContentId = this._params.mediaContentId; - this._mediaContentType = this._params.mediaContentType; - this._action = this._params.action || "play"; + this._navigateIds = [ + { + media_content_id: this._params.mediaContentId, + media_content_type: this._params.mediaContentType, + }, + ]; } public closeDialog() { this._params = undefined; + this._navigateIds = undefined; + this._currentItem = undefined; fireEvent(this, "dialog-closed", { dialog: this.localName }); } protected render(): TemplateResult { - if (!this._params) { + if (!this._params || !this._navigateIds) { return html``; } @@ -50,22 +55,60 @@ class DialogMediaPlayerBrowse extends LitElement { escapeKeyAction hideActions flexContent + .heading=${true} @closed=${this.closeDialog} > + + ${this._navigateIds.length > 1 + ? html` + + ` + : ""} + + ${!this._currentItem + ? this.hass.localize( + "ui.components.media-browser.media-player-browser" + ) + : this._currentItem.title} + + + + `; } + private _goBack() { + this._navigateIds = this._navigateIds?.slice(0, -1); + this._currentItem = undefined; + } + + private _mediaBrowsed(ev: { detail: HASSDomEvents["media-browsed"] }) { + this._navigateIds = ev.detail.ids; + this._currentItem = ev.detail.current; + } + private _mediaPicked(ev: HASSDomEvent): void { this._params!.mediaPickedCallback(ev.detail); if (this._action !== "play") { @@ -73,6 +116,10 @@ class DialogMediaPlayerBrowse extends LitElement { } } + private get _action(): MediaPlayerBrowseAction { + return this._params!.action || "play"; + } + static get styles(): CSSResultGroup { return [ haStyleDialog, @@ -83,7 +130,7 @@ class DialogMediaPlayerBrowse extends LitElement { } ha-media-player-browse { - --media-browser-max-height: 100vh; + --media-browser-max-height: calc(100vh - 65px); } @media (min-width: 800px) { @@ -95,10 +142,17 @@ class DialogMediaPlayerBrowse extends LitElement { } ha-media-player-browse { position: initial; - --media-browser-max-height: 100vh - 72px; + --media-browser-max-height: 100vh - 137px; width: 700px; } } + + ha-header-bar { + --mdc-theme-on-primary: var(--primary-text-color); + --mdc-theme-primary: var(--mdc-theme-surface); + flex-shrink: 0; + border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); + } `, ]; } diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 19672ca8de..2f8e21686a 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -1,7 +1,7 @@ import "@material/mwc-button/mwc-button"; import "@material/mwc-list/mwc-list"; import "@material/mwc-list/mwc-list-item"; -import { mdiArrowLeft, mdiClose, mdiPlay, mdiPlus } from "@mdi/js"; +import { mdiPlay, mdiPlus } from "@mdi/js"; import "@polymer/paper-item/paper-item"; import "@polymer/paper-listbox/paper-listbox"; import "@polymer/paper-tooltip/paper-tooltip"; @@ -18,6 +18,7 @@ import { eventOptions, property, query, + queryAll, state, } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; @@ -26,6 +27,7 @@ import { styleMap } from "lit/directives/style-map"; import { fireEvent } from "../../common/dom/fire_event"; import { computeRTLDirection } from "../../common/util/compute_rtl"; import { debounce } from "../../common/util/debounce"; +import { getSignedPath } from "../../data/auth"; import type { MediaPlayerItem } from "../../data/media-player"; import { browseLocalMediaPlayer, @@ -43,247 +45,299 @@ import { documentationUrl } from "../../util/documentation-url"; import "../entity/ha-entity-picker"; import "../ha-button-menu"; import "../ha-card"; +import type { HaCard } from "../ha-card"; import "../ha-circular-progress"; -import "../ha-fab"; import "../ha-icon-button"; import "../ha-svg-icon"; +import "../ha-fab"; declare global { interface HASSDomEvents { "media-picked": MediaPickedEvent; + "media-browsed": { ids: MediaPlayerItemId[]; current?: MediaPlayerItem }; } } +export interface MediaPlayerItemId { + media_content_id: string | undefined; + media_content_type: string | undefined; +} + @customElement("ha-media-player-browse") export class HaMediaPlayerBrowse extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property() public entityId!: string; - @property() public mediaContentId?: string; - - @property() public mediaContentType?: string; - @property() public action: MediaPlayerBrowseAction = "play"; @property({ type: Boolean }) public dialog = false; + @property() public navigateIds!: MediaPlayerItemId[]; + @property({ type: Boolean, attribute: "narrow", reflect: true }) + // @ts-ignore private _narrow = false; @property({ type: Boolean, attribute: "scroll", reflect: true }) private _scrolled = false; - @state() private _loading = false; - @state() private _error?: { message: string; code: string }; - @state() private _mediaPlayerItems: MediaPlayerItem[] = []; + @state() private _parentItem?: MediaPlayerItem; + + @state() private _currentItem?: MediaPlayerItem; @query(".header") private _header?: HTMLDivElement; @query(".content") private _content?: HTMLDivElement; + @queryAll(".lazythumbnail") private _thumbnails?: HaCard[]; + private _headerOffsetHeight = 0; private _resizeObserver?: ResizeObserver; + // @ts-ignore + private _intersectionObserver?: IntersectionObserver; + public connectedCallback(): void { super.connectedCallback(); - this.updateComplete.then(() => this._attachObserver()); + this.updateComplete.then(() => this._attachResizeObserver()); } public disconnectedCallback(): void { if (this._resizeObserver) { this._resizeObserver.disconnect(); } + if (this._intersectionObserver) { + this._intersectionObserver.disconnect(); + } } - public navigateBack() { - this._mediaPlayerItems!.pop(); - const item = this._mediaPlayerItems!.pop(); - if (!item) { - return; + public play(): void { + if (this._currentItem?.can_play) { + this._runAction(this._currentItem); } - this._navigate(item); } protected render(): TemplateResult { - if (this._loading) { + if (this._error) { + return html` +
${this._renderError(this._error)}
+ `; + } + + if (!this._currentItem) { return html``; } - if (this._error && !this._mediaPlayerItems.length) { - if (this.dialog) { - this._closeDialogAction(); - showAlertDialog(this, { - title: this.hass.localize( - "ui.components.media-browser.media_browsing_error" - ), - text: this._renderError(this._error), - }); - } else { - return html` -
${this._renderError(this._error)}
- `; - } - } - - if (!this._mediaPlayerItems.length) { - return html``; - } - - const currentItem = - this._mediaPlayerItems[this._mediaPlayerItems.length - 1]; - - const previousItem: MediaPlayerItem | undefined = - this._mediaPlayerItems.length > 1 - ? this._mediaPlayerItems[this._mediaPlayerItems.length - 2] - : undefined; + const currentItem = this._currentItem; const subtitle = this.hass.localize( `ui.components.media-browser.class.${currentItem.media_class}` ); + const mediaClass = MediaClassBrowserSettings[currentItem.media_class]; const childrenMediaClass = MediaClassBrowserSettings[currentItem.children_media_class]; return html` -
-
- ${currentItem.thumbnail - ? html` -
- ${this._narrow && currentItem?.can_play - ? html` - - - ${this.hass.localize( - `ui.components.media-browser.${this.action}` - )} - - ` - : ""} -
- ` - : html``} -
- - ${this.dialog - ? html` - - ` - : ""} -
-
- ${this._error - ? html` -
${this._renderError(this._error)}
- ` - : currentItem.children?.length - ? childrenMediaClass.layout === "grid" - ? html` -
- ${currentItem.children.map( - (child) => html` + : currentItem.children?.length + ? childrenMediaClass.layout === "grid" + ? html`
-
- - ${!child.thumbnail - ? html` - - ` - : ""} - - ${child.can_play - ? html` + ${currentItem.children.map( + (child) => html` +
+ +
+ ${child.thumbnail + ? html` +
+ ` + : html` +
+ +
+ `} + ${child.can_play + ? html` + + ` + : ""} +
+
+ ${child.title} + ${child.title} +
+
+
+ ` + )} +
+ ` + : html` + + ${currentItem.children.map( + (child) => html` + +
- ` - : ""} -
-
- ${child.title} - ${child.title} -
-
- ${this.hass.localize( - `ui.components.media-browser.content-type.${child.media_content_type}` - )} -
-
+
+ ${child.title} + +
  • + ` + )} + ` - )} -
    - ` - : html` - - ${currentItem.children.map( - (child) => html` - -
    - -
    - ${child.title} -
    -
  • - ` - )} -
    - ` - : html` -
    - ${this.hass.localize("ui.components.media-browser.no_items")} -
    - ${currentItem.media_content_id === - "media-source://media_source/local/." - ? html`
    ${this.hass.localize( - "ui.components.media-browser.learn_adding_local_media", - "documentation", - html`${this.hass.localize( - "ui.components.media-browser.documentation" - )}` + : html` +
    + ${this.hass.localize( + "ui.components.media-browser.no_items" )}
    - ${this.hass.localize( - "ui.components.media-browser.local_media_files" - )}` - : ""} -
    - `} + ${currentItem.media_content_id === + "media-source://media_source/local/." + ? html`
    ${this.hass.localize( + "ui.components.media-browser.learn_adding_local_media", + "documentation", + html`${this.hass.localize( + "ui.components.media-browser.documentation" + )}` + )} +
    + ${this.hass.localize( + "ui.components.media-browser.local_media_files" + )}` + : ""} +
    + ` + } +
    +
    `; } protected firstUpdated(): void { this._measureCard(); - this._attachObserver(); + this._attachResizeObserver(); + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.size > 1 || !changedProps.has("hass")) { + return true; + } + const oldHass = changedProps.get("hass") as this["hass"]; + return oldHass === undefined || oldHass.localize !== this.hass.localize; + } + + public willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + + if (changedProps.has("entityId")) { + this._setError(undefined); + } + if (!changedProps.has("navigateIds")) { + return; + } + const oldNavigateIds = changedProps.get("navigateIds") as + | this["navigateIds"] + | undefined; + + // We're navigating. Reset the shizzle. + this._content?.scrollTo(0, 0); + this._scrolled = false; + const oldCurrentItem = this._currentItem; + const oldParentItem = this._parentItem; + this._currentItem = undefined; + this._parentItem = undefined; + const currentId = this.navigateIds[this.navigateIds.length - 1]; + const parentId = + this.navigateIds.length > 1 + ? this.navigateIds[this.navigateIds.length - 2] + : undefined; + let currentProm: Promise | undefined; + let parentProm: Promise | undefined; + + // See if we can take loading shortcuts if navigating to parent or child + if (!changedProps.has("entityId")) { + if ( + // Check if we navigated to a child + oldNavigateIds && + this.navigateIds.length > oldNavigateIds.length && + oldNavigateIds.every((oldVal, idx) => { + const curVal = this.navigateIds[idx]; + return ( + curVal.media_content_id === oldVal.media_content_id && + curVal.media_content_type === oldVal.media_content_type + ); + }) + ) { + parentProm = Promise.resolve(oldCurrentItem!); + } else if ( + // Check if we navigated to a parent + oldNavigateIds && + this.navigateIds.length < oldNavigateIds.length && + this.navigateIds.every((curVal, idx) => { + const oldVal = oldNavigateIds[idx]; + return ( + curVal.media_content_id === oldVal.media_content_id && + curVal.media_content_type === oldVal.media_content_type + ); + }) + ) { + currentProm = Promise.resolve(oldParentItem!); + } + } + // Fetch current + if (!currentProm) { + currentProm = this._fetchData( + this.entityId, + currentId.media_content_id, + currentId.media_content_type + ); + } + currentProm.then( + (item) => { + this._currentItem = item; + fireEvent(this, "media-browsed", { + ids: this.navigateIds, + current: item, + }); + }, + (err) => this._setError(err) + ); + // Fetch parent + if (!parentProm && parentId !== undefined) { + parentProm = this._fetchData( + this.entityId, + parentId.media_content_id, + parentId.media_content_type + ); + } + if (parentProm) { + parentProm.then((parent) => { + this._parentItem = parent; + }); + } } protected updated(changedProps: PropertyValues): void { super.updated(changedProps); - if ( - changedProps.has("_mediaPlayerItems") && - this._mediaPlayerItems.length - ) { - this._setHeaderHeight(); - } - - if ( - changedProps.get("_scrolled") !== undefined && - this._mediaPlayerItems.length - ) { + if (changedProps.has("_scrolled")) { this._animateHeaderHeight(); - } - - if ( - !changedProps.has("entityId") && - !changedProps.has("mediaContentId") && - !changedProps.has("mediaContentType") && - !changedProps.has("action") - ) { - return; - } - - if (changedProps.has("entityId")) { - this._error = undefined; - this._mediaPlayerItems = []; - } - - this._fetchData(this.mediaContentId, this.mediaContentType) - .then((itemData) => { - if (!itemData) { - return; - } - - this._mediaPlayerItems = [itemData]; - }) - .catch((err) => { - this._error = err; - }); - } - - private async _setHeaderHeight() { - await this.updateComplete; - const header = this._header; - const content = this._content; - if (!header || !content) { - return; - } - this._headerOffsetHeight = header.offsetHeight; - content.style.marginTop = `${this._headerOffsetHeight}px`; - content.style.maxHeight = `calc(var(--media-browser-max-height, 100%) - ${this._headerOffsetHeight}px)`; - } - - private _animateHeaderHeight() { - let start; - const animate = (time) => { - if (start === undefined) { - start = time; - } - const elapsed = time - start; + } else if (changedProps.has("_currentItem")) { this._setHeaderHeight(); - if (elapsed < 400) { - requestAnimationFrame(animate); - } - }; - requestAnimationFrame(animate); + this._attachIntersectionObserver(); + } } private _actionClicked(ev: MouseEvent): void { @@ -489,71 +532,26 @@ export class HaMediaPlayerBrowse extends LitElement { return; } - this._navigate(item); - } - - private async _navigate(item: MediaPlayerItem) { - this._error = undefined; - - let itemData: MediaPlayerItem; - - try { - itemData = await this._fetchData( - item.media_content_id, - item.media_content_type - ); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.components.media-browser.media_browsing_error" - ), - text: this._renderError(err), - }); - return; - } - - this._content?.scrollTo(0, 0); - this._scrolled = false; - this._mediaPlayerItems = [...this._mediaPlayerItems, itemData]; + fireEvent(this, "media-browsed", { + ids: [...this.navigateIds, item], + }); } private async _fetchData( + entityId: string, mediaContentId?: string, mediaContentType?: string ): Promise { - this._loading = true; - let itemData: any; - try { - itemData = - this.entityId !== BROWSER_PLAYER - ? await browseMediaPlayer( - this.hass, - this.entityId, - mediaContentId, - mediaContentType - ) - : await browseLocalMediaPlayer(this.hass, mediaContentId); - } finally { - this._loading = false; - } - return itemData; + return entityId !== BROWSER_PLAYER + ? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType) + : browseLocalMediaPlayer(this.hass, mediaContentId); } private _measureCard(): void { this._narrow = (this.dialog ? window.innerWidth : this.offsetWidth) < 450; } - @eventOptions({ passive: true }) - private _scroll(ev: Event): void { - const content = ev.currentTarget as HTMLDivElement; - if (!this._scrolled && content.scrollTop > this._headerOffsetHeight) { - this._scrolled = true; - } else if (this._scrolled && content.scrollTop < this._headerOffsetHeight) { - this._scrolled = false; - } - } - - private async _attachObserver(): Promise { + private async _attachResizeObserver(): Promise { if (!this._resizeObserver) { await installResizeObserver(); this._resizeObserver = new ResizeObserver( @@ -564,10 +562,67 @@ export class HaMediaPlayerBrowse extends LitElement { this._resizeObserver.observe(this); } + /** + * Load thumbnails for images on demand as they become visible. + */ + private async _attachIntersectionObserver(): Promise { + if (!("IntersectionObserver" in window) || !this._thumbnails) { + return; + } + if (!this._intersectionObserver) { + this._intersectionObserver = new IntersectionObserver( + async (entries, observer) => { + await Promise.all( + entries.map(async (entry) => { + if (!entry.isIntersecting) { + return; + } + const thumbnailCard = entry.target as HTMLElement; + let thumbnailUrl = thumbnailCard.dataset.src; + if (!thumbnailUrl) { + return; + } + if (thumbnailUrl.startsWith("/")) { + // Thumbnails served by local API require authentication + const signedPath = await getSignedPath(this.hass, thumbnailUrl); + thumbnailUrl = signedPath.path; + } + thumbnailCard.style.backgroundImage = `url(${thumbnailUrl})`; + observer.unobserve(thumbnailCard); // loaded, so no need to observe anymore + }) + ); + } + ); + } + const observer = this._intersectionObserver!; + for (const thumbnailCard of this._thumbnails) { + observer.observe(thumbnailCard); + } + } + private _closeDialogAction(): void { fireEvent(this, "close-dialog"); } + private _setError(error: any) { + if (!this.dialog) { + this._error = error; + return; + } + + if (!error) { + return; + } + + this._closeDialogAction(); + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.media-browser.media_browsing_error" + ), + text: this._renderError(error), + }); + } + private _renderError(err: { message: string; code: string }) { if (err.message === "Media directory does not exist.") { return html` @@ -602,6 +657,43 @@ export class HaMediaPlayerBrowse extends LitElement { return html`${err.message}`; } + private async _setHeaderHeight() { + await this.updateComplete; + const header = this._header; + const content = this._content; + if (!header || !content) { + return; + } + this._headerOffsetHeight = header.offsetHeight; + content.style.marginTop = `${this._headerOffsetHeight}px`; + content.style.maxHeight = `calc(var(--media-browser-max-height, 100%) - ${this._headerOffsetHeight}px)`; + } + + private _animateHeaderHeight() { + let start; + const animate = (time) => { + if (start === undefined) { + start = time; + } + const elapsed = time - start; + this._setHeaderHeight(); + if (elapsed < 400) { + requestAnimationFrame(animate); + } + }; + requestAnimationFrame(animate); + } + + @eventOptions({ passive: true }) + private _scroll(ev: Event): void { + const content = ev.currentTarget as HTMLDivElement; + if (!this._scrolled && content.scrollTop > this._headerOffsetHeight) { + this._scrolled = true; + } else if (this._scrolled && content.scrollTop < this._headerOffsetHeight) { + this._scrolled = false; + } + } + static get styles(): CSSResultGroup { return [ haStyle, @@ -623,12 +715,17 @@ export class HaMediaPlayerBrowse extends LitElement { padding: 16px; } + .no-items { + padding-left: 32px; + } + .content { overflow-y: auto; - padding-bottom: 20px; box-sizing: border-box; } + /* HEADER */ + .header { display: flex; justify-content: space-between; @@ -639,30 +736,26 @@ export class HaMediaPlayerBrowse extends LitElement { right: 0; left: 0; z-index: 5; - padding: 20px 24px 10px; + padding: 16px; } - .header_button { position: relative; right: -8px; } - .header-content { display: flex; flex-wrap: wrap; flex-grow: 1; align-items: flex-start; } - .header-content .img { - height: 200px; - width: 200px; + height: 175px; + width: 175px; margin-right: 16px; background-size: cover; - border-radius: 4px; + border-radius: 2px; transition: width 0.4s, height 0.4s; } - .header-info { display: flex; flex-direction: column; @@ -671,19 +764,18 @@ export class HaMediaPlayerBrowse extends LitElement { min-width: 0; flex: 1; } - .header-info mwc-button { display: block; --mdc-theme-primary: var(--primary-color); + padding-bottom: 16px; } - .breadcrumb { display: flex; flex-direction: column; overflow: hidden; flex-grow: 1; + padding-top: 16px; } - .breadcrumb .title { font-size: 32px; line-height: 1.2; @@ -695,7 +787,6 @@ export class HaMediaPlayerBrowse extends LitElement { -webkit-line-clamp: 2; padding-right: 8px; } - .breadcrumb .previous-title { font-size: 14px; padding-bottom: 8px; @@ -705,7 +796,6 @@ export class HaMediaPlayerBrowse extends LitElement { cursor: pointer; --mdc-icon-size: 14px; } - .breadcrumb .subtitle { font-size: 16px; overflow: hidden; @@ -738,8 +828,7 @@ export class HaMediaPlayerBrowse extends LitElement { minmax(var(--media-browse-item-size, 175px), 0.1fr) ); grid-gap: 16px; - padding: 0px 24px; - margin: 8px 0px; + padding: 16px; } :host([dialog]) .children { @@ -755,42 +844,60 @@ export class HaMediaPlayerBrowse extends LitElement { cursor: pointer; } - .ha-card-parent { + ha-card { position: relative; width: 100%; + box-sizing: border-box; } - .children ha-card { + .children ha-card .thumbnail { width: 100%; - padding-bottom: 100%; position: relative; box-sizing: border-box; - background-size: cover; - background-repeat: no-repeat; - background-position: center; transition: padding-bottom 0.1s ease-out; + padding-bottom: 100%; } - .portrait.children ha-card { + .portrait.children ha-card .thumbnail { padding-bottom: 150%; } - .child .folder, - .child .play { + ha-card .image { + border-radius: 3px 3px 0 0; + } + + .image { position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + + .centered-image { + margin: 0 8px; + background-size: contain; + } + + .children ha-card .icon-holder { + display: flex; + justify-content: center; + align-items: center; } .child .folder { color: var(--secondary-text-color); - top: calc(50% - (var(--mdc-icon-size) / 2)); - left: calc(50% - (var(--mdc-icon-size) / 2)); --mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4); } .child .play { + position: absolute; transition: color 0.5s; border-radius: 50%; - bottom: calc(50% - 35px); + top: calc(50% - 50px); right: calc(50% - 35px); opacity: 0; transition: opacity 0.1s ease-out; @@ -801,45 +908,57 @@ export class HaMediaPlayerBrowse extends LitElement { --mdc-icon-size: 48px; } - .ha-card-parent:hover .play:not(.can_expand) { + ha-card:hover .play { opacity: 1; + } + + ha-card:hover .play:not(.can_expand) { color: var(--primary-color); } + ha-card:hover .play.can_expand { + bottom: 8px; + } + .child .play.can_expand { - opacity: 1; background-color: rgba(var(--rgb-card-background-color), 0.5); - bottom: 4px; - right: 4px; + top: auto; + bottom: 0px; + right: 8px; + transition: bottom 0.1s ease-out, opacity 0.1s ease-out; } .child .play:hover { color: var(--primary-color); } - .ha-card-parent:hover ha-card { + ha-card:hover .lazythumbnail { opacity: 0.5; } .child .title { font-size: 16px; - padding-top: 8px; + padding-top: 16px; padding-left: 2px; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; text-overflow: ellipsis; } - .child .type { - font-size: 12px; - color: var(--secondary-text-color); - padding-left: 2px; + .child ha-card .title { + margin-bottom: 16px; + padding-left: 16px; } mwc-list-item .graphic { - background-size: cover; + background-size: contain; + border-radius: 2px; + display: flex; + align-content: center; + align-items: center; + line-height: initial; } mwc-list-item .graphic .play { @@ -852,7 +971,7 @@ export class HaMediaPlayerBrowse extends LitElement { mwc-list-item:hover .graphic .play { opacity: 1; - color: var(--primary-color); + color: var(--primary-text-color); } mwc-list-item .graphic .play.show { @@ -874,29 +993,32 @@ export class HaMediaPlayerBrowse extends LitElement { padding: 0; } + :host([narrow]) .media-source { + padding: 0 24px; + } + + :host([narrow]) .children { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; + } + :host([narrow]) .breadcrumb .title { font-size: 24px; } - :host([narrow]) .header { padding: 0; } - :host([narrow]) .header.no-dialog { display: block; } - :host([narrow]) .header_button { position: absolute; top: 14px; right: 8px; } - :host([narrow]) .header-content { flex-direction: column; flex-wrap: nowrap; } - :host([narrow]) .header-content .img { height: auto; width: 100%; @@ -908,94 +1030,75 @@ export class HaMediaPlayerBrowse extends LitElement { border-radius: 0; transition: width 0.4s, height 0.4s, padding-bottom 0.4s; } - ha-fab { position: absolute; --mdc-theme-secondary: var(--primary-color); bottom: -20px; right: 20px; } - :host([narrow]) .header-info mwc-button { margin-top: 16px; margin-bottom: 8px; } - :host([narrow]) .header-info { - padding: 20px 24px 10px; - } - - :host([narrow]) .media-source { - padding: 0 24px; - } - - :host([narrow]) .children { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; + padding: 0 16px 8px; } /* ============= Scroll ============= */ - :host([scroll]) .breadcrumb .subtitle { height: 0; margin: 0; } - :host([scroll]) .breadcrumb .title { -webkit-line-clamp: 1; } - :host(:not([narrow])[scroll]) .header:not(.no-img) ha-icon-button { align-self: center; } - :host([scroll]) .header-info mwc-button, .no-img .header-info mwc-button { padding-right: 4px; } - :host([scroll][narrow]) .no-img .header-info mwc-button { padding-right: 16px; } - :host([scroll]) .header-info { flex-direction: row; } - :host([scroll]) .header-info mwc-button { align-self: center; margin-top: 0; margin-bottom: 0; + padding-bottom: 0; } - :host([scroll][narrow]) .no-img .header-info { flex-direction: row-reverse; } - :host([scroll][narrow]) .header-info { padding: 20px 24px 10px 24px; align-items: center; } - :host([scroll]) .header-content { align-items: flex-end; flex-direction: row; } - :host([scroll]) .header-content .img { height: 75px; width: 75px; } - + :host([scroll]) .breadcrumb { + padding-top: 0; + align-self: center; + } :host([scroll][narrow]) .header-content .img { height: 100px; width: 100px; padding-bottom: initial; margin-bottom: 0; } - :host([scroll]) ha-fab { - bottom: 4px; - right: 4px; + bottom: 0px; + right: -24px; --mdc-fab-box-shadow: none; --mdc-theme-secondary: rgba(var(--rgb-primary-color), 0.5); } diff --git a/src/components/trace/ha-trace-blueprint-config.ts b/src/components/trace/ha-trace-blueprint-config.ts index 64e4bbc810..ec2a0e5440 100644 --- a/src/components/trace/ha-trace-blueprint-config.ts +++ b/src/components/trace/ha-trace-blueprint-config.ts @@ -17,6 +17,7 @@ export class HaTraceBlueprintConfig extends LitElement { `; } diff --git a/src/components/trace/ha-trace-config.ts b/src/components/trace/ha-trace-config.ts index 237a051110..64b71f78cd 100644 --- a/src/components/trace/ha-trace-config.ts +++ b/src/components/trace/ha-trace-config.ts @@ -17,6 +17,7 @@ export class HaTraceConfig extends LitElement { `; } diff --git a/src/components/trace/ha-trace-path-details.ts b/src/components/trace/ha-trace-path-details.ts index 5f1e8c56c5..b27ead74a0 100644 --- a/src/components/trace/ha-trace-path-details.ts +++ b/src/components/trace/ha-trace-path-details.ts @@ -150,6 +150,7 @@ export class HaTracePathDetails extends LitElement { ? html`` : "Unable to find config"; } diff --git a/src/data/cloud.ts b/src/data/cloud.ts index a2ddc98c1b..4679d97a75 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -51,11 +51,13 @@ export interface CloudStatusLoggedIn { google_registered: boolean; google_entities: EntityFilter; google_domains: string[]; + alexa_registered: boolean; alexa_entities: EntityFilter; prefs: CloudPreferences; remote_domain: string | undefined; remote_connected: boolean; remote_certificate: undefined | CertificateInformation; + http_use_ssl: boolean; } export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn; diff --git a/src/data/config_flow.ts b/src/data/config_flow.ts index 95af6f6e12..41842496d0 100644 --- a/src/data/config_flow.ts +++ b/src/data/config_flow.ts @@ -114,5 +114,8 @@ export const localizeConfigFlowTitle = ( args.push(key); args.push(placeholders[key]); }); - return localize(`component.${flow.handler}.config.flow_title`, ...args); + return localize(`component.${flow.handler}.config.flow_title`, ...args) || + "name" in placeholders + ? placeholders.name + : domainToName(localize, flow.handler); }; diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 7a16c1ec10..b2610c109c 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -1,5 +1,6 @@ import { Connection, createCollection } from "home-assistant-js-websocket"; import { computeStateName } from "../common/entity/compute_state_name"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; import { debounce } from "../common/util/debounce"; import { HomeAssistant } from "../types"; import { EntityRegistryEntry } from "./entity_registry"; @@ -99,3 +100,8 @@ export const subscribeDeviceRegistry = ( conn, onChange ); + +export const sortDeviceRegistryByName = (entries: DeviceRegistryEntry[]) => + entries.sort((entry1, entry2) => + caseInsensitiveStringCompare(entry1.name || "", entry2.name || "") + ); diff --git a/src/data/diagnostics.ts b/src/data/diagnostics.ts new file mode 100644 index 0000000000..e533244232 --- /dev/null +++ b/src/data/diagnostics.ts @@ -0,0 +1,33 @@ +import { HomeAssistant } from "../types"; + +export interface DiagnosticInfo { + domain: string; + handlers: { + config_entry: boolean; + device: boolean; + }; +} + +export const fetchDiagnosticHandlers = ( + hass: HomeAssistant +): Promise => + hass.callWS({ + type: "diagnostics/list", + }); + +export const fetchDiagnosticHandler = ( + hass: HomeAssistant, + domain: string +): Promise => + hass.callWS({ + type: "diagnostics/get", + domain, + }); + +export const getConfigEntryDiagnosticsDownloadUrl = (entry_id: string) => + `/api/diagnostics/config_entry/${entry_id}`; + +export const getDeviceDiagnosticsDownloadUrl = ( + entry_id: string, + device_id: string +) => `/api/diagnostics/config_entry/${entry_id}/device/${device_id}`; diff --git a/src/data/entity_attributes.ts b/src/data/entity_attributes.ts new file mode 100644 index 0000000000..faa42e8fab --- /dev/null +++ b/src/data/entity_attributes.ts @@ -0,0 +1,105 @@ +import { html, TemplateResult } from "lit"; +import { until } from "lit/directives/until"; +import checkValidDate from "../common/datetime/check_valid_date"; +import { formatDate } from "../common/datetime/format_date"; +import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time"; +import { formatNumber } from "../common/number/format_number"; +import { capitalizeFirstLetter } from "../common/string/capitalize-first-letter"; +import { isDate } from "../common/string/is_date"; +import { isTimestamp } from "../common/string/is_timestamp"; +import { HomeAssistant } from "../types"; + +let jsYamlPromise: Promise; + +export const STATE_ATTRIBUTES = [ + "assumed_state", + "attribution", + "custom_ui_more_info", + "custom_ui_state_card", + "device_class", + "editable", + "emulated_hue_name", + "emulated_hue", + "entity_picture", + "friendly_name", + "haaska_hidden", + "haaska_name", + "icon", + "initial_state", + "last_reset", + "restored", + "state_class", + "supported_features", + "unit_of_measurement", +]; + +// Convert from internal snake_case format to user-friendly format +export function formatAttributeName(value: string): string { + value = value + .replace(/_/g, " ") + .replace(/\bid\b/g, "ID") + .replace(/\bip\b/g, "IP") + .replace(/\bmac\b/g, "MAC") + .replace(/\bgps\b/g, "GPS"); + return capitalizeFirstLetter(value); +} + +export function formatAttributeValue( + hass: HomeAssistant, + value: any +): string | TemplateResult { + if (value === null) { + return "—"; + } + + // YAML handling + if ( + (Array.isArray(value) && value.some((val) => val instanceof Object)) || + (!Array.isArray(value) && value instanceof Object) + ) { + if (!jsYamlPromise) { + jsYamlPromise = import("../resources/js-yaml-dump"); + } + const yaml = jsYamlPromise.then((jsYaml) => jsYaml.dump(value)); + return html`
    ${until(yaml, "")}
    `; + } + + if (typeof value === "number") { + return formatNumber(value, hass.locale); + } + + if (typeof value === "string") { + // URL handling + if (value.startsWith("http")) { + try { + // If invalid URL, exception will be raised + const url = new URL(value); + if (url.protocol === "http:" || url.protocol === "https:") + return html`${value}`; + } catch (_) { + // Nothing to do here + } + } + + // Date handling + if (isDate(value, true)) { + // Timestamp handling + if (isTimestamp(value)) { + const date = new Date(value); + if (checkValidDate(date)) { + return formatDateTimeWithSeconds(date, hass.locale); + } + } + + // Value was not a timestamp, so only do date formatting + const date = new Date(value); + if (checkValidDate(date)) { + return formatDate(date, hass.locale); + } + } + } + + return Array.isArray(value) ? value.join(", ") : value; +} diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 84fdbccec5..83fcc601b5 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -1,6 +1,7 @@ import { Connection, createCollection } from "home-assistant-js-websocket"; import { Store } from "home-assistant-js-websocket/dist/store"; import { computeStateName } from "../common/entity/compute_state_name"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; import { debounce } from "../common/util/debounce"; import { HomeAssistant } from "../types"; @@ -133,3 +134,8 @@ export const subscribeEntityRegistry = ( conn, onChange ); + +export const sortEntityRegistryByName = (entries: EntityRegistryEntry[]) => + entries.sort((entry1, entry2) => + caseInsensitiveStringCompare(entry1.name || "", entry2.name || "") + ); diff --git a/src/data/google_assistant.ts b/src/data/google_assistant.ts index 982f3c456a..2665be90b3 100644 --- a/src/data/google_assistant.ts +++ b/src/data/google_assistant.ts @@ -8,3 +8,6 @@ export interface GoogleEntity { export const fetchCloudGoogleEntities = (hass: HomeAssistant) => hass.callWS({ type: "cloud/google_assistant/entities" }); + +export const syncCloudGoogleEntities = (hass: HomeAssistant) => + hass.callApi("POST", "cloud/google_actions/sync"); diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index 93ac68c506..ccd099edc7 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -302,7 +302,8 @@ export const installHassioAddon = async ( export const updateHassioAddon = async ( hass: HomeAssistant, - slug: string + slug: string, + backup: boolean ): Promise => { if (atLeastVersion(hass.config.version, 2021, 2, 4)) { await hass.callWS({ @@ -310,11 +311,13 @@ export const updateHassioAddon = async ( endpoint: `/store/addons/${slug}/update`, method: "post", timeout: null, + data: { backup }, }); } else { await hass.callApi>( "POST", - `hassio/addons/${slug}/update` + `hassio/addons/${slug}/update`, + { backup } ); } }; diff --git a/src/data/hassio/backup.ts b/src/data/hassio/backup.ts index c5af101857..c01b9440da 100644 --- a/src/data/hassio/backup.ts +++ b/src/data/hassio/backup.ts @@ -20,6 +20,7 @@ export interface HassioBackup { slug: string; date: string; name: string; + size: number; type: "full" | "partial"; protected: boolean; content: BackupContent; diff --git a/src/data/input_button.ts b/src/data/input_button.ts new file mode 100644 index 0000000000..785a381a48 --- /dev/null +++ b/src/data/input_button.ts @@ -0,0 +1,41 @@ +import { HomeAssistant } from "../types"; + +export interface InputButton { + id: string; + name: string; + icon?: string; +} + +export interface InputButtonMutableParams { + name: string; + icon: string; +} + +export const fetchInputButton = (hass: HomeAssistant) => + hass.callWS({ type: "input_button/list" }); + +export const createInputButton = ( + hass: HomeAssistant, + values: InputButtonMutableParams +) => + hass.callWS({ + type: "input_button/create", + ...values, + }); + +export const updateInputButton = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "input_button/update", + input_button_id: id, + ...updates, + }); + +export const deleteInputButton = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "input_button/delete", + input_button_id: id, + }); diff --git a/src/data/media-player.ts b/src/data/media-player.ts index ee4245f1b9..ee15481785 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -88,7 +88,7 @@ export const BROWSER_PLAYER = "browser"; export type MediaClassBrowserSetting = { icon: string; thumbnail_ratio?: string; - layout?: string; + layout?: "grid"; show_list_images?: boolean; }; @@ -320,3 +320,25 @@ export const computeMediaControls = ( return buttons.length > 0 ? buttons : undefined; }; + +export const formatMediaTime = (seconds: number): string => { + if (!seconds) { + return ""; + } + + let secondsString = new Date(seconds * 1000).toISOString(); + secondsString = + seconds > 3600 + ? secondsString.substring(11, 16) + : secondsString.substring(14, 19); + return secondsString.replace(/^0+/, "").padStart(4, "0"); +}; + +export const cleanupMediaTitle = (title?: string): string | undefined => { + if (!title) { + return undefined; + } + + const index = title.indexOf("?authSig="); + return index > 0 ? title.slice(0, index) : title; +}; diff --git a/src/data/media_source.ts b/src/data/media_source.ts new file mode 100644 index 0000000000..0b2b70b989 --- /dev/null +++ b/src/data/media_source.ts @@ -0,0 +1,15 @@ +import { HomeAssistant } from "../types"; + +export interface ResolvedMediaSource { + url: string; + mime_type: string; +} + +export const resolveMediaSource = ( + hass: HomeAssistant, + media_content_id: string +) => + hass.callWS({ + type: "media_source/resolve_media", + media_content_id, + }); diff --git a/src/data/scene.ts b/src/data/scene.ts index 568db76998..f97cc2fa59 100644 --- a/src/data/scene.ts +++ b/src/data/scene.ts @@ -6,13 +6,15 @@ import { navigate } from "../common/navigate"; import { HomeAssistant, ServiceCallResponse } from "../types"; export const SCENE_IGNORED_DOMAINS = [ - "sensor", "binary_sensor", - "device_tracker", - "person", - "persistent_notification", + "button", "configuration", + "device_tracker", "image_processing", + "input_button", + "persistent_notification", + "person", + "sensor", "sun", "weather", "zone", diff --git a/src/data/supervisor/core.ts b/src/data/supervisor/core.ts index 2d0cec2df7..5e816a8822 100644 --- a/src/data/supervisor/core.ts +++ b/src/data/supervisor/core.ts @@ -6,15 +6,18 @@ export const restartCore = async (hass: HomeAssistant) => { await hass.callService("homeassistant", "restart"); }; -export const updateCore = async (hass: HomeAssistant) => { +export const updateCore = async (hass: HomeAssistant, backup: boolean) => { if (atLeastVersion(hass.config.version, 2021, 2, 4)) { await hass.callWS({ type: "supervisor/api", endpoint: "/core/update", method: "post", timeout: null, + data: { backup }, }); } else { - await hass.callApi>("POST", `hassio/core/update`); + await hass.callApi>("POST", `hassio/core/update`, { + backup, + }); } }; diff --git a/src/data/supervisor/root.ts b/src/data/supervisor/root.ts new file mode 100644 index 0000000000..51fe449ecd --- /dev/null +++ b/src/data/supervisor/root.ts @@ -0,0 +1,58 @@ +import { HomeAssistant } from "../../types"; + +interface SupervisorBaseAvailableUpdates { + panel_path?: string; + update_type?: string; + version_latest?: string; +} + +interface SupervisorAddonAvailableUpdates + extends SupervisorBaseAvailableUpdates { + update_type?: "addon"; + icon?: string; + name?: string; +} + +interface SupervisorCoreAvailableUpdates + extends SupervisorBaseAvailableUpdates { + update_type?: "core"; +} + +interface SupervisorOsAvailableUpdates extends SupervisorBaseAvailableUpdates { + update_type?: "os"; +} + +interface SupervisorSupervisorAvailableUpdates + extends SupervisorBaseAvailableUpdates { + update_type?: "supervisor"; +} + +export type SupervisorAvailableUpdates = + | SupervisorAddonAvailableUpdates + | SupervisorCoreAvailableUpdates + | SupervisorOsAvailableUpdates + | SupervisorSupervisorAvailableUpdates; + +export interface SupervisorAvailableUpdatesResponse { + available_updates: SupervisorAvailableUpdates[]; +} + +export const fetchSupervisorAvailableUpdates = async ( + hass: HomeAssistant +): Promise => + ( + await hass.callWS({ + type: "supervisor/api", + endpoint: "/available_updates", + method: "get", + }) + ).available_updates; + +export const refreshSupervisorAvailableUpdates = async ( + hass: HomeAssistant +): Promise => + hass.callWS({ + type: "supervisor/api", + endpoint: "/refresh_updates", + method: "post", + }); diff --git a/src/data/supervisor/supervisor.ts b/src/data/supervisor/supervisor.ts index 20926b50ce..1a38c4cbe2 100644 --- a/src/data/supervisor/supervisor.ts +++ b/src/data/supervisor/supervisor.ts @@ -70,42 +70,6 @@ export interface Supervisor { localize: LocalizeFunc; } -interface SupervisorBaseAvailableUpdates { - panel_path?: string; - update_type?: string; - version_latest?: string; -} - -interface SupervisorAddonAvailableUpdates - extends SupervisorBaseAvailableUpdates { - update_type?: "addon"; - icon?: string; - name?: string; -} - -interface SupervisorCoreAvailableUpdates - extends SupervisorBaseAvailableUpdates { - update_type?: "core"; -} - -interface SupervisorOsAvailableUpdates extends SupervisorBaseAvailableUpdates { - update_type?: "os"; -} - -interface SupervisorSupervisorAvailableUpdates - extends SupervisorBaseAvailableUpdates { - update_type?: "supervisor"; -} - -export type SupervisorAvailableUpdates = - | SupervisorAddonAvailableUpdates - | SupervisorCoreAvailableUpdates - | SupervisorOsAvailableUpdates - | SupervisorSupervisorAvailableUpdates; - -export interface SupervisorAvailableUpdatesResponse { - available_updates: SupervisorAvailableUpdates[]; -} export const supervisorApiWsRequest = ( conn: Connection, request: supervisorApiRequest @@ -175,14 +139,3 @@ export const subscribeSupervisorEvents = ( getSupervisorEventCollection(hass.connection, key, endpoint).subscribe( onChange ); - -export const fetchSupervisorAvailableUpdates = async ( - hass: HomeAssistant -): Promise => - ( - await hass.callWS({ - type: "supervisor/api", - endpoint: "/supervisor/available_updates", - method: "get", - }) - ).available_updates; diff --git a/src/data/system_log.ts b/src/data/system_log.ts index f2acb5f82b..ced7085658 100644 --- a/src/data/system_log.ts +++ b/src/data/system_log.ts @@ -14,7 +14,7 @@ export interface LoggedError { } export const fetchSystemLog = (hass: HomeAssistant) => - hass.callApi("GET", "error/all"); + hass.callWS({ type: "system_log/list" }); export const getLoggedErrorIntegration = (item: LoggedError) => { // Try to derive from logger name diff --git a/src/data/user.ts b/src/data/user.ts index c4afd4aa68..39493be53e 100644 --- a/src/data/user.ts +++ b/src/data/user.ts @@ -1,3 +1,9 @@ +import { + mdiCrownCircleOutline, + mdiAlphaSCircleOutline, + mdiHomeCircleOutline, + mdiCancel, +} from "@mdi/js"; import { HomeAssistant } from "../types"; import { Credential } from "./auth"; @@ -73,7 +79,36 @@ export const computeUserInitials = (name: string) => { .split(" ") .slice(0, 3) // Of each word, take first letter - .map((s) => s.substr(0, 1)) + .map((s) => s.substring(0, 1)) .join("") ); }; + +const OWNER_ICON = mdiCrownCircleOutline; +const SYSTEM_ICON = mdiAlphaSCircleOutline; +const LOCAL_ICON = mdiHomeCircleOutline; +const DISABLED_ICON = mdiCancel; + +export const computeUserBadges = ( + hass: HomeAssistant, + user: User, + includeSystem: boolean +) => { + const labels: [string, string][] = []; + const translate = (key) => hass.localize(`ui.panel.config.users.${key}`); + + if (user.is_owner) { + labels.push([OWNER_ICON, translate("is_owner")]); + } + if (includeSystem && user.system_generated) { + labels.push([SYSTEM_ICON, translate("is_system")]); + } + if (user.local_only) { + labels.push([LOCAL_ICON, translate("is_local")]); + } + if (!user.is_active) { + labels.push([DISABLED_ICON, translate("is_not_active")]); + } + + return labels; +}; diff --git a/src/data/weather.ts b/src/data/weather.ts index be668288d1..e3ef7fa9ec 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -436,3 +436,19 @@ export const getWeatherStateIcon = ( return undefined; }; + +const DAY_IN_MILLISECONDS = 86400000; + +export const isForecastHourly = ( + forecast?: ForecastAttribute[] +): boolean | undefined => { + if (forecast && forecast?.length && forecast?.length > 2) { + const date1 = new Date(forecast[1].datetime); + const date2 = new Date(forecast[2].datetime); + const timeDiff = date2.getTime() - date1.getTime(); + + return timeDiff < DAY_IN_MILLISECONDS; + } + + return undefined; +}; diff --git a/src/data/webhook.ts b/src/data/webhook.ts index c3d96dc11f..1332de74b2 100644 --- a/src/data/webhook.ts +++ b/src/data/webhook.ts @@ -4,6 +4,7 @@ export interface Webhook { webhook_id: string; domain: string; name: string; + local_only: boolean; } export interface WebhookError { code: number; diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index e7fcb1572b..c8273d7825 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -252,6 +252,7 @@ class DataEntryFlowDialog extends LitElement { ` diff --git a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts index c1141d729a..b0217ec057 100644 --- a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts @@ -96,6 +96,7 @@ export type LoadingReason = export interface DataEntryFlowDialogParams { startFlowHandler?: string; + searchQuery?: string; continueFlowId?: string; dialogClosedCallback?: (params: { flowFinished: boolean; diff --git a/src/dialogs/config-flow/step-flow-create-entry.ts b/src/dialogs/config-flow/step-flow-create-entry.ts index 39be634cf0..f2f1c36a53 100644 --- a/src/dialogs/config-flow/step-flow-create-entry.ts +++ b/src/dialogs/config-flow/step-flow-create-entry.ts @@ -8,6 +8,7 @@ import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-area-picker"; import { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow"; import { + computeDeviceName, DeviceRegistryEntry, updateDeviceRegistryEntry, } from "../../data/device_registry"; @@ -50,7 +51,7 @@ class StepFlowCreateEntry extends LitElement { html`
    - ${device.name}
    + ${computeDeviceName(device, this.hass)}
    ${device.model} (${device.manufacturer})
    { const handlers: HandlerObj[] = h.map((handler) => ({ name: domainToName(this.hass.localize, handler), @@ -66,11 +75,7 @@ class StepFlowPickHandler extends LitElement { ); protected render(): TemplateResult { - const handlers = this._getHandlers( - this.handlers, - this._filter, - this.hass.localize - ); + const handlers = this._getHandlers(); return html`

    ${this.hass.localize("ui.panel.config.integrations.new")}

    @@ -80,6 +85,7 @@ class StepFlowPickHandler extends LitElement { .filter=${this._filter} @value-changed=${this._filterChanged} .label=${this.hass.localize("ui.panel.config.integrations.search")} + @keypress=${this._maybeSubmit} >
    0) { + fireEvent(this, "handler-picked", { + handler: handlers[0].slug, + }); + } + } + static get styles(): CSSResultGroup { return [ configFlowContentStyles, diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index 5471d4ce03..9a96b34312 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -26,6 +26,7 @@ interface ShowDialogParams { dialogTag: keyof HTMLElementTagNameMap; dialogImport: () => Promise; dialogParams: T; + addHistory?: boolean; } export interface DialogClosedParams { @@ -124,8 +125,15 @@ export const makeDialogManager = ( element.addEventListener( "show-dialog", (e: HASSDomEvent>) => { - const { dialogTag, dialogImport, dialogParams } = e.detail; - showDialog(element, root, dialogTag, dialogParams, dialogImport); + const { dialogTag, dialogImport, dialogParams, addHistory } = e.detail; + showDialog( + element, + root, + dialogTag, + dialogParams, + dialogImport, + addHistory + ); } ); }; diff --git a/src/dialogs/more-info/controls/more-info-climate.ts b/src/dialogs/more-info/controls/more-info-climate.ts index dbae025e7e..6074e2be62 100644 --- a/src/dialogs/more-info/controls/more-info-climate.ts +++ b/src/dialogs/more-info/controls/more-info-climate.ts @@ -103,8 +103,9 @@ class MoreInfoClimate extends LitElement { stateObj.attributes.temperature !== null ? html` @@ -169,48 +175,49 @@ class MoreInfoWeather extends LitElement {
    ${this.hass.localize("ui.card.weather.forecast")}:
    - ${this.stateObj.attributes.forecast.map( - (item) => html` -
    - ${item.condition - ? html` - - ` - : ""} - ${!this._showValue(item.templow) - ? html` -
    - ${formatTimeWeekday( - new Date(item.datetime), - this.hass.locale - )} -
    - ` - : ""} - ${this._showValue(item.templow) - ? html` -
    - ${formatDateWeekday( - new Date(item.datetime), - this.hass.locale - )} -
    -
    - ${formatNumber(item.templow, this.hass.locale)} - ${getWeatherUnit(this.hass, "temperature")} -
    - ` - : ""} -
    - ${this._showValue(item.temperature) - ? `${formatNumber(item.temperature, this.hass.locale)} - ${getWeatherUnit(this.hass, "temperature")}` + ${this.stateObj.attributes.forecast.map((item) => + this._showValue(item.templow) || this._showValue(item.temperature) + ? html`
    + ${item.condition + ? html` + + ` : ""} -
    -
    - ` + ${hourly + ? html` +
    + ${formatTimeWeekday( + new Date(item.datetime), + this.hass.locale + )} +
    + ` + : html` +
    + ${formatDateWeekday( + new Date(item.datetime), + this.hass.locale + )} +
    + `} +
    + ${this._showValue(item.templow) + ? `${formatNumber(item.templow, this.hass.locale)} + ${getWeatherUnit(this.hass, "temperature")}` + : hourly + ? "" + : "—"} +
    +
    + ${this._showValue(item.temperature) + ? `${formatNumber(item.temperature, this.hass.locale)} + ${getWeatherUnit(this.hass, "temperature")}` + : "—"} +
    +
    ` + : "" )} ` : ""} diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 3394c575a2..d95f5743c1 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -1,3 +1,4 @@ +import "../../components/ha-textfield"; import { Layout1d, scroll } from "@lit-labs/virtualizer"; import "@material/mwc-list/mwc-list"; import type { List } from "@material/mwc-list/mwc-list"; @@ -33,7 +34,6 @@ import { import { debounce } from "../../common/util/debounce"; import "../../components/ha-chip"; import "../../components/ha-circular-progress"; -import "../../components/ha-dialog"; import "../../components/ha-header-bar"; import "../../components/ha-icon-button"; import { domainToName } from "../../data/integration"; @@ -95,7 +95,11 @@ export class QuickBar extends LitElement { @state() private _done = false; - @query("paper-input", false) private _filterInputField?: HTMLElement; + @state() private _narrow = false; + + @state() private _hint?: string; + + @query("ha-textfield", false) private _filterInputField?: HTMLElement; private _focusSet = false; @@ -103,6 +107,8 @@ export class QuickBar extends LitElement { public async showDialog(params: QuickBarParams) { this._commandMode = params.commandMode || this._toggleIfAlreadyOpened(); + this._hint = params.hint; + this._narrow = matchMedia("(max-width: 600px)").matches; this._initializeItemsIfNeeded(); this._opened = true; } @@ -137,63 +143,90 @@ export class QuickBar extends LitElement { @closed=${this.closeDialog} hideActions > - ${this._search}` : this._search} - @keydown=${this._handleInputKeyDown} - @focus=${this._setFocusFirstListItem} - > - ${this._commandMode - ? html`` - : html``} - ${this._search && - html` - - `} - +
    + ${this._search}` : this._search} + .icon=${true} + .iconTrailing=${this._search !== undefined} + @input=${this._handleSearchChange} + @keydown=${this._handleInputKeyDown} + @focus=${this._setFocusFirstListItem} + > + ${this._commandMode + ? html` + + ` + : html` + + `} + ${this._search && + html` + + `} + + ${this._narrow + ? html` + + ` + : ""} +
    ${!items ? html`` - : html` - ${scroll({ - items, - layout: Layout1d, - renderItem: (item: QuickBarItem, index) => - this._renderItem(item, index), - })} - `} + : items.length === 0 + ? html` +
    + ${this.hass.localize("ui.dialogs.quick-bar.nothing_found")} +
    + ` + : html` + + ${scroll({ + items, + layout: Layout1d, + renderItem: (item: QuickBarItem, index) => + this._renderItem(item, index), + })} + + `} + ${!this._narrow && this._hint + ? html`
    ${this._hint}
    ` + : ""} `; } @@ -337,15 +370,29 @@ export class QuickBar extends LitElement { } private _handleSearchChange(ev: CustomEvent): void { - const newFilter = ev.detail.value; + const newFilter = (ev.currentTarget as any).value; const oldCommandMode = this._commandMode; + const oldSearch = this._search; + let newCommandMode: boolean; + let newSearch: string; if (newFilter.startsWith(">")) { - this._commandMode = true; - this._search = newFilter.substring(1); + newCommandMode = true; + newSearch = newFilter.substring(1); } else { - this._commandMode = false; - this._search = newFilter; + newCommandMode = false; + newSearch = newFilter; + } + + if (oldCommandMode === newCommandMode && oldSearch === newSearch) { + return; + } + + this._commandMode = newCommandMode; + this._search = newSearch; + + if (this._hint) { + this._hint = undefined; } if (oldCommandMode !== this._commandMode) { @@ -428,32 +475,54 @@ export class QuickBar extends LitElement { } private _generateReloadCommands(): CommandItem[] { - const reloadableDomains = componentsWithService(this.hass, "reload").sort(); + // Get all domains that have a direct "reload" service + const reloadableDomains = componentsWithService(this.hass, "reload"); - return reloadableDomains.map((domain) => { - const commandItem = { - primaryText: - this.hass.localize( - `ui.dialogs.quick-bar.commands.reload.${domain}` - ) || - this.hass.localize( - "ui.dialogs.quick-bar.commands.reload.reload", - "domain", - domainToName(this.hass.localize, domain) - ), - action: () => this.hass.callService(domain, "reload"), - iconPath: mdiReload, - categoryText: this.hass.localize( - `ui.dialogs.quick-bar.commands.types.reload` + const commands = reloadableDomains.map((domain) => ({ + primaryText: + this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || + this.hass.localize( + "ui.dialogs.quick-bar.commands.reload.reload", + "domain", + domainToName(this.hass.localize, domain) ), - }; + action: () => this.hass.callService(domain, "reload"), + iconPath: mdiReload, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.reload` + ), + })); - return { - ...commandItem, - categoryKey: "reload", - strings: [`${commandItem.categoryText} ${commandItem.primaryText}`], - }; + // Add "frontend.reload_themes" + commands.push({ + primaryText: this.hass.localize( + "ui.dialogs.quick-bar.commands.reload.themes" + ), + action: () => this.hass.callService("frontend", "reload_themes"), + iconPath: mdiReload, + categoryText: this.hass.localize( + "ui.dialogs.quick-bar.commands.types.reload" + ), }); + + // Add "homeassistant.reload_core_config" + commands.push({ + primaryText: this.hass.localize( + "ui.dialogs.quick-bar.commands.reload.core" + ), + action: () => + this.hass.callService("homeassistant", "reload_core_config"), + iconPath: mdiReload, + categoryText: this.hass.localize( + "ui.dialogs.quick-bar.commands.types.reload" + ), + }); + + return commands.map((command) => ({ + ...command, + categoryKey: "reload", + strings: [`${command.categoryText} ${command.primaryText}`], + })); } private _generateServerControlCommands(): CommandItem[] { @@ -517,21 +586,27 @@ export class QuickBar extends LitElement { for (const sectionKey of Object.keys(configSections)) { for (const page of configSections[sectionKey]) { - if (canShowPage(this.hass, page)) { - if (page.component) { - const info = this._getNavigationInfoFromConfig(page); - - // Add to list, but only if we do not already have an entry for the same path and component - if ( - info && - !items.some( - (e) => e.path === info.path && e.component === info.component - ) - ) { - items.push(info); - } - } + if (!canShowPage(this.hass, page)) { + continue; } + if (!page.component) { + continue; + } + const info = this._getNavigationInfoFromConfig(page); + + if (!info) { + continue; + } + // Add to list, but only if we do not already have an entry for the same path and component + if ( + items.some( + (e) => e.path === info.path && e.component === info.component + ) + ) { + continue; + } + + items.push(info); } } @@ -541,14 +616,15 @@ export class QuickBar extends LitElement { private _getNavigationInfoFromConfig( page: PageNavigation ): NavigationInfo | undefined { - if (page.component) { - const caption = this.hass.localize( - `ui.dialogs.quick-bar.commands.navigation.${page.component}` - ); + if (!page.component) { + return undefined; + } + const caption = this.hass.localize( + `ui.dialogs.quick-bar.commands.navigation.${page.component}` + ); - if (page.translationKey && caption) { - return { ...page, primaryText: caption }; - } + if (page.translationKey && caption) { + return { ...page, primaryText: caption }; } return undefined; @@ -605,7 +681,13 @@ export class QuickBar extends LitElement { haStyleDialog, css` .heading { - padding: 8px 20px 0px; + display: flex; + align-items: center; + --mdc-theme-primary: var(--primary-text-color); + } + + .heading ha-textfield { + flex-grow: 1; } ha-dialog { @@ -629,11 +711,10 @@ export class QuickBar extends LitElement { } ha-svg-icon.prefix { - margin: 8px; color: var(--primary-text-color); } - paper-input ha-icon-button { + ha-textfield ha-icon-button { --mdc-icon-button-size: 24px; color: var(--primary-text-color); } @@ -666,6 +747,17 @@ export class QuickBar extends LitElement { mwc-list-item.command-item { text-transform: capitalize; } + + .hint { + padding: 20px; + font-style: italic; + text-align: center; + } + + .nothing-found { + padding: 16px 0px; + text-align: center; + } `, ]; } diff --git a/src/dialogs/quick-bar/show-dialog-quick-bar.ts b/src/dialogs/quick-bar/show-dialog-quick-bar.ts index 09ba816341..01e2af978e 100644 --- a/src/dialogs/quick-bar/show-dialog-quick-bar.ts +++ b/src/dialogs/quick-bar/show-dialog-quick-bar.ts @@ -3,6 +3,7 @@ import { fireEvent } from "../../common/dom/fire_event"; export interface QuickBarParams { entityFilter?: string; commandMode?: boolean; + hint?: string; } export const loadQuickBar = () => import("./ha-quick-bar"); @@ -15,5 +16,6 @@ export const showQuickBar = ( dialogTag: "ha-quick-bar", dialogImport: loadQuickBar, dialogParams, + addHistory: false, }); }; diff --git a/src/external_app/external_app_entrypoint.ts b/src/external_app/external_app_entrypoint.ts new file mode 100644 index 0000000000..46d5a39dee --- /dev/null +++ b/src/external_app/external_app_entrypoint.ts @@ -0,0 +1,52 @@ +/* +All commands that do UI stuff need to be loaded from the app bundle as UI stuff +in core bundle slows things down and causes duplicate registration. + +This is the entry point for providing external app stuff from app entrypoint. +*/ + +import { fireEvent } from "../common/dom/fire_event"; +import { HomeAssistantMain } from "../layouts/home-assistant-main"; +import type { EMExternalMessageCommands } from "./external_messaging"; + +export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => { + window.addEventListener("haptic", (ev) => + hassMainEl.hass.auth.external!.fireMessage({ + type: "haptic", + payload: { hapticType: ev.detail }, + }) + ); + + hassMainEl.hass.auth.external!.addCommandHandler((msg) => + handleExternalMessage(hassMainEl, msg) + ); +}; + +const handleExternalMessage = ( + hassMainEl: HomeAssistantMain, + msg: EMExternalMessageCommands +): boolean => { + const bus = hassMainEl.hass.auth.external!; + + if (msg.command === "restart") { + hassMainEl.hass.connection.reconnect(true); + bus.fireMessage({ + id: msg.id, + type: "result", + success: true, + result: null, + }); + } else if (msg.command === "notifications/show") { + fireEvent(hassMainEl, "hass-show-notifications"); + bus.fireMessage({ + id: msg.id, + type: "result", + success: true, + result: null, + }); + } else { + return false; + } + + return true; +}; diff --git a/src/external_app/external_auth.ts b/src/external_app/external_auth.ts index 1917e4bec2..26b1f1f892 100644 --- a/src/external_app/external_auth.ts +++ b/src/external_app/external_auth.ts @@ -128,14 +128,14 @@ export class ExternalAuth extends Auth { } } -export const createExternalAuth = (hassUrl: string) => { +export const createExternalAuth = async (hassUrl: string) => { const auth = new ExternalAuth(hassUrl); if ( (window.externalApp && window.externalApp.externalBus) || (window.webkit && window.webkit.messageHandlers.externalBus) ) { auth.external = new ExternalMessaging(); - auth.external.attach(); + await auth.external.attach(); } return auth; }; diff --git a/src/external_app/external_config.ts b/src/external_app/external_config.ts deleted file mode 100644 index 27728deaf0..0000000000 --- a/src/external_app/external_config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ExternalMessaging } from "./external_messaging"; - -export interface ExternalConfig { - hasSettingsScreen: boolean; - canWriteTag: boolean; - hasExoPlayer: boolean; -} - -export const getExternalConfig = ( - bus: ExternalMessaging -): Promise => { - if (!bus.cache.cfg) { - bus.cache.cfg = bus.sendMessage({ - type: "config/get", - }); - } - return bus.cache.cfg; -}; diff --git a/src/external_app/external_events_forwarder.ts b/src/external_app/external_events_forwarder.ts deleted file mode 100644 index 4ac2258105..0000000000 --- a/src/external_app/external_events_forwarder.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ExternalMessaging } from "./external_messaging"; - -export const externalForwardConnectionEvents = (bus: ExternalMessaging) => { - window.addEventListener("connection-status", (ev) => - bus.fireMessage({ - type: "connection-status", - payload: { event: ev.detail }, - }) - ); -}; - -export const externalForwardHaptics = (bus: ExternalMessaging) => - window.addEventListener("haptic", (ev) => - bus.fireMessage({ type: "haptic", payload: { hapticType: ev.detail } }) - ); diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts index 7dd03095cb..fdab1ab2eb 100644 --- a/src/external_app/external_messaging.ts +++ b/src/external_app/external_messaging.ts @@ -1,9 +1,3 @@ -import { Connection } from "home-assistant-js-websocket"; -import { - externalForwardConnectionEvents, - externalForwardHaptics, -} from "./external_events_forwarder"; - const CALLBACK_EXTERNAL_BUS = "externalBus"; interface CommandInFlight { @@ -42,24 +36,54 @@ interface EMExternalMessageRestart { command: "restart"; } +interface EMExternMessageShowNotifications { + id: number; + type: "command"; + command: "notifications/show"; +} + +export type EMExternalMessageCommands = + | EMExternalMessageRestart + | EMExternMessageShowNotifications; + type ExternalMessage = | EMMessageResultSuccess | EMMessageResultError - | EMExternalMessageRestart; + | EMExternalMessageCommands; + +type ExternalMessageHandler = (msg: EMExternalMessageCommands) => boolean; + +export interface ExternalConfig { + hasSettingsScreen: boolean; + hasSidebar: boolean; + canWriteTag: boolean; + hasExoPlayer: boolean; +} export class ExternalMessaging { + public config!: ExternalConfig; + public commands: { [msgId: number]: CommandInFlight } = {}; - public connection?: Connection; - - public cache: Record = {}; - public msgId = 0; - public attach() { - externalForwardConnectionEvents(this); - externalForwardHaptics(this); + private _commandHandler?: ExternalMessageHandler; + + public async attach() { window[CALLBACK_EXTERNAL_BUS] = (msg) => this.receiveMessage(msg); + window.addEventListener("connection-status", (ev) => + this.fireMessage({ + type: "connection-status", + payload: { event: ev.detail }, + }) + ); + this.config = await this.sendMessage({ + type: "config/get", + }); + } + + public addCommandHandler(handler: ExternalMessageHandler) { + this._commandHandler = handler; } /** @@ -97,36 +121,25 @@ export class ExternalMessaging { } if (msg.type === "command") { - if (!this.connection) { + if (!this._commandHandler || !this._commandHandler(msg)) { + let code: string; + let message: string; + if (this._commandHandler) { + code = "not_ready"; + message = "Command handler not ready"; + } else { + code = "unknown_command"; + message = `Unknown command ${msg.command}`; + } // eslint-disable-next-line no-console - console.warn("Received command without having connection set", msg); + console.warn(message, msg); this.fireMessage({ id: msg.id, type: "result", success: false, error: { - code: "commands_not_init", - message: `Commands connection not set`, - }, - }); - } else if (msg.command === "restart") { - this.connection.reconnect(true); - this.fireMessage({ - id: msg.id, - type: "result", - success: true, - result: null, - }); - } else { - // eslint-disable-next-line no-console - console.warn("Received unknown command", msg.command, msg); - this.fireMessage({ - id: msg.id, - type: "result", - success: false, - error: { - code: "unknown_command", - message: `Unknown command ${msg.command}`, + code, + message, }, }); } diff --git a/src/fake_data/entity.ts b/src/fake_data/entity.ts index 89bdbfb5b0..c7c31d5702 100644 --- a/src/fake_data/entity.ts +++ b/src/fake_data/entity.ts @@ -247,6 +247,44 @@ class InputNumberEntity extends Entity { } } +class InputTextEntity extends Entity { + public async handleService( + domain, + service, + // @ts-ignore + data + ) { + if (domain !== this.domain) { + return; + } + + if (service === "set_value") { + this.update("" + data.value); + } else { + super.handleService(domain, service, data); + } + } +} + +class InputSelectEntity extends Entity { + public async handleService( + domain, + service, + // @ts-ignore + data + ) { + if (domain !== this.domain) { + return; + } + + if (service === "select_option") { + this.update("" + data.option); + } else { + super.handleService(domain, service, data); + } + } +} + class ClimateEntity extends Entity { public async handleService(domain, service, data) { if (domain !== this.domain) { @@ -301,6 +339,8 @@ const TYPES = { group: GroupEntity, input_boolean: ToggleEntity, input_number: InputNumberEntity, + input_text: InputTextEntity, + input_select: InputSelectEntity, light: LightEntity, lock: LockEntity, media_player: MediaPlayerEntity, diff --git a/src/html/_style_base.html.template b/src/html/_style_base.html.template index 21d047c1b8..e1e60a32b6 100644 --- a/src/html/_style_base.html.template +++ b/src/html/_style_base.html.template @@ -1,4 +1,4 @@ - +