From 677f0d511b2fc5a12b7e2095415d65ec7639d331 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 20 Dec 2021 23:26:45 -0800 Subject: [PATCH] Skeleton live carousel. --- src/browse-media-util.ts | 62 +++++-- src/card.ts | 45 +---- src/components/carousel.ts | 31 ++-- src/components/live.ts | 247 +++++++++++++++++++++++---- src/components/thumbnail-carousel.ts | 10 +- src/components/viewer.ts | 20 ++- src/scss/live.scss | 2 +- 7 files changed, 308 insertions(+), 109 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index d635ca1f..62124a0a 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -1,11 +1,17 @@ -import type { BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant } from './types.js'; +import type { + BrowseMediaQueryParameters, + BrowseMediaSource, + CameraConfig, + ExtendedHomeAssistant, +} from './types.js'; import { HomeAssistant } from 'custom-card-helpers'; import { homeAssistantWSRequest } from './common.js'; import { browseMediaSourceSchema } from './types.js'; import dayjs from 'dayjs'; import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; - +import { View } from './view.js'; + dayjs.extend(dayjs_custom_parse_format); export class BrowseMediaUtil { @@ -16,7 +22,8 @@ export class BrowseMediaUtil { */ static extractEventID(media: BrowseMediaSource): string | null { const result = media.media_content_id.match( - /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/); + /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/, + ); return result && result.groups ? result.groups['id'] : null; } @@ -25,9 +32,7 @@ export class BrowseMediaUtil { * @param browseMedia The media object to extract the start time from. * @returns The start time in unix/epoch time, or null if it cannot be determined. */ - static extractEventStartTime( - browseMedia: BrowseMediaSource, - ): number | null { + static extractEventStartTime(browseMedia: BrowseMediaSource): number | null { // Example: 2021-08-27 20:57:22 [10s, Person 76%] const result = browseMedia.title.match(/^(?.+) \[/); if (result && result.groups) { @@ -57,16 +62,14 @@ export class BrowseMediaUtil { * @param media The media object with children. * @returns The first true media item found. */ - static getFirstTrueMediaChildIndex( - media: BrowseMediaSource | null, - ): number | null { + static getFirstTrueMediaChildIndex(media: BrowseMediaSource | null): number | null { if (!media || !media.children) { return null; } const index = media.children.findIndex((child) => this.isTrueMedia(child)); return index >= 0 ? index : null; } - + /** * Browse Frigate media with a media content id. May throw. * @param hass The HomeAssistant object. @@ -86,7 +89,7 @@ export class BrowseMediaUtil { }; return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); } - + // Browse Frigate media with query parameters. /** @@ -117,4 +120,41 @@ export class BrowseMediaUtil { ].join('/'), ); } + + /** + * Get the parameters to search for media. + * @returns A BrowseMediaQueryParameters object. + */ + static getBrowseMediaQueryParameters( + mediaType: 'clips' | 'snapshots', + cameraConfig?: CameraConfig, + ): BrowseMediaQueryParameters | undefined { + if (!cameraConfig || !cameraConfig.camera_name) { + return undefined; + } + return { + mediaType: mediaType, + clientId: cameraConfig.client_id, + cameraName: cameraConfig.camera_name, + label: cameraConfig.label, + zone: cameraConfig.zone, + }; + } + + /** + * Get the parameters to search for media related to the current view. + * @returns A BrowseMediaQueryParameters object. + */ + static getBrowseMediaQueryParametersFromView( + view: View, + cameraConfig: CameraConfig, + ): BrowseMediaQueryParameters | undefined { + if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { + return undefined; + } + return BrowseMediaUtil.getBrowseMediaQueryParameters( + view.isClipRelatedView() ? 'clips' : 'snapshots', + cameraConfig, + ); + } } diff --git a/src/card.ts b/src/card.ts index d457cbac..1d640274 100644 --- a/src/card.ts +++ b/src/card.ts @@ -884,36 +884,6 @@ export class FrigateCard extends LitElement { `; } - /** - * Get the parameters to search for media related to the current view. - * @returns A BrowseMediaQueryParameters object. - */ - protected _getBrowseMediaQueryParameters( - mediaType?: 'clips' | 'snapshots', - ): BrowseMediaQueryParameters | undefined { - const cameraConfig = this._getSelectedCameraConfig(); - - if ( - !cameraConfig || - !cameraConfig.camera_name || - !this._view || - !( - this._view.isClipRelatedView() || - this._view.isSnapshotRelatedView() || - mediaType - ) - ) { - return undefined; - } - return { - mediaType: mediaType || (this._view.isClipRelatedView() ? 'clips' : 'snapshots'), - clientId: cameraConfig.client_id, - cameraName: cameraConfig.camera_name, - label: cameraConfig.label, - zone: cameraConfig.zone, - }; - } - /** * Handler for media play event. */ @@ -1158,7 +1128,10 @@ export class FrigateCard extends LitElement { ? html` { - this._loadCarousel(); - }); - } + this.updateComplete.then(() => { + this._loadCarousel(); + }); + } + + /** + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getOptions(): EmblaOptionsType | undefined { + return undefined; } /** @@ -56,8 +56,11 @@ export class FrigateCardCarousel extends LitElement { '.embla__viewport', ) as HTMLElement; - if (!this._carousel && carouselNode) { - this._carousel = EmblaCarousel(carouselNode, this._options); + if (carouselNode) { + if (this._carousel) { + this._carousel.destroy(); + } + this._carousel = EmblaCarousel(carouselNode, this._getOptions()); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { const selected = this.carouselSelected(); diff --git a/src/components/live.ts b/src/components/live.ts index 4bc80db0..7ebf27ae 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,6 +1,10 @@ +// TODO controls +// TODO media events +// TODO lazy loading +// TODO height adapting + import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import type { - BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant, CameraConfig, @@ -9,11 +13,13 @@ import type { MediaShowInfo, WebRTCConfig, } from '../types.js'; +import { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; +import { FrigateCardCarousel } from './carousel.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { View } from '../view.js'; import { localize } from '../localize/localize.js'; @@ -34,6 +40,7 @@ import liveStyle from '../scss/live.scss'; import liveFrigateStyle from '../scss/live-frigate.scss'; import liveJSMPEGStyle from '../scss/live-jsmpeg.scss'; import liveWebRTCStyle from '../scss/live-webrtc.scss'; +import mediaCarouselStyle from '../scss/media-carousel.scss'; // Number of seconds a signed URL is valid for. const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -50,14 +57,11 @@ export class FrigateCardLive extends LitElement { protected view?: View; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected cameras?: Map; @property({ attribute: false }) protected liveConfig?: LiveConfig; - @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; - @property({ attribute: false }) set preload(preload: boolean) { this._preload = preload; @@ -97,15 +101,19 @@ export class FrigateCardLive extends LitElement { } const fetchThumbnailsThenRender = async (): Promise => { - if (!this.hass || !this.browseMediaQueryParameters) { + if (!this.hass || !this.cameras || !this.view || !this.liveConfig) { + return; + } + const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( + this.liveConfig.controls.thumbnails.media, + this.cameras.get(this.view.camera), + ); + if (!browseMediaParams) { return; } let parent: BrowseMediaSource | null; try { - parent = await BrowseMediaUtil.browseMediaQuery( - this.hass, - this.browseMediaQueryParameters, - ); + parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams); } catch (e) { return dispatchErrorMessageEvent(this, (e as Error).message); } @@ -113,10 +121,11 @@ export class FrigateCardLive extends LitElement { if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { return html` ) => { - const mediaType = this.browseMediaQueryParameters?.mediaType; + const mediaType = browseMediaParams.mediaType; if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { new View({ view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific', @@ -139,7 +148,7 @@ export class FrigateCardLive extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.liveConfig || !this.cameraConfig) { + if (!this.hass || !this.liveConfig || !this.cameras) { return; } @@ -147,28 +156,19 @@ export class FrigateCardLive extends LitElement { ${this.liveConfig.controls.thumbnails.mode === 'above' ? this.renderThumbnails() : ''} - ${this.liveConfig.provider == 'frigate' - ? html` - ` - : this.liveConfig.provider == 'webrtc' - ? html` - ` - : html` - `} + { + // Re-rendering the component will cause the thumbnails to be + // re-fetched. + this.requestUpdate(); + }} + > + ${this.liveConfig.controls.thumbnails.mode === 'below' ? this.renderThumbnails() : ''} @@ -183,6 +183,183 @@ export class FrigateCardLive extends LitElement { } } +@customElement('frigate-card-live-carousel') +export class FrigateCardLiveCarousel extends FrigateCardCarousel { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view?: View; + + @property({ attribute: false }) + protected cameras?: Map; + + @property({ attribute: false }) + protected liveConfig?: LiveConfig; + + /** + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getOptions(): EmblaOptionsType { + let startIndex = -1; + if (this.cameras && this.view) { + startIndex = Array.from(this.cameras.keys()).indexOf(this.view.camera); + } + + return { + startIndex: startIndex < 0 ? undefined : startIndex, + // TODO: draggable: this.viewerConfig.draggable, + }; + } + + /** + * Load the carousel. + */ + protected _loadCarousel(): void { + super._loadCarousel(); + + // Necessary because typescript local type narrowing is not paying attention + // to the side-effect of the call to super._loadCarousel(). + const carousel = this._carousel as EmblaCarouselType | undefined; + carousel?.on('select', this._selectSlideSetViewHandler.bind(this)); + } + + /** + * Get slides to include in the render. + * @returns The slides to include in the render. + */ + protected _getSlides(): TemplateResult[] { + if (!this.cameras) { + return []; + } + return Array.from(this.cameras.values()).map(this._renderLive.bind(this)); + } + + // /** + // * Handle the user selecting a new slide in the carousel. + // */ + protected _selectSlideSetViewHandler(): void { + if (!this._carousel || !this.view || !this.cameras) { + return; + } + + const selectedSnap = this._carousel.selectedScrollSnap(); + this.view.camera = Array.from(this.cameras.keys())[selectedSnap]; + } + + protected _renderLive(cameraConfig: CameraConfig): TemplateResult { + // TODO: Add lazy load + return html`
+ + +
`; + } + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + const slides = this._getSlides(); + if (!slides) { + return; + } + + // ${neighbors && neighbors.previous + // ? html` { + // this._nextPreviousHandler('previous'); + // }} + // >` + // : ``} + + return html`
+
+
${slides}
+
+
`; + // ${neighbors && neighbors.next + // ? html` { + // this._nextPreviousHandler('next'); + // }} + // >` + // : ``} + } + + /** + * Get element styles. + */ + static get styles(): CSSResultGroup { + return [super.styles, unsafeCSS(mediaCarouselStyle)]; + } +} + +@customElement('frigate-card-live-provider') +export class FrigateCardLiveProvider extends LitElement { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected cameraConfig?: CameraConfig; + + @property({ attribute: false }) + protected liveConfig?: LiveConfig; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.liveConfig || !this.cameraConfig) { + return; + } + + return html` + ${this.liveConfig.provider == 'frigate' + ? html` + ` + : this.liveConfig.provider == 'webrtc' + ? html` + ` + : html` + `} + `; + } +} + @customElement('frigate-card-live-frigate') export class FrigateCardLiveFrigate extends LitElement { @property({ attribute: false }) @@ -478,7 +655,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); } return html`${this._jsmpegCanvasElement}`; - } + }; return html`${until(_render(), renderProgressIndicator())}`; } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index f3e90278..fce95c99 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,5 +1,6 @@ import { BrowseMediaUtil } from '../browse-media-util.js'; import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit'; +import { EmblaOptionsType } from 'embla-carousel'; import { customElement, property } from 'lit/decorators.js'; import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; @@ -38,9 +39,12 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { this.style.setProperty('--frigate-card-carousel-thumbnail-opacity', value ? '0.6' : '1.0'); } - constructor() { - super(); - this._options = { + /** + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getOptions(): EmblaOptionsType { + return { containScroll: 'keepSnaps', dragFree: true, }; diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 02005515..c47d6789 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,6 +1,6 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { BrowseMediaUtil } from '../browse-media-util.js'; -import { EmblaCarouselType } from 'embla-carousel'; +import { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; import { HomeAssistant } from 'custom-card-helpers'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; @@ -250,13 +250,10 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { protected _slideHasBeenLazyLoaded: Record = {}; /** - * Load the carousel with "slides" (clips or snapshots). + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. */ - protected _loadCarousel(): void { - if (this._carousel || !this.viewerConfig) { - return; - } - + protected _getOptions(): EmblaOptionsType { // Start the carousel on the selected child number. const startIndex = Number( Object.keys(this._slideToChild).find( @@ -264,11 +261,16 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { ), ); - this._options = { + return { startIndex: isNaN(startIndex) ? undefined : startIndex, - draggable: this.viewerConfig.draggable, + draggable: this.viewerConfig?.draggable, }; + } + /** + * Load the carousel with "slides" (clips or snapshots). + */ + protected _loadCarousel(): void { super._loadCarousel(); // Necessary because typescript local type narrowing is not paying attention diff --git a/src/scss/live.scss b/src/scss/live.scss index 850a3197..b12a21a2 100644 --- a/src/scss/live.scss +++ b/src/scss/live.scss @@ -6,7 +6,7 @@ gap: 5px; } -frigate-card-live { +frigate-card-live-carousel { flex: 1; min-height: 0; }