Repository: dermotduffy/frigate-hass-card
Pull request #1549 (2024-09-21 02:32:49)

feat: Add loading spinner on card initialization


Patch:
diff --git a/docs/configuration/performance.md b/docs/configuration/performance.md
index f0597905..4fb87857 100644
--- a/docs/configuration/performance.md
+++ b/docs/configuration/performance.md
@@ -24,7 +24,7 @@ performance:
 
 | 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). |
 | `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.                                                                                                                                                 |
 
diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts
index 127f69bb..15239d63 100644
--- a/src/card-controller/initialization-manager.ts
+++ b/src/card-controller/initialization-manager.ts
@@ -31,12 +31,17 @@ export class InitializationManager {
   // initialization" (above) are followed.
   protected _initializationQueue = new PQueue({ concurrency: 1 });
   protected _initializer: Initializer;
+  protected _everInitialized = false;
 
   constructor(api: CardInitializerAPI, initializer?: Initializer) {
     this._api = api;
     this._initializer = initializer ?? new Initializer();
   }
 
+  public wasEverInitialized(): boolean {
+    return this._everInitialized;
+  }
+
   public isInitializedMandatory(): boolean {
     const config = this._api.getConfigManager().getConfig();
     if (!config) {
@@ -113,6 +118,7 @@ export class InitializationManager {
       return;
     }
 
+    this._everInitialized = true;
     this._api.getCardElementManager().update();
   }
 
diff --git a/src/card.ts b/src/card.ts
index 57321d83..0ac95d60 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -12,6 +12,7 @@ import { CardController } from './card-controller/controller';
 import { MenuButtonController } from './components-lib/menu-button-controller';
 import './components/elements.js';
 import { FrigateCardElements } from './components/elements.js';
+import './components/loading.js';
 import './components/menu.js';
 import { FrigateCardMenu } from './components/menu.js';
 import './components/message.js';
@@ -183,7 +184,6 @@ class FrigateCard extends LitElement {
 
     if (!this._controller.getInitializationManager().isInitializedMandatory()) {
       this._controller.getInitializationManager().initializeMandatory();
-      return false;
     }
     return true;
   }
@@ -338,6 +338,11 @@ class FrigateCard extends LitElement {
 
     const actions = this._controller.getActionsManager().getMergedActions();
     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
     // ensure the hover menu styling continues to work.
@@ -366,35 +371,35 @@ class FrigateCard extends LitElement {
         }
         @frigate-card:focus=${() => this.focus()}
       >
+        ${renderLoadingSpinner
+          ? html`<frigate-card-loading .show=${showLoadingSpinner}>
+            </frigate-card-loading>`
+          : ''}
         ${this._renderMenuStatusContainer('top')}
         <div ${ref(this._refMain)} class="${classMap(mainClasses)}">
           ${this._renderMenuStatusContainer('overlay')}
-          ${
-            // 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)}
-              .hass=${this._hass}
-              .viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
-              .cameraManager=${cameraManager}
-              .resolvedMediaCache=${this._controller.getResolvedMediaCache()}
-              .nonOverriddenConfig=${this._controller
-                .getConfigManager()
-                .getNonOverriddenConfig()}
-              .overriddenConfig=${this._controller.getConfigManager().getConfig()}
-              .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
-              .rawConfig=${this._controller.getConfigManager().getRawConfig()}
-              .configManager=${this._controller.getConfigManager()}
-              .conditionsManagerEpoch=${this._controller
-                .getConditionsManager()
-                ?.getEpoch()}
-              .hide=${!!this._controller.getMessageManager().hasMessage()}
-              .microphoneManager=${this._controller.getMicrophoneManager()}
-              .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
-                ? this._controller.getTriggersManager().getTriggeredCameraIDs()
-                : undefined}
-            ></frigate-card-views>`
-          }
+          <frigate-card-views
+            ${ref(this._refViews)}
+            .hass=${this._hass}
+            .viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
+            .cameraManager=${cameraManager}
+            .resolvedMediaCache=${this._controller.getResolvedMediaCache()}
+            .nonOverriddenConfig=${this._controller
+              .getConfigManager()
+              .getNonOverriddenConfig()}
+            .overriddenConfig=${this._controller.getConfigManager().getConfig()}
+            .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
+            .rawConfig=${this._controller.getConfigManager().getRawConfig()}
+            .configManager=${this._controller.getConfigManager()}
+            .conditionsManagerEpoch=${this._controller
+              .getConditionsManager()
+              ?.getEpoch()}
+            .hide=${!!this._controller.getMessageManager().hasMessage()}
+            .microphoneManager=${this._controller.getMicrophoneManager()}
+            .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
+              ? this._controller.getTriggersManager().getTriggeredCameraIDs()
+              : undefined}
+          ></frigate-card-views>
           ${
             // Keep message rendering to last to show messages that may have been
             // generated during the render.
diff --git a/src/components/loading.ts b/src/components/loading.ts
new file mode 100644
index 00000000..03e5bf6a
--- /dev/null
+++ b/src/components/loading.ts
@@ -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;
+  }
+}
diff --git a/src/scss/card.scss b/src/scss/card.scss
index 1005e734..3a76d388 100644
--- a/src/scss/card.scss
+++ b/src/scss/card.scss
@@ -13,6 +13,7 @@
   border-radius: var(--ha-card-border-radius, 4px);
 
   height: var(--frigate-card-height);
+  min-height: 100px;
 
   // Ensure all clicks at the top level work.
   pointer-events: all;
@@ -29,6 +30,12 @@
   --frigate-card-height: auto;
 }
 
+frigate-card-loading {
+  position: absolute;
+  inset: 0;
+  z-index: 1;
+}
+
 :host([dark]) {
   filter: brightness(75%);
 }
diff --git a/src/scss/loading.scss b/src/scss/loading.scss
new file mode 100644
index 00000000..7a21999b
--- /dev/null
+++ b/src/scss/loading.scss
@@ -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);
+  }
+}


Test patch:
diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts
index 628cf94e..8052d8cc 100644
--- a/tests/card-controller/initialization-manager.test.ts
+++ b/tests/card-controller/initialization-manager.test.ts
@@ -57,6 +57,7 @@ describe('InitializationManager', () => {
     it('without hass', async () => {
       const manager = new InitializationManager(createCardAPI());
       await manager.initializeMandatory();
+      expect(manager.wasEverInitialized()).toBeFalsy();
     });
 
     it('without config', async () => {
@@ -67,6 +68,7 @@ describe('InitializationManager', () => {
       vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);
 
       await manager.initializeMandatory();
+      expect(manager.wasEverInitialized()).toBeFalsy();
     });
 
     it('successfully', async () => {
@@ -94,6 +96,8 @@ describe('InitializationManager', () => {
       expect(api.getViewManager().initialize).toBeCalled();
       expect(api.getMicrophoneManager().connect).not.toBeCalled();
       expect(api.getCardElementManager().update).toBeCalled();
+
+      expect(manager.wasEverInitialized()).toBeTruthy();
     });
 
     it('successfully with microphone if configured', async () => {