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) {