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`
+
+ `}