diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index 185ef3aa65..c5d741a5af 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -3,10 +3,10 @@ const webpack = require("webpack"); const path = require("path"); const TerserPlugin = require("terser-webpack-plugin"); const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); -const paths = require("./paths.js"); -const bundle = require("./bundle.js"); const log = require("fancy-log"); const WebpackBar = require("webpackbar"); +const paths = require("./paths.js"); +const bundle = require("./bundle.js"); class LogStartCompilePlugin { ignoredFirst = false; @@ -138,6 +138,8 @@ const createWebpackConfig = ({ "lit/directives/cache$": "lit/directives/cache.js", "lit/directives/repeat$": "lit/directives/repeat.js", "lit/polyfill-support$": "lit/polyfill-support.js", + "@lit-labs/virtualizer/layouts/grid": + "@lit-labs/virtualizer/layouts/grid.js", }, }, output: { diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 01f7efbbd8..8a4f3f905e 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -151,6 +151,7 @@ class DialogMediaPlayerBrowse extends LitElement { ha-media-player-browse { --media-browser-max-height: calc(100vh - 65px); + height: calc(100vh - 65px); } @media (min-width: 800px) { @@ -163,6 +164,7 @@ class DialogMediaPlayerBrowse extends LitElement { ha-media-player-browse { position: initial; --media-browser-max-height: 100vh - 137px; + height: 100vh - 137px; width: 700px; } } diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 3de84c431d..5f219bf247 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -3,6 +3,8 @@ import "@material/mwc-list/mwc-list"; import "@material/mwc-list/mwc-list-item"; import { mdiArrowUpRight, mdiPlay, mdiPlus } from "@mdi/js"; import "@polymer/paper-tooltip/paper-tooltip"; +import { grid } from "@lit-labs/virtualizer/layouts/grid"; +import "@lit-labs/virtualizer"; import { css, CSSResultGroup, @@ -16,16 +18,13 @@ import { eventOptions, property, query, - queryAll, state, } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { ifDefined } from "lit/directives/if-defined"; -import { styleMap } from "lit/directives/style-map"; +import { until } from "lit/directives/until"; 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 { browseMediaPlayer, @@ -40,18 +39,18 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; import { installResizeObserver } from "../../panels/lovelace/common/install-resize-observer"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; -import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url"; 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-browse-media-tts"; import type { TtsMediaPickedEvent } from "./ha-browse-media-tts"; +import { getSignedPath } from "../../data/auth"; +import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url"; declare global { interface HASSDomEvents { @@ -101,8 +100,6 @@ export class HaMediaPlayerBrowse extends LitElement { @query(".content") private _content?: HTMLDivElement; - @queryAll(".lazythumbnail") private _thumbnails?: HaCard[]; - private _headerOffsetHeight = 0; private _resizeObserver?: ResizeObserver; @@ -148,326 +145,6 @@ export class HaMediaPlayerBrowse extends LitElement { } } - protected render(): TemplateResult { - if (this._error) { - return html` -
${this._renderError(this._error)}
- `; - } - - if (!this._currentItem) { - return html``; - } - - const currentItem = this._currentItem; - - const subtitle = this.hass.localize( - `ui.components.media-browser.class.${currentItem.media_class}` - ); - const children = currentItem.children || []; - const mediaClass = MediaClassBrowserSettings[currentItem.media_class]; - const childrenMediaClass = currentItem.children_media_class - ? MediaClassBrowserSettings[currentItem.children_media_class] - : MediaClassBrowserSettings.directory; - - return html` - ${ - currentItem.can_play - ? html`
-
- ${currentItem.thumbnail - ? html` -
- ${this._narrow && currentItem?.can_play - ? html` - - - ${this.hass.localize( - `ui.components.media-browser.${this.action}` - )} - - ` - : ""} -
- ` - : html``} -
- - ${currentItem.can_play && - (!currentItem.thumbnail || !this._narrow) - ? html` - - - ${this.hass.localize( - `ui.components.media-browser.${this.action}` - )} - - ` - : ""} -
-
-
` - : "" - } -
- ${ - this._error - ? html` -
- ${this._renderError(this._error)} -
- ` - : isTTSMediaSource(currentItem.media_content_id) - ? html` - - ` - : !children.length && !currentItem.not_shown - ? html` -
- ${currentItem.media_content_id === - "media-source://media_source/local/." - ? html` -
- - - - - ${this.hass.localize( - "ui.components.media-browser.file_management.highlight_button" - )} - -
- ` - : this.hass.localize( - "ui.components.media-browser.no_items" - )} -
- ` - : childrenMediaClass.layout === "grid" - ? html` -
- ${children.map( - (child) => html` -
- -
- ${child.thumbnail - ? html` -
- ` - : html` -
- -
- `} - ${child.can_play - ? html` - - ` - : ""} -
-
- ${child.title} - ${child.title} -
-
-
- ` - )} - ${currentItem.not_shown - ? html` -
-
- ${this.hass.localize( - "ui.components.media-browser.not_shown", - { count: currentItem.not_shown } - )} -
-
- ` - : ""} -
- ` - : html` - - ${children.map( - (child) => html` - -
- -
- ${child.title} -
-
  • - ` - )} - ${currentItem.not_shown - ? html` - - - ${this.hass.localize( - "ui.components.media-browser.not_shown", - { count: currentItem.not_shown } - )} - - - ` - : ""} -
    - ` - } -
    - - - `; - } - - protected firstUpdated(): void { - this._measureCard(); - 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); @@ -583,6 +260,19 @@ export class HaMediaPlayerBrowse extends LitElement { } } + 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; + } + + protected firstUpdated(): void { + this._measureCard(); + this._attachResizeObserver(); + } + protected updated(changedProps: PropertyValues): void { super.updated(changedProps); @@ -590,16 +280,368 @@ export class HaMediaPlayerBrowse extends LitElement { this._animateHeaderHeight(); } else if (changedProps.has("_currentItem")) { this._setHeaderHeight(); - this._attachIntersectionObserver(); } } - private _actionClicked(ev: MouseEvent): void { + protected render(): TemplateResult { + if (this._error) { + return html` +
    ${this._renderError(this._error)}
    + `; + } + + if (!this._currentItem) { + return html``; + } + + const currentItem = this._currentItem; + + const subtitle = this.hass.localize( + `ui.components.media-browser.class.${currentItem.media_class}` + ); + const children = currentItem.children || []; + const mediaClass = MediaClassBrowserSettings[currentItem.media_class]; + const childrenMediaClass = currentItem.children_media_class + ? MediaClassBrowserSettings[currentItem.children_media_class] + : MediaClassBrowserSettings.directory; + + const backgroundImage = currentItem.thumbnail + ? this._getSignedThumbnail(currentItem.thumbnail).then( + (value) => `url(${value})` + ) + : "none"; + + return html` + ${ + currentItem.can_play + ? html` +
    +
    + ${currentItem.thumbnail + ? html` +
    + ${this._narrow && currentItem?.can_play + ? html` + + + ${this.hass.localize( + `ui.components.media-browser.${this.action}` + )} + + ` + : ""} +
    + ` + : html``} +
    + + ${currentItem.can_play && + (!currentItem.thumbnail || !this._narrow) + ? html` + + + ${this.hass.localize( + `ui.components.media-browser.${this.action}` + )} + + ` + : ""} +
    +
    +
    + ` + : "" + } +
    + ${ + this._error + ? html` +
    + ${this._renderError(this._error)} +
    + ` + : isTTSMediaSource(currentItem.media_content_id) + ? html` + + ` + : !children.length && !currentItem.not_shown + ? html` +
    + ${currentItem.media_content_id === + "media-source://media_source/local/." + ? html` +
    + + + + + ${this.hass.localize( + "ui.components.media-browser.file_management.highlight_button" + )} + +
    + ` + : this.hass.localize( + "ui.components.media-browser.no_items" + )} +
    + ` + : childrenMediaClass.layout === "grid" + ? html` + + ${currentItem.not_shown + ? html` +
    +
    + ${this.hass.localize( + "ui.components.media-browser.not_shown", + { count: currentItem.not_shown } + )} +
    +
    + ` + : ""} + ` + : html` + + + ${currentItem.not_shown + ? html` + + + ${this.hass.localize( + "ui.components.media-browser.not_shown", + { count: currentItem.not_shown } + )} + + + ` + : ""} + + ` + } +
    + + + `; + } + + private _renderGridItem = (child: MediaPlayerItem): TemplateResult => { + const backgroundImage = child.thumbnail + ? this._getSignedThumbnail(child.thumbnail).then( + (value) => `url(${value})` + ) + : "none"; + + return html` +
    + +
    + ${child.thumbnail + ? html` +
    + ` + : html` +
    + +
    + `} + ${child.can_play + ? html` + + ` + : ""} +
    +
    + ${child.title} + ${child.title} +
    +
    +
    + `; + }; + + private _renderListItem = (child: MediaPlayerItem): TemplateResult => { + const currentItem = this._currentItem; + const mediaClass = MediaClassBrowserSettings[currentItem!.media_class]; + + const backgroundImage = + mediaClass.show_list_images && child.thumbnail + ? this._getSignedThumbnail(child.thumbnail).then( + (value) => `url(${value})` + ) + : "none"; + + return html` + +
    + +
    + ${child.title} +
    +
  • + `; + }; + + private async _getSignedThumbnail( + thumbnailUrl: string | undefined + ): Promise { + if (!thumbnailUrl) { + return ""; + } + + if (thumbnailUrl.startsWith("/")) { + // Thumbnails served by local API require authentication + return (await getSignedPath(this.hass, thumbnailUrl)).path; + } + + if (thumbnailUrl.startsWith("https://brands.home-assistant.io")) { + // The backend is not aware of the theme used by the users, + // so we rewrite the URL to show a proper icon + thumbnailUrl = brandsUrl({ + domain: extractDomainFromBrandUrl(thumbnailUrl), + type: "icon", + useFallback: true, + darkOptimized: this.hass.themes?.darkMode, + }); + } + + return thumbnailUrl; + } + + private _actionClicked = (ev: MouseEvent): void => { ev.stopPropagation(); const item = (ev.currentTarget as any).item; this._runAction(item); - } + }; private _runAction(item: MediaPlayerItem): void { fireEvent(this, "media-picked", { item, navigateIds: this.navigateIds }); @@ -615,7 +657,7 @@ export class HaMediaPlayerBrowse extends LitElement { }); } - private async _childClicked(ev: MouseEvent): Promise { + private _childClicked = async (ev: MouseEvent): Promise => { const target = ev.currentTarget as any; const item: MediaPlayerItem = target.item; @@ -631,7 +673,7 @@ export class HaMediaPlayerBrowse extends LitElement { fireEvent(this, "media-browsed", { ids: [...this.navigateIds, item], }); - } + }; private async _fetchData( entityId: string, @@ -658,55 +700,6 @@ 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; - } else if ( - thumbnailUrl.startsWith("https://brands.home-assistant.io") - ) { - // The backend is not aware of the theme used by the users, - // so we rewrite the URL to show a proper icon - thumbnailUrl = brandsUrl({ - domain: extractDomainFromBrandUrl(thumbnailUrl), - type: "icon", - useFallback: true, - darkOptimized: this.hass.themes?.darkMode, - }); - } - 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"); } @@ -841,6 +834,7 @@ export class HaMediaPlayerBrowse extends LitElement { .content { overflow-y: auto; box-sizing: border-box; + height: 100%; } /* HEADER */ @@ -926,6 +920,7 @@ export class HaMediaPlayerBrowse extends LitElement { .not-shown { font-style: italic; color: var(--secondary-text-color); + padding: 8px 16px 8px; } .grid.not-shown { @@ -951,7 +946,11 @@ export class HaMediaPlayerBrowse extends LitElement { border-bottom-color: var(--divider-color); } - .children { + mwc-list-item { + width: 100%; + } + + div.children { display: grid; grid-template-columns: repeat( auto-fit, @@ -988,7 +987,7 @@ export class HaMediaPlayerBrowse extends LitElement { padding-bottom: 100%; } - .portrait.children ha-card .thumbnail { + .portrait ha-card .thumbnail { padding-bottom: 150%; } @@ -1062,10 +1061,6 @@ export class HaMediaPlayerBrowse extends LitElement { color: var(--primary-color); } - ha-card:hover .lazythumbnail { - opacity: 0.5; - } - .child .title { font-size: 16px; padding-top: 16px; @@ -1127,7 +1122,7 @@ export class HaMediaPlayerBrowse extends LitElement { padding: 0 24px; } - :host([narrow]) .children { + :host([narrow]) div.children { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important; } @@ -1232,6 +1227,16 @@ export class HaMediaPlayerBrowse extends LitElement { --mdc-fab-box-shadow: none; --mdc-theme-secondary: rgba(var(--rgb-primary-color), 0.5); } + + lit-virtualizer { + height: 100%; + overflow: overlay !important; + contain: size layout !important; + } + + lit-virtualizer.not_shown { + height: calc(100% - 36px); + } `, ]; }