diff --git a/package.json b/package.json index 78b08336b1..445e4db90e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "deep-clone-simple": "^1.1.1", "es6-object-assign": "^1.1.0", "fecha": "^3.0.0", + "hls.js": "^0.12.3", "home-assistant-js-websocket": "^3.3.0", "intl-messageformat": "^2.2.0", "jquery": "^3.3.1", @@ -107,6 +108,7 @@ "@gfx/zopfli": "^1.0.9", "@types/chai": "^4.1.7", "@types/codemirror": "^0.0.71", + "@types/hls.js": "^0.12.2", "@types/leaflet": "^1.4.3", "@types/memoize-one": "^4.1.0", "@types/mocha": "^5.2.5", diff --git a/src/data/camera.ts b/src/data/camera.ts index cde6abb596..64eec22d15 100644 --- a/src/data/camera.ts +++ b/src/data/camera.ts @@ -1,12 +1,37 @@ -import { HomeAssistant } from "../types"; +import { HomeAssistant, CameraEntity } from "../types"; export interface CameraThumbnail { content_type: string; content: string; } +export interface Stream { + url: string; +} + +export const computeMJPEGStreamUrl = (entity: CameraEntity) => + `/api/camera_proxy_stream/${entity.entity_id}?token=${ + entity.attributes.access_token + }`; + export const fetchThumbnail = (hass: HomeAssistant, entityId: string) => hass.callWS({ type: "camera_thumbnail", entity_id: entityId, }); + +export const fetchStreamUrl = ( + hass: HomeAssistant, + entityId: string, + format?: "hls" +) => { + const data = { + type: "camera/stream", + entity_id: entityId, + }; + if (format) { + // @ts-ignore + data.format = format; + } + return hass.callWS(data); +}; diff --git a/src/dialogs/more-info/controls/more-info-camera.js b/src/dialogs/more-info/controls/more-info-camera.js deleted file mode 100644 index 21d45c3e6e..0000000000 --- a/src/dialogs/more-info/controls/more-info-camera.js +++ /dev/null @@ -1,85 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; - -import computeStateName from "../../../common/entity/compute_state_name"; -import emptyImageBase64 from "../../../common/empty_image_base64"; -import EventsMixin from "../../../mixins/events-mixin"; - -/* - * @appliesMixin EventsMixin - */ -class MoreInfoCamera extends EventsMixin(PolymerElement) { - static get template() { - return html` - - - [[_computeStateName(stateObj)]] - `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - - stateObj: { - type: Object, - }, - - isVisible: { - type: Boolean, - value: true, - }, - }; - } - - connectedCallback() { - super.connectedCallback(); - this.isVisible = true; - } - - disconnectedCallback() { - this.isVisible = false; - super.disconnectedCallback(); - } - - imageLoaded() { - this.fire("iron-resize"); - } - - _computeStateName(stateObj) { - return computeStateName(stateObj); - } - - computeCameraImageUrl(hass, stateObj, isVisible) { - if (hass.demo) { - return "/demo/webcam.jpg"; - } - if (stateObj && isVisible) { - return ( - "/api/camera_proxy_stream/" + - stateObj.entity_id + - "?token=" + - stateObj.attributes.access_token - ); - } - // Return an empty image if no stateObj (= dialog not open) or in cleanup mode. - return emptyImageBase64; - } -} - -customElements.define("more-info-camera", MoreInfoCamera); diff --git a/src/dialogs/more-info/controls/more-info-camera.ts b/src/dialogs/more-info/controls/more-info-camera.ts new file mode 100644 index 0000000000..a7a7097997 --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-camera.ts @@ -0,0 +1,113 @@ +import { property, UpdatingElement, PropertyValues } 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 } from "../../../data/camera"; + +type HLSModule = typeof import("hls.js"); + +class MoreInfoCamera extends UpdatingElement { + @property() public hass?: HomeAssistant; + @property() public stateObj?: CameraEntity; + private _mode: "loading" | "hls" | "mjpeg" = "loading"; + + public disconnectedCallback() { + super.disconnectedCallback(); + this._teardownPlayback(); + } + + protected updated(changedProps: PropertyValues) { + if (!changedProps.has("stateObj")) { + return; + } + + const oldState = changedProps.get("stateObj") as this["stateObj"]; + const oldEntityId = oldState ? oldState.entity_id : undefined; + const curEntityId = this.stateObj ? this.stateObj.entity_id : undefined; + + // Same entity, ignore. + if (curEntityId === oldEntityId) { + 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 { + if (!this.stateObj) { + return; + } + // tslint:disable-next-line + const Hls = ((await import("hls.js")) as any).default as HLSModule; + + if (Hls.isSupported()) { + try { + const { url } = await fetchStreamUrl( + this.hass!, + this.stateObj.entity_id + ); + this._renderHLS(Hls, url); + return; + } catch (err) { + // Fails if entity doesn't support it. In that case we go + // for mjpeg. + } + } + + this._renderMJPEG(); + } + + private async _renderHLS( + // tslint:disable-next-line + Hls: HLSModule, + url: string + ) { + const videoEl = document.createElement("video"); + videoEl.style.width = "100%"; + videoEl.autoplay = true; + videoEl.controls = true; + videoEl.muted = true; + const hls = new Hls(); + await new Promise((resolve) => { + hls.on(Hls.Events.MEDIA_ATTACHED, resolve); + hls.attachMedia(videoEl); + }); + hls.loadSource(url); + this.appendChild(videoEl); + videoEl.addEventListener("loadeddata", () => + fireEvent(this, "iron-resize") + ); + } + + private _renderMJPEG() { + this._mode = "mjpeg"; + 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.appendChild(img); + } + + private _teardownPlayback(): any { + if (this._mode === "hls") { + // do something + } + this._mode = "loading"; + while (this.lastChild) { + this.removeChild(this.lastChild); + } + } +} + +customElements.define("more-info-camera", MoreInfoCamera); diff --git a/src/types.ts b/src/types.ts index bcefb5418a..9666032908 100644 --- a/src/types.ts +++ b/src/types.ts @@ -200,6 +200,15 @@ export type GroupEntity = HassEntityBase & { }; }; +export type CameraEntity = HassEntityBase & { + attributes: HassEntityAttributeBase & { + model_name: string; + access_token: string; + brand: string; + motion_detection: boolean; + }; +}; + export interface PanelInfo { component_name: string; icon?: string; diff --git a/yarn.lock b/yarn.lock index a7955d8337..35e77887b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1690,6 +1690,11 @@ "@types/node" "*" "@types/vinyl" "*" +"@types/hls.js@^0.12.2": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/hls.js/-/hls.js-0.12.2.tgz#e16293a2b1cf4e975fad55a65621764a539f9657" + integrity sha512-VXLfVlZYlKWfsdsU2lo7rzwrKBTJj+DFdPIUyF84uTePbYXd30Gx0K6g62kzOPvojgYF/bMLkk1VDXHDb1X+VA== + "@types/html-minifier@^3.5.1": version "3.5.2" resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.2.tgz#f897a13d847a774e9b6fd91497e9b0e0ead71c35" @@ -5706,7 +5711,7 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= -eventemitter3@^3.0.0: +eventemitter3@3.1.0, eventemitter3@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== @@ -7203,6 +7208,14 @@ he@1.2.x: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hls.js@^0.12.3: + version "0.12.3" + resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-0.12.3.tgz#6743456fa443ed6050ab2888083e4b75c39b396f" + integrity sha512-tNvH/LIQzjLIXSI1AaAFYDLKxJKKKnE/rqCcFr76Ez6fVpMczWe65pI7qlYxFQC+urVhz9JokJYmeZod6d+5JA== + dependencies: + eventemitter3 "3.1.0" + url-toolkit "^2.1.6" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -13634,6 +13647,11 @@ url-to-options@^1.0.1: resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k= +url-toolkit@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.1.6.tgz#6d03246499e519aad224c44044a4ae20544154f2" + integrity sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw== + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"