fix: Preload should reliably preload initial load (#2466)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent bc366626f1
commit d8ad347dfa
6 changed files with 248 additions and 33 deletions
+2 -2
View File
@@ -17,10 +17,10 @@ live:
| `controls` | | Configuration for the `live` view controls. See [`controls`](#controls). |
| `display` | | Controls whether to show a single or grid `live` view. See [`display`](#display). |
| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. |
| `lazy_load` | `true` | Whether or not to lazily load cameras in the camera carousel. Setting this will `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if both `lazy_load` and `preload` are `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. |
| `lazy_load` | `true` | Whether or not to lazily load cameras in the camera carousel. Setting this to `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if `preload` is also `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. |
| `lazy_unload` | `[]` | A list of conditions in which live camera feeds are unloaded. `unselected` will unload a camera when it is not visible in the carousel/grid and `hidden` will unload a camera when the browser itself is minimized or the browser tab changes. An empty list (`[]`, the default) will never automatically unload a stream once loaded unless the user navigates away entirely, so that it's always instantly visible on carousel scroll. Once unloaded, subsequently revisiting the camera will cause a reloading delay. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting. |
| `microphone` | | See [`microphone`](#microphone). |
| `preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. This consumes additional network/CPU resources continually. |
| `preload` | `false` | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. The currently-selected camera's media is loaded in the background; other cameras follow the `lazy_load` setting (set `lazy_load: false` to preload them all). This consumes additional network/CPU resources continually. |
| `show_image_during_load` | `true` | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. |
| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. |
| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. |
+35 -10
View File
@@ -3,12 +3,26 @@ import { LazyUnloadCondition } from '../config/schema/common/media-actions';
type LazyLoadListener = (loaded: boolean) => void;
interface LazyLoadConfiguration {
// Whether to wait for the host to intersect (and the document to be visible)
// before loading. `false` loads eagerly on first call.
lazyLoad?: boolean;
// Conditions under which an already-loaded host should unload.
lazyUnloadConditions?: LazyUnloadCondition[];
// Treat the host as the selected/visible item, regardless of what
// IntersectionObserver reports.
forceSelected?: boolean;
}
export class LazyLoadController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _documentVisible = true;
private _intersects = false;
private _forceSelected = false;
private _loaded = false;
private _unloadConditions: LazyUnloadCondition[] | null = null;
private _unloadConditions: LazyUnloadCondition[] = [];
private _intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
@@ -19,14 +33,18 @@ export class LazyLoadController implements ReactiveController {
this._host.addController(this);
}
public setConfiguration(
lazyLoad?: boolean,
lazyUnloadConditions?: LazyUnloadCondition[],
) {
if (!lazyLoad && !this._loaded) {
public setConfiguration(configuration: LazyLoadConfiguration): void {
this._unloadConditions = configuration.lazyUnloadConditions ?? [];
this._forceSelected = configuration.forceSelected ?? false;
// Eager-load fast path: skip re-evaluation so an immediately-applied
// `unselected` unload condition can't undo the eager load before the
// intersection observer has had a chance to fire.
if (configuration.lazyLoad === false && !this._loaded) {
this._setLoaded(true);
return;
}
this._unloadConditions = lazyUnloadConditions ?? null;
this._lazyLoadOrUnloadIfNecessary();
}
public destroy(): void {
@@ -51,6 +69,11 @@ export class LazyLoadController implements ReactiveController {
}
public hostConnected(): void {
// Capture the document's actual visibility state on connection. The
// `visibilitychange` listener only fires on transitions, so without this
// sync read a host that connects while the tab is already hidden would
// incorrectly believe the document is visible until the next transition.
this._documentVisible = document.visibilityState === 'visible';
this._addEventHandlers();
}
@@ -70,11 +93,13 @@ export class LazyLoadController implements ReactiveController {
}
private _lazyLoadOrUnloadIfNecessary(): void {
const shouldBeLoaded = !this._loaded && this._documentVisible && this._intersects;
const effectivelyIntersects = this._intersects || this._forceSelected;
const shouldBeLoaded =
!this._loaded && this._documentVisible && effectivelyIntersects;
const shouldBeUnloaded =
this._loaded &&
((this._unloadConditions?.includes('hidden') && !this._documentVisible) ||
(this._unloadConditions?.includes('unselected') && !this._intersects));
((this._unloadConditions.includes('hidden') && !this._documentVisible) ||
(this._unloadConditions.includes('unselected') && !effectivelyIntersects));
if (shouldBeLoaded) {
this._setLoaded(true);
+3
View File
@@ -228,6 +228,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
const isSelectedSlide = !!view?.camera && cameraID === view.camera;
return html`
<div class="embla__slide">
${keyed(
@@ -244,6 +246,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
.forceSelected=${isSelectedSlide}
@advanced-camera-card:zoom:change=${(
ev: CustomEvent<ZoomSettingsObserved>,
) =>
+13 -8
View File
@@ -59,6 +59,13 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
@property({ attribute: false })
public zoom = true;
// Whether to force this slide to behave as if it is selected and
// intersecting. Set by the carousel on its currently-selected slide so
// `live.preload` actually warms up the active stream. See
// `LazyLoadConfiguration.forceSelected`.
@property({ attribute: false })
public forceSelected = false;
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
getTargetID: () => this.targetID ?? null,
});
@@ -129,14 +136,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
protected willUpdate(changedProps: PropertyValues): void {
if (
changedProps.has('liveConfig') ||
(!this._lazyLoadController && this.liveConfig)
) {
this._lazyLoadController.setConfiguration(
this.liveConfig?.lazy_load,
this.liveConfig?.lazy_unload,
);
if (changedProps.has('liveConfig') || changedProps.has('forceSelected')) {
this._lazyLoadController.setConfiguration({
lazyLoad: this.liveConfig?.lazy_load,
lazyUnloadConditions: this.liveConfig?.lazy_unload,
forceSelected: this.forceSelected,
});
}
if (changedProps.has('liveConfig')) {
+4 -5
View File
@@ -145,11 +145,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
protected willUpdate(changedProps: PropertyValues): void {
if (
changedProps.has('viewerConfig') ||
(!this._lazyLoadController && this.viewerConfig)
) {
this._lazyLoadController.setConfiguration(this.viewerConfig?.lazy_load);
if (changedProps.has('viewerConfig')) {
this._lazyLoadController.setConfiguration({
lazyLoad: this.viewerConfig?.lazy_load,
});
}
if (
@@ -31,6 +31,10 @@ describe('LazyLoadController', () => {
beforeEach(() => {
vi.spyOn(global.document, 'addEventListener');
vi.spyOn(global.document, 'removeEventListener');
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
});
afterEach(() => {
@@ -44,7 +48,7 @@ describe('LazyLoadController', () => {
it('should not be loaded by default when lazy load is set to true', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration(true);
controller.setConfiguration({ lazyLoad: true });
expect(controller.isLoaded()).toBe(false);
});
@@ -63,7 +67,10 @@ describe('LazyLoadController', () => {
it('should remove handlers and listeners on destroy', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration(true, ['unselected', 'hidden']);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected', 'hidden'],
});
controller.hostConnected();
const listener = vi.fn();
@@ -92,17 +99,83 @@ describe('LazyLoadController', () => {
expect(controller.isLoaded()).toBe(false);
expect(listener).not.toBeCalled();
controller.setConfiguration(false);
controller.setConfiguration({ lazyLoad: false });
expect(controller.isLoaded()).toBe(true);
expect(listener).toBeCalled();
});
it('should re-evaluate unload when conditions change while loaded', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
callIntersectionHandler(true);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
});
expect(controller.isLoaded()).toBe(false);
});
it('should unload via `hidden` immediately when toggled on a hidden tab', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: false });
controller.hostConnected();
expect(controller.isLoaded()).toBe(true);
callVisibilityHandler(false);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['hidden'],
});
expect(controller.isLoaded()).toBe(false);
});
it('should reset omitted fields to their defaults', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
// Each call is a complete snapshot: omitting `forceSelected` resets it
// to false, and omitting `lazyUnloadConditions` resets it to []. With
// forceSelected reset and intersection then dropping, no `unselected`
// condition remains to act on it, so the host stays loaded.
controller.setConfiguration({ lazyLoad: true });
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
// Re-introducing `unselected` while not intersecting (and with
// forceSelected still defaulted to false) must now unload, proving
// forceSelected was actually reset by the previous call.
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
});
expect(controller.isLoaded()).toBe(false);
});
});
describe('should lazy load', () => {
it('should load when both visible and intersecting', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration(true);
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
expect(controller.isLoaded()).toBe(false);
@@ -120,7 +193,7 @@ describe('LazyLoadController', () => {
const controller = new LazyLoadController(createLitElement());
// No lazy loading.
controller.setConfiguration(false);
controller.setConfiguration({ lazyLoad: false });
controller.hostConnected();
expect(controller.isLoaded()).toBe(true);
@@ -147,7 +220,10 @@ describe('LazyLoadController', () => {
'when unload conditions are: %s',
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration(true, unloadConditions);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: unloadConditions,
});
controller.hostConnected();
callIntersectionHandler(true);
@@ -170,7 +246,10 @@ describe('LazyLoadController', () => {
'when unload conditions are: %s',
(unloadConditions: LazyUnloadCondition[], shouldBeLoaded: boolean) => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration(true, unloadConditions);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: unloadConditions,
});
controller.hostConnected();
callIntersectionHandler(true);
@@ -184,10 +263,114 @@ describe('LazyLoadController', () => {
});
});
describe('should force selected', () => {
it('should load when forced selected even if not intersecting', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(false);
controller.setConfiguration({ forceSelected: true });
expect(controller.isLoaded()).toBe(true);
});
it('should not load while document is already hidden on connect', () => {
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['hidden'],
});
controller.hostConnected();
controller.setConfiguration({ forceSelected: true });
expect(controller.isLoaded()).toBe(false);
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
});
it('should stay loaded with `unselected` unload condition while forced selected', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
});
it('should still unload via `hidden` while forced selected', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['hidden'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
callVisibilityHandler(false);
expect(controller.isLoaded()).toBe(false);
});
it('should unload after force-selected is released and `unselected` applies', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: true,
});
controller.hostConnected();
callVisibilityHandler(true);
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected'],
forceSelected: false,
});
expect(controller.isLoaded()).toBe(false);
});
it('should keep selected stream loaded with default `unload: []`', () => {
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration({ lazyLoad: true });
controller.hostConnected();
controller.setConfiguration({ forceSelected: true });
callVisibilityHandler(true);
expect(controller.isLoaded()).toBe(true);
controller.setConfiguration({ forceSelected: false });
callIntersectionHandler(false);
expect(controller.isLoaded()).toBe(true);
});
});
it('should call listeners', () => {
const listener = vi.fn();
const controller = new LazyLoadController(createLitElement());
controller.setConfiguration(true, ['unselected', 'hidden']);
controller.setConfiguration({
lazyLoad: true,
lazyUnloadConditions: ['unselected', 'hidden'],
});
controller.hostConnected();
controller.addListener(listener);