Convert viewer to Lit component.
This commit is contained in:
+37
-366
@@ -29,35 +29,25 @@ import './editor';
|
||||
import './components/menu';
|
||||
import './components/message';
|
||||
import './components/gallery';
|
||||
import './components/viewer';
|
||||
|
||||
import cardStyle from './scss/card.scss';
|
||||
|
||||
import {
|
||||
MenuButton,
|
||||
browseMediaSourceSchema,
|
||||
frigateCardConfigSchema,
|
||||
resolvedMediaSchema,
|
||||
signedPathSchema,
|
||||
} from './types';
|
||||
import type {
|
||||
BrowseMediaNeighbors,
|
||||
BrowseMediaSource,
|
||||
BrowseMediaQueryParameters,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardConfig,
|
||||
ResolvedMedia,
|
||||
} from './types';
|
||||
import { CARD_VERSION } from './const';
|
||||
import { localize } from './localize/localize';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
|
||||
|
||||
import { ZodSchema, z } from 'zod';
|
||||
import { MessageBase } from 'home-assistant-js-websocket';
|
||||
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
|
||||
// Load dayjs plugin(s).
|
||||
dayjs.extend(dayjs_custom_parse_format);
|
||||
import { getParseErrorKeys, homeAssistantWSRequest } from './common';
|
||||
|
||||
/* eslint no-console: 0 */
|
||||
console.info(
|
||||
@@ -206,11 +196,6 @@ export class FrigateCard extends LitElement {
|
||||
return buttons;
|
||||
}
|
||||
|
||||
protected _getParseErrorKeys(error: z.ZodError): string[] {
|
||||
const errors = error.format();
|
||||
return Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||
}
|
||||
|
||||
// Set the object configuration.
|
||||
public setConfig(inputConfig: FrigateCardConfig): void {
|
||||
if (!inputConfig) {
|
||||
@@ -219,7 +204,7 @@ export class FrigateCard extends LitElement {
|
||||
|
||||
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
|
||||
if (!parseResult.success) {
|
||||
const keys = this._getParseErrorKeys(parseResult.error);
|
||||
const keys = getParseErrorKeys(parseResult.error);
|
||||
throw new Error(localize('error.invalid_configuration') + ': ' + keys.join(', '));
|
||||
}
|
||||
const config = parseResult.data;
|
||||
@@ -259,8 +244,16 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
|
||||
protected _changeViewHandler(e: CustomEvent<View>): void {
|
||||
this._changeView(e.detail);
|
||||
const view = e.detail;
|
||||
|
||||
if (view === undefined) {
|
||||
this._view = new View({ view: this.config.view_default });
|
||||
} else {
|
||||
this._view = view;
|
||||
}
|
||||
this._resetJSMPEGIfNecessary();
|
||||
}
|
||||
|
||||
// Update the card view.
|
||||
protected _changeView(view?: View | undefined): void {
|
||||
if (view === undefined) {
|
||||
@@ -296,86 +289,6 @@ export class FrigateCard extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Make a websocket request to Home Assistant.
|
||||
protected async _makeWSRequest<T>(
|
||||
schema: ZodSchema<T>,
|
||||
request: MessageBase,
|
||||
): Promise<T | null> {
|
||||
if (!this._hass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await this._hass.callWS<T>(request);
|
||||
|
||||
if (!response) {
|
||||
const error_message = `${localize('error.empty_response')}: ${JSON.stringify(
|
||||
request,
|
||||
)}`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
const parseResult = schema.safeParse(response);
|
||||
if (!parseResult.success) {
|
||||
const keys = this._getParseErrorKeys(parseResult.error);
|
||||
const error_message =
|
||||
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
|
||||
localize('error.invalid_keys') +
|
||||
`: '${keys}'`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
// Browse Frigate media with a media content id.
|
||||
protected async _browseMedia(
|
||||
media_content_id: string,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
const request = {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: media_content_id,
|
||||
};
|
||||
return this._makeWSRequest(browseMediaSourceSchema, request);
|
||||
}
|
||||
|
||||
// Browse Frigate media with query parameters.
|
||||
protected async _browseMediaQuery(
|
||||
want_clips?: boolean,
|
||||
before?: number,
|
||||
after?: number,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
return this._browseMedia(
|
||||
// Defined in:
|
||||
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
|
||||
[
|
||||
'media-source://frigate',
|
||||
this.config.frigate_client_id,
|
||||
'event-search',
|
||||
want_clips ? 'clips' : 'snapshots',
|
||||
'', // Name/Title to render (not necessary here)
|
||||
after ? String(after) : '',
|
||||
before ? String(before) : '',
|
||||
this.config.frigate_camera_name,
|
||||
this.config.label,
|
||||
this.config.zone,
|
||||
].join('/'),
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve Frigate media identifier to a real URL.
|
||||
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 this._makeWSRequest(resolvedMediaSchema, request);
|
||||
}
|
||||
|
||||
protected _menuActionHandler(name: string): void {
|
||||
switch (name) {
|
||||
case 'frigate':
|
||||
@@ -398,23 +311,6 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
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 Frigate UI url.
|
||||
protected _getFrigateURLFromContext(): string | null {
|
||||
if (!this.config.frigate_url) {
|
||||
@@ -426,219 +322,6 @@ export class FrigateCard extends LitElement {
|
||||
return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`;
|
||||
}
|
||||
|
||||
// From a BrowseMediaSource item extract the first true media item (i.e. a
|
||||
// clip/snapshot, not a folder).
|
||||
protected _getFirstTrueMediaChildIndex(
|
||||
media: BrowseMediaSource | null,
|
||||
): number | null {
|
||||
if (!media || !media.children) {
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < media.children.length; i++) {
|
||||
if (!media.children[i].can_expand) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Render the next/previous controls.
|
||||
protected _renderNextPreviousControls(
|
||||
previous: boolean,
|
||||
parent?: BrowseMediaSource,
|
||||
targetChildIndex?: number,
|
||||
neighbor?: BrowseMediaSource,
|
||||
): TemplateResult {
|
||||
if (!neighbor || this.config.controls?.nextprev === 'none') {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
'frigate-media-controls': true,
|
||||
previous: previous,
|
||||
next: !previous,
|
||||
thumbnails:
|
||||
!this.config.controls?.nextprev ||
|
||||
this.config.controls?.nextprev === 'thumbnails',
|
||||
chevrons: this.config.controls?.nextprev === 'chevrons',
|
||||
button: this.config.controls?.nextprev === 'chevrons',
|
||||
};
|
||||
|
||||
const clickChangeView = () => {
|
||||
this._view = new View({
|
||||
view: this._view.view,
|
||||
target: parent,
|
||||
childIndex: targetChildIndex,
|
||||
previous: this._view,
|
||||
});
|
||||
};
|
||||
|
||||
if (this.config.controls?.nextprev == 'chevrons') {
|
||||
return html` <ha-icon-button
|
||||
icon=${previous ? 'mdi:chevron-left' : 'mdi:chevron-right'}
|
||||
class="${classMap(classes)}"
|
||||
title=${neighbor.title}
|
||||
@click=${clickChangeView}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
if (!neighbor.thumbnail) {
|
||||
return html``;
|
||||
}
|
||||
return html`<img
|
||||
src="${neighbor.thumbnail}"
|
||||
class="${classMap(classes)}"
|
||||
title="${neighbor.title}"
|
||||
@click=${clickChangeView}
|
||||
/>`;
|
||||
}
|
||||
|
||||
// Render the view for media.
|
||||
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 this._browseMediaQuery(this._view.is('clip'));
|
||||
} catch (e: any) {
|
||||
return renderErrorMessage(e.message);
|
||||
}
|
||||
childIndex = this._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.config.autoplay_clip;
|
||||
}
|
||||
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`
|
||||
${this._renderNextPreviousControls(
|
||||
true,
|
||||
parent,
|
||||
neighbors?.previousIndex ?? undefined,
|
||||
neighbors?.previous ?? undefined,
|
||||
)}
|
||||
${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
|
||||
@play=${() => {
|
||||
this._clipPlaying = true;
|
||||
}}
|
||||
@pause=${() => {
|
||||
this._clipPlaying = false;
|
||||
}}
|
||||
?autoplay="${autoplay}"
|
||||
>
|
||||
<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) {
|
||||
this._changeView(
|
||||
new View({
|
||||
view: 'clip',
|
||||
target: relatedClip,
|
||||
previous: this._view,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>`}
|
||||
${this._renderNextPreviousControls(
|
||||
false,
|
||||
parent,
|
||||
neighbors?.nextIndex ?? undefined,
|
||||
neighbors?.next ?? undefined,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
public updated(): void {
|
||||
this.updateComplete.then(() => {
|
||||
// DOM elements are not always present until after updateComplete promise
|
||||
@@ -668,36 +351,6 @@ export class FrigateCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 this._browseMediaQuery(
|
||||
true,
|
||||
startTime + 1,
|
||||
startTime,
|
||||
);
|
||||
if (clipsAtSameTime) {
|
||||
const index = this._getFirstTrueMediaChildIndex(clipsAtSameTime);
|
||||
if (index != null && clipsAtSameTime.children?.length) {
|
||||
return clipsAtSameTime.children[index];
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Pass. This is best effort.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async _getJSMPEGURL(): Promise<string | null> {
|
||||
if (!this._hass) {
|
||||
return null;
|
||||
@@ -712,7 +365,7 @@ export class FrigateCard extends LitElement {
|
||||
// Sign the path so it includes an authSig parameter.
|
||||
let response;
|
||||
try {
|
||||
response = await this._makeWSRequest(signedPathSchema, request);
|
||||
response = await homeAssistantWSRequest(this._hass, signedPathSchema, request);
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
return null;
|
||||
@@ -818,6 +471,19 @@ export class FrigateCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters {
|
||||
return {
|
||||
mediaType: this._view.view == 'clips' ? 'clips' : 'snapshots',
|
||||
clientId: this.config.frigate_client_id,
|
||||
// frigate_camera_name cannot be null, it will be set to a default value
|
||||
// in setConfig if not specified in the configuration.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
cameraName: this.config.frigate_camera_name!,
|
||||
label: this.config.label,
|
||||
zone: this.config.zone,
|
||||
};
|
||||
}
|
||||
|
||||
// Render the call (master render method).
|
||||
protected render(): TemplateResult | void {
|
||||
if (this.config.show_warning) {
|
||||
@@ -833,17 +499,22 @@ export class FrigateCard extends LitElement {
|
||||
${this._view.is('clips') || this._view.is('snapshots')
|
||||
? html` <frigate-card-gallery
|
||||
.hass=${this._hass}
|
||||
.cameraName=${this.config.frigate_camera_name}
|
||||
.clientId=${this.config.frigate_client_id}
|
||||
.label=${this.config.label}
|
||||
.zone=${this.config.zone}
|
||||
.view=${this._view}
|
||||
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
|
||||
@frigate-card:change-view=${this._changeViewHandler}
|
||||
>
|
||||
</frigate-card-gallery>`
|
||||
: ``}
|
||||
${this._view.is('clip') || this._view.is('snapshot')
|
||||
? until(this._renderViewer(), renderProgressIndicator())
|
||||
? html` <frigate-card-viewer
|
||||
.hass=${this._hass}
|
||||
.view=${this._view}
|
||||
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
|
||||
.nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'}
|
||||
.autoplayClip=${this.config.autoplay_clip}
|
||||
@frigate-card:change-view=${this._changeViewHandler}
|
||||
>
|
||||
</frigate-card-viewer>`
|
||||
: ``}
|
||||
${this._view.is('live')
|
||||
? until(this._renderLiveViewer(), renderProgressIndicator())
|
||||
|
||||
+30
-15
@@ -2,7 +2,12 @@ import { ZodSchema, z } from 'zod';
|
||||
import { MessageBase } from 'home-assistant-js-websocket';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { localize } from './localize/localize';
|
||||
import { BrowseMediaSource, browseMediaSourceSchema, ExtendedHomeAssistant } from './types';
|
||||
import type {
|
||||
BrowseMediaQueryParameters,
|
||||
BrowseMediaSource,
|
||||
ExtendedHomeAssistant,
|
||||
} from './types';
|
||||
import { browseMediaSourceSchema } from './types';
|
||||
|
||||
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
|
||||
const errors = error.format();
|
||||
@@ -54,7 +59,7 @@ export function getFirstTrueMediaChildIndex(
|
||||
|
||||
// Browse Frigate media with a media content id.
|
||||
export async function browseMedia(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant | null,
|
||||
hass: (HomeAssistant & ExtendedHomeAssistant) | null,
|
||||
media_content_id: string,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
if (!hass) {
|
||||
@@ -67,21 +72,13 @@ export async function browseMedia(
|
||||
return homeAssistantWSRequest(hass, browseMediaSourceSchema, request);
|
||||
}
|
||||
|
||||
interface BrowseMediaQueryParameters {
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
mediaType: "clips" | "snapshots",
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
label?: string,
|
||||
zone?: string,
|
||||
before?: number,
|
||||
after?: number,
|
||||
}
|
||||
|
||||
// Browse Frigate media with query parameters.
|
||||
export async function browseMediaQuery(params: BrowseMediaQueryParameters): Promise<BrowseMediaSource | null> {
|
||||
export async function browseMediaQuery(
|
||||
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||
params: BrowseMediaQueryParameters,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
return browseMedia(
|
||||
params.hass,
|
||||
hass,
|
||||
// Defined in:
|
||||
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
|
||||
[
|
||||
@@ -98,3 +95,21 @@ export async function browseMediaQuery(params: BrowseMediaQueryParameters): Prom
|
||||
].join('/'),
|
||||
);
|
||||
}
|
||||
|
||||
export function dispatchPlayEvent(node: HTMLElement): void {
|
||||
node.dispatchEvent(
|
||||
new CustomEvent<void>('frigate-card:play', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function dispatchPauseEvent(node: HTMLElement): void {
|
||||
node.dispatchEvent(
|
||||
new CustomEvent<void>('frigate-card:pause', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
+19
-33
@@ -9,7 +9,11 @@ import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
import galleryStyle from '../scss/gallery.scss';
|
||||
|
||||
import type { ExtendedHomeAssistant } from '../types';
|
||||
import type {
|
||||
BrowseMediaSource,
|
||||
BrowseMediaQueryParameters,
|
||||
ExtendedHomeAssistant,
|
||||
} from '../types';
|
||||
import { localize } from '../localize/localize';
|
||||
|
||||
import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common';
|
||||
@@ -18,22 +22,13 @@ import { View } from '../view';
|
||||
@customElement('frigate-card-gallery')
|
||||
export class FrigateCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass: (HomeAssistant & ExtendedHomeAssistant) | null = null;
|
||||
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected cameraName: string | null = null;
|
||||
protected view!: View;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected clientId: string | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view: View | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected label?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected zone?: string;
|
||||
protected browseMediaQueryParameters!: BrowseMediaQueryParameters;
|
||||
|
||||
protected _getMediaType(): 'clips' | 'snapshots' {
|
||||
return this.view?.view == 'clips' ? 'clips' : 'snapshots';
|
||||
@@ -44,29 +39,18 @@ export class FrigateCardGallery extends LitElement {
|
||||
}
|
||||
|
||||
protected async _renderEvents(): Promise<TemplateResult> {
|
||||
if (!this.hass || !this.clientId || !this.cameraName || !this.view) {
|
||||
return renderErrorMessage(localize('error.internal'));
|
||||
}
|
||||
|
||||
let parent;
|
||||
let parent: BrowseMediaSource | null;
|
||||
try {
|
||||
if (this.view.target) {
|
||||
parent = await browseMedia(this.hass, this.view.target.media_content_id);
|
||||
} else {
|
||||
parent = await browseMediaQuery({
|
||||
hass: this.hass,
|
||||
clientId: this.clientId,
|
||||
mediaType: this._getMediaType(),
|
||||
cameraName: this.cameraName,
|
||||
label: this.label,
|
||||
zone: this.zone,
|
||||
});
|
||||
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
|
||||
}
|
||||
} catch (e: any) {
|
||||
return renderErrorMessage(e.message);
|
||||
}
|
||||
|
||||
if (getFirstTrueMediaChildIndex(parent) == null) {
|
||||
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) {
|
||||
return renderMessage(
|
||||
this._getMediaType() == 'clips'
|
||||
? localize('common.no_clips')
|
||||
@@ -83,7 +67,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
<ha-card
|
||||
@click=${() => {
|
||||
if (this.view && this.view.previous) {
|
||||
this.view.previous.generateChangeEvent(this);
|
||||
this.view.previous.dispatchChangeEvent(this);
|
||||
}
|
||||
}}
|
||||
outlined=""
|
||||
@@ -107,7 +91,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
view: this._getMediaType(),
|
||||
target: child,
|
||||
previous: this.view ?? undefined,
|
||||
}).generateChangeEvent(this);
|
||||
}).dispatchChangeEvent(this);
|
||||
}}
|
||||
outlined=""
|
||||
class="frigate-card-gallery-folder"
|
||||
@@ -115,19 +99,21 @@ export class FrigateCardGallery extends LitElement {
|
||||
<div>${child.title}</div>
|
||||
</ha-card>
|
||||
</div>`
|
||||
: html`<img
|
||||
: child.thumbnail
|
||||
? html`<img
|
||||
title="${child.title}"
|
||||
class="mdc-image-list__image"
|
||||
src="${child.thumbnail}"
|
||||
@click=${() => {
|
||||
new View({
|
||||
view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot',
|
||||
target: parent,
|
||||
target: parent ?? undefined,
|
||||
childIndex: index,
|
||||
previous: this.view ?? undefined,
|
||||
}).generateChangeEvent(this);
|
||||
}).dispatchChangeEvent(this);
|
||||
}}
|
||||
/>`}
|
||||
/>`
|
||||
: ``}
|
||||
</div>
|
||||
</li>`,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators';
|
||||
import { classMap } from 'lit/directives/class-map';
|
||||
import controlStyle from '../scss/next-previous-control.scss';
|
||||
import { BrowseMediaSource, NextPreviousControlStyle } from '../types';
|
||||
import { View } from '../view';
|
||||
|
||||
@customElement('frigate-card-next-previous-control')
|
||||
export class FrigateCardMessage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected control!: "next" | "previous";
|
||||
|
||||
@property({ attribute: false })
|
||||
protected controlStyle!: NextPreviousControlStyle;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected parent!: BrowseMediaSource;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected childIndex!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view!: View;
|
||||
|
||||
protected _changeView(): void {
|
||||
new View({
|
||||
view: this.view.view,
|
||||
target: this.parent,
|
||||
childIndex: this.childIndex,
|
||||
}).dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
protected render() : TemplateResult {
|
||||
if (this.controlStyle == 'none' || !this.parent.children) {
|
||||
return html``;
|
||||
}
|
||||
const target = this.parent.children[this.childIndex];
|
||||
if (!target) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
controls: true,
|
||||
previous: this.control == "previous",
|
||||
next: this.control == "next",
|
||||
thumbnails: this.controlStyle == "thumbnails",
|
||||
chevrons: this.controlStyle == "chevrons",
|
||||
button: this.controlStyle == "chevrons",
|
||||
};
|
||||
|
||||
if (this.controlStyle == "chevrons") {
|
||||
return html` <ha-icon-button
|
||||
icon=${this.control == "previous" ? 'mdi:chevron-left' : 'mdi:chevron-right'}
|
||||
class="${classMap(classes)}"
|
||||
title=${target.title}
|
||||
@click=${this._changeView}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
if (!target.thumbnail) {
|
||||
return html``;
|
||||
}
|
||||
return html`<img
|
||||
src="${target.thumbnail}"
|
||||
class="${classMap(classes)}"
|
||||
title="${target.title}"
|
||||
@click=${this._changeView}
|
||||
/>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(controlStyle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,6 @@
|
||||
padding: 10%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
video, img {
|
||||
display: block;
|
||||
}
|
||||
@@ -80,41 +78,3 @@ webrtc-camera ha-card {
|
||||
border-radius: 0px;
|
||||
background-color: var(--secondary-background-color, black);
|
||||
}
|
||||
|
||||
.frigate-media-controls {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.frigate-media-controls.previous {
|
||||
left: 45px;
|
||||
}
|
||||
.frigate-media-controls.next {
|
||||
right: 45px;
|
||||
}
|
||||
|
||||
.frigate-media-controls.chevrons {
|
||||
top: calc(50% - (40px / 2));
|
||||
}
|
||||
|
||||
.frigate-media-controls.thumbnails {
|
||||
border-radius: 50%;
|
||||
height: 48px;
|
||||
top: calc(50% - (48px / 2));
|
||||
box-shadow: 0px 0px 30px 1px black;
|
||||
transition: all .2s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.frigate-media-controls.thumbnails:hover {
|
||||
opacity: 1 !important;
|
||||
height: 72px;
|
||||
top: calc(50% - (72px / 2));
|
||||
}
|
||||
|
||||
.frigate-media-controls.previous.thumbnails:hover {
|
||||
left: 33px;
|
||||
}
|
||||
|
||||
.frigate-media-controls.next.thumbnails:hover {
|
||||
right: 33px;
|
||||
}
|
||||
@@ -13,4 +13,8 @@ ha-icon-button.button {
|
||||
|
||||
ha-icon-button.button.emphasize {
|
||||
color: var(--primary-color, white);
|
||||
}
|
||||
|
||||
video, img {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@use './common.scss';
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.controls.previous {
|
||||
left: 45px;
|
||||
}
|
||||
.controls.next {
|
||||
right: 45px;
|
||||
}
|
||||
|
||||
.controls.chevrons {
|
||||
top: calc(50% - (40px / 2));
|
||||
}
|
||||
|
||||
.controls.thumbnails {
|
||||
border-radius: 50%;
|
||||
height: 48px;
|
||||
top: calc(50% - (48px / 2));
|
||||
box-shadow: 0px 0px 30px 1px black;
|
||||
transition: all .2s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.controls.thumbnails:hover {
|
||||
opacity: 1 !important;
|
||||
height: 72px;
|
||||
top: calc(50% - (72px / 2));
|
||||
}
|
||||
|
||||
.controls.previous.thumbnails:hover {
|
||||
left: 33px;
|
||||
}
|
||||
|
||||
.controls.next.thumbnails:hover {
|
||||
right: 33px;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
img.media,video.media,canvas.media {
|
||||
width: 100%;
|
||||
}
|
||||
+47
-27
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
LovelaceCard,
|
||||
LovelaceCardEditor,
|
||||
} from 'custom-card-helpers';
|
||||
import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers';
|
||||
import { z } from 'zod';
|
||||
|
||||
declare global {
|
||||
@@ -43,12 +40,14 @@ export const FRIGATE_MENU_MODES = [
|
||||
] as const;
|
||||
export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
|
||||
|
||||
export const NEXT_PREVIOUS_CONTROL_STYLES = ['none', 'thumbnails', 'chevrons'] as const;
|
||||
export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[number];
|
||||
|
||||
export const frigateCardConfigSchema = z.object({
|
||||
camera_entity: z.string(),
|
||||
// No URL validation to allow relative URLs within HA (e.g. addons).
|
||||
frigate_url: z.string().optional(),
|
||||
frigate_client_id: z.string().optional().default("frigate"),
|
||||
frigate_client_id: z.string().optional().default('frigate'),
|
||||
frigate_camera_name: z.string().optional(),
|
||||
view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'),
|
||||
view_timeout: z
|
||||
@@ -59,31 +58,42 @@ export const frigateCardConfigSchema = z.object({
|
||||
.regex(/^\d+$/)
|
||||
.transform((val) => Number(val)),
|
||||
)
|
||||
.optional().default(180),
|
||||
.optional()
|
||||
.default(180),
|
||||
live_provider: z.enum(['frigate', 'frigate-jsmpeg', 'webrtc']).default('frigate'),
|
||||
webrtc: z.object({
|
||||
entity: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}).passthrough().optional(),
|
||||
webrtc: z
|
||||
.object({
|
||||
entity: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
label: z.string().optional(),
|
||||
zone: z.string().optional(),
|
||||
autoplay_clip: z.boolean().default(false),
|
||||
menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'),
|
||||
menu_buttons: z.object({
|
||||
frigate: z.boolean().default(true),
|
||||
live: z.boolean().default(true),
|
||||
clips: z.boolean().default(true),
|
||||
snapshots: z.boolean().default(true),
|
||||
frigate_ui: z.boolean().default(true),
|
||||
}).optional(),
|
||||
entities: z.object({
|
||||
entity: z.string(),
|
||||
show: z.boolean().default(true),
|
||||
icon: z.string().optional(),
|
||||
}).array().optional(),
|
||||
controls: z.object({
|
||||
nextprev: z.enum(['thumbnails', 'chevrons', 'none']).default('thumbnails'),
|
||||
}).optional(),
|
||||
menu_buttons: z
|
||||
.object({
|
||||
frigate: z.boolean().default(true),
|
||||
live: z.boolean().default(true),
|
||||
clips: z.boolean().default(true),
|
||||
snapshots: z.boolean().default(true),
|
||||
frigate_ui: z.boolean().default(true),
|
||||
})
|
||||
.optional(),
|
||||
entities: z
|
||||
.object({
|
||||
entity: z.string(),
|
||||
show: z.boolean().default(true),
|
||||
icon: z.string().optional(),
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
controls: z
|
||||
.object({
|
||||
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Stock lovelace card config.
|
||||
type: z.string(),
|
||||
@@ -103,6 +113,16 @@ export interface ExtendedHomeAssistant {
|
||||
hassUrl(path?): string;
|
||||
}
|
||||
|
||||
export interface BrowseMediaQueryParameters {
|
||||
mediaType: 'clips' | 'snapshots';
|
||||
clientId: string;
|
||||
cameraName: string;
|
||||
label?: string;
|
||||
zone?: string;
|
||||
before?: number;
|
||||
after?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media Browser API types.
|
||||
*/
|
||||
@@ -119,7 +139,7 @@ export interface BrowseMediaSource {
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
children_media_class: string | null;
|
||||
thumbnail: string | null
|
||||
thumbnail: string | null;
|
||||
children?: BrowseMediaSource[] | null;
|
||||
}
|
||||
|
||||
@@ -134,7 +154,7 @@ export const browseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(()
|
||||
children_media_class: z.string().nullable(),
|
||||
thumbnail: z.string().nullable(),
|
||||
children: z.array(browseMediaSourceSchema).nullable().optional(),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export class View {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public generateChangeEvent(node: HTMLElement): void {
|
||||
public dispatchChangeEvent(node: HTMLElement): void {
|
||||
node.dispatchEvent(
|
||||
new CustomEvent<View>('frigate-card:change-view', {
|
||||
bubbles: true,
|
||||
|
||||
Reference in New Issue
Block a user