Skeleton live carousel.

This commit is contained in:
Dermot Duffy
2022-01-14 21:31:16 -08:00
parent ca581c5078
commit 677f0d511b
7 changed files with 308 additions and 109 deletions
+48 -8
View File
@@ -1,10 +1,16 @@
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);
@@ -16,7 +22,8 @@ export class BrowseMediaUtil {
*/
static extractEventID(media: BrowseMediaSource): string | null {
const result = media.media_content_id.match(
/^media-source:\/\/frigate\/.*\/(?<id>[.0-9]+-[a-zA-Z0-9]+)$/);
/^media-source:\/\/frigate\/.*\/(?<id>[.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(/^(?<iso_datetime>.+) \[/);
if (result && result.groups) {
@@ -57,9 +62,7 @@ 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;
}
@@ -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,
);
}
}
+9 -36
View File
@@ -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` <frigate-card-gallery
.hass=${this._hass}
.view=${this._view}
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
.browseMediaQueryParameters=${BrowseMediaUtil.getBrowseMediaQueryParametersFromView(
this._view,
cameraConfig,
)}
class="${classMap(galleryClasses)}"
@frigate-card:change-view=${this._changeViewHandler}
@frigate-card:message=${this._messageHandler}
@@ -1169,7 +1142,10 @@ export class FrigateCard extends LitElement {
? html` <frigate-card-viewer
.hass=${this._hass}
.view=${this._view}
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
.browseMediaQueryParameters=${BrowseMediaUtil.getBrowseMediaQueryParametersFromView(
this._view,
cameraConfig,
)}
.viewerConfig=${this.config.event_viewer}
.resolvedMediaCache=${this._resolvedMediaCache}
class="${classMap(viewerClasses)}"
@@ -1189,11 +1165,8 @@ export class FrigateCard extends LitElement {
<frigate-card-live
.hass=${this._hass}
.view=${this._view}
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters(
this.config.live.controls.thumbnails.media,
)}
.liveConfig=${this.config.live}
.cameraConfig=${cameraConfig}
.cameras=${this._cameras}
.preload=${this.config.live.preload && !this._view.is('live')}
class="${classMap(liveClasses)}"
@frigate-card:change-view=${this._changeViewHandler}
+17 -14
View File
@@ -1,9 +1,4 @@
import {
CSSResultGroup,
LitElement,
unsafeCSS,
PropertyValues,
} from 'lit';
import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit';
import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
import { dispatchFrigateCardEvent } from '../common';
@@ -15,7 +10,6 @@ export interface CarouselSelect {
}
export class FrigateCardCarousel extends LitElement {
protected _options?: EmblaOptionsType;
protected _carousel?: EmblaCarouselType;
/**
@@ -41,11 +35,17 @@ export class FrigateCardCarousel extends LitElement {
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!this._carousel) {
this.updateComplete.then(() => {
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();
+212 -35
View File
@@ -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<string, CameraConfig>;
@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<TemplateResult | void> => {
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` <frigate-card-thumbnail-carousel
.target=${parent}
.view=${this.view}
.config=${this.liveConfig?.controls.thumbnails}
.highlightSelected=${false}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
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` <frigate-card-live-frigate
.hass=${this.hass}
.cameraEntity=${this.cameraConfig.camera_entity}
@frigate-card:media-show=${this._mediaShowHandler}
>
</frigate-card-live-frigate>`
: this.liveConfig.provider == 'webrtc'
? html`<frigate-card-live-webrtc
.hass=${this.hass}
.webRTCConfig=${this.liveConfig.webrtc || {}}
@frigate-card:media-show=${this._mediaShowHandler}
>
</frigate-card-live-webrtc>`
: html` <frigate-card-live-jsmpeg
.hass=${this.hass}
.cameraName=${this.cameraConfig.camera_name}
.clientId=${this.cameraConfig.client_id}
.jsmpegConfig=${this.liveConfig.jsmpeg}
@frigate-card:media-show=${this._mediaShowHandler}
>
</frigate-card-live-jsmpeg>`}
<frigate-card-live-carousel
.hass=${this.hass}
.view=${this.view}
.cameras=${this.cameras}
.liveConfig=${this.liveConfig}
@frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:carousel:select=${() => {
// Re-rendering the component will cause the thumbnails to be
// re-fetched.
this.requestUpdate();
}}
>
</frigate-card-live-carousel>
${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<string, CameraConfig>;
@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` <div class="embla__slide">
<frigate-card-live-provider
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.liveConfig=${this.liveConfig}
>
</frigate-card-live-provider>
</div>`;
}
/**
* 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`<frigate-card-next-previous-control
// .direction=${'previous'}
// .controlConfig=${this.viewerConfig?.controls.next_previous}
// .thumbnail=${neighbors.previous.thumbnail}
// .title=${neighbors.previous.title}
// .actionHandler=${actionHandler({
// hasHold: false,
// hasDoubleClick: false,
// })}
// @action=${() => {
// this._nextPreviousHandler('previous');
// }}
// ></frigate-card-next-previous-control>`
// : ``}
return html`<div class="embla">
<div class="embla__viewport">
<div class="embla__container">${slides}</div>
</div>
</div>`;
// ${neighbors && neighbors.next
// ? html`<frigate-card-next-previous-control
// .direction=${'next'}
// .controlConfig=${this.viewerConfig?.controls.next_previous}
// .thumbnail=${neighbors.next.thumbnail}
// .title=${neighbors.next.title}
// .actionHandler=${actionHandler({
// hasHold: false,
// hasDoubleClick: false,
// })}
// @action=${() => {
// this._nextPreviousHandler('next');
// }}
// ></frigate-card-next-previous-control>`
// : ``}
}
/**
* 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` <frigate-card-live-frigate
.hass=${this.hass}
.cameraEntity=${this.cameraConfig.camera_entity}
>
</frigate-card-live-frigate>`
: this.liveConfig.provider == 'webrtc'
? html`<frigate-card-live-webrtc
.hass=${this.hass}
.webRTCConfig=${this.liveConfig.webrtc || {}}
>
</frigate-card-live-webrtc>`
: html` <frigate-card-live-jsmpeg
.hass=${this.hass}
.cameraName=${this.cameraConfig.camera_name}
.clientId=${this.cameraConfig.client_id}
.jsmpegConfig=${this.liveConfig.jsmpeg}
>
</frigate-card-live-jsmpeg>`}
`;
}
}
@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())}`;
}
+7 -3
View File
@@ -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,
};
+11 -9
View File
@@ -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<number, boolean> = {};
/**
* 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
+1 -1
View File
@@ -6,7 +6,7 @@
gap: 5px;
}
frigate-card-live {
frigate-card-live-carousel {
flex: 1;
min-height: 0;
}