Files
advanced-camera-card/src/components/viewer.ts
T

269 lines
8.4 KiB
TypeScript

import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
import { until } from 'lit/directives/until.js';
import { View } from '../view';
import {
renderMessage,
renderErrorMessage,
renderProgressIndicator,
} from '../components/message';
import { HomeAssistant } from 'custom-card-helpers';
import viewerStyle from '../scss/viewer.scss';
import { resolvedMediaSchema } from '../types';
import type {
BrowseMediaNeighbors,
BrowseMediaQueryParameters,
BrowseMediaSource,
ExtendedHomeAssistant,
NextPreviousControlStyle,
ResolvedMedia,
} from '../types';
import { localize } from '../localize/localize';
import {
browseMediaQuery,
dispatchPauseEvent,
dispatchPlayEvent,
getFirstTrueMediaChildIndex,
homeAssistantWSRequest,
} from '../common';
import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
import './next-prev-control';
// Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format);
@customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
protected hass!: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false })
protected view!: View;
@property({ attribute: false })
protected browseMediaQueryParameters!: BrowseMediaQueryParameters;
@property({ attribute: false })
protected nextPreviousControlStyle!: NextPreviousControlStyle;
@property({ attribute: false })
protected autoplayClip!: boolean;
protected async _resolveMedia(
mediaSource: BrowseMediaSource | null,
): Promise<ResolvedMedia | null> {
if (!mediaSource) {
return null;
}
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
return homeAssistantWSRequest(this.hass, resolvedMediaSchema, request);
}
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 real media items, given the index
protected _getMediaNeighbors(
parent: BrowseMediaSource,
index: number | null,
): BrowseMediaNeighbors | null {
if (index == null || !parent.children) {
return null;
}
// Work backwards from the index to get the previous real media.
let prevIndex: number | null = null;
for (let i = index - 1; i >= 0; i--) {
const media = parent.children[i];
if (media && !media.can_expand) {
prevIndex = i;
break;
}
}
// Work forwards from the index to get the next real media.
let nextIndex: number | null = null;
for (let i = index + 1; i < parent.children.length; i++) {
const media = parent.children[i];
if (media && !media.can_expand) {
nextIndex = i;
break;
}
}
return {
previousIndex: prevIndex,
previous: prevIndex != null ? parent.children[prevIndex] : null,
nextIndex: nextIndex,
next: nextIndex != null ? parent.children[nextIndex] : null,
};
}
// Get a clip at the same time as a snapshot.
protected async _findRelatedClips(
snapshot: BrowseMediaSource | null,
): Promise<BrowseMediaSource | null> {
if (!snapshot) {
return null;
}
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
if (startTime) {
try {
// Fetch clips within the same second (same camera/zone/label, etc).
const clipsAtSameTime = await browseMediaQuery(this.hass, {
...this.browseMediaQueryParameters,
before: startTime + 1,
after: startTime,
});
if (clipsAtSameTime) {
const index = getFirstTrueMediaChildIndex(clipsAtSameTime);
if (index != null && clipsAtSameTime.children?.length) {
return clipsAtSameTime.children[index];
}
}
} catch (e: any) {
// Pass. This is best effort.
}
}
return null;
}
protected render(): TemplateResult | void {
return html`${until(this._renderViewer(), renderProgressIndicator())}`;
}
protected async _renderViewer(): Promise<TemplateResult> {
let autoplay = true;
let parent: BrowseMediaSource | null = null;
let childIndex: number | null = null;
let mediaToRender: BrowseMediaSource | null = null;
if (this.view.target) {
parent = this.view.target;
childIndex = this.view.childIndex ?? null;
mediaToRender = this.view.media ?? null;
} else {
try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} catch (e) {
return renderErrorMessage((e as Error).message);
}
childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return renderMessage(
this.view.is('clip')
? localize('common.no_clip')
: localize('common.no_snapshot'),
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
mediaToRender = parent.children[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
// may be disabled by configuration. If does not make sense to disable
// autoplay when the user has explicitly picked an event to play in the
// gallery.
autoplay = this.autoplayClip;
}
const resolvedMedia = await this._resolveMedia(mediaToRender);
if (!mediaToRender || !resolvedMedia) {
// Home Assistant could not resolve media item.
return renderErrorMessage(localize('error.could_not_resolve'));
}
const neighbors = this._getMediaNeighbors(parent, childIndex);
return html`
${neighbors?.previousIndex != null
? html`<frigate-card-next-previous-control
.control=${'previous'}
.controlStyle=${this.nextPreviousControlStyle}
.parent=${parent}
.childIndex=${neighbors.previousIndex}
.view=${this.view}
></frigate-card-next-previous-control>`
: ``}
${this.view.is('clip')
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
? html`<ha-hls-player
class="media"
.hass=${this.hass}
.url=${resolvedMedia.url}
title="${mediaToRender.title}"
muted
controls
playsinline
allow-exoplayer
?autoplay="${autoplay}"
>
</ha-hls-player>`
: html`<video
class="media"
title="${mediaToRender.title}"
muted
controls
playsinline
?autoplay="${autoplay}"
@play=${() => dispatchPlayEvent(this)}
@pause=${() => dispatchPauseEvent(this)}
>
<source src="${resolvedMedia.url}" type="${resolvedMedia.mime_type}" />
</video>`
: html`<img
src=${resolvedMedia.url}
class="media"
title="${mediaToRender.title}"
@click=${() => {
// Get clips potentially related to this snapshot.
this._findRelatedClips(mediaToRender).then((relatedClip) => {
if (relatedClip) {
new View({
view: 'clip',
target: relatedClip,
}).dispatchChangeEvent(this);
}
});
}}
/>`}
${neighbors?.nextIndex != null
? html`<frigate-card-next-previous-control
.control=${'next'}
.controlStyle=${this.nextPreviousControlStyle}
.parent=${parent}
.childIndex=${neighbors.nextIndex}
.view=${this.view}
></frigate-card-next-previous-control>`
: ``}
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(viewerStyle);
}
}