From a1a2a78531cecc798df6199532ee63cb8c6b181b Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Mon, 15 Apr 2019 22:55:13 -0400 Subject: [PATCH] Add Stream Element (#3086) * initial commit for stream element * lit elements are apparently not self closing * add disconnectedCallback to teardown on unload * refactor stream element to UpdatingElement and bundle MJPEG handling with it * attach video element for HLS native * update hui-image to optionally show a live camera view (video or mjpeg) * fix playing inline video on iOS * implement review feedback * Fix update bugs * Tweaks * Fix stateObj changed --- src/components/ha-camera-stream.ts | 211 ++++++++++++++++++ .../more-info/controls/more-info-camera.ts | 141 ++---------- .../cards/hui-picture-elements-card.ts | 1 + .../lovelace/cards/hui-picture-entity-card.ts | 1 + .../lovelace/cards/hui-picture-glance-card.ts | 1 + src/panels/lovelace/cards/types.ts | 4 + src/panels/lovelace/components/hui-image.ts | 49 ++-- 7 files changed, 267 insertions(+), 141 deletions(-) create mode 100644 src/components/ha-camera-stream.ts diff --git a/src/components/ha-camera-stream.ts b/src/components/ha-camera-stream.ts new file mode 100644 index 0000000000..6d8b17c515 --- /dev/null +++ b/src/components/ha-camera-stream.ts @@ -0,0 +1,211 @@ +import { + property, + PropertyValues, + LitElement, + TemplateResult, + html, + CSSResult, + css, + customElement, +} from "lit-element"; + +import computeStateName from "../common/entity/compute_state_name"; +import { HomeAssistant, CameraEntity } from "../types"; +import { fireEvent } from "../common/dom/fire_event"; +import { + CAMERA_SUPPORT_STREAM, + fetchStreamUrl, + computeMJPEGStreamUrl, +} from "../data/camera"; +import { supportsFeature } from "../common/entity/supports-feature"; + +type HLSModule = typeof import("hls.js"); + +@customElement("ha-camera-stream") +class HaCameraStream extends LitElement { + @property() public hass?: HomeAssistant; + @property() public stateObj?: CameraEntity; + @property({ type: Boolean }) public showControls = false; + @property() private _attached = false; + // We keep track if we should force MJPEG with a string + // that way it automatically resets if we change entity. + @property() private _forceMJPEG: string | undefined = undefined; + private _hlsPolyfillInstance?: Hls; + + public connectedCallback() { + super.connectedCallback(); + this._attached = true; + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._attached = false; + } + + protected render(): TemplateResult | void { + if (!this.stateObj || !this._attached) { + return html``; + } + + return html` + ${this._shouldRenderMJPEG + ? html` + + ` + : html` + + `} + `; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + + const stateObjChanged = changedProps.has("stateObj"); + const attachedChanged = changedProps.has("_attached"); + + const oldState = changedProps.get("stateObj") as this["stateObj"]; + const oldEntityId = oldState ? oldState.entity_id : undefined; + const curEntityId = this.stateObj ? this.stateObj.entity_id : undefined; + + if ( + (!stateObjChanged && !attachedChanged) || + (stateObjChanged && oldEntityId === curEntityId) + ) { + return; + } + + // If we are no longer attached, destroy polyfill. + if (attachedChanged && !this._attached) { + this._destroyPolyfill(); + return; + } + + // Nothing to do if we are render MJPEG. + if (this._shouldRenderMJPEG) { + return; + } + + // Tear down existing polyfill, if available + this._destroyPolyfill(); + + if (curEntityId) { + this._startHls(); + } + } + + private get _shouldRenderMJPEG() { + return ( + this._forceMJPEG === this.stateObj!.entity_id || + !this.hass!.config.components.includes("stream") || + !supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM) + ); + } + + private get _videoEl(): HTMLVideoElement { + return this.shadowRoot!.querySelector("video")!; + } + + private async _startHls(): Promise { + // tslint:disable-next-line + const Hls = ((await import(/* webpackChunkName: "hls.js" */ "hls.js")) as any) + .default as HLSModule; + let hlsSupported = Hls.isSupported(); + const videoEl = this._videoEl; + + if (!hlsSupported) { + hlsSupported = + videoEl.canPlayType("application/vnd.apple.mpegurl") !== ""; + } + + if (!hlsSupported) { + this._forceMJPEG = this.stateObj!.entity_id; + return; + } + + try { + const { url } = await fetchStreamUrl( + this.hass!, + this.stateObj!.entity_id + ); + + if (Hls.isSupported()) { + this._renderHLSPolyfill(videoEl, Hls, url); + } else { + this._renderHLSNative(videoEl, url); + } + return; + } catch (err) { + // Fails if we were unable to get a stream + // tslint:disable-next-line + console.error(err); + this._forceMJPEG = this.stateObj!.entity_id; + } + } + + private async _renderHLSNative(videoEl: HTMLVideoElement, url: string) { + videoEl.src = url; + await new Promise((resolve) => + videoEl.addEventListener("loadedmetadata", resolve) + ); + videoEl.play(); + } + + private async _renderHLSPolyfill( + videoEl: HTMLVideoElement, + // tslint:disable-next-line + Hls: HLSModule, + url: string + ) { + const hls = new Hls(); + this._hlsPolyfillInstance = hls; + hls.attachMedia(videoEl); + hls.on(Hls.Events.MEDIA_ATTACHED, () => { + hls.loadSource(url); + }); + } + + private _elementResized() { + fireEvent(this, "iron-resize"); + } + + private _destroyPolyfill(): void { + if (this._hlsPolyfillInstance) { + this._hlsPolyfillInstance.destroy(); + this._hlsPolyfillInstance = undefined; + } + } + + static get styles(): CSSResult { + return css` + :host, + img, + video { + display: block; + } + + img, + video { + width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-camera-stream": HaCameraStream; + } +} diff --git a/src/dialogs/more-info/controls/more-info-camera.ts b/src/dialogs/more-info/controls/more-info-camera.ts index ec4264b3ef..0b92d5f95a 100644 --- a/src/dialogs/more-info/controls/more-info-camera.ts +++ b/src/dialogs/more-info/controls/more-info-camera.ts @@ -8,39 +8,36 @@ import { css, } from "lit-element"; -import computeStateName from "../../../common/entity/compute_state_name"; import { HomeAssistant, CameraEntity } from "../../../types"; -import { fireEvent } from "../../../common/dom/fire_event"; import { - fetchStreamUrl, - computeMJPEGStreamUrl, CAMERA_SUPPORT_STREAM, CameraPreferences, fetchCameraPrefs, updateCameraPrefs, } from "../../../data/camera"; import { supportsFeature } from "../../../common/entity/supports-feature"; +import "../../../components/ha-camera-stream"; import "@polymer/paper-checkbox/paper-checkbox"; // Not duplicate import, it's for typing // tslint:disable-next-line import { PaperCheckboxElement } from "@polymer/paper-checkbox/paper-checkbox"; -type HLSModule = typeof import("hls.js"); - class MoreInfoCamera extends LitElement { @property() public hass?: HomeAssistant; @property() public stateObj?: CameraEntity; @property() private _cameraPrefs?: CameraPreferences; - private _hlsPolyfillInstance?: Hls; - - public disconnectedCallback() { - super.disconnectedCallback(); - this._teardownPlayback(); - } protected render(): TemplateResult | void { + if (!this.hass || !this.stateObj) { + return html``; + } + return html` -
+ ${this._cameraPrefs ? html` { - if (!this.stateObj) { - return; - } - if ( - !this.hass!.config.components.includes("stream") || - !supportsFeature(this.stateObj, CAMERA_SUPPORT_STREAM) + curEntityId && + this.hass!.config.components.includes("stream") && + supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM) ) { - this._renderMJPEG(); - return; + // Fetch in background while we set up the video. + this._fetchCameraPrefs(); } - - const videoEl = document.createElement("video"); - videoEl.style.width = "100%"; - videoEl.autoplay = true; - videoEl.controls = true; - videoEl.muted = true; - - // tslint:disable-next-line - const Hls = ((await import(/* webpackChunkName: "hls.js" */ "hls.js")) as any) - .default as HLSModule; - let hlsSupported = Hls.isSupported(); - - if (!hlsSupported) { - hlsSupported = - videoEl.canPlayType("application/vnd.apple.mpegurl") !== ""; - } - - if (hlsSupported) { - try { - const { url } = await fetchStreamUrl( - this.hass!, - this.stateObj.entity_id - ); - // Fetch in background while we set up the video. - this._fetchCameraPrefs(); - - if (Hls.isSupported()) { - this._renderHLSPolyfill(videoEl, Hls, url); - } else { - this._renderHLSNative(videoEl, url); - } - return; - } catch (err) { - // When an error happens, we will do nothing so we render mjpeg. - } - } - - this._renderMJPEG(); - } - - private get _videoRoot(): HTMLDivElement { - return this.shadowRoot!.getElementById("root")! as HTMLDivElement; - } - - private async _renderHLSNative(videoEl: HTMLVideoElement, url: string) { - videoEl.src = url; - this._videoRoot.appendChild(videoEl); - await new Promise((resolve) => - videoEl.addEventListener("loadedmetadata", resolve) - ); - videoEl.play(); - } - - private async _renderHLSPolyfill( - videoEl: HTMLVideoElement, - // tslint:disable-next-line - Hls: HLSModule, - url: string - ) { - const hls = new Hls(); - this._hlsPolyfillInstance = hls; - await new Promise((resolve) => { - hls.on(Hls.Events.MEDIA_ATTACHED, resolve); - hls.attachMedia(videoEl); - }); - hls.loadSource(url); - this._videoRoot.appendChild(videoEl); - videoEl.addEventListener("loadeddata", () => - fireEvent(this, "iron-resize") - ); - } - - private _renderMJPEG() { - const img = document.createElement("img"); - img.style.width = "100%"; - img.addEventListener("load", () => fireEvent(this, "iron-resize")); - img.src = __DEMO__ - ? "/demo/webcamp.jpg" - : computeMJPEGStreamUrl(this.stateObj!); - img.alt = computeStateName(this.stateObj!); - this._videoRoot.appendChild(img); - } - - private _teardownPlayback(): any { - if (this._hlsPolyfillInstance) { - this._hlsPolyfillInstance.destroy(); - this._hlsPolyfillInstance = undefined; - } - const root = this._videoRoot; - while (root.lastChild) { - root.removeChild(root.lastChild); - } - this.stateObj = undefined; - this._cameraPrefs = undefined; } private async _fetchCameraPrefs() { diff --git a/src/panels/lovelace/cards/hui-picture-elements-card.ts b/src/panels/lovelace/cards/hui-picture-elements-card.ts index 0aa673df83..13157273a5 100644 --- a/src/panels/lovelace/cards/hui-picture-elements-card.ts +++ b/src/panels/lovelace/cards/hui-picture-elements-card.ts @@ -60,6 +60,7 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard { .image="${this._config.image}" .stateImage="${this._config.state_image}" .cameraImage="${this._config.camera_image}" + .cameraView="${this._config.camera_view}" .entity="${this._config.entity}" .aspectRatio="${this._config.aspect_ratio}" > diff --git a/src/panels/lovelace/cards/hui-picture-entity-card.ts b/src/panels/lovelace/cards/hui-picture-entity-card.ts index a65526e901..7c719ce96c 100644 --- a/src/panels/lovelace/cards/hui-picture-entity-card.ts +++ b/src/panels/lovelace/cards/hui-picture-entity-card.ts @@ -108,6 +108,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard { .cameraImage="${computeDomain(this._config.entity) === "camera" ? this._config.entity : this._config.camera_image}" + .cameraView="${this._config.camera_view}" .entity="${this._config.entity}" .aspectRatio="${this._config.aspect_ratio}" @ha-click="${this._handleTap}" diff --git a/src/panels/lovelace/cards/hui-picture-glance-card.ts b/src/panels/lovelace/cards/hui-picture-glance-card.ts index 0fb78c5298..a57978fdfc 100644 --- a/src/panels/lovelace/cards/hui-picture-glance-card.ts +++ b/src/panels/lovelace/cards/hui-picture-glance-card.ts @@ -131,6 +131,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard { .image="${this._config.image}" .stateImage="${this._config.state_image}" .cameraImage="${this._config.camera_image}" + .cameraView="${this._config.camera_view}" .entity="${this._config.entity}" .aspectRatio="${this._config.aspect_ratio}" > diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 3c8a00b8ad..93240c6d16 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -2,6 +2,7 @@ import { LovelaceCardConfig, ActionConfig } from "../../../data/lovelace"; import { Condition } from "../common/validate-condition"; import { EntityConfig } from "../entity-rows/types"; import { LovelaceElementConfig } from "../elements/types"; +import { HuiImage } from "../components/hui-image"; export interface AlarmPanelCardConfig extends LovelaceCardConfig { entity: string; @@ -129,6 +130,7 @@ export interface PictureElementsCardConfig extends LovelaceCardConfig { title?: string; image?: string; camera_image?: string; + camera_view?: HuiImage["cameraView"]; state_image?: {}; aspect_ratio?: string; entity?: string; @@ -140,6 +142,7 @@ export interface PictureEntityCardConfig extends LovelaceCardConfig { name?: string; image?: string; camera_image?: string; + camera_view?: HuiImage["cameraView"]; state_image?: {}; aspect_ratio?: string; tap_action?: ActionConfig; @@ -153,6 +156,7 @@ export interface PictureGlanceCardConfig extends LovelaceCardConfig { title?: string; image?: string; camera_image?: string; + camera_view?: HuiImage["cameraView"]; state_image?: {}; aspect_ratio?: string; entity?: string; diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts index 7f9bd87e95..0d5ad799d7 100644 --- a/src/panels/lovelace/components/hui-image.ts +++ b/src/panels/lovelace/components/hui-image.ts @@ -14,7 +14,7 @@ import { query, customElement, } from "lit-element"; -import { HomeAssistant } from "../../../types"; +import { HomeAssistant, CameraEntity } from "../../../types"; import { styleMap } from "lit-html/directives/style-map"; import { classMap } from "lit-html/directives/class-map"; import { b64toBlob } from "../../../common/file/b64-to-blob"; @@ -28,7 +28,7 @@ export interface StateSpecificConfig { } @customElement("hui-image") -class HuiImage extends LitElement { +export class HuiImage extends LitElement { @property() public hass?: HomeAssistant; @property() public entity?: string; @@ -39,6 +39,8 @@ class HuiImage extends LitElement { @property() public cameraImage?: string; + @property() public cameraView?: "live" | "auto"; + @property() public aspectRatio?: string; @property() public filter?: string; @@ -60,7 +62,9 @@ class HuiImage extends LitElement { public connectedCallback(): void { super.connectedCallback(); this._attached = true; - this._startUpdateCameraInterval(); + if (this.cameraImage && this.cameraView !== "live") { + this._startUpdateCameraInterval(); + } } public disconnectedCallback(): void { @@ -77,11 +81,17 @@ class HuiImage extends LitElement { // Figure out image source to use let imageSrc: string | undefined; + let cameraObj: CameraEntity | undefined; // Track if we are we using a fallback image, used for filter. let imageFallback = !this.stateImage; if (this.cameraImage) { - imageSrc = this._cameraImageSrc; + if (this.cameraView === "live") { + cameraObj = + this.hass && (this.hass.states[this.cameraImage] as CameraEntity); + } else { + imageSrc = this._cameraImageSrc; + } } else if (this.stateImage) { const stateImage = this.stateImage[state]; @@ -119,16 +129,25 @@ class HuiImage extends LitElement { ratio: Boolean(ratio && ratio.w > 0 && ratio.h > 0), })} > - + ${this.cameraImage && this.cameraView === "live" + ? html` + + ` + : html` + + `}