Implement downloading and correct MediaShow events in carousel.

This commit is contained in:
Dermot Duffy
2021-10-29 16:36:36 -07:00
parent 715810dcc9
commit 66ae262654
13 changed files with 582 additions and 275 deletions
+8 -4
View File
@@ -10,7 +10,7 @@ import {
MenuStateIcon,
PictureElements,
} from '../types.js';
import { dispatchErrorMessageEvent, dispatchEvent } from '../common.js';
import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js';
import elementsStyle from '../scss/elements.scss';
import { localize } from '../localize/localize.js';
@@ -134,7 +134,11 @@ export class FrigateCardElements extends LitElement {
protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchEvent<MenuButton>(this, 'menu-remove', (ev as CustomEvent).detail);
dispatchFrigateCardEvent<MenuButton>(
this,
'menu-remove',
(ev as CustomEvent).detail,
);
}
protected _menuAddHandler(ev: Event): void {
@@ -274,13 +278,13 @@ export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
dispatchEvent<T>(this, 'menu-add', this._config);
dispatchFrigateCardEvent<T>(this, 'menu-add', this._config);
}
}
disconnectedCallback(): void {
if (this._config) {
dispatchEvent<T>(this, 'menu-remove', this._config);
dispatchFrigateCardEvent<T>(this, 'menu-remove', this._config);
}
super.disconnectedCallback();
}
+8 -11
View File
@@ -1,28 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { until } from 'lit/directives/until.js';
import type {
BrowseMediaSource,
BrowseMediaQueryParameters,
ExtendedHomeAssistant,
} from '../types.js';
import { BrowseMediaUtil } from '../browse-media-util.js';
import { View } from '../view.js';
import {
browseMedia,
browseMediaQuery,
dispatchErrorMessageEvent,
dispatchMessageEvent,
getFirstTrueMediaChildIndex,
} from '../common.js';
import { localize } from '../localize/localize.js';
import { renderProgressIndicator } from './message.js';
import galleryStyle from '../scss/gallery.scss';
import { styleMap } from 'lit/directives/style-map.js';
const MAX_THUMBNAIL_WIDTH = 175;
const DEFAULT_COLUMNS = 5;
@@ -79,15 +76,15 @@ export class FrigateCardGallery extends LitElement {
let parent: BrowseMediaSource | null;
try {
if (this.view.target) {
parent = await browseMedia(this.hass, this.view.target.media_content_id);
parent = await BrowseMediaUtil.browseMedia(this.hass, this.view.target.media_content_id);
} else {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
parent = await BrowseMediaUtil.browseMediaQuery(this.hass, this.browseMediaQueryParameters);
}
} catch (e: any) {
return dispatchErrorMessageEvent(this, e.message);
}
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) {
if (!parent || !parent.children || BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) == null) {
return dispatchMessageEvent(
this,
this._getMediaType() == 'clips'
@@ -149,7 +146,7 @@ export class FrigateCardGallery extends LitElement {
src="${child.thumbnail}"
@click=${() => {
new View({
view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot',
view: this._getMediaType() == 'clips' ? 'clip-specific' : 'snapshot-specific',
target: parent ?? undefined,
childIndex: index,
previous: this.view ?? undefined,
+2 -2
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { dispatchMediaLoadEvent } from '../common.js';
import { dispatchMediaShowEvent } from '../common.js';
import imageStyle from '../scss/image.scss';
import defaultImage from '../images/frigate-bird-in-sky.jpg'
@@ -15,7 +15,7 @@ export class FrigateCardImage extends LitElement {
return html` <img
src=${this.image || defaultImage}
@load=${(e) => {
dispatchMediaLoadEvent(this, e);
dispatchMediaShowEvent(this, e);
}}
>`;
}
+15 -18
View File
@@ -1,19 +1,17 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { HomeAssistant } from 'custom-card-helpers';
import { signedPathSchema } from '../types.js';
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { localize } from '../localize/localize.js';
import {
dispatchErrorMessageEvent,
dispatchMediaLoadEvent,
dispatchMediaShowEvent,
dispatchMessageEvent,
dispatchPauseEvent,
dispatchPlayEvent,
homeAssistantWSRequest,
homeAssistantSignPath,
} from '../common.js';
import { renderProgressIndicator } from '../components/message.js';
@@ -142,7 +140,7 @@ export class FrigateCardLiveWebRTC extends LitElement {
if (onloadedmetadata) {
onloadedmetadata.call(video, e);
}
dispatchMediaLoadEvent(this, video);
dispatchMediaShowEvent(this, video);
};
video.onplay = (e) => {
if (onplay) {
@@ -184,21 +182,20 @@ export class FrigateCardLiveJSMPEG extends LitElement {
return null;
}
const request = {
type: 'auth/sign_path',
path: `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`,
expires: URL_SIGN_EXPIRY_SECONDS,
};
// Sign the path so it includes an authSig parameter.
let response;
let response: string | null | undefined;
try {
response = await homeAssistantWSRequest(this.hass, signedPathSchema, request);
response = await homeAssistantSignPath(
this.hass,
`/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`,
URL_SIGN_EXPIRY_SECONDS);
} catch (err) {
console.warn(err);
return null;
}
const url = this.hass.hassUrl(response.path);
return url.replace(/^http/i, 'ws');
if (!response) {
return null;
}
return response.replace(/^http/i, 'ws');
}
protected _createJSMPEGPlayer(): JSMpeg.VideoElement {
@@ -229,7 +226,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
// ignore any subsequent calls.
if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true;
dispatchMediaLoadEvent(this, this._jsmpegCanvasElement);
dispatchMediaShowEvent(this, this._jsmpegCanvasElement);
}
},
},
+112 -71
View File
@@ -6,36 +6,33 @@ import {
unsafeCSS,
PropertyValues,
} from 'lit';
import { BrowseMediaUtil } from '../browse-media-util.js';
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
import { until } from 'lit/directives/until.js';
import type {
BrowseMediaNeighbors,
BrowseMediaQueryParameters,
BrowseMediaSource,
ExtendedHomeAssistant,
MediaShowInfo,
NextPreviousControlStyle,
} from '../types.js';
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
import { localize } from '../localize/localize.js';
import { View } from '../view.js';
import {
browseMediaQuery,
createMediaShowInfo,
dispatchErrorMessageEvent,
dispatchMediaLoadEvent,
dispatchMessageEvent,
dispatchPauseEvent,
dispatchPlayEvent,
getFirstTrueMediaChildIndex,
isTrueMedia,
dispatchExistingMediaShowInfoAsEvent,
isValidMediaShowInfo,
} from '../common.js';
import { View } from '../view.js';
import { localize } from '../localize/localize.js';
import { renderProgressIndicator } from '../components/message.js';
import './next-prev-control.js';
@@ -45,9 +42,6 @@ import viewerStyle from '../scss/viewer.scss';
const IMG_TRANSPARENT_1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
// Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format);
@customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
@@ -89,7 +83,7 @@ export class FrigateCardViewer extends LitElement {
let errorFree = true;
for (let i = 0; target.children && i < (target.children || []).length; ++i) {
if (isTrueMedia(target.children[i])) {
if (BrowseMediaUtil.isTrueMedia(target.children[i])) {
errorFree &&= !!(await ResolvedMediaUtil.resolveMedia(
this.hass,
target.children[i],
@@ -110,16 +104,18 @@ export class FrigateCardViewer extends LitElement {
}
let autoplay = true;
let view = this.view;
if (!view.target) {
if (this.view.is('clip') || this.view.is('snapshot')) {
let parent: BrowseMediaSource | null = null;
try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
parent = await BrowseMediaUtil.browseMediaQuery(
this.hass,
this.browseMediaQueryParameters,
);
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
this,
@@ -129,11 +125,8 @@ export class FrigateCardViewer extends LitElement {
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
view = new View({
view: this.view.view,
target: parent,
childIndex: childIndex,
});
this.view.target = parent;
this.view.childIndex = childIndex;
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
@@ -143,12 +136,12 @@ export class FrigateCardViewer extends LitElement {
autoplay = this.autoplayClip ?? true;
}
if (view.target && !(await this._resolveAllMediaForTarget(view.target))) {
if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) {
return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
}
return html` <frigate-card-viewer-core
.view=${view}
.view=${this.view}
.nextPreviousControlStyle=${this.nextPreviousControlStyle}
.resolvedMediaCache=${this.resolvedMediaCache}
.autoplayClip=${autoplay}
@@ -198,6 +191,10 @@ export class FrigateCardViewerCore extends LitElement {
// (Folders are not media items that can be rendered).
protected _slideToChild: Record<number, number> = {};
// A "map" from slide number to MediaShowInfo object or null if the slide has
// been lazy loaded, but the MediaShowInfo object is not yet available.
protected _mediaShowInfo: Record<number, MediaShowInfo | null> = {};
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
@@ -233,8 +230,9 @@ export class FrigateCardViewerCore extends LitElement {
this._carousel = EmblaCarousel(carouselNode, {
startIndex: isNaN(startIndex) ? undefined : startIndex,
});
// Update views based on slide selections.
this._carousel.on('select', this._slideSelectHandler.bind(this));
// Update views and dispatch media-show events based on slide selections.
this._carousel.on('select', this._selectSlideSetViewHandler.bind(this));
this._carousel.on('select', this._selectSlideMediaShowHandler.bind(this));
// Lazily load media that is displayed. These handlers are registered
// regardless of the value of this.lazyLoad to allow that value to change
@@ -245,28 +243,6 @@ export class FrigateCardViewerCore extends LitElement {
}
}
/**
* Get the event start time from a media object.
* @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.
*/
protected _extractEventStartTimeFromBrowseMedia(
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) {
const iso_datetime_str = result.groups['iso_datetime'];
if (iso_datetime_str) {
const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true);
if (iso_datetime.isValid()) {
return iso_datetime.unix();
}
}
}
return null;
}
/**
* Get the previous and next true media items from the current view.
* @returns A BrowseMediaNeighbors with indices and objects of true media
@@ -286,7 +262,7 @@ export class FrigateCardViewerCore extends LitElement {
let prevIndex: number | null = null;
for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = this.view.target.children[i];
if (media && isTrueMedia(media)) {
if (media && BrowseMediaUtil.isTrueMedia(media)) {
prevIndex = i;
break;
}
@@ -296,7 +272,7 @@ export class FrigateCardViewerCore extends LitElement {
let nextIndex: number | null = null;
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = this.view.target.children[i];
if (media && isTrueMedia(media)) {
if (media && BrowseMediaUtil.isTrueMedia(media)) {
nextIndex = i;
break;
}
@@ -330,7 +306,7 @@ export class FrigateCardViewerCore extends LitElement {
return null;
}
const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot);
if (!snapshotStartTime) {
return null;
}
@@ -348,10 +324,10 @@ export class FrigateCardViewerCore extends LitElement {
let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i];
if (!isTrueMedia(child)) {
if (!BrowseMediaUtil.isTrueMedia(child)) {
continue;
}
const startTime = this._extractEventStartTimeFromBrowseMedia(child);
const startTime = BrowseMediaUtil.extractEventStartTime(child);
if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime;
@@ -367,7 +343,7 @@ export class FrigateCardViewerCore extends LitElement {
let clips: BrowseMediaSource | null;
try {
clips = await browseMediaQuery(this.hass, {
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
...this.browseMediaQueryParameters,
mediaType: 'clips',
before: latest,
@@ -384,13 +360,13 @@ export class FrigateCardViewerCore extends LitElement {
for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i];
if (!isTrueMedia(child)) {
if (!BrowseMediaUtil.isTrueMedia(child)) {
continue;
}
const clipStartTime = this._extractEventStartTimeFromBrowseMedia(child);
const clipStartTime = BrowseMediaUtil.extractEventStartTime(child);
if (clipStartTime && clipStartTime === snapshotStartTime) {
return new View({
view: 'clip',
view: 'clip-specific',
target: clips,
childIndex: i,
previous: this.view,
@@ -403,12 +379,12 @@ export class FrigateCardViewerCore extends LitElement {
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _slideSelectHandler(): void {
protected _selectSlideSetViewHandler(): void {
if (!this._carousel || !this.view) {
return;
}
// Update the childIndex in the view (without re-render)
// Update the childIndex in the view.
const slidesInView = this._carousel.slidesInView(true);
if (slidesInView.length) {
const childIndex = this._slideToChild[slidesInView[0]];
@@ -434,8 +410,6 @@ export class FrigateCardViewerCore extends LitElement {
/**
* Lazily load media in the carousel.
* @param eventName The Embla event name that triggered this load.
* // TODO delete eventName above?
*/
protected _lazyLoadMediaHandler(): void {
if (!this.lazyLoad || !this._carousel) {
@@ -459,6 +433,12 @@ export class FrigateCardViewerCore extends LitElement {
}
slidesToLoad.forEach((index) => {
// Only lazy loads slides that are not already loaded.
if (index in this._mediaShowInfo) {
return;
}
this._mediaShowInfo[index] = null;
const slide = slides[index];
// Snapshots.
@@ -505,7 +485,7 @@ export class FrigateCardViewerCore extends LitElement {
this._slideToChild = {};
for (let i = 0; i < this.view.target.children?.length; ++i) {
const slide = this._renderMediaItem(this.view.target.children[i]);
const slide = this._renderMediaItem(this.view.target.children[i], slides.length);
if (slide) {
this._slideToChild[slides.length] = i;
slides.push(slide);
@@ -541,15 +521,72 @@ export class FrigateCardViewerCore extends LitElement {
</div>`;
}
/**
* Fire a media show event when a slide is selected.
*/
protected _selectSlideMediaShowHandler(): void {
if (!this._carousel || !this.view) {
return;
}
this._carousel.slidesInView(true).forEach((slideIndex) => {
if (slideIndex in this._mediaShowInfo) {
const mediaShowInfo = this._mediaShowInfo[slideIndex];
if (mediaShowInfo) {
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
}
}
});
}
/**
* Handle a media-show event that is generated by a child component, saving the
* contents for future use when the relevant slide is shown.
* @param slideIndex The relevant slide index.
* @param event The media-show event from the child component.
*/
protected _mediaShowEventHandler(
slideIndex: number,
event: CustomEvent<MediaShowInfo>,
): void {
this._mediaShowInfoHandler(slideIndex, event.detail);
// Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown.
event.stopPropagation();
}
/**
* Handle a MediaShowInfo object that is generated on media load, by saving it
* for future, or immediate use, when the relevant slide is displayed.
* @param slideIndex The relevant slide index.
* @param mediaShowInfo The MediaShowInfo object generated by the media.
*/
protected _mediaShowInfoHandler(
slideIndex: number,
mediaShowInfo?: MediaShowInfo | null,
): void {
// isValidMediaShowInfo is used to weed out the initial load of the
// transparent 1x1 placeholders.
if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) {
this._mediaShowInfo[slideIndex] = mediaShowInfo;
if (this._carousel && this._carousel?.slidesInView(true).includes(slideIndex)) {
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
}
}
}
/**
* Render a given media item.
* @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered.
*/
protected _renderMediaItem(mediaToRender: BrowseMediaSource): TemplateResult | void {
protected _renderMediaItem(
mediaToRender: BrowseMediaSource,
slideIndex: number,
): TemplateResult | void {
// media that can be expanded (folders) cannot be resolved to a single media
// item, skip them.
if (!this.view || !isTrueMedia(mediaToRender)) {
if (!this.view || !BrowseMediaUtil.isTrueMedia(mediaToRender)) {
return;
}
@@ -560,7 +597,7 @@ export class FrigateCardViewerCore extends LitElement {
return html`
<div class="embla__slide">
${this.view.is('clip')
${this.view.isClipRelatedView()
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
? html`<frigate-card-ha-hls-player
.hass=${this.hass}
@@ -572,6 +609,8 @@ export class FrigateCardViewerCore extends LitElement {
playsinline
allow-exoplayer
?autoplay="${this.autoplayClip}"
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)}
>
</frigate-card-ha-hls-player>`
: html`<video
@@ -580,7 +619,9 @@ export class FrigateCardViewerCore extends LitElement {
controls
playsinline
?autoplay="${this.autoplayClip}"
@loadedmetadata="${(e) => dispatchMediaLoadEvent(this, e)}"
@loadedmetadata="${(e: Event) => {
this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}}"
@play=${() => dispatchPlayEvent(this)}
@pause=${() => dispatchPauseEvent(this)}
>
@@ -603,9 +644,9 @@ export class FrigateCardViewerCore extends LitElement {
});
}
}}
@load=${(e) => {
dispatchMediaLoadEvent(this, e);
}}
@load="${(e: Event) => {
this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}}"
/>`}
</div>
`;