mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 17:56:46 +00:00
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:
parent
6ed2d288e6
commit
a1a2a78531
211
src/components/ha-camera-stream.ts
Normal file
211
src/components/ha-camera-stream.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
@ -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>
|
||||
|
@ -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}"
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user