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
This commit is contained in:
Jason Hunter 2019-04-15 22:55:13 -04:00 committed by Paulus Schoutsen
parent 6ed2d288e6
commit a1a2a78531
7 changed files with 267 additions and 141 deletions

View File

@ -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`
<img
@load=${this._elementResized}
.src=${__DEMO__
? "/demo/webcamp.jpg"
: computeMJPEGStreamUrl(this.stateObj)}
.alt=${computeStateName(this.stateObj)}
/>
`
: html`
<video
autoplay
muted
playsinline
?controls=${this.showControls}
@loadeddata=${this._elementResized}
></video>
`}
`;
}
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<void> {
// 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;
}
}

View File

@ -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`
<div id="root"></div>
<ha-camera-stream
.hass="${this.hass}"
.stateObj="${this.stateObj}"
showcontrols
></ha-camera-stream>
${this._cameraPrefs
? html`
<paper-checkbox
@ -68,122 +65,14 @@ class MoreInfoCamera extends LitElement {
return;
}
// Tear down if we have something and we need to build it up
if (oldEntityId) {
this._teardownPlayback();
}
if (curEntityId) {
this._startPlayback();
}
}
private async _startPlayback(): Promise<void> {
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() {

View File

@ -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}"
></hui-image>

View File

@ -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}"

View File

@ -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}"
></hui-image>

View File

@ -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;

View File

@ -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),
})}
>
<img
id="image"
src=${imageSrc}
@error=${this._onImageError}
@load=${this._onImageLoad}
style=${styleMap({
filter,
display: this._loadError ? "none" : "block",
})}
/>
${this.cameraImage && this.cameraView === "live"
? html`
<ha-camera-stream
.hass="${this.hass}"
.stateObj="${cameraObj}"
></ha-camera-stream>
`
: html`
<img
id="image"
src=${imageSrc}
@error=${this._onImageError}
@load=${this._onImageLoad}
style=${styleMap({
filter,
display: this._loadError ? "none" : "block",
})}
/>
`}
<div
id="brokenImage"
style=${styleMap({
@ -141,7 +160,7 @@ class HuiImage extends LitElement {
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("cameraImage")) {
if (changedProps.has("cameraImage") && this.cameraView !== "live") {
this._updateCameraImageSrc();
this._startUpdateCameraInterval();
return;