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 11e4bab0..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 } 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. @@ -53,10 +58,19 @@ export class FrigateCardCarousel extends LitElement { return undefined; } + /** + * Get the Embla plugins to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getPlugins(): EmblaPluginType[] | undefined { + return undefined; + } + protected _destroyCarousel(): void { if (this._carousel) { this._carousel.destroy(); } + this._plugins = {}; this._carousel = undefined; } @@ -69,7 +83,13 @@ export class FrigateCardCarousel extends LitElement { ) as HTMLElement; if (carouselNode) { - this._carousel = EmblaCarousel(carouselNode, this._getOptions()); + 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 new file mode 100644 index 00000000..b8c5bf25 --- /dev/null +++ b/src/components/embla-plugins/media-autoplay.ts @@ -0,0 +1,100 @@ +import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel'; +import { FrigateCardMediaPlayer } from '../../types'; + +export type MediaAutoPlayPauseOptionsType = { + autoplay?: boolean; + autopause?: boolean; + playerSelector: string; +}; + +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 MediaAutoPlayPauseType = EmblaPluginType & { + play: () => void; +} + +export function MediaAutoPlayPause( + userOptions?: MediaAutoPlayPauseOptionsType, +): MediaAutoPlayPauseType { + const options = Object.assign({}, defaultOptions, userOptions); + + let carousel: EmblaCarouselType; + let slides: HTMLElement[]; + + /** + * Initialize the plugin. + */ + function init(embla: EmblaCarouselType): void { + carousel = embla; + slides = carousel.slideNodes(); + + if (options.autopause) { + carousel.on('destroy', pauseAllHandler); + carousel.on('select', pausePrevious); + } + + if (options.autoplay) { + carousel.on('select', play); + carousel.on('init', play); + } + } + + /** + * Destroy the plugin. + */ + function destroy(): void { + if (options.autopause) { + carousel.off('destroy', pauseAllHandler); + carousel.off('select', pausePrevious); + } + + if (options.autoplay) { + carousel.off('select', play); + carousel.off('init', play); + } + } + + /** + * Get the media player from a slide. + * @param slide + * @returns A FrigateCardMediaPlayer object or `null`. + */ + function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { + return slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null; + } + + /** + * Pause all clips. + */ + function pauseAllHandler(): void { + slides.forEach((slide) => getPlayer(slide)?.pause()); + } + + /** + * Autoplay the current slide. + */ + function play(): void { + getPlayer(slides[carousel.selectedScrollSnap()])?.play(); + } + + /** + * Autopause the previous slide. + */ + function pausePrevious(): void { + getPlayer(slides[carousel.previousScrollSnap()])?.pause(); + } + + const self: MediaAutoPlayPauseType = { + name: 'MediaAutoPlayPause', + options, + init, + destroy, + play, + }; + return self; +} diff --git a/src/components/live.ts b/src/components/live.ts index 3f05c191..be86e2a2 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -19,16 +19,18 @@ import { LiveProvider, frigateCardConfigDefaults, } from '../types.js'; -import { EmblaOptionsType } from 'embla-carousel'; +import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; +import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { customElement, property, state } from 'lit/decorators.js'; -import { ref } from 'lit/directives/ref'; import { until } from 'lit/directives/until.js'; 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 { 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'; @@ -291,6 +293,25 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { }; } + /** + * Get the Embla plugins to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getPlugins(): EmblaPluginType[] | undefined { + return [ + ...(this.liveConfig?.lazy_load + ? [ + Lazyload({ + lazyloadCallback: this._lazyLoadSlide.bind(this), + }), + ] + : []), + MediaAutoPlayPause({ + playerSelector: 'frigate-card-live-provider', + }), + ]; + } + /** * 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 @@ -381,7 +402,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { return html`
= createRef(); + + /** + * Play the video. + */ + public play(): void { + this._providerRef.value?.play(); + } + + /** + * Pause the video. + */ + public pause(): void { + this._providerRef.value?.pause(); + } + protected _getResolvedProvider(): LiveProvider { if (this.cameraConfig?.live_provider === 'auto') { if (this.cameraConfig?.webrtc?.entity || this.cameraConfig?.webrtc?.url) { @@ -545,18 +584,21 @@ export class FrigateCardLiveProvider extends LitElement { return html` ${provider == 'frigate' ? html` ` : provider == 'webrtc' ? html` ` : html` = createRef(); + + /** + * Play the video. + */ + public play(): void { + this._playerRef.value?.play(); + } + + /** + * Pause the video. + */ + public pause(): void { + this._playerRef.value?.pause(); + } + /** * Master render method. * @returns A rendered template. @@ -591,6 +649,7 @@ export class FrigateCardLiveFrigate extends LitElement { ); } 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. + }) + } + + /** + * Pause the video. + */ + public pause(): void { + this._getPlayer()?.pause(); + } + + /** + * Get the underlying video player. + * @returns The player or `null` if not found. + */ + protected _getPlayer(): HTMLVideoElement | null { + return this.renderRoot.querySelector('#video') as HTMLVideoElement | null; + } + /** * Create the WebRTC element. May throw. */ @@ -675,7 +760,7 @@ export class FrigateCardLiveWebRTC extends LitElement { // Extract the video component after it has been rendered and generate the // media load event. this.updateComplete.then(() => { - const video = this.renderRoot.querySelector('#video') as HTMLVideoElement; + const video = this._getPlayer(); if (video) { const onloadedmetadata = video.onloadedmetadata; const onplay = video.onplay; @@ -725,6 +810,20 @@ export class FrigateCardLiveJSMPEG extends LitElement { protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; protected _refreshPlayerTimerID?: number; + /** + * Play the video. + */ + public play(): void { + this._jsmpegVideoPlayer?.play(); + } + + /** + * Pause the video. + */ + public pause(): void { + this._jsmpegVideoPlayer?.stop(); + } + /** * Get a signed player URL. * @returns A URL or null. diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 5b20e55a..a7aa7454 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,31 @@ 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. May be overridden to control when + * autoplay should happen. */ - protected _getLazyLoadCount(): number | null { - // Defaults to fully-lazy loading. - return 0; + protected _autoplayHandler(): 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._autoplayHandler); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('frigate-card:media-show', this._autoplayHandler); } protected _destroyCarousel(): void { @@ -61,9 +62,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 +90,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 +156,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 +225,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 1d4686d8..406dd764 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -7,7 +7,7 @@ import { unsafeCSS, } from 'lit'; import { BrowseMediaUtil } from '../browse-media-util.js'; -import { EmblaOptionsType } from 'embla-carousel'; +import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import { Task } from '@lit-labs/task'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; @@ -29,6 +29,8 @@ import { FrigateCardThumbnailCarousel, ThumbnailCarouselTap, } from './thumbnail-carousel.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 { @@ -215,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 && @@ -263,6 +265,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { super.updated(changedProperties); } + /** + * Play the media on the selected slide. May be overridden to control when + * autoplay should happen. + */ + protected _autoplayHandler(): void { + if (this.viewerConfig?.autoplay_clip) { + super._autoplayHandler(); + } + } + protected _destroyCarousel(): void { super._destroyCarousel(); @@ -294,15 +306,28 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } /** - * 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 + * Get the Embla plugins to use. + * @returns An EmblaOptionsType object or undefined for no options. */ - 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), + }), + ] + : []), + + // Don't need autoplay/pause for snapshots. + ...(this.view?.is('clip') + ? [ + MediaAutoPlayPause({ + playerSelector: 'frigate-card-ha-hls-player', + }), + ] + : []), + ]; } /** @@ -532,70 +557,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } } - protected _playOrPauseClip(action: 'play' | 'pause', slide: HTMLElement): void { - const player = slide.querySelector('frigate-card-ha-hls-player') as - | (HTMLElement & { play: () => void; pause: () => void }) - | undefined; - if (player) { - if (action === 'play') { - player.play(); - } else if (action === 'pause') { - player.pause(); - } - } - } - - /** - * Pause all clips. - */ - protected _pauseAllHandler(): void { - if (this._carousel) { - this._carousel - .slideNodes() - .forEach((slide) => this._playOrPauseClip('pause', slide)); - } - } - - /** - * Play the clip being shown to the user and pause the prior. - */ - protected _autoplayPauseHandler(pausePrevious: boolean): void { - if (!this._carousel) { - return; - } - - const slides = this._carousel.slideNodes(); - - // Pause the previous/current slide. - if (pausePrevious) { - this._carousel - .slidesInView(false) - .forEach((slide) => { - this._playOrPauseClip('pause', slides[slide]) - }); - } - - // Play the target slide. - this._carousel - .slidesInView(true) - .forEach((slide) => { - this._playOrPauseClip('play', slides[slide]) - }); - } - - /** - * Initialize the carousel. - */ - protected _initCarousel(): void { - super._initCarousel(); - - if (this._carousel && this.viewerConfig && this.viewerConfig.autoplay_clip) { - this._carousel.on('destroy', () => this._pauseAllHandler()); - this._carousel.on('init', () => this._autoplayPauseHandler(false)); - this._carousel.on('select', () => this._autoplayPauseHandler(true)); - } - } - /** * Get slides to include in the render. * @returns The slides to include in the render and an index keyed by slide @@ -644,7 +605,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(), @@ -711,7 +672,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; @@ -754,7 +715,9 @@ 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)); } diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 83408a43..a94874d9 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -9,6 +9,7 @@ // available as compilation time. // ==================================================================== +import { Ref, createRef, ref } from 'lit/directives/ref'; import { TemplateResult, css, html } from 'lit'; import { customElement } from 'lit/decorators.js'; import { dispatchMediaShowEvent } from '../common.js'; @@ -34,10 +35,31 @@ customElements.whenDefined('ha-camera-stream').then(() => { @customElement('frigate-card-ha-camera-stream') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') { + protected _playerRef: Ref = createRef(); + // ======================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts // ======================================================================================== + + /** + * Play the video. + */ + public play(): void { + this._playerRef.value?.play(); + } + + /** + * Pause the video. + */ + public pause(): void { + this._playerRef.value?.pause(); + } + + /** + * Master render method. + * @returns A rendered template. + */ protected render(): TemplateResult { if (!this.stateObj) { return html``; @@ -59,6 +81,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { : this._url ? html`