diff --git a/src/card.ts b/src/card.ts index 7de4addf..cc1f67db 100644 --- a/src/card.ts +++ b/src/card.ts @@ -10,10 +10,13 @@ import { import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; import { until } from 'lit/directives/until.js'; - +import { View } from './view'; import { FrigateCardMenu } from './components/menu'; -import { renderMessage, renderErrorMessage, renderProgressIndicator } from './components/message'; - +import { + renderMessage, + renderErrorMessage, + renderProgressIndicator, +} from './components/message'; import { HomeAssistant, LovelaceCardEditor, @@ -23,9 +26,9 @@ import { } from 'custom-card-helpers'; import './editor'; -import './components/menu' -import './components/message' - +import './components/menu'; +import './components/message'; +import './components/gallery'; import cardStyle from './scss/card.scss'; @@ -41,7 +44,6 @@ import type { BrowseMediaSource, ExtendedHomeAssistant, FrigateCardConfig, - FrigateCardView, ResolvedMedia, } from './types'; import { CARD_VERSION } from './const'; @@ -100,41 +102,6 @@ function shouldUpdateBasedOnHass( return false; } -interface ViewParameters { - view?: FrigateCardView; - target?: BrowseMediaSource; - childIndex?: number; - previous?: View; -} - -class View { - view: FrigateCardView; - target?: BrowseMediaSource; - childIndex?: number; - previous?: View; - - constructor(params?: ViewParameters) { - this.view = params?.view || 'live'; - this.target = params?.target; - this.childIndex = params?.childIndex; - this.previous = params?.previous; - } - - public is(name: string): boolean { - return this.view == name; - } - - get media(): BrowseMediaSource | undefined { - if (this.target) { - if (this.target.children && this.childIndex !== undefined) { - return this.target.children[this.childIndex]; - } - return this.target; - } - return undefined; - } -} - // Main FrigateCard class. @customElement('frigate-card') export class FrigateCard extends LitElement { @@ -172,7 +139,7 @@ export class FrigateCard extends LitElement { // Whether or not there is an active clip being played. protected _clipPlaying = false; - @query("frigate-card-menu") + @query('frigate-card-menu') _menu!: FrigateCardMenu | null; // A small cache to avoid needing to create a new list of entities every time @@ -291,6 +258,9 @@ export class FrigateCard extends LitElement { this._changeView(); } + protected _changeViewHandler(e: CustomEvent): void { + this._changeView(e.detail); + } // Update the card view. protected _changeView(view?: View | undefined): void { if (view === undefined) { @@ -406,89 +376,6 @@ export class FrigateCard extends LitElement { return this._makeWSRequest(resolvedMediaSchema, request); } - // Render Frigate events into a card gallery. - protected async _renderEvents(): Promise { - let parent; - try { - if (this._view.target) { - parent = await this._browseMedia(this._view.target.media_content_id); - } else { - parent = await this._browseMediaQuery(this._view.is('clips')); - } - } catch (e: any) { - return renderErrorMessage(e.message); - } - - if (this._getFirstTrueMediaChildIndex(parent) == null) { - return renderMessage( - this._view.is('clips') - ? localize('common.no_clips') - : localize('common.no_snapshots'), - this._view.is('clips') ? 'mdi:filmstrip-off' : 'mdi:camera-off', - ); - } - - return html` `; - } - protected _menuActionHandler(name: string): void { switch (name) { case 'frigate': @@ -886,10 +773,7 @@ export class FrigateCard extends LitElement { // is always rendered (but sometimes hidden). protected async _renderLiveViewer(): Promise { if (!this._hass || !(this.config.camera_entity in this._hass.states)) { - return renderMessage( - localize('error.no_live_camera'), - 'mdi:camera-off', - ); + return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); } if (this._webrtcElement) { return html`${this._webrtcElement}`; @@ -947,7 +831,16 @@ export class FrigateCard extends LitElement {
${this._view.is('clips') || this._view.is('snapshots') - ? until(this._renderEvents(), renderProgressIndicator()) + ? html` + ` : ``} ${this._view.is('clip') || this._view.is('snapshot') ? until(this._renderViewer(), renderProgressIndicator()) diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 00000000..3e6fddcc --- /dev/null +++ b/src/common.ts @@ -0,0 +1,100 @@ +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'; + +export function getParseErrorKeys(error: z.ZodError): string[] { + const errors = error.format(); + return Object.keys(errors).filter((v) => !v.startsWith('_')); +} + +export async function homeAssistantWSRequest( + hass: HomeAssistant & ExtendedHomeAssistant, + schema: ZodSchema, + request: MessageBase, +): Promise { + const response = await hass.callWS(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 = 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; +} + +// From a BrowseMediaSource item extract the first true media item (i.e. a +// clip/snapshot, not a folder). +export function 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; +} + +// Browse Frigate media with a media content id. +export async function browseMedia( + hass: HomeAssistant & ExtendedHomeAssistant | null, + media_content_id: string, +): Promise { + if (!hass) { + return null; + } + const request = { + type: 'media_source/browse_media', + media_content_id: media_content_id, + }; + 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 { + return browseMedia( + params.hass, + // Defined in: + // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py + [ + 'media-source://frigate', + params.clientId, + 'event-search', + params.mediaType, + '', // Name/Title to render (not necessary here) + params.after ? String(params.after) : '', + params.before ? String(params.before) : '', + params.cameraName, + params.label, + params.zone, + ].join('/'), + ); +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts new file mode 100644 index 00000000..2eb7a217 --- /dev/null +++ b/src/components/gallery.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; + +import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; + +import { HomeAssistant } from 'custom-card-helpers'; + +import galleryStyle from '../scss/gallery.scss'; + +import type { ExtendedHomeAssistant } from '../types'; +import { localize } from '../localize/localize'; + +import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; +import { View } from '../view'; + +@customElement('frigate-card-gallery') +export class FrigateCardGallery extends LitElement { + @property({ attribute: false }) + protected hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; + + @property({ attribute: false }) + protected cameraName: string | null = null; + + @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 _getMediaType(): 'clips' | 'snapshots' { + return this.view?.view == 'clips' ? 'clips' : 'snapshots'; + } + + protected render(): TemplateResult | void { + return html`${until(this._renderEvents(), renderProgressIndicator())}`; + } + + protected async _renderEvents(): Promise { + if (!this.hass || !this.clientId || !this.cameraName || !this.view) { + return renderErrorMessage(localize('error.internal')); + } + + let parent; + 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, + }); + } + } catch (e: any) { + return renderErrorMessage(e.message); + } + + if (getFirstTrueMediaChildIndex(parent) == null) { + return renderMessage( + this._getMediaType() == 'clips' + ? localize('common.no_clips') + : localize('common.no_snapshots'), + this._getMediaType() == 'clips' ? 'mdi:filmstrip-off' : 'mdi:camera-off', + ); + } + + return html` `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(galleryStyle); + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 0f46a492..a9d75f48 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -84,6 +84,7 @@ "could_not_resolve": "Could not resolve media URL", "no_live_camera": "No live camera", "invalid_configuration": "Invalid configuration", - "missing_webrtc": "WebRTC component not found" + "missing_webrtc": "WebRTC component not found", + "internal": "Internal error" } } diff --git a/src/scss/card.scss b/src/scss/card.scss index 2055ea1b..7d72a96d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,5 +1,3 @@ -@use "@material/image-list/mdc-image-list"; -@use "@material/image-list"; @use './common.scss'; .container_16_9 { @@ -52,24 +50,7 @@ padding: 10%; } -.frigate-card-image-list { - @include image-list.standard-columns(5, 1px); - @include image-list.shape-radius(5px); -} -ha-card .frigate-card-image-list-folder { - display: flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - text-align: center; - border-style: 2px solid; - opacity: 0.7; - color: var(--secondary-text-color, white); - border-color: var(--secondary-text-color, black); - background-color: var(--primary-background-color, black); - padding: 10px; -} video, img { display: block; diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss new file mode 100644 index 00000000..f1731a48 --- /dev/null +++ b/src/scss/gallery.scss @@ -0,0 +1,22 @@ +@use "@material/image-list/mdc-image-list"; +@use "@material/image-list"; + +.frigate-card-gallery { + @include image-list.standard-columns(5, 1px); + @include image-list.shape-radius(5px); +} + +ha-card.frigate-card-gallery-folder { + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + text-align: center; + border-style: 2px solid; + opacity: 0.7; + color: var(--secondary-text-color, white); + border-color: var(--secondary-text-color, black); + background-color: var(--primary-background-color, black); + padding: 10px; + height: 100%; +} \ No newline at end of file diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 00000000..1c693eb8 --- /dev/null +++ b/src/view.ts @@ -0,0 +1,46 @@ +import type { BrowseMediaSource, FrigateCardView } from './types'; + +export interface ViewParameters { + view?: FrigateCardView; + target?: BrowseMediaSource; + childIndex?: number; + previous?: View; +} + +export class View { + view: FrigateCardView; + target?: BrowseMediaSource; + childIndex?: number; + previous?: View; + + constructor(params?: ViewParameters) { + this.view = params?.view || 'live'; + this.target = params?.target; + this.childIndex = params?.childIndex; + this.previous = params?.previous; + } + + public is(name: string): boolean { + return this.view == name; + } + + get media(): BrowseMediaSource | undefined { + if (this.target) { + if (this.target.children && this.childIndex !== undefined) { + return this.target.children[this.childIndex]; + } + return this.target; + } + return undefined; + } + + public generateChangeEvent(node: HTMLElement): void { + node.dispatchEvent( + new CustomEvent('frigate-card:change-view', { + bubbles: true, + composed: true, + detail: this, + }), + ); + } +}