From 34448549b68504fbe41d73a8a8ba2f60bb5c7b8b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 22 Jan 2022 18:23:30 -0800 Subject: [PATCH] Make autoplay/pause work correctly with lazyloading. --- package.json | 2 +- src/components/carousel.ts | 16 +++- src/components/embla-plugins/lazyload.ts | 91 ++++++++++++++++++ .../embla-plugins/media-autoplay.ts | 43 +++++---- src/components/live.ts | 20 +++- src/components/media-carousel.ts | 92 ++++--------------- src/components/viewer.ts | 40 ++++---- 7 files changed, 182 insertions(+), 122 deletions(-) create mode 100644 src/components/embla-plugins/lazyload.ts diff --git a/package.json b/package.json index 55963fd0..1e3a10d0 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@material/rtl": "^13.0.0", "custom-card-helpers": "^1.8.0", "dayjs": "^1.10.7", - "embla-carousel": "^6.1.0", + "embla-carousel": "^6.1.1", "home-assistant-js-websocket": "^5.11.1", "lit": "^2.0.2", "lodash-es": "^4.17.21", diff --git a/src/components/carousel.ts b/src/components/carousel.ts index ae8f2c7c..02bf84ea 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,5 +1,9 @@ import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit'; -import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import EmblaCarousel, { + EmblaCarouselType, + EmblaOptionsType, + EmblaPluginType, +} from 'embla-carousel'; import { dispatchFrigateCardEvent } from '../common'; @@ -11,6 +15,7 @@ export interface CarouselSelect { export class FrigateCardCarousel extends LitElement { protected _carousel?: EmblaCarouselType; + protected _plugins: Record = {}; /** * Scroll to a particular slide. @@ -65,6 +70,7 @@ export class FrigateCardCarousel extends LitElement { if (this._carousel) { this._carousel.destroy(); } + this._plugins = {}; this._carousel = undefined; } @@ -77,7 +83,13 @@ export class FrigateCardCarousel extends LitElement { ) as HTMLElement; if (carouselNode) { - this._carousel = EmblaCarousel(carouselNode, this._getOptions(), this._getPlugins()); + const plugins = this._getPlugins() ?? []; + this._plugins = plugins.reduce((acc, cur) => { + acc[cur.name] = cur; + return acc; + }, {}); + + this._carousel = EmblaCarousel(carouselNode, this._getOptions(), plugins); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { const selected = this.carouselSelected(); diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts new file mode 100644 index 00000000..a78e6ca9 --- /dev/null +++ b/src/components/embla-plugins/lazyload.ts @@ -0,0 +1,91 @@ +import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel'; + +export type LazyloadOptionsType = { + count?: number; + lazyloadCallback: (index: number, slide: HTMLElement) => void; +}; + +export const defaultOptions: Partial = { + count: 0, +}; + +export type LazyloadType = EmblaPluginType & { + hasLazyloaded: (index: number) => boolean; +}; + +export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { + const options = Object.assign({}, defaultOptions, userOptions); + + let carousel: EmblaCarouselType; + let slides: HTMLElement[]; + const isSlideLazyloaded: Record = {}; + + /** + * Initialize the plugin. + */ + function init(embla: EmblaCarouselType): void { + carousel = embla; + slides = carousel.slideNodes(); + + carousel.on('init', lazyLoadHandler); + carousel.on('select', lazyLoadHandler); + carousel.on('resize', lazyLoadHandler); + } + + /** + * Destroy the plugin. + */ + function destroy(): void { + carousel.off('init', lazyLoadHandler); + carousel.off('select', lazyLoadHandler); + carousel.off('resize', lazyLoadHandler); + } + + /** + * Determine if a slide index has been lazily loaded. + * @param index Slide index. + * @returns `true` if the slide has been lazily loaded. + */ + function hasLazyloaded(index: number): boolean { + return !!isSlideLazyloaded[index]; + } + + /** + * Lazily load media in the carousel. + */ + function lazyLoadHandler(): void { + const lazyLoadCount = options.count ?? 0; + const slidesInView = carousel.slidesInView(true); + const slidesToLoad = new Set(); + + const minSlide = Math.min(...slidesInView); + const maxSlide = Math.max(...slidesInView); + + // Lazily load 'count' slides on either side of the slides in view. + for (let i = 1; i <= lazyLoadCount && minSlide - i >= 0; i++) { + slidesToLoad.add(minSlide - i); + } + slidesInView.forEach((index) => slidesToLoad.add(index)); + for (let i = 1; i <= lazyLoadCount && maxSlide + i < slides.length; i++) { + slidesToLoad.add(maxSlide + i); + } + + slidesToLoad.forEach((index) => { + // Only lazy load slides that are not already loaded. + if (isSlideLazyloaded[index]) { + return; + } + isSlideLazyloaded[index] = true; + options.lazyloadCallback(index, slides[index]); + }); + } + + const self: LazyloadType = { + name: 'Lazyload', + options, + init, + destroy, + hasLazyloaded, + }; + return self; +} diff --git a/src/components/embla-plugins/media-autoplay.ts b/src/components/embla-plugins/media-autoplay.ts index d4a1a7e8..b8c5bf25 100644 --- a/src/components/embla-plugins/media-autoplay.ts +++ b/src/components/embla-plugins/media-autoplay.ts @@ -1,22 +1,26 @@ import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel'; import { FrigateCardMediaPlayer } from '../../types'; -export type MediaAutoplayOptionsType = { +export type MediaAutoPlayPauseOptionsType = { autoplay?: boolean; autopause?: boolean; playerSelector: string; }; -export const defaultOptions: Partial = { - autoplay: true, +export const defaultOptions: Partial = { + // Frigate card media autoplays when the media loads, not necessarily when the + // slide is selected. + autoplay: false, autopause: true, }; -export type MediaAutoplayType = EmblaPluginType; +export type MediaAutoPlayPauseType = EmblaPluginType & { + play: () => void; +} -export function MediaAutoplay( - userOptions?: MediaAutoplayOptionsType, -): MediaAutoplayType { +export function MediaAutoPlayPause( + userOptions?: MediaAutoPlayPauseOptionsType, +): MediaAutoPlayPauseType { const options = Object.assign({}, defaultOptions, userOptions); let carousel: EmblaCarouselType; @@ -31,12 +35,12 @@ export function MediaAutoplay( if (options.autopause) { carousel.on('destroy', pauseAllHandler); - carousel.on('select', autopausePreviousHandler); + carousel.on('select', pausePrevious); } if (options.autoplay) { - carousel.on('select', autoplayCurrentHandler); - carousel.on('init', autoplayCurrentHandler); + carousel.on('select', play); + carousel.on('init', play); } } @@ -46,12 +50,12 @@ export function MediaAutoplay( function destroy(): void { if (options.autopause) { carousel.off('destroy', pauseAllHandler); - carousel.off('select', autopausePreviousHandler); + carousel.off('select', pausePrevious); } if (options.autoplay) { - carousel.off('select', autoplayCurrentHandler); - carousel.off('init', autoplayCurrentHandler); + carousel.off('select', play); + carousel.off('init', play); } } @@ -60,8 +64,8 @@ export function MediaAutoplay( * @param slide * @returns A FrigateCardMediaPlayer object or `null`. */ - function getPlayer(slide: HTMLElement): FrigateCardMediaPlayer | null { - return slide.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null; + function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { + return slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null; } /** @@ -74,22 +78,23 @@ export function MediaAutoplay( /** * Autoplay the current slide. */ - function autoplayCurrentHandler(): void { + function play(): void { getPlayer(slides[carousel.selectedScrollSnap()])?.play(); } /** * Autopause the previous slide. */ - function autopausePreviousHandler(): void { + function pausePrevious(): void { getPlayer(slides[carousel.previousScrollSnap()])?.pause(); } - const self: MediaAutoplayType = { - name: 'MediaAutoplay', + const self: MediaAutoPlayPauseType = { + name: 'MediaAutoPlayPause', options, init, destroy, + play, }; return self; } diff --git a/src/components/live.ts b/src/components/live.ts index 718ae0ac..e3d9b9fe 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -29,7 +29,8 @@ import { BrowseMediaUtil } from '../browse-media-util.js'; import { ConditionState, getOverriddenConfig } from '../card-condition.js'; import { FrigateCardMediaCarousel } from './media-carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; -import { MediaAutoplay } from './embla-plugins/media-autoplay.js'; +import { Lazyload } from './embla-plugins/lazyload.js'; +import { MediaAutoPlayPause } from './embla-plugins/media-autoplay.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { View } from '../view.js'; import { localize } from '../localize/localize.js'; @@ -298,7 +299,14 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { */ protected _getPlugins(): EmblaPluginType[] | undefined { return [ - MediaAutoplay({ + ...(this.liveConfig?.lazy_load + ? [ + Lazyload({ + lazyloadCallback: this._lazyLoadSlide.bind(this), + }), + ] + : []), + MediaAutoPlayPause({ playerSelector: 'frigate-card-live-provider', }), ]; @@ -394,7 +402,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { return html`
{ + // WebRTC appears to generate additional spurious load events, which may + // result in loads after a play() call, which causes the browser to spam + // the logs unless the promise rejection is handled here. + }) } /** diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 5b20e55a..09f5b676 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -15,6 +15,7 @@ import './next-prev-control.js'; import mediaCarouselStyle from '../scss/media-carousel.scss'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; +import { MediaAutoPlayPauseType } from './embla-plugins/media-autoplay.js'; const getEmptyImageSrc = (width: number, height: number) => `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; @@ -24,31 +25,30 @@ export const IMG_EMPTY = getEmptyImageSrc(16, 9); export class FrigateCardMediaCarousel extends FrigateCardCarousel { // A "map" from slide number to MediaShowInfo object. protected _mediaShowInfo: Record = {}; - - // Whether or not a given slide has been successfully lazily loaded. - protected _slideHasBeenLazyLoaded: Record = {}; - protected _nextControlRef: Ref = createRef(); protected _previousControlRef: Ref = createRef(); /** - * Returns the number of slides to lazily load. 0 means all slides are lazy - * loaded, 1 means that 1 slide on each side of the currently selected slide - * should lazy load, etc. `null` means lazy loading is disabled and everything - * should load simultaneously. - * @returns + * Play the media on the selected slide. */ - protected _getLazyLoadCount(): number | null { - // Defaults to fully-lazy loading. - return 0; + protected _playSelectedMediaHandler(): void { + (this._plugins['MediaAutoPlayPause'] as MediaAutoPlayPauseType | undefined)?.play(); } /** - * Determine if lazy loading is being used. - * @returns `true` is lazy loading is in use. + * Component connected callback. */ - protected _isLazyLoading(): boolean { - return this._getLazyLoadCount() !== null; + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('frigate-card:media-show', this._playSelectedMediaHandler); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('frigate-card:media-show', this._playSelectedMediaHandler); } protected _destroyCarousel(): void { @@ -61,9 +61,6 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { // media would not reload) -- as such, leave this alone on carousel // destroy. New media in that slide will replace the prior contents on // load. - // * this._slideHasBeenLazyLoaded: This is a performance optimization and - // can be safely reset. - this._slideHasBeenLazyLoaded = {}; } /** @@ -92,13 +89,6 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { carousel?.on('init', this._adaptiveHeightSetHandler.bind(this)); carousel?.on('select', this._adaptiveHeightSetHandler.bind(this)); carousel?.on('resize', this._adaptiveHeightSetHandler.bind(this)); - - if (this._getLazyLoadCount() != null) { - // Load media as the carousel is moved (if lazy loading is in use). - carousel?.on('init', this._lazyLoadMediaHandler.bind(this)); - carousel?.on('select', this._lazyLoadMediaHandler.bind(this)); - carousel?.on('resize', this._lazyLoadMediaHandler.bind(this)); - } } /** @@ -165,54 +155,6 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { } } - /** - * Lazily load media in the carousel. - */ - protected _lazyLoadMediaHandler(): void { - if (!this._carousel) { - return; - } - const lazyLoadCount = this._getLazyLoadCount(); - if (lazyLoadCount === null) { - return; - } - - const slides = this._carousel.slideNodes(); - const slidesInView = this._carousel.slidesInView(true); - const slidesToLoad = new Set(); - - const minSlide = Math.min(...slidesInView); - const maxSlide = Math.max(...slidesInView); - - // Lazily load 'lazyLoadCount' slides on either side of the slides in view. - for (let i = 1; i <= lazyLoadCount && minSlide - i >= 0; i++) { - slidesToLoad.add(minSlide - i); - } - slidesInView.forEach((index) => slidesToLoad.add(index)); - for (let i = 1; i <= lazyLoadCount && maxSlide + i < slides.length; i++) { - slidesToLoad.add(maxSlide + i); - } - - slidesToLoad.forEach((index) => { - // Only lazy load slides that are not already loaded. - if (this._slideHasBeenLazyLoaded[index]) { - return; - } - this._slideHasBeenLazyLoaded[index] = true; - this._lazyLoadSlide(index, slides[index]); - }); - } - - /** - * Lazy load a slide. - * @param _index The index of the slide to lazy load. - * @param _slide The slide to lazy load. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected _lazyLoadSlide(_index: number, _slide: HTMLElement): void { - // To be overridden in children. - } - /** * Fire a media show event when a slide is selected. */ @@ -282,7 +224,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { * available. */ const firstMediaLoad = !Object.keys(this._mediaShowInfo).length; - if (firstMediaLoad && this._getLazyLoadCount() != null) { + if (firstMediaLoad) { const replacementImageSrc = getEmptyImageSrc( mediaShowInfo.width, mediaShowInfo.height, diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 920d331e..6483a0d4 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -29,7 +29,8 @@ import { FrigateCardThumbnailCarousel, ThumbnailCarouselTap, } from './thumbnail-carousel.js'; -import { MediaAutoplay } from './embla-plugins/media-autoplay.js'; +import { MediaAutoPlayPause } from './embla-plugins/media-autoplay.js'; +import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { View } from '../view.js'; import { @@ -216,7 +217,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { async ([target]: (BrowseMediaSource | undefined)[]): Promise => { for ( let i = 0; - !this._isLazyLoading() && + !this.viewerConfig?.lazy_load && this.hass && target && target.children && @@ -298,22 +299,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * Get the Embla plugins to use. * @returns An EmblaOptionsType object or undefined for no options. */ - protected _getPlugins(): EmblaPluginType[] | undefined { - return [MediaAutoplay({ - autoplay: this.viewerConfig?.autoplay_clip, - playerSelector: 'frigate-card-ha-hls-player' })]; - } - - /** - * Returns the number of slides to lazily load. 0 means all slides are lazy - * loaded, 1 means that 1 slide on each side of the currently selected slide - * should lazy load, etc. `null` means lazy loading is disabled and everything - * should load simultaneously. - * @returns - */ - protected _getLazyLoadCount(): number | null { - // Defaults to fully-lazy loading. - return this.viewerConfig?.lazy_load === false ? null : 0; + protected _getPlugins(): EmblaPluginType[] | undefined { + return [ + ...(this.viewerConfig?.lazy_load + ? [ + Lazyload({ + lazyloadCallback: this._lazyLoadSlide.bind(this), + }), + ] + : []), + MediaAutoPlayPause({ + playerSelector: 'frigate-card-ha-hls-player', + }), + ]; } /** @@ -591,7 +589,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // If lazy loading is not enabled, wait for the media resolver task to // complete and show a progress indictator until this. - if (!this._isLazyLoading() && !this._isMediaFullyResolved()) { + if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) { return html`${this._mediaResolutionTask.render({ initial: () => renderProgressIndicator(), pending: () => renderProgressIndicator(), @@ -658,7 +656,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return; } - const lazyLoad = this._isLazyLoading(); + const lazyLoad = this.viewerConfig.lazy_load; const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id); if (!resolvedMedia && !lazyLoad) { return; @@ -701,7 +699,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // images in media-carousel.ts). Here we need to only call the // media load handler on a 'real' load. !lazyLoad || - this._slideHasBeenLazyLoaded[slideIndex] + (this._plugins['Lazyload'] as LazyloadType | undefined)?.hasLazyloaded(slideIndex) ) { this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); }