Skip to content

Commit

Permalink
feat: Add loading spinner on card initialization (#1549)
Browse files Browse the repository at this point in the history
* feat: Add loading spinner on card initialization

* Minor doc fix.
  • Loading branch information
dermotduffy committed Sep 21, 2024
1 parent 650d99e commit b46ea36
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 28 deletions.
2 changes: 1 addition & 1 deletion docs/configuration/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
6 changes: 6 additions & 0 deletions src/card-controller/initialization-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -113,6 +118,7 @@ export class InitializationManager {
return;
}

this._everInitialized = true;
this._api.getCardElementManager().update();
}

Expand Down
59 changes: 32 additions & 27 deletions src/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,7 +184,6 @@ class FrigateCard extends LitElement {

if (!this._controller.getInitializationManager().isInitializedMandatory()) {
this._controller.getInitializationManager().initializeMandatory();
return false;
}
return true;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions src/components/loading.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions src/scss/card.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,12 @@
--frigate-card-height: auto;
}

frigate-card-loading {
position: absolute;
inset: 0;
z-index: 1;
}

:host([dark]) {
filter: brightness(75%);
}
Expand Down
40 changes: 40 additions & 0 deletions src/scss/loading.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 4 additions & 0 deletions tests/card-controller/initialization-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -67,6 +68,7 @@ describe('InitializationManager', () => {
vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue(true);

await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
});

it('successfully', async () => {
Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit b46ea36

Please sign in to comment.