From 26b7bbbb9ed43aa1a89d4a40ab1bbb6f989c2f73 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 3 May 2022 22:30:43 -0700 Subject: [PATCH] Add new lazily unload criteria . --- README.md | 2 +- src/components/embla-plugins/lazyload.ts | 87 ++++++++++++++---------- src/components/live.ts | 24 ++++--- src/config-mgmt.ts | 17 +++++ src/editor.ts | 13 +++- src/localize/languages/en.json | 6 ++ src/types.ts | 7 +- 7 files changed, 105 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 1ba85316..b9b63596 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ live: | `preload` | `false` | :heavy_multiplication_x: | 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. | | `auto_unmute` | `false` | :heavy_multiplication_x: | Whether or not to automatically unmute live cameras. Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead. | | `lazy_load` | `true` | :heavy_multiplication_x: | 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_unload` | `false` | :heavy_multiplication_x: | Whether or not to lazily **un**load lazyily-loaded cameras in the camera carousel, or just leave the camera paused. Setting this to `true` will cause cameras to be entirely unloaded when they are no longer visible (either because the carousel has scrolled past them, or because the document has been marked hidden/inactive by the browser). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. | +| `lazy_unload` | `never` | :heavy_multiplication_x: | When to lazily **un**load lazyily-loaded cameras. `never` will never lazily-unload, `unselected` will lazy-unload a camera when it is unselected in the carousel, `hidden` will lazy-unload all cameras when the browser/tab is hidden or `all` on any opportunity to lazily unload (i.e. either case). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.| | `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | | `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | | `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.| diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 40a74282..772ab3ff 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -1,16 +1,18 @@ import { EmblaCarouselType, EmblaEventType, EmblaPluginType } from 'embla-carousel'; +import { LazyUnloadCondition } from '../../types'; export type LazyloadOptionsType = { // Number of slides to lazyload left/right of selected (0 == only selected // slide). - lazyloadCount?: number; + lazyLoadCount?: number; + lazyUnloadCondition?: LazyUnloadCondition; - lazyloadCallback?: (index: number, slide: HTMLElement) => void; - lazyunloadCallback?: (index: number, slide: HTMLElement) => void; + lazyLoadCallback?: (index: number, slide: HTMLElement) => void; + lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; }; export const defaultOptions: Partial = { - lazyloadCount: 0, + lazyLoadCount: 0, }; export type LazyloadType = EmblaPluginType & { @@ -22,7 +24,7 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { let carousel: EmblaCarouselType; let slides: HTMLElement[]; - const isSlideLazyloaded: Record = {}; + const lazyLoadedSlides: Set = new Set(); const loadEvents: EmblaEventType[] = ['init', 'select', 'resize']; const unloadEvents: EmblaEventType[] = ['select']; @@ -34,11 +36,15 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { carousel = embla; slides = carousel.slideNodes(); - if (options.lazyloadCallback) { - loadEvents.forEach((evt) => carousel.on(evt, lazyloadHandler)); + if (options.lazyLoadCallback) { + loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler)); } - if (options.lazyunloadCallback) { - unloadEvents.forEach((evt) => carousel.on(evt, lazyunloadHandler)); + if ( + options.lazyUnloadCallback && + options.lazyUnloadCondition && + ['all', 'unselected'].includes(options.lazyUnloadCondition) + ) { + unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler)); } document.addEventListener('visibilitychange', visibilityHandler); } @@ -47,11 +53,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { * Destroy the plugin. */ function destroy(): void { - if (options.lazyloadCallback) { - loadEvents.forEach((evt) => carousel.off(evt, lazyloadHandler)); + if (options.lazyLoadCallback) { + loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler)); } - if (options.lazyunloadCallback) { - unloadEvents.forEach((evt) => carousel.off(evt, lazyunloadHandler)); + if (options.lazyUnloadCallback) { + unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler)); } document.removeEventListener('visibilitychange', visibilityHandler); } @@ -60,10 +66,15 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { * Handle document visibility changes. */ function visibilityHandler(): void { - if (document.visibilityState == 'hidden' && lazyunloadHandler) { - lazyunloadHandler(); - } else if (document.visibilityState == 'visible' && lazyloadHandler) { - lazyloadHandler(); + if ( + document.visibilityState === 'hidden' && + options.lazyUnloadCallback && + options.lazyUnloadCondition && + ['all', 'hidden'].includes(options.lazyUnloadCondition) + ) { + lazyUnloadAllHandler(); + } else if (document.visibilityState === 'visible' && options.lazyLoadCallback) { + lazyLoadHandler(); } } @@ -73,14 +84,14 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { * @returns `true` if the slide has been lazily loaded. */ function hasLazyloaded(index: number): boolean { - return !!isSlideLazyloaded[index]; + return lazyLoadedSlides.has(index); } /** * Lazily load media in the carousel. */ - function lazyloadHandler(): void { - const lazyLoadCount = options.lazyloadCount ?? 0; + function lazyLoadHandler(): void { + const lazyLoadCount = options.lazyLoadCount ?? 0; const currentIndex = carousel.selectedScrollSnap(); const slidesToLoad = new Set(); @@ -94,30 +105,34 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } slidesToLoad.forEach((index) => { - // Only lazy load slides that are not already loaded. - if (isSlideLazyloaded[index]) { - return; - } - if (options.lazyloadCallback) { - isSlideLazyloaded[index] = true; - options.lazyloadCallback(index, slides[index]); + if (!hasLazyloaded(index) && options.lazyLoadCallback) { + lazyLoadedSlides.add(index); + options.lazyLoadCallback(index, slides[index]); } }); } /** - * Lazily unload media in the carousel. + * Lazily unload all media in the carousel. */ - function lazyunloadHandler(): void { + function lazyUnloadAllHandler(): void { + lazyLoadedSlides.forEach((index) => { + if (options.lazyUnloadCallback) { + options.lazyUnloadCallback(index, slides[index]); + lazyLoadedSlides.delete(index); + } + }); + } + + /** + * Lazily unload the previously selected media in the carousel. + */ + function lazyUnloadPreviousHandler(): void { const index = carousel.previousScrollSnap(); - // Only lazy unload slides that are lazy loaded. - if (!isSlideLazyloaded[index]) { - return; - } - if (options.lazyunloadCallback) { - options.lazyunloadCallback(index, slides[index]); - isSlideLazyloaded[index] = false; + if (hasLazyloaded(index) && options.lazyUnloadCallback) { + options.lazyUnloadCallback(index, slides[index]); + lazyLoadedSlides.delete(index); } } diff --git a/src/components/live.ts b/src/components/live.ts index 7dc20f0e..d718a904 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -288,12 +288,14 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { return [ ...super._getPlugins(), Lazyload({ - lazyloadCallback: this.liveConfig?.lazy_load - ? (...args) => this._lazyloadOrUnloadSlide('load', ...args) - : undefined, - lazyunloadCallback: this.liveConfig?.lazy_unload - ? (...args) => this._lazyloadOrUnloadSlide('unload', ...args) - : undefined, + ...(this.liveConfig?.lazy_load && { + lazyLoadCallback: (index, slide) => + this._lazyloadOrUnloadSlide('load', index, slide), + }), + + lazyUnloadCondition: this.liveConfig?.lazy_unload, + lazyUnloadCallback: (index, slide) => + this._lazyloadOrUnloadSlide('unload', index, slide), }), AutoMediaPlugin({ playerSelector: 'frigate-card-live-provider', @@ -380,7 +382,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { 'frigate-card-live-provider', ) as FrigateCardLiveProvider; if (liveProvider) { - liveProvider.disabled = action == 'load' ? false : true; + liveProvider.disabled = action !== 'load'; } } @@ -795,7 +797,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { /** * Create the WebRTC element. May throw. */ - protected _createWebRTC(): HTMLElement | undefined { + protected _createWebRTC(): HTMLElement | null { // eslint-disable-next-line @typescript-eslint/no-explicit-any const webrtcElement = this._webrtcTask.value; if (webrtcElement && this.hass) { @@ -818,7 +820,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { webrtc.hass = this.hass; return webrtc; } - return undefined; + return null; } /** @@ -827,7 +829,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { */ protected render(): TemplateResult | void { const render = (): TemplateResult | void => { - let webrtcElement: HTMLElement | undefined; + let webrtcElement: HTMLElement | null; try { webrtcElement = this._createWebRTC(); } catch (e) { @@ -973,7 +975,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { canvas: this._jsmpegCanvasElement, }, { - // The media carousel automatically pauses when the browser tab is + // The media carousel may automatically pause when the browser tab is // inactive, JSMPEG does not need to also do so independently. pauseWhenHidden: false, protocols: [], diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index c6896fc7..14d79c79 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -15,6 +15,7 @@ import { CONF_IMAGE_URL, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_PRELOAD, CONF_LIVE_WEBRTC_CARD, CONF_MENU, @@ -322,6 +323,19 @@ const upgradeWithOverrides = function ( return upgradeMoveToWithOverrides(path, path, transform); }; +/** + * Upgrade a property in place without overrides. + * @param path The old property path. + * @param transform An optional transform for the value. + * @returns A function that returns `true` if the configuration was modified. + */ +const upgrade = function ( + path: string, + transform?: (valueIn: unknown) => unknown, +): (obj: RawFrigateCardConfig) => boolean { + return upgradeMoveTo(path, path, transform); +}; + /** * Given a path to an array, apply an upgrade to each object in the array. * @param arrayPath The path to the array to upgrade. @@ -575,4 +589,7 @@ const UPGRADES = [ upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE_UI, menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject), + upgrade(CONF_LIVE_LAZY_UNLOAD, (val) => + typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, + ), ]; diff --git a/src/editor.ts b/src/editor.ts index fcf4fdcf..19d977a9 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -349,6 +349,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'auto', label: localize('config.view.dark_modes.auto') }, ]; + protected _lazyUnloadConditions: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'all', label: localize('config.live.lazy_unload_conditions.all') }, + { value: 'unselected', label: localize('config.live.lazy_unload_conditions.unselected') }, + { value: 'hidden', label: localize('config.live.lazy_unload_conditions.hidden') }, + { value: 'never', label: localize('config.live.lazy_unload_conditions.never') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -990,7 +998,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderSwitch(CONF_LIVE_PRELOAD, defaults.live.preload)} ${this._renderSwitch(CONF_LIVE_DRAGGABLE, defaults.live.draggable)} ${this._renderSwitch(CONF_LIVE_LAZY_LOAD, defaults.live.lazy_load)} - ${this._renderSwitch(CONF_LIVE_LAZY_UNLOAD, defaults.live.lazy_unload)} + ${this._renderOptionSelector( + CONF_LIVE_LAZY_UNLOAD, + this._lazyUnloadConditions, + )} ${this._renderSwitch(CONF_LIVE_AUTO_UNMUTE, defaults.live.auto_unmute)} ${this._renderOptionSelector( CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 2217ac3c..07732abf 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -117,6 +117,12 @@ "draggable": "Live cameras view can be dragged/swiped", "lazy_load": "Live cameras are lazily loaded", "lazy_unload": "Live cameras are lazily unloaded", + "lazy_unload_conditions": { + "all": "All opportunities", + "unselected": "On unselection", + "hidden": "On browser/tab hiding", + "never": "Never" + }, "auto_unmute": "Automatically unmute live cameras", "transition_effect": "Live camera transition effect", "controls": { diff --git a/src/types.ts b/src/types.ts index 03fe6866..8c598e08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,9 @@ export const FRIGATE_MENU_PRIORITY_MAX = 100; const LIVE_PROVIDERS = ['auto', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const; export type LiveProvider = typeof LIVE_PROVIDERS[number]; +const LAZY_UNLOAD_CONDITIONS = ['all', 'unselected', 'hidden', 'never'] as const; +export type LazyUnloadCondition = typeof LAZY_UNLOAD_CONDITIONS[number]; + export class FrigateCardError extends Error {} /** @@ -560,7 +563,7 @@ const liveConfigDefault = { auto_unmute: false, preload: false, lazy_load: true, - lazy_unload: false, + lazy_unload: 'never' as const, draggable: true, transition_effect: 'slide' as const, controls: { @@ -665,7 +668,7 @@ const liveConfigSchema = liveOverridableConfigSchema auto_unmute: z.boolean().default(liveConfigDefault.auto_unmute), preload: z.boolean().default(liveConfigDefault.preload), lazy_load: z.boolean().default(liveConfigDefault.lazy_load), - lazy_unload: z.boolean().default(liveConfigDefault.lazy_unload), + lazy_unload: z.enum(LAZY_UNLOAD_CONDITIONS).default(liveConfigDefault.lazy_unload), draggable: z.boolean().default(liveConfigDefault.draggable), transition_effect: transitionEffectConfigSchema.default( liveConfigDefault.transition_effect,