From e3ccbb078719974a1dc4f8e73f1e4ac0568e8ff4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 23 Sep 2021 20:38:39 -0700 Subject: [PATCH 1/9] Dynamically extract dimensions from loaded media. --- package.json | 1 - rollup.config.js | 4 +- src/card.ts | 90 ++++++++++++++++++++++++++++++--- src/common.ts | 47 +++++++++++++---- src/components/live.ts | 42 +++++++++++---- src/components/viewer.ts | 13 +++-- src/patches/ha-camera-stream.ts | 74 +++++++++++++++++++++++++++ src/patches/ha-hls-player.ts | 40 +++++++++++++++ src/scss/card.scss | 19 +++---- src/scss/live.scss | 1 + src/scss/viewer.scss | 5 ++ src/types.ts | 16 +++++- src/view.ts | 4 ++ 13 files changed, 312 insertions(+), 44 deletions(-) create mode 100644 src/patches/ha-camera-stream.ts create mode 100644 src/patches/ha-hls-player.ts diff --git a/package.json b/package.json index 50ff7ff0..0313b1fa 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/plugin-proposal-decorators": "^7.15.4", "@rollup/plugin-json": "^4.1.0", - "@rollup/plugin-multi-entry": "^4.1.0", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", "eslint": "^7.32.0", diff --git a/rollup.config.js b/rollup.config.js index 3b875029..30276be4 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -6,7 +6,6 @@ import { terser } from 'rollup-plugin-terser'; import serve from 'rollup-plugin-serve'; import json from '@rollup/plugin-json'; import styles from 'rollup-plugin-styles'; -import multi from '@rollup/plugin-multi-entry'; const dev = process.env.ROLLUP_WATCH; @@ -21,7 +20,6 @@ const serveopts = { }; const plugins = [ - multi(), styles({ modules: false, // Behavior of inject mode, without actually injecting style @@ -44,7 +42,7 @@ const plugins = [ export default [ { - input: ['src/card.ts'], + input: 'src/card.ts', output: { file: 'dist/frigate-hass-card.js', format: 'es', diff --git a/src/card.ts b/src/card.ts index a51e2ed5..8e05b562 100644 --- a/src/card.ts +++ b/src/card.ts @@ -9,6 +9,7 @@ import { } from 'lit'; import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; import { HomeAssistant, LovelaceCardEditor, @@ -17,14 +18,12 @@ import { stateIcon, } from 'custom-card-helpers'; -import { - MenuButton, - frigateCardConfigSchema, -} from './types'; +import { MenuButton, frigateCardConfigSchema } from './types'; import type { BrowseMediaQueryParameters, ExtendedHomeAssistant, FrigateCardConfig, + MediaLoadInfo, } from './types'; import { CARD_VERSION } from './const'; @@ -39,9 +38,14 @@ import './components/live'; import './components/menu'; import './components/message'; import './components/viewer'; +import './patches/ha-camera-stream'; +import './patches/ha-hls-player'; import cardStyle from './scss/card.scss'; +const MEDIA_HEIGHT_CUTOFF = 50; +const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; + /* eslint no-console: 0 */ console.info( `%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `, @@ -109,6 +113,9 @@ export class FrigateCard extends LitElement { // a hass update arrives. protected _entitiesToMonitor: string[] | null = null; + // Information about the most recently loaded media item. + protected _mediaInfo: MediaLoadInfo | null = null; + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { this._hass = hass; this._updateMenu(); @@ -357,6 +364,55 @@ export class FrigateCard extends LitElement { }; } + protected _mediaLoadHandler(e: CustomEvent): void { + const mediaInfo = e.detail; + + // In Safari, with WebRTC, 0x0 is occasionally returned during loading, + // so treat anything less than a safety cutoff as bogus. + if (mediaInfo.height < MEDIA_HEIGHT_CUTOFF || mediaInfo.width < MEDIA_WIDTH_CUTOFF) { + return; + } + + let requestRefresh = false; + if ( + this.config.dimensions?.aspect_ratio_mode != 'static' && + (mediaInfo.width != this._mediaInfo?.width || + mediaInfo.height != this._mediaInfo?.height) + ) { + requestRefresh = true; + } + + this._mediaInfo = mediaInfo; + if (requestRefresh) { + this.requestUpdate(); + } + } + + protected _getAspectRatioPadding(): number | null { + const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'auto'; + + // Do not constrain aspect ratio if it's not a gallery (clips or snapshots), + // if the aspect ratio is not static and if there is a loaded media item. + if ( + !this._view.isGalleryView() && + aspect_ratio_mode != 'static' && + this._mediaInfo + ) { + return null; + } + + if (aspect_ratio_mode == 'auto' && this._mediaInfo) { + return (this._mediaInfo.height / this._mediaInfo.width) * 100; + } + + const default_aspect_ratio = this.config.dimensions?.aspect_ratio; + if (default_aspect_ratio) { + return (default_aspect_ratio[1] / default_aspect_ratio[0]) * 100; + } else { + return (9 / 16) * 100; + } + } + // Render the call (master render method). protected render(): TemplateResult | void { if (this.config.show_warning) { @@ -365,10 +421,24 @@ export class FrigateCard extends LitElement { if (this.config.show_error) { return this._showError(localize('common.show_error')); } + + const padding = this._getAspectRatioPadding(); + let container_style_map = {}; + if (padding != null) { + container_style_map = { + 'padding-top': `${padding}%`, + }; + } + + const content_classes = { + 'frigate-card-contents': true, + absolute: (padding != null), + }; + return html` ${this.config.menu_mode == 'above' ? this._renderMenu() : ''} -
-
+
+
${this._view.is('clips') || this._view.is('snapshots') ? html` ` : ``} @@ -393,6 +465,7 @@ export class FrigateCard extends LitElement { ? html` ` : ``} @@ -426,6 +499,9 @@ export class FrigateCard extends LitElement { // Get the Lovelace card size. public getCardSize(): number { + if (this._mediaInfo) { + return this._mediaInfo.height / 50; + } return 6; } } diff --git a/src/common.ts b/src/common.ts index 79898353..34ff07b1 100644 --- a/src/common.ts +++ b/src/common.ts @@ -6,6 +6,7 @@ import type { BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant, + MediaLoadInfo, } from './types'; import { browseMediaSourceSchema } from './types'; @@ -96,20 +97,46 @@ export async function browseMediaQuery( ); } -export function dispatchPlayEvent(node: HTMLElement): void { - node.dispatchEvent( - new CustomEvent('frigate-card:play', { +export function dispatchEvent(element: HTMLElement, name: string, detail?: T): void { + element.dispatchEvent( + new CustomEvent(`frigate-card:${name}`, { bubbles: true, composed: true, + detail: detail, }), ); } -export function dispatchPauseEvent(node: HTMLElement): void { - node.dispatchEvent( - new CustomEvent('frigate-card:pause', { - bubbles: true, - composed: true, - }), - ); +export function dispatchPlayEvent(element: HTMLElement): void { + dispatchEvent(element, 'play') } + +export function dispatchPauseEvent(element: HTMLElement): void { + dispatchEvent(element, 'pause') +} + +export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTMLElement): void { + let target: HTMLElement | EventTarget; + if (source instanceof Event) { + target = source.composedPath()[0]; + } else { + target = source; + } + + if (target instanceof HTMLImageElement) { + dispatchEvent(element, 'media-load', { + width: (target as HTMLImageElement).naturalWidth, + height: (target as HTMLImageElement).naturalHeight, + }); + } else if (target instanceof HTMLVideoElement) { + dispatchEvent(element, 'media-load', { + width: (target as HTMLVideoElement).videoWidth, + height: (target as HTMLVideoElement).videoHeight, + }); + } else if (target instanceof HTMLCanvasElement) { + dispatchEvent(element, 'media-load', { + width: (target as HTMLCanvasElement).width, + height: (target as HTMLCanvasElement).height, + }); + } +} \ No newline at end of file diff --git a/src/components/live.ts b/src/components/live.ts index caf6604f..3a815929 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -7,7 +7,7 @@ import { signedPathSchema } from '../types'; import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; import { localize } from '../localize/localize'; -import { homeAssistantWSRequest } from '../common'; +import { dispatchMediaLoadEvent, homeAssistantWSRequest } from '../common'; import { renderMessage, renderErrorMessage, @@ -68,13 +68,13 @@ export class FrigateCardViewerFrigate extends LitElement { if (!(this.cameraEntity in this.hass.states)) { return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); } - return html` - `; + `; } static get styles(): CSSResultGroup { @@ -119,6 +119,19 @@ export class FrigateCardViewerWebRTC extends LitElement { return html`${this._webRTCElement}`; } + public updated(): void { + // Extract the video component after it has been rendered and generate the + // media load event. + this.updateComplete.then(() => { + const video = this.renderRoot.querySelector('#video') as HTMLVideoElement; + if (video) { + video.onloadedmetadata = () => { + dispatchMediaLoadEvent(this, video); + } + } + }) + } + static get styles(): CSSResultGroup { return unsafeCSS(liveStyle); } @@ -135,7 +148,7 @@ export class FrigateCardViewerJSMPEG extends LitElement { @property({ attribute: false }) protected clientId!: string; - protected _jsmpegCanvasElement: HTMLElement | null = null; + protected _jsmpegCanvasElement: HTMLCanvasElement | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any protected _jsmpegVideoPlayer: any | null = null; @@ -187,10 +200,7 @@ export class FrigateCardViewerJSMPEG extends LitElement { return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); } - // Return the html canvas node only after the JSMPEG video has loaded and - // is playing, to reduce the amount of time the user is staring at a blank - // white canvas (instead they get the progress spinner until this promise - // resolves). + let videoDecoded = false; return new Promise((resolve) => { this._jsmpegVideoPlayer = new JSMpeg.VideoElement( this, @@ -198,12 +208,26 @@ export class FrigateCardViewerJSMPEG extends LitElement { { canvas: this._jsmpegCanvasElement, hooks: { + // Don't resolve the promise until it's playing to minimize the + // amount of time the canvas is empty (and show the spinner + // instead). play: () => { resolve(html`${this._jsmpegCanvasElement}`); }, }, }, - { protocols: [], videoBufferSize: 1024 * 1024 * 4 }, + { protocols: [], + videoBufferSize: 1024 * 1024 * 4, + onVideoDecode: () => { + // This is the only callback that is called after the dimensions + // are available. It's called on every frame decode, so just + // ignore any subsequent calls. + if (!videoDecoded && this._jsmpegCanvasElement) { + videoDecoded = true; + dispatchMediaLoadEvent(this, this._jsmpegCanvasElement); + } + } + }, ); }); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 08cd821d..134d2862 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -18,6 +18,7 @@ import type { import { localize } from '../localize/localize'; import { browseMediaQuery, + dispatchMediaLoadEvent, dispatchPauseEvent, dispatchPlayEvent, getFirstTrueMediaChildIndex, @@ -199,7 +200,7 @@ export class FrigateCardViewer extends LitElement { const neighbors = this._getMediaNeighbors(parent, childIndex); - return html` + return html`
${neighbors?.previousIndex != null ? html` - ` + ` : html`
`; } static get styles(): CSSResultGroup { diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts new file mode 100644 index 00000000..92be67fb --- /dev/null +++ b/src/patches/ha-camera-stream.ts @@ -0,0 +1,74 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +// ==================================================================== +// ** Keep modifications to this file to a minimum ** +// +// Type checking is disabled since this is a modified copy-and-paste of +// underlying render() function, but the rest of the class source it not +// available as compilation time. +// ==================================================================== + +import { TemplateResult, html } from 'lit'; +import { customElement } from 'lit/decorators'; +import { dispatchMediaLoadEvent } from '../common'; + +customElements.whenDefined('ha-camera-stream').then(() => { + // ======================================================================================== + // From: + // - https://github.com/home-assistant/frontend/blob/dev/src/data/camera.ts + // - https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_state_name.ts + // - https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_object_id.ts + // ======================================================================================== + const computeMJPEGStreamUrl = (entity: CameraEntity): string => + `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`; + + const computeObjectId = (entityId: string): string => + entityId.substr(entityId.indexOf('.') + 1); + + const computeStateName = (stateObj: HassEntity): string => + stateObj.attributes.friendly_name === undefined + ? computeObjectId(stateObj.entity_id).replace(/_/g, ' ') + : stateObj.attributes.friendly_name || ''; + + @customElement('frigate-card-ha-camera-stream') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') { + // ======================================================================================== + // Minor modifications from: + // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts + // ======================================================================================== + protected render(): TemplateResult { + if (!this.stateObj) { + return html``; + } + + return html` + ${this._shouldRenderMJPEG + ? html` + { + this._elementResized(); + dispatchMediaLoadEvent(this, e); + }} + .src=${computeMJPEGStreamUrl(this.stateObj)} + .alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`} + /> + ` + : this._url + ? html` + + ` + : ''} + `; + } + } +}); diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts new file mode 100644 index 00000000..40e03d2c --- /dev/null +++ b/src/patches/ha-hls-player.ts @@ -0,0 +1,40 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +// ==================================================================== +// ** Keep modifications to this file to a minimum ** +// +// Type checking is disabled since this is a modified copy-and-paste of +// underlying render() function, but the rest of the class source is not +// available as compilation time. +// ==================================================================== + +import { + TemplateResult, + html, +} from 'lit'; +import { customElement } from 'lit/decorators'; +import { dispatchMediaLoadEvent } from '../common'; + +customElements.whenDefined("ha-hls-player").then(() => { + @customElement("frigate-card-ha-hls-player") + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class FrigateCardHaHlsPlayer extends customElements.get("ha-hls-player") { + // ===================================================================================== + // Minor modifications from: + // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts + // ===================================================================================== + protected render(): TemplateResult { + return html` + + `; + } + } +}) \ No newline at end of file diff --git a/src/scss/card.scss b/src/scss/card.scss index fce1e04f..4acdb6e7 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,16 +1,8 @@ -.container_16_9 { - /* 16:9 Aspect Ratio. When Safari supports 'aspect-ratio' this should not be - necessary */ +.container { position: relative; - padding-top: 56.25%; // 9 / 16 == 0.5625 } .frigate-card-contents { - position: absolute; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; width: 100%; height: 100%; overflow: auto; @@ -24,6 +16,15 @@ } /* The 'hover' menu mode is styling applied outside of the menu itself */ +.frigate-card-contents.absolute { + position: absolute; + top: 0px; + right: 0px; + bottom: 0px; + left: 0px; +} + +/* Support the 'hover' menu mode. */ .hover-menu { z-index: 1; transition: all 0.5s ease; diff --git a/src/scss/live.scss b/src/scss/live.scss index 790803c8..f55ab496 100644 --- a/src/scss/live.scss +++ b/src/scss/live.scss @@ -1,5 +1,6 @@ canvas { width: 100%; + display: block; } /* Don't drop shadow or have radius for nested webrtc card */ diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss index ab01dce9..53c96ca5 100644 --- a/src/scss/viewer.scss +++ b/src/scss/viewer.scss @@ -4,4 +4,9 @@ ha-hls-player { img,video { width: 100%; height: 100%; + display: block; +} +div { + // Keep the controls positioned relative to the video. + position: relative; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index eca53815..a76b8a96 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,15 @@ export const frigateCardConfigSchema = z.object({ nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), }) .optional(), + dimensions: z.object({ + aspect_ratio_mode: z.enum(['dynamic', 'static']).default('dynamic'), + aspect_ratio: + z.number().array().length(2).or( + z.string() + .regex(/^\s*\d+\s*\/\s*\d+\s*$/) + .transform((input) => input.split("/").map((d) => Number(d))) + ).default([16, 9]), + }).optional(), // Stock lovelace card config. type: z.string(), @@ -126,8 +135,13 @@ export interface BrowseMediaQueryParameters { after?: number; } +export interface MediaLoadInfo { + width: number; + height: number; +} + /** - * Media Browser API types. + * Home Assistant API types. */ // Recursive type, cannot use type interference: diff --git a/src/view.ts b/src/view.ts index 067e9a68..236944e0 100644 --- a/src/view.ts +++ b/src/view.ts @@ -24,6 +24,10 @@ export class View { return this.view == name; } + public isGalleryView(): boolean { + return this.view == 'clips' || this.view == 'snapshots'; + } + get media(): BrowseMediaSource | undefined { if (this.target) { if (this.target.children && this.childIndex !== undefined) { From ea71a2ed4b995b3b224fb14b2d6f390509ad893e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 23 Sep 2021 21:14:21 -0700 Subject: [PATCH 2/9] Add an unconstrained aspect ratio mode. --- src/card.ts | 23 +++++++++++++---------- src/types.ts | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/card.ts b/src/card.ts index 8e05b562..d98920de 100644 --- a/src/card.ts +++ b/src/card.ts @@ -372,10 +372,9 @@ export class FrigateCard extends LitElement { if (mediaInfo.height < MEDIA_HEIGHT_CUTOFF || mediaInfo.width < MEDIA_WIDTH_CUTOFF) { return; } - let requestRefresh = false; if ( - this.config.dimensions?.aspect_ratio_mode != 'static' && + (this.config.dimensions?.aspect_ratio_mode ?? 'dynamic') == 'dynamic' && (mediaInfo.width != this._mediaInfo?.width || mediaInfo.height != this._mediaInfo?.height) ) { @@ -389,19 +388,23 @@ export class FrigateCard extends LitElement { } protected _getAspectRatioPadding(): number | null { - const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'auto'; + const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; - // Do not constrain aspect ratio if it's not a gallery (clips or snapshots), - // if the aspect ratio is not static and if there is a loaded media item. - if ( - !this._view.isGalleryView() && - aspect_ratio_mode != 'static' && - this._mediaInfo + // Do not constrain aspect ratio if either it's entire disabled or it's a + // media view (i.e. not the gallery) and there's a loaded media item in + // dynamic mode (as the aspect_ratio is essentially whatever the media + // dimensions are). + if (aspect_ratio_mode == 'unconstrained' || + ( + !this._view.isGalleryView() && + aspect_ratio_mode == 'dynamic' && + this._mediaInfo + ) ) { return null; } - if (aspect_ratio_mode == 'auto' && this._mediaInfo) { + if (aspect_ratio_mode == 'dynamic' && this._mediaInfo) { return (this._mediaInfo.height / this._mediaInfo.width) * 100; } diff --git a/src/types.ts b/src/types.ts index a76b8a96..a4cd7aa8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,7 +98,7 @@ export const frigateCardConfigSchema = z.object({ }) .optional(), dimensions: z.object({ - aspect_ratio_mode: z.enum(['dynamic', 'static']).default('dynamic'), + aspect_ratio_mode: z.enum(['dynamic', 'static', 'unconstrained']).default('dynamic'), aspect_ratio: z.number().array().length(2).or( z.string() From bc0198d242c7df5f032399b24bc39cf5078bf554 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 23 Sep 2021 21:48:44 -0700 Subject: [PATCH 3/9] Add aspect ratio editing. --- src/editor.ts | 35 +++++++++++++++++++++++++++++++++++ src/types.ts | 4 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index a33d4918..a50f0ffa 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -137,6 +137,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor none: localize('control.none'), }; + const aspectRatioModes = { + '': '', + dynamic: localize('aspect_ratio_mode.dynamic'), + static: localize('aspect_ratio_mode.static'), + unconstrained: localize('aspect_ratio_mode.unconstrained'), + } + return html`
@@ -296,6 +303,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
+ + + ${Object.keys(aspectRatioModes).map((key) => { + return html` + ${aspectRatioModes[key]} + `; + })} + + +
+ input.split("/").map((d) => Number(d))) + .regex(/^\s*\d+\s*[:\/]\s*\d+\s*$/) + .transform((input) => input.split(/[:\/]/).map((d) => Number(d))) ).default([16, 9]), }).optional(), From 1ff0cdf6213f7b00f300d5a9d9832c7dd2a940b3 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 23 Sep 2021 22:13:47 -0700 Subject: [PATCH 4/9] Add documentation for aspect-ratio options. --- README.md | 38 +++++++++++++++++++++++++++++++++- src/localize/languages/en.json | 9 ++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 13c60eb7..f3ff46a5 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,44 @@ lovelace: | `menu_mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.| | `menu_buttons.{frigate, live, clips, snapshots, frigate_ui}` | `true` | Whether or not to show these builtin actions in the card menu. | | `controls.nextprev` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | +| `dimensions.aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| +| `dimensions.aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| -### Advanced + + +#### Aspect Ratio + +The card can show live cameras, stored events (clip or snapshot) and an event gallery (clips or snapshots). Of these [views](#views), the gallery views have no intrinsic aspect-ratio, whereas the other views have the aspect-ratio of the media. + +The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and `dimensions.aspect_ratio` appearance options. + +If no aspect ratio is specified or available, but one is needed then `16:9` will be used by default. + +#### `dimensions.aspect_ratio_mode`: + +| Option | Description | +| ------------- | --------------------------------------------- | +| `dynamic` | The aspect-ratio of the card will match the aspect-ratio of the last loaded media. | +| `static` | A fixed aspect-ratio (as defined by `dimensions.aspect_ratio`) will be applied to all views. | +| `unconstrained` | No aspect ratio is enforced in any view, the card will expand with the content (may be especially useful for a panel-mode dashboard). | + +#### `dimensions.aspect_ratio`: + +* `16 / 9` or `16:9`: Default widescreen ratio. +* `4 / 3` or `4:3`: Default fullscreen ratio. +* `/` or `:`: Any arbitrary aspect-ratio. + +#### Example aspect ratio configuration + +Force the aspect-ratio to always be `4:3`: + +```yaml +dimensions: + aspect_ratio_mode: dynamic + aspect_ratio: '4:3' +``` + +### Advanced Options | Option | Default | Description | | ------------- | - | --------------------------------------------- | diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a9d75f48..0d8dd53a 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -49,6 +49,15 @@ "chevrons": "Chevrons", "none": "None" }, + "dimensions": { + "aspect_ratio_mode": "Aspect Ratio Mode", + "aspect_ratio": "Default aspect ratio (Optional)" + }, + "aspect_ratio_mode": { + "unconstrained": "Unconstrained aspect ratio", + "dynamic": "Aspect ratio adjusts to media", + "static": "Static aspect ratio" + }, "menu_mode": { "none": "No menu", "hidden-top": "Hidden Top", From 3d22dcb6869d7b94b6379e69eb714ccf3e270ae4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 23 Sep 2021 23:10:30 -0700 Subject: [PATCH 5/9] Add play/pause event support. --- src/card.ts | 49 ++++++++++++------------------------ src/components/live.ts | 44 ++++++++++++++++++++++++++------ src/patches/ha-hls-player.ts | 4 ++- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/card.ts b/src/card.ts index d98920de..0777f25c 100644 --- a/src/card.ts +++ b/src/card.ts @@ -106,8 +106,8 @@ export class FrigateCard extends LitElement { @query('frigate-card-menu') _menu!: FrigateCardMenu; - // Whether or not there is an active clip being played. - protected _clipPlaying = false; + // Whether or not media is actively playing (live or clip). + protected _mediaPlaying = false; // A small cache to avoid needing to create a new list of entities every time // a hass update arrives. @@ -253,7 +253,7 @@ export class FrigateCard extends LitElement { // are browsing the mini-gallery). Do not allow re-rendering from a Home // Assistant update if there's been recent interaction (e.g. clicks on the // card) or if there is a clip active playing. - if (this._interactionTimerID || this._clipPlaying) { + if (this._interactionTimerID || this._mediaPlaying) { return false; } return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); @@ -294,35 +294,6 @@ export class FrigateCard extends LitElement { return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`; } - public updated(): void { - this.updateComplete.then(() => { - // DOM elements are not always present until after updateComplete promise - // is resolved. Note that children of children (i.e. the underlying video - // element) is not always present even when the promise returns, so - // capture the event at the upper shadow root instead. - const hls_player = this.renderRoot - ?.querySelector('ha-card') - ?.querySelector('ha-hls-player'); - - if (hls_player) { - hls_player.shadowRoot?.addEventListener( - 'play', - () => { - this._clipPlaying = true; - }, - true, - ); - hls_player.shadowRoot?.addEventListener( - 'pause', - () => { - this._clipPlaying = true; - }, - true, - ); - } - }); - } - // Record interactions with the card. protected _interactionHandler(): void { if (!this.config.view_timeout) { @@ -363,6 +334,14 @@ export class FrigateCard extends LitElement { zone: this.config.zone, }; } + + protected _playHandler(): void { + this._mediaPlaying = true; + } + + protected _pauseHandler(): void { + this._mediaPlaying = false; + } protected _mediaLoadHandler(e: CustomEvent): void { const mediaInfo = e.detail; @@ -461,7 +440,9 @@ export class FrigateCard extends LitElement { .autoplayClip=${this.config.autoplay_clip} @frigate-card:change-view=${this._changeViewHandler} @frigate-card:media-load=${this._mediaLoadHandler} - > + @frigate-card:pause=${this._pauseHandler} + @frigate-card:play=${this._playHandler} + > ` : ``} ${this._view.is('live') @@ -469,6 +450,8 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .config=${this.config} @frigate-card:media-load=${this._mediaLoadHandler} + @frigate-card:pause=${this._pauseHandler} + @frigate-card:play=${this._playHandler} > ` : ``} diff --git a/src/components/live.ts b/src/components/live.ts index 3a815929..6cee32c6 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -7,7 +7,12 @@ import { signedPathSchema } from '../types'; import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; import { localize } from '../localize/localize'; -import { dispatchMediaLoadEvent, homeAssistantWSRequest } from '../common'; +import { + dispatchMediaLoadEvent, + dispatchPauseEvent, + dispatchPlayEvent, + homeAssistantWSRequest, +} from '../common'; import { renderMessage, renderErrorMessage, @@ -95,7 +100,6 @@ export class FrigateCardViewerWebRTC extends LitElement { protected _webRTCElement: HTMLElement | null = null; protected _createWebRTC(): TemplateResult | void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const webrtcElement = customElements.get('webrtc-camera') as any; if (webrtcElement) { @@ -125,11 +129,30 @@ export class FrigateCardViewerWebRTC extends LitElement { this.updateComplete.then(() => { const video = this.renderRoot.querySelector('#video') as HTMLVideoElement; if (video) { - video.onloadedmetadata = () => { + const onloadedmetadata = video.onloadedmetadata; + const onplay = video.onplay; + const onpause = video.onpause; + + video.onloadedmetadata = (e) => { + if (onloadedmetadata) { + onloadedmetadata.call(video, e); + } dispatchMediaLoadEvent(this, video); - } + }; + video.onplay = (e) => { + if (onplay) { + onplay.call(video, e); + } + dispatchPlayEvent(this); + }; + video.onpause = (e) => { + if (onpause) { + onpause.call(video, e); + } + dispatchPauseEvent(this); + }; } - }) + }); } static get styles(): CSSResultGroup { @@ -212,11 +235,16 @@ export class FrigateCardViewerJSMPEG extends LitElement { // amount of time the canvas is empty (and show the spinner // instead). play: () => { + dispatchPlayEvent(this); resolve(html`${this._jsmpegCanvasElement}`); }, + pause: () => { + dispatchPauseEvent(this); + }, }, }, - { protocols: [], + { + protocols: [], videoBufferSize: 1024 * 1024 * 4, onVideoDecode: () => { // This is the only callback that is called after the dimensions @@ -226,8 +254,8 @@ export class FrigateCardViewerJSMPEG extends LitElement { videoDecoded = true; dispatchMediaLoadEvent(this, this._jsmpegCanvasElement); } - } - }, + }, + }, ); }); } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 40e03d2c..e05422c1 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -14,7 +14,7 @@ import { html, } from 'lit'; import { customElement } from 'lit/decorators'; -import { dispatchMediaLoadEvent } from '../common'; +import { dispatchMediaLoadEvent, dispatchPauseEvent, dispatchPlayEvent } from '../common'; customElements.whenDefined("ha-hls-player").then(() => { @customElement("frigate-card-ha-hls-player") @@ -33,6 +33,8 @@ customElements.whenDefined("ha-hls-player").then(() => { ?controls=${this.controls} @loadedmetadata=${(e) => dispatchMediaLoadEvent(this, e)} @loadeddata=${this._elementResized} + @pause=${() => dispatchPauseEvent(this)} + @play=${() => dispatchPlayEvent(this)} > `; } From 413f152d10a1dce896aa1f3772328a5c25192065 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 24 Sep 2021 07:32:47 -0700 Subject: [PATCH 6/9] Formatting tweaks. --- src/card.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/card.ts b/src/card.ts index 0777f25c..d4068947 100644 --- a/src/card.ts +++ b/src/card.ts @@ -252,7 +252,7 @@ export class FrigateCard extends LitElement { // arrive), but also is a jarring experience for the user (e.g. if they // are browsing the mini-gallery). Do not allow re-rendering from a Home // Assistant update if there's been recent interaction (e.g. clicks on the - // card) or if there is a clip active playing. + // card) or if there is media active playing. if (this._interactionTimerID || this._mediaPlaying) { return false; } @@ -334,7 +334,7 @@ export class FrigateCard extends LitElement { zone: this.config.zone, }; } - + protected _playHandler(): void { this._mediaPlaying = true; } @@ -373,12 +373,9 @@ export class FrigateCard extends LitElement { // media view (i.e. not the gallery) and there's a loaded media item in // dynamic mode (as the aspect_ratio is essentially whatever the media // dimensions are). - if (aspect_ratio_mode == 'unconstrained' || - ( - !this._view.isGalleryView() && - aspect_ratio_mode == 'dynamic' && - this._mediaInfo - ) + if ( + aspect_ratio_mode == 'unconstrained' || + (!this._view.isGalleryView() && aspect_ratio_mode == 'dynamic' && this._mediaInfo) ) { return null; } @@ -414,7 +411,7 @@ export class FrigateCard extends LitElement { const content_classes = { 'frigate-card-contents': true, - absolute: (padding != null), + absolute: padding != null, }; return html` @@ -442,7 +439,7 @@ export class FrigateCard extends LitElement { @frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:pause=${this._pauseHandler} @frigate-card:play=${this._playHandler} - > + > ` : ``} ${this._view.is('live') From 32cc7d1a70a51076b4b1e4f9fbe0e8370abadc29 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 24 Sep 2021 07:38:41 -0700 Subject: [PATCH 7/9] README fix. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f3ff46a5..9aeb668d 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ If no aspect ratio is specified or available, but one is needed then `16:9` will #### Example aspect ratio configuration -Force the aspect-ratio to always be `4:3`: +Have the card aspect-ratio dynamically follow the last loaded media, but use `4:3` as the default when there is no such media: ```yaml dimensions: From fee4d209c9352ef57cb88c2ec8dc0e3e5cf59d1e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 24 Sep 2021 07:45:58 -0700 Subject: [PATCH 8/9] Add a code comment on the pains of media callbacks. --- src/card.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/card.ts b/src/card.ts index d4068947..80377448 100644 --- a/src/card.ts +++ b/src/card.ts @@ -46,6 +46,23 @@ import cardStyle from './scss/card.scss'; const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; +/** A note on media callbacks: + * + * We need media elements (e.g.