From 6498ae22cd193dd58234a6085e932ffa82915bc7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 19 Sep 2021 20:59:40 -0700 Subject: [PATCH] Convert viewer to Lit component. --- src/card.ts | 403 +++------------------------- src/common.ts | 45 ++-- src/components/gallery.ts | 52 ++-- src/components/next-prev-control.ts | 74 +++++ src/components/viewer.ts | 268 ++++++++++++++++++ src/scss/card.scss | 40 --- src/scss/common.scss | 4 + src/scss/next-previous-control.scss | 39 +++ src/scss/viewer.scss | 3 + src/types.ts | 74 +++-- src/view.ts | 2 +- 11 files changed, 522 insertions(+), 482 deletions(-) create mode 100644 src/components/next-prev-control.ts create mode 100644 src/components/viewer.ts create mode 100644 src/scss/next-previous-control.scss create mode 100644 src/scss/viewer.scss diff --git a/src/card.ts b/src/card.ts index cc1f67db..0c8bba72 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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): 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( - schema: ZodSchema, - request: MessageBase, - ): Promise { - if (!this._hass) { - return null; - } - - const response = await this._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 = 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 { - 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 { - 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 { - 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(/^(?.+) \[/); - 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` `; - } - - if (!neighbor.thumbnail) { - return html``; - } - return html``; - } - - // Render the view for media. - protected async _renderViewer(): Promise { - 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` - ` - : html`` - : html` { - // 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 { - 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 { 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` ` : ``} ${this._view.is('clip') || this._view.is('snapshot') - ? until(this._renderViewer(), renderProgressIndicator()) + ? html` + ` : ``} ${this._view.is('live') ? until(this._renderLiveViewer(), renderProgressIndicator()) diff --git a/src/common.ts b/src/common.ts index 3e6fddcc..79898353 100644 --- a/src/common.ts +++ b/src/common.ts @@ -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(error: z.ZodError): 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 { 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 { +export async function browseMediaQuery( + hass: HomeAssistant & ExtendedHomeAssistant, + params: BrowseMediaQueryParameters, +): Promise { 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('frigate-card:play', { + bubbles: true, + composed: true, + }), + ); +} + +export function dispatchPauseEvent(node: HTMLElement): void { + node.dispatchEvent( + new CustomEvent('frigate-card:pause', { + bubbles: true, + composed: true, + }), + ); +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 2eb7a217..0b1148a9 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -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 { - 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 { { 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 {
${child.title}
` - : html` { new View({ view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot', - target: parent, + target: parent ?? undefined, childIndex: index, previous: this.view ?? undefined, - }).generateChangeEvent(this); + }).dispatchChangeEvent(this); }} - />`} + />` + : ``} `, )} diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts new file mode 100644 index 00000000..120e6dcf --- /dev/null +++ b/src/components/next-prev-control.ts @@ -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` `; + } + + if (!target.thumbnail) { + return html``; + } + return html``; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(controlStyle); + } +} \ No newline at end of file diff --git a/src/components/viewer.ts b/src/components/viewer.ts new file mode 100644 index 00000000..db97b624 --- /dev/null +++ b/src/components/viewer.ts @@ -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 { + 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(/^(?.+) \[/); + 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 { + 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 { + 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`` + : ``} + ${this.view.is('clip') + ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' + ? html` + ` + : html`` + : html` { + // 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`` + : ``} + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(viewerStyle); + } +} diff --git a/src/scss/card.scss b/src/scss/card.scss index 7d72a96d..bbb38c8d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -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; -} \ No newline at end of file diff --git a/src/scss/common.scss b/src/scss/common.scss index a8dac10b..068edef6 100644 --- a/src/scss/common.scss +++ b/src/scss/common.scss @@ -13,4 +13,8 @@ ha-icon-button.button { ha-icon-button.button.emphasize { color: var(--primary-color, white); +} + +video, img { + display: block; } \ No newline at end of file diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss new file mode 100644 index 00000000..96db20f6 --- /dev/null +++ b/src/scss/next-previous-control.scss @@ -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; +} \ No newline at end of file diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss new file mode 100644 index 00000000..f8f34bdf --- /dev/null +++ b/src/scss/viewer.scss @@ -0,0 +1,3 @@ +img.media,video.media,canvas.media { + width: 100%; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 425c6043..ad087b43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = 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 diff --git a/src/view.ts b/src/view.ts index 1c693eb8..067e9a68 100644 --- a/src/view.ts +++ b/src/view.ts @@ -34,7 +34,7 @@ export class View { return undefined; } - public generateChangeEvent(node: HTMLElement): void { + public dispatchChangeEvent(node: HTMLElement): void { node.dispatchEvent( new CustomEvent('frigate-card:change-view', { bubbles: true,