feat: Add loading spinner on card initialization (#1549)

* feat: Add loading spinner on card initialization

* Minor doc fix.
This commit is contained in:
Dermot Duffy
2024-09-20 19:39:17 -07:00
committed by GitHub
parent 650d99ec00
commit b46ea36265
7 changed files with 139 additions and 28 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ performance:
| Option | Default | Description | | Option | Default | Description |
| ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `animated_progress_indicator` | `true` | Will show the animated progress indicator 'spinner' when `true` or a simple loading icon when `false`. | | `animated_progress_indicator` | `true` | Will show the animated progress indicator 'spinners' when `true`. |
| `media_chunk_size` | `50` | How many media items to fetch and render at a time (e.g. thumbnails under a live view, or number of snapshots to load in the media viewer). This may only make partial sense in some contexts (e.g. the 'infinite gallery' is still infinite, it just loads thumbnails this many items at a time) or not at all (e.g. the timeline will show the number of events dictated by the time span the user navigates to). | | `media_chunk_size` | `50` | How many media items to fetch and render at a time (e.g. thumbnails under a live view, or number of snapshots to load in the media viewer). This may only make partial sense in some contexts (e.g. the 'infinite gallery' is still infinite, it just loads thumbnails this many items at a time) or not at all (e.g. the timeline will show the number of events dictated by the time span the user navigates to). |
| `max_simultaneous_engine_requests` | _Infinity_ | How many camera engine requests to allow occur in parallel. Setting lower values will slow the card down since more requests will run in sequence, but it will increase the chances of positive cache hit rates and reduce the chances of overwhelming the backend. | | `max_simultaneous_engine_requests` | _Infinity_ | How many camera engine requests to allow occur in parallel. Setting lower values will slow the card down since more requests will run in sequence, but it will increase the chances of positive cache hit rates and reduce the chances of overwhelming the backend. |
@@ -31,12 +31,17 @@ export class InitializationManager {
// initialization" (above) are followed. // initialization" (above) are followed.
protected _initializationQueue = new PQueue({ concurrency: 1 }); protected _initializationQueue = new PQueue({ concurrency: 1 });
protected _initializer: Initializer; protected _initializer: Initializer;
protected _everInitialized = false;
constructor(api: CardInitializerAPI, initializer?: Initializer) { constructor(api: CardInitializerAPI, initializer?: Initializer) {
this._api = api; this._api = api;
this._initializer = initializer ?? new Initializer(); this._initializer = initializer ?? new Initializer();
} }
public wasEverInitialized(): boolean {
return this._everInitialized;
}
public isInitializedMandatory(): boolean { public isInitializedMandatory(): boolean {
const config = this._api.getConfigManager().getConfig(); const config = this._api.getConfigManager().getConfig();
if (!config) { if (!config) {
@@ -113,6 +118,7 @@ export class InitializationManager {
return; return;
} }
this._everInitialized = true;
this._api.getCardElementManager().update(); this._api.getCardElementManager().update();
} }
+12 -7
View File
@@ -12,6 +12,7 @@ import { CardController } from './card-controller/controller';
import { MenuButtonController } from './components-lib/menu-button-controller'; import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/elements.js'; import './components/elements.js';
import { FrigateCardElements } from './components/elements.js'; import { FrigateCardElements } from './components/elements.js';
import './components/loading.js';
import './components/menu.js'; import './components/menu.js';
import { FrigateCardMenu } from './components/menu.js'; import { FrigateCardMenu } from './components/menu.js';
import './components/message.js'; import './components/message.js';
@@ -183,7 +184,6 @@ class FrigateCard extends LitElement {
if (!this._controller.getInitializationManager().isInitializedMandatory()) { if (!this._controller.getInitializationManager().isInitializedMandatory()) {
this._controller.getInitializationManager().initializeMandatory(); this._controller.getInitializationManager().initializeMandatory();
return false;
} }
return true; return true;
} }
@@ -338,6 +338,11 @@ class FrigateCard extends LitElement {
const actions = this._controller.getActionsManager().getMergedActions(); const actions = this._controller.getActionsManager().getMergedActions();
const cameraManager = this._controller.getCameraManager(); const cameraManager = this._controller.getCameraManager();
const renderLoadingSpinner =
this._config?.performance?.features.animated_progress_indicator !== false;
const showLoadingSpinner =
!this._controller.getInitializationManager().wasEverInitialized() &&
!this._controller.getMessageManager().hasMessage();
// Caution: Keep the main div and the menu next to one another in order to // Caution: Keep the main div and the menu next to one another in order to
// ensure the hover menu styling continues to work. // ensure the hover menu styling continues to work.
@@ -366,13 +371,14 @@ class FrigateCard extends LitElement {
} }
@frigate-card:focus=${() => this.focus()} @frigate-card:focus=${() => this.focus()}
> >
${renderLoadingSpinner
? html`<frigate-card-loading .show=${showLoadingSpinner}>
</frigate-card-loading>`
: ''}
${this._renderMenuStatusContainer('top')} ${this._renderMenuStatusContainer('top')}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}"> <div ${ref(this._refMain)} class="${classMap(mainClasses)}">
${this._renderMenuStatusContainer('overlay')} ${this._renderMenuStatusContainer('overlay')}
${ <frigate-card-views
// Always want to render <frigate-card-views> even if there's a message, to
// ensure live preload is always present (even if not displayed).
html`<frigate-card-views
${ref(this._refViews)} ${ref(this._refViews)}
.hass=${this._hass} .hass=${this._hass}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()} .viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
@@ -393,8 +399,7 @@ class FrigateCard extends LitElement {
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs() ? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined} : undefined}
></frigate-card-views>` ></frigate-card-views>
}
${ ${
// Keep message rendering to last to show messages that may have been // Keep message rendering to last to show messages that may have been
// generated during the render. // generated during the render.
+49
View File
@@ -0,0 +1,49 @@
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
html,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import irisLogo from '../images/camera-iris.svg';
import controlStyle from '../scss/loading.scss';
import { Timer } from '../utils/timer';
// Number of seconds after the loading spinner is hidden before rendering this
// component as empty. Should be longer than the opacity css transition time.
const LOADING_EMPTY_SECONDS = 2;
@customElement('frigate-card-loading')
export class FrigateCardLoading extends LitElement {
@property({ attribute: true, reflect: true, type: Boolean })
public show = false;
@state()
protected _empty = false;
protected _timer = new Timer();
protected render(): TemplateResult {
return this._empty ? html`` : html` <img src="${irisLogo}" /> `;
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('show') && !this.show) {
this._timer.start(LOADING_EMPTY_SECONDS, () => {
this._empty = true;
});
}
}
static get styles(): CSSResultGroup {
return unsafeCSS(controlStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-loading': FrigateCardLoading;
}
}
+7
View File
@@ -13,6 +13,7 @@
border-radius: var(--ha-card-border-radius, 4px); border-radius: var(--ha-card-border-radius, 4px);
height: var(--frigate-card-height); height: var(--frigate-card-height);
min-height: 100px;
// Ensure all clicks at the top level work. // Ensure all clicks at the top level work.
pointer-events: all; pointer-events: all;
@@ -29,6 +30,12 @@
--frigate-card-height: auto; --frigate-card-height: auto;
} }
frigate-card-loading {
position: absolute;
inset: 0;
z-index: 1;
}
:host([dark]) { :host([dark]) {
filter: brightness(75%); filter: brightness(75%);
} }
+40
View File
@@ -0,0 +1,40 @@
:host {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
transition: opacity 1s;
}
:host([show]) {
opacity: 1;
}
:host(:not([show])) {
opacity: 0;
}
img {
width: 40%;
height: 40%;
opacity: 0.2;
filter: invert(100%);
animation: rotate 8s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -57,6 +57,7 @@ describe('InitializationManager', () => {
it('without hass', async () => { it('without hass', async () => {
const manager = new InitializationManager(createCardAPI()); const manager = new InitializationManager(createCardAPI());
await manager.initializeMandatory(); await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
}); });
it('without config', async () => { it('without config', async () => {
@@ -67,6 +68,7 @@ describe('InitializationManager', () => {
vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true); vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
await manager.initializeMandatory(); await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
}); });
it('successfully', async () => { it('successfully', async () => {
@@ -94,6 +96,8 @@ describe('InitializationManager', () => {
expect(api.getViewManager().initialize).toBeCalled(); expect(api.getViewManager().initialize).toBeCalled();
expect(api.getMicrophoneManager().connect).not.toBeCalled(); expect(api.getMicrophoneManager().connect).not.toBeCalled();
expect(api.getCardElementManager().update).toBeCalled(); expect(api.getCardElementManager().update).toBeCalled();
expect(manager.wasEverInitialized()).toBeTruthy();
}); });
it('successfully with microphone if configured', async () => { it('successfully with microphone if configured', async () => {