Split live providers into chunks.
This commit is contained in:
@@ -35,4 +35,4 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: frigate-hass-card
|
name: frigate-hass-card
|
||||||
path: dist/frigate-hass-card.js
|
path: dist/*.js
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -18,14 +19,14 @@ jobs:
|
|||||||
yarn install
|
yarn install
|
||||||
yarn run build
|
yarn run build
|
||||||
|
|
||||||
# Upload build file to the releas as an asset.
|
# Upload build file to the release as an asset.
|
||||||
- name: Upload zip to release
|
- name: Upload zip to release
|
||||||
uses: svenstaro/upload-release-action@v1-release
|
uses: svenstaro/upload-release-action@v1-release
|
||||||
|
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
file: dist/frigate-hass-card.js
|
file: dist/*.js
|
||||||
asset_name: frigate-hass-card.js
|
file_glob: true
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -65,8 +65,10 @@ const plugins = [
|
|||||||
*/
|
*/
|
||||||
const config = {
|
const config = {
|
||||||
input: 'src/card.ts',
|
input: 'src/card.ts',
|
||||||
|
preserveEntrySignatures: false,
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/frigate-hass-card.js',
|
entryFileNames: 'frigate-hass-card.js',
|
||||||
|
dir: 'dist',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
...(dev && {
|
...(dev && {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ import { FrigateCardElements } from './components/elements.js';
|
|||||||
import './components/gallery.js';
|
import './components/gallery.js';
|
||||||
import './components/image.js';
|
import './components/image.js';
|
||||||
import { FrigateCardImage } from './components/image.js';
|
import { FrigateCardImage } from './components/image.js';
|
||||||
import './components/live.js';
|
import './components/live/live.js';
|
||||||
import { FrigateCardLive } from './components/live.js';
|
import type { FrigateCardLive } from './components/live/live.js';
|
||||||
import './components/menu.js';
|
import './components/menu.js';
|
||||||
import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js';
|
import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js';
|
||||||
import './components/message.js';
|
import './components/message.js';
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { localize } from '../../localize/localize.js';
|
||||||
|
import liveFrigateStyle from '../../scss/live-frigate.scss';
|
||||||
|
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
||||||
|
import { getCameraTitle } from '../../utils/camera.js';
|
||||||
|
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
||||||
|
import '../../patches/ha-camera-stream';
|
||||||
|
|
||||||
|
@customElement('frigate-card-live-ha')
|
||||||
|
export class FrigateCardLiveHA extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
|
protected _playerRef: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the video.
|
||||||
|
*/
|
||||||
|
public play(): void {
|
||||||
|
this._playerRef.value?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the video.
|
||||||
|
*/
|
||||||
|
public pause(): void {
|
||||||
|
this._playerRef.value?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mute the video.
|
||||||
|
*/
|
||||||
|
public mute(): void {
|
||||||
|
this._playerRef.value?.mute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmute the video.
|
||||||
|
*/
|
||||||
|
public unmute(): void {
|
||||||
|
this._playerRef.value?.unmute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
this._playerRef.value?.seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master render method.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!this.hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.cameraConfig?.camera_entity) {
|
||||||
|
return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), {
|
||||||
|
context: this.cameraConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
|
||||||
|
if (!stateObj) {
|
||||||
|
return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), {
|
||||||
|
context: this.cameraConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateObj.state === 'unavailable') {
|
||||||
|
// Don't treat state unavailability as an error per se.
|
||||||
|
return dispatchMessageEvent(
|
||||||
|
this,
|
||||||
|
localize('error.live_camera_unavailable'),
|
||||||
|
'info',
|
||||||
|
{
|
||||||
|
icon: 'mdi:connection',
|
||||||
|
context: getCameraTitle(this.hass, this.cameraConfig),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html` <frigate-card-ha-camera-stream
|
||||||
|
${ref(this._playerRef)}
|
||||||
|
.hass=${this.hass}
|
||||||
|
.stateObj=${stateObj}
|
||||||
|
.controls=${true}
|
||||||
|
.muted=${true}
|
||||||
|
>
|
||||||
|
</frigate-card-ha-camera-stream>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveFrigateStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'frigate-card-live-ha': FrigateCardLiveHA;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||||
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { until } from 'lit/directives/until.js';
|
||||||
|
import { renderProgressIndicator } from '../../components/message.js';
|
||||||
|
import { localize } from '../../localize/localize.js';
|
||||||
|
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
|
||||||
|
import {
|
||||||
|
CameraConfig,
|
||||||
|
CardWideConfig,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
JSMPEGConfig,
|
||||||
|
} from '../../types.js';
|
||||||
|
import { homeAssistantSignPath } from '../../utils/ha';
|
||||||
|
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
|
||||||
|
import { dispatchErrorMessageEvent } from '../message.js';
|
||||||
|
import { contentsChanged, errorToConsole } from '../../utils/basic.js';
|
||||||
|
|
||||||
|
// Number of seconds a signed URL is valid for.
|
||||||
|
const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
// Number of seconds before the expiry to trigger a refresh.
|
||||||
|
const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
|
||||||
|
|
||||||
|
@customElement('frigate-card-live-jsmpeg')
|
||||||
|
export class FrigateCardLiveJSMPEG extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public jsmpegConfig?: JSMPEGConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
protected hass?: ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
protected _jsmpegCanvasElement?: HTMLCanvasElement;
|
||||||
|
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
||||||
|
protected _refreshPlayerTimerID?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the video.
|
||||||
|
*/
|
||||||
|
public play(): void {
|
||||||
|
this._jsmpegVideoPlayer?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the video.
|
||||||
|
*/
|
||||||
|
public pause(): void {
|
||||||
|
this._jsmpegVideoPlayer?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mute the video (included for completeness, JSMPEG live disables audio as
|
||||||
|
* Frigate does not encode it).
|
||||||
|
*/
|
||||||
|
public mute(): void {
|
||||||
|
const player = this._jsmpegVideoPlayer?.player;
|
||||||
|
if (player) {
|
||||||
|
player.volume = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmute the video (included for completeness, JSMPEG live disables audio as
|
||||||
|
* Frigate does not encode it).
|
||||||
|
*/
|
||||||
|
public unmute(): void {
|
||||||
|
const player = this._jsmpegVideoPlayer?.player;
|
||||||
|
if (player) {
|
||||||
|
player.volume = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video (unsupported).
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public seek(_seconds: number): void {
|
||||||
|
// JSMPEG does not support seeking.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed player URL.
|
||||||
|
* @returns A URL or null.
|
||||||
|
*/
|
||||||
|
protected async _getURL(): Promise<string | null> {
|
||||||
|
if (
|
||||||
|
!this.hass ||
|
||||||
|
!this.cameraConfig?.frigate.client_id ||
|
||||||
|
!this.cameraConfig?.frigate.camera_name
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: string | null | undefined;
|
||||||
|
try {
|
||||||
|
response = await homeAssistantSignPath(
|
||||||
|
this.hass,
|
||||||
|
`/api/frigate/${this.cameraConfig.frigate.client_id}` +
|
||||||
|
`/jsmpeg/${this.cameraConfig.frigate.camera_name}`,
|
||||||
|
URL_SIGN_EXPIRY_SECONDS,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e as Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.replace(/^http/i, 'ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JSMPEG player.
|
||||||
|
* @param url The URL for the player to connect to.
|
||||||
|
* @returns A JSMPEG player.
|
||||||
|
*/
|
||||||
|
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
|
||||||
|
return new Promise<JSMpeg.VideoElement>((resolve) => {
|
||||||
|
let videoDecoded = false;
|
||||||
|
const player = new JSMpeg.VideoElement(
|
||||||
|
this,
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
canvas: this._jsmpegCanvasElement,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The media carousel may automatically pause when the browser tab is
|
||||||
|
// inactive, JSMPEG does not need to also do so independently.
|
||||||
|
pauseWhenHidden: false,
|
||||||
|
autoplay: false,
|
||||||
|
protocols: [],
|
||||||
|
audio: false,
|
||||||
|
videoBufferSize: 1024 * 1024 * 4,
|
||||||
|
|
||||||
|
// Override with user-specified options.
|
||||||
|
...this.jsmpegConfig?.options,
|
||||||
|
|
||||||
|
// Don't allow the player to internally reconnect, as it may re-use a
|
||||||
|
// URL with a (newly) invalid signature, e.g. during a Home Assistant
|
||||||
|
// restart.
|
||||||
|
reconnectInterval: 0,
|
||||||
|
onVideoDecode: () => {
|
||||||
|
// This is the only callback that is called after the dimensions
|
||||||
|
// are available. It's called on every frame decode, so just
|
||||||
|
// ignore any subsequent calls.
|
||||||
|
if (!videoDecoded && this._jsmpegCanvasElement) {
|
||||||
|
videoDecoded = true;
|
||||||
|
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
|
||||||
|
resolve(player);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset / destroy the player.
|
||||||
|
*/
|
||||||
|
protected _resetPlayer(): void {
|
||||||
|
if (this._refreshPlayerTimerID) {
|
||||||
|
window.clearTimeout(this._refreshPlayerTimerID);
|
||||||
|
this._refreshPlayerTimerID = undefined;
|
||||||
|
}
|
||||||
|
if (this._jsmpegVideoPlayer) {
|
||||||
|
try {
|
||||||
|
this._jsmpegVideoPlayer.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
// Pass.
|
||||||
|
}
|
||||||
|
this._jsmpegVideoPlayer = undefined;
|
||||||
|
}
|
||||||
|
if (this._jsmpegCanvasElement) {
|
||||||
|
this._jsmpegCanvasElement.remove();
|
||||||
|
this._jsmpegCanvasElement = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component connected callback.
|
||||||
|
*/
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this.isConnected) {
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component disconnected callback.
|
||||||
|
*/
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this._resetPlayer();
|
||||||
|
}
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the JSMPEG player.
|
||||||
|
*/
|
||||||
|
protected async _refreshPlayer(): Promise<void> {
|
||||||
|
this._resetPlayer();
|
||||||
|
|
||||||
|
this._jsmpegCanvasElement = document.createElement('canvas');
|
||||||
|
this._jsmpegCanvasElement.className = 'media';
|
||||||
|
|
||||||
|
if (!this.cameraConfig?.frigate.camera_name) {
|
||||||
|
return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
|
||||||
|
context: this.cameraConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await this._getURL();
|
||||||
|
if (url) {
|
||||||
|
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url);
|
||||||
|
this._refreshPlayerTimerID = window.setTimeout(() => {
|
||||||
|
this.requestUpdate();
|
||||||
|
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||||
|
} else {
|
||||||
|
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master render method.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
const _render = async (): Promise<TemplateResult | void> => {
|
||||||
|
await this._refreshPlayer();
|
||||||
|
|
||||||
|
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
||||||
|
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
|
||||||
|
}
|
||||||
|
return html`${this._jsmpegCanvasElement}`;
|
||||||
|
};
|
||||||
|
return html`${until(
|
||||||
|
_render(),
|
||||||
|
renderProgressIndicator({
|
||||||
|
cardWideConfig: this.cardWideConfig,
|
||||||
|
}),
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveJSMPEGStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { Task } from '@lit-labs/task';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { localize } from '../../localize/localize.js';
|
||||||
|
import liveWebRTCStyle from '../../scss/live-webrtc.scss';
|
||||||
|
import {
|
||||||
|
CameraConfig,
|
||||||
|
CardWideConfig,
|
||||||
|
FrigateCardError,
|
||||||
|
WebRTCCardConfig,
|
||||||
|
} from '../../types.js';
|
||||||
|
import { contentsChanged } from '../../utils/basic.js';
|
||||||
|
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
|
||||||
|
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
||||||
|
import { renderTask } from '../../utils/task.js';
|
||||||
|
|
||||||
|
// Create a wrapper for AlexxIT's WebRTC card
|
||||||
|
// - https://github.com/AlexxIT/WebRTC
|
||||||
|
@customElement('frigate-card-live-webrtc-card')
|
||||||
|
export class FrigateCardLiveWebRTCCard extends LitElement {
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public webRTCConfig?: WebRTCCardConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cameraConfig?: CameraConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
protected hass?: HomeAssistant;
|
||||||
|
|
||||||
|
// A task to await the load of the WebRTC component.
|
||||||
|
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the video.
|
||||||
|
*/
|
||||||
|
public play(): void {
|
||||||
|
this._getPlayer()
|
||||||
|
?.play()
|
||||||
|
.catch(() => {
|
||||||
|
// WebRTC appears to generate additional spurious load events, which may
|
||||||
|
// result in loads after a play() call, which causes the browser to spam
|
||||||
|
// the logs unless the promise rejection is handled here.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the video.
|
||||||
|
*/
|
||||||
|
public pause(): void {
|
||||||
|
this._getPlayer()?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mute the video.
|
||||||
|
*/
|
||||||
|
public mute(): void {
|
||||||
|
const player = this._getPlayer();
|
||||||
|
if (player) {
|
||||||
|
player.muted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmute the video.
|
||||||
|
*/
|
||||||
|
public unmute(): void {
|
||||||
|
const player = this._getPlayer();
|
||||||
|
if (player) {
|
||||||
|
player.muted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek the video.
|
||||||
|
*/
|
||||||
|
public seek(seconds: number): void {
|
||||||
|
const player = this._getPlayer();
|
||||||
|
if (player) {
|
||||||
|
player.currentTime = seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying video player.
|
||||||
|
* @returns The player or `null` if not found.
|
||||||
|
*/
|
||||||
|
protected _getPlayer(): HTMLVideoElement | null {
|
||||||
|
return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _getWebRTCCardElement(): Promise<
|
||||||
|
CustomElementConstructor | undefined
|
||||||
|
> {
|
||||||
|
await customElements.whenDefined('webrtc-camera');
|
||||||
|
return customElements.get('webrtc-camera');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the WebRTC element. May throw.
|
||||||
|
*/
|
||||||
|
protected _createWebRTC(): HTMLElement | null {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const webrtcElement = this._webrtcTask.value;
|
||||||
|
if (webrtcElement && this.hass) {
|
||||||
|
const webrtc = new webrtcElement() as HTMLElement & {
|
||||||
|
hass: HomeAssistant;
|
||||||
|
setConfig: (config: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
const config = { ...this.webRTCConfig };
|
||||||
|
|
||||||
|
// If the live WebRTC configuration does not specify a URL/entity to use,
|
||||||
|
// then take values from the camera configuration instead (if there are
|
||||||
|
// any).
|
||||||
|
if (!config.url) {
|
||||||
|
config.url = this.cameraConfig?.webrtc_card?.url;
|
||||||
|
}
|
||||||
|
if (!config.entity) {
|
||||||
|
config.entity = this.cameraConfig?.webrtc_card?.entity;
|
||||||
|
}
|
||||||
|
webrtc.setConfig(config);
|
||||||
|
webrtc.hass = this.hass;
|
||||||
|
return webrtc;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master render method.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
const render = (): TemplateResult | void => {
|
||||||
|
let webrtcElement: HTMLElement | null;
|
||||||
|
try {
|
||||||
|
webrtcElement = this._createWebRTC();
|
||||||
|
} catch (e) {
|
||||||
|
return dispatchErrorMessageEvent(
|
||||||
|
this,
|
||||||
|
e instanceof FrigateCardError
|
||||||
|
? e.message
|
||||||
|
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||||
|
{ context: (e as FrigateCardError).context },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (webrtcElement) {
|
||||||
|
// Set the id to ensure that the relevant CSS styles will have
|
||||||
|
// sufficient specifity to overcome some styles that are otherwise
|
||||||
|
// applied to <ha-card> in Safari.
|
||||||
|
webrtcElement.id = 'webrtc';
|
||||||
|
}
|
||||||
|
return html`${webrtcElement}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use a task to allow us to asynchronously wait for the WebRTC card to
|
||||||
|
// load, but yet still have the card load be followed by the updated()
|
||||||
|
// lifecycle callback (unlike just using `until`).
|
||||||
|
return renderTask(this, this._webrtcTask, render, {
|
||||||
|
inProgressFunc: () =>
|
||||||
|
renderProgressIndicator({
|
||||||
|
message: localize('error.webrtc_card_waiting'),
|
||||||
|
cardWideConfig: this.cardWideConfig,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updated lifecycle callback.
|
||||||
|
*/
|
||||||
|
public updated(): void {
|
||||||
|
// Extract the video component after it has been rendered and generate the
|
||||||
|
// media load event.
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
const video = this._getPlayer();
|
||||||
|
if (video) {
|
||||||
|
const onloadeddata = video.onloadeddata;
|
||||||
|
|
||||||
|
video.onloadeddata = (e) => {
|
||||||
|
if (onloadeddata) {
|
||||||
|
onloadeddata.call(video, e);
|
||||||
|
}
|
||||||
|
dispatchMediaLoadedEvent(this, video);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveWebRTCStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
|
||||||
import { Task } from '@lit-labs/task';
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
|
||||||
import { EmblaOptionsType } from 'embla-carousel';
|
import { EmblaOptionsType } from 'embla-carousel';
|
||||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||||
import {
|
import {
|
||||||
@@ -15,67 +12,49 @@ import { customElement, property, state } from 'lit/decorators.js';
|
|||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { until } from 'lit/directives/until.js';
|
import { ConditionState, getOverriddenConfig } from '../../card-condition.js';
|
||||||
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
import liveStyle from '../../scss/live.scss';
|
||||||
import { localize } from '../localize/localize.js';
|
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import liveFrigateStyle from '../scss/live-frigate.scss';
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||||
import liveJSMPEGStyle from '../scss/live-jsmpeg.scss';
|
|
||||||
import liveWebRTCStyle from '../scss/live-webrtc.scss';
|
|
||||||
import liveStyle from '../scss/live.scss';
|
|
||||||
import liveCarouselStyle from '../scss/live-carousel.scss';
|
|
||||||
import liveProviderStyle from '../scss/live-provider.scss';
|
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
frigateCardConfigDefaults,
|
frigateCardConfigDefaults,
|
||||||
FrigateCardError,
|
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
JSMPEGConfig,
|
|
||||||
LiveConfig,
|
LiveConfig,
|
||||||
LiveOverrides,
|
LiveOverrides,
|
||||||
LiveProvider,
|
LiveProvider,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
Message,
|
Message,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
WebRTCCardConfig,
|
} from '../../types.js';
|
||||||
} from '../types.js';
|
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
import { contentsChanged } from '../../utils/basic.js';
|
||||||
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
import { getCameraIcon, getCameraTitle } from '../../utils/camera.js';
|
||||||
import { getCameraIcon, getCameraTitle } from '../utils/camera.js';
|
import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js';
|
||||||
import { homeAssistantSignPath } from '../utils/ha';
|
|
||||||
import { getFullDependentBrowseMediaQueryParameters } from '../utils/ha/browse-media.js';
|
|
||||||
import {
|
import {
|
||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
dispatchMediaLoadedEvent,
|
|
||||||
dispatchMediaUnloadedEvent,
|
dispatchMediaUnloadedEvent,
|
||||||
} from '../utils/media-info.js';
|
} from '../../utils/media-info.js';
|
||||||
import { dispatchViewContextChangeEvent, View } from '../view.js';
|
import { dispatchViewContextChangeEvent, View } from '../../view.js';
|
||||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||||
import {
|
import {
|
||||||
FrigateCardMediaCarousel,
|
FrigateCardMediaCarousel,
|
||||||
wrapMediaLoadedEventForCarousel,
|
wrapMediaLoadedEventForCarousel,
|
||||||
wrapMediaUnloadedEventForCarousel,
|
wrapMediaUnloadedEventForCarousel,
|
||||||
} from './media-carousel.js';
|
} from '../media-carousel.js';
|
||||||
import { dispatchErrorMessageEvent } from './message.js';
|
import '../next-prev-control.js';
|
||||||
import './next-prev-control.js';
|
import '../title-control.js';
|
||||||
import './title-control.js';
|
import '../surround.js';
|
||||||
import './surround.js';
|
import '../../patches/ha-camera-stream';
|
||||||
import '../patches/ha-camera-stream';
|
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||||
import { EmblaCarouselPlugins } from './carousel.js';
|
|
||||||
import { renderTask } from '../utils/task.js';
|
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import './image';
|
import '../image';
|
||||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||||
import { DataManager } from '../utils/data-manager.js';
|
import { DataManager } from '../../utils/data-manager.js';
|
||||||
|
|
||||||
// Number of seconds a signed URL is valid for.
|
|
||||||
const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
|
||||||
|
|
||||||
// Number of seconds before the expiry to trigger a refresh.
|
|
||||||
const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
|
|
||||||
|
|
||||||
@customElement('frigate-card-live')
|
@customElement('frigate-card-live')
|
||||||
export class FrigateCardLive extends LitElement {
|
export class FrigateCardLive extends LitElement {
|
||||||
@@ -492,7 +471,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
|
|
||||||
const liveProvider = slide?.querySelector(
|
const liveProvider = slide?.querySelector(
|
||||||
'frigate-card-live-provider',
|
'frigate-card-live-provider',
|
||||||
) as FrigateCardLiveProvider;
|
) as FrigateCardLiveProvider | null;
|
||||||
if (liveProvider) {
|
if (liveProvider) {
|
||||||
liveProvider.disabled = action !== 'load';
|
liveProvider.disabled = action !== 'load';
|
||||||
}
|
}
|
||||||
@@ -782,6 +761,15 @@ export class FrigateCardLiveProvider extends LitElement {
|
|||||||
if (changedProps.has('liveConfig')) {
|
if (changedProps.has('liveConfig')) {
|
||||||
updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout);
|
updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout);
|
||||||
}
|
}
|
||||||
|
if (changedProps.has('cameraConfig')) {
|
||||||
|
if (this._getResolvedProvider() === 'frigate-jsmpeg') {
|
||||||
|
import('./live-jsmpeg.js');
|
||||||
|
} else if (this._getResolvedProvider() === 'ha') {
|
||||||
|
import('./live-ha.js');
|
||||||
|
} else if (this._getResolvedProvider() === 'webrtc-card') {
|
||||||
|
import('./live-webrtc.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -856,522 +844,8 @@ export class FrigateCardLiveProvider extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-live-ha')
|
|
||||||
export class FrigateCardLiveFrigate extends LitElement {
|
|
||||||
@property({ attribute: false })
|
|
||||||
public hass?: HomeAssistant;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameraConfig?: CameraConfig;
|
|
||||||
|
|
||||||
protected _playerRef: Ref<FrigateCardLiveFrigate> = createRef();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play the video.
|
|
||||||
*/
|
|
||||||
public play(): void {
|
|
||||||
this._playerRef.value?.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause the video.
|
|
||||||
*/
|
|
||||||
public pause(): void {
|
|
||||||
this._playerRef.value?.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mute the video.
|
|
||||||
*/
|
|
||||||
public mute(): void {
|
|
||||||
this._playerRef.value?.mute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmute the video.
|
|
||||||
*/
|
|
||||||
public unmute(): void {
|
|
||||||
this._playerRef.value?.unmute();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek the video.
|
|
||||||
*/
|
|
||||||
public seek(seconds: number): void {
|
|
||||||
this._playerRef.value?.seek(seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
|
||||||
if (!this.hass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.cameraConfig?.camera_entity) {
|
|
||||||
return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), {
|
|
||||||
context: this.cameraConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
|
|
||||||
if (!stateObj) {
|
|
||||||
return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), {
|
|
||||||
context: this.cameraConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateObj.state === 'unavailable') {
|
|
||||||
// Don't treat state unavailability as an error per se.
|
|
||||||
return dispatchMessageEvent(
|
|
||||||
this,
|
|
||||||
localize('error.live_camera_unavailable'),
|
|
||||||
'info',
|
|
||||||
{
|
|
||||||
icon: 'mdi:connection',
|
|
||||||
context: getCameraTitle(this.hass, this.cameraConfig),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return html` <frigate-card-ha-camera-stream
|
|
||||||
${ref(this._playerRef)}
|
|
||||||
.hass=${this.hass}
|
|
||||||
.stateObj=${stateObj}
|
|
||||||
.controls=${true}
|
|
||||||
.muted=${true}
|
|
||||||
>
|
|
||||||
</frigate-card-ha-camera-stream>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return unsafeCSS(liveFrigateStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a wrapper for AlexxIT's WebRTC card
|
|
||||||
// - https://github.com/AlexxIT/WebRTC
|
|
||||||
@customElement('frigate-card-live-webrtc-card')
|
|
||||||
export class FrigateCardLiveWebRTCCard extends LitElement {
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
|
||||||
public webRTCConfig?: WebRTCCardConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameraConfig?: CameraConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cardWideConfig?: CardWideConfig;
|
|
||||||
|
|
||||||
protected hass?: HomeAssistant;
|
|
||||||
|
|
||||||
// A task to await the load of the WebRTC component.
|
|
||||||
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play the video.
|
|
||||||
*/
|
|
||||||
public play(): void {
|
|
||||||
this._getPlayer()
|
|
||||||
?.play()
|
|
||||||
.catch(() => {
|
|
||||||
// WebRTC appears to generate additional spurious load events, which may
|
|
||||||
// result in loads after a play() call, which causes the browser to spam
|
|
||||||
// the logs unless the promise rejection is handled here.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause the video.
|
|
||||||
*/
|
|
||||||
public pause(): void {
|
|
||||||
this._getPlayer()?.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mute the video.
|
|
||||||
*/
|
|
||||||
public mute(): void {
|
|
||||||
const player = this._getPlayer();
|
|
||||||
if (player) {
|
|
||||||
player.muted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmute the video.
|
|
||||||
*/
|
|
||||||
public unmute(): void {
|
|
||||||
const player = this._getPlayer();
|
|
||||||
if (player) {
|
|
||||||
player.muted = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek the video.
|
|
||||||
*/
|
|
||||||
public seek(seconds: number): void {
|
|
||||||
const player = this._getPlayer();
|
|
||||||
if (player) {
|
|
||||||
player.currentTime = seconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the underlying video player.
|
|
||||||
* @returns The player or `null` if not found.
|
|
||||||
*/
|
|
||||||
protected _getPlayer(): HTMLVideoElement | null {
|
|
||||||
return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async _getWebRTCCardElement(): Promise<
|
|
||||||
CustomElementConstructor | undefined
|
|
||||||
> {
|
|
||||||
await customElements.whenDefined('webrtc-camera');
|
|
||||||
return customElements.get('webrtc-camera');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the WebRTC element. May throw.
|
|
||||||
*/
|
|
||||||
protected _createWebRTC(): HTMLElement | null {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const webrtcElement = this._webrtcTask.value;
|
|
||||||
if (webrtcElement && this.hass) {
|
|
||||||
const webrtc = new webrtcElement() as HTMLElement & {
|
|
||||||
hass: HomeAssistant;
|
|
||||||
setConfig: (config: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
const config = { ...this.webRTCConfig };
|
|
||||||
|
|
||||||
// If the live WebRTC configuration does not specify a URL/entity to use,
|
|
||||||
// then take values from the camera configuration instead (if there are
|
|
||||||
// any).
|
|
||||||
if (!config.url) {
|
|
||||||
config.url = this.cameraConfig?.webrtc_card?.url;
|
|
||||||
}
|
|
||||||
if (!config.entity) {
|
|
||||||
config.entity = this.cameraConfig?.webrtc_card?.entity;
|
|
||||||
}
|
|
||||||
webrtc.setConfig(config);
|
|
||||||
webrtc.hass = this.hass;
|
|
||||||
return webrtc;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
|
||||||
const render = (): TemplateResult | void => {
|
|
||||||
let webrtcElement: HTMLElement | null;
|
|
||||||
try {
|
|
||||||
webrtcElement = this._createWebRTC();
|
|
||||||
} catch (e) {
|
|
||||||
return dispatchErrorMessageEvent(
|
|
||||||
this,
|
|
||||||
e instanceof FrigateCardError
|
|
||||||
? e.message
|
|
||||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
|
||||||
{ context: (e as FrigateCardError).context },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (webrtcElement) {
|
|
||||||
// Set the id to ensure that the relevant CSS styles will have
|
|
||||||
// sufficient specifity to overcome some styles that are otherwise
|
|
||||||
// applied to <ha-card> in Safari.
|
|
||||||
webrtcElement.id = 'webrtc';
|
|
||||||
}
|
|
||||||
return html`${webrtcElement}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use a task to allow us to asynchronously wait for the WebRTC card to
|
|
||||||
// load, but yet still have the card load be followed by the updated()
|
|
||||||
// lifecycle callback (unlike just using `until`).
|
|
||||||
return renderTask(this, this._webrtcTask, render, {
|
|
||||||
inProgressFunc: () =>
|
|
||||||
renderProgressIndicator({
|
|
||||||
message: localize('error.webrtc_card_waiting'),
|
|
||||||
cardWideConfig: this.cardWideConfig,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updated lifecycle callback.
|
|
||||||
*/
|
|
||||||
public updated(): void {
|
|
||||||
// Extract the video component after it has been rendered and generate the
|
|
||||||
// media load event.
|
|
||||||
this.updateComplete.then(() => {
|
|
||||||
const video = this._getPlayer();
|
|
||||||
if (video) {
|
|
||||||
const onloadeddata = video.onloadeddata;
|
|
||||||
|
|
||||||
video.onloadeddata = (e) => {
|
|
||||||
if (onloadeddata) {
|
|
||||||
onloadeddata.call(video, e);
|
|
||||||
}
|
|
||||||
dispatchMediaLoadedEvent(this, video);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return unsafeCSS(liveWebRTCStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement('frigate-card-live-jsmpeg')
|
|
||||||
export class FrigateCardLiveJSMPEG extends LitElement {
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameraConfig?: CameraConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
|
||||||
public jsmpegConfig?: JSMPEGConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cardWideConfig?: CardWideConfig;
|
|
||||||
|
|
||||||
protected hass?: ExtendedHomeAssistant;
|
|
||||||
|
|
||||||
protected _jsmpegCanvasElement?: HTMLCanvasElement;
|
|
||||||
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
|
||||||
protected _refreshPlayerTimerID?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Play the video.
|
|
||||||
*/
|
|
||||||
public play(): void {
|
|
||||||
this._jsmpegVideoPlayer?.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause the video.
|
|
||||||
*/
|
|
||||||
public pause(): void {
|
|
||||||
this._jsmpegVideoPlayer?.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mute the video (included for completeness, JSMPEG live disables audio as
|
|
||||||
* Frigate does not encode it).
|
|
||||||
*/
|
|
||||||
public mute(): void {
|
|
||||||
const player = this._jsmpegVideoPlayer?.player;
|
|
||||||
if (player) {
|
|
||||||
player.volume = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmute the video (included for completeness, JSMPEG live disables audio as
|
|
||||||
* Frigate does not encode it).
|
|
||||||
*/
|
|
||||||
public unmute(): void {
|
|
||||||
const player = this._jsmpegVideoPlayer?.player;
|
|
||||||
if (player) {
|
|
||||||
player.volume = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seek the video (unsupported).
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
public seek(_seconds: number): void {
|
|
||||||
// JSMPEG does not support seeking.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a signed player URL.
|
|
||||||
* @returns A URL or null.
|
|
||||||
*/
|
|
||||||
protected async _getURL(): Promise<string | null> {
|
|
||||||
if (
|
|
||||||
!this.hass ||
|
|
||||||
!this.cameraConfig?.frigate.client_id ||
|
|
||||||
!this.cameraConfig?.frigate.camera_name
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: string | null | undefined;
|
|
||||||
try {
|
|
||||||
response = await homeAssistantSignPath(
|
|
||||||
this.hass,
|
|
||||||
`/api/frigate/${this.cameraConfig.frigate.client_id}` +
|
|
||||||
`/jsmpeg/${this.cameraConfig.frigate.camera_name}`,
|
|
||||||
URL_SIGN_EXPIRY_SECONDS,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return response.replace(/^http/i, 'ws');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a JSMPEG player.
|
|
||||||
* @param url The URL for the player to connect to.
|
|
||||||
* @returns A JSMPEG player.
|
|
||||||
*/
|
|
||||||
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
|
|
||||||
return new Promise<JSMpeg.VideoElement>((resolve) => {
|
|
||||||
let videoDecoded = false;
|
|
||||||
const player = new JSMpeg.VideoElement(
|
|
||||||
this,
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
canvas: this._jsmpegCanvasElement,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The media carousel may automatically pause when the browser tab is
|
|
||||||
// inactive, JSMPEG does not need to also do so independently.
|
|
||||||
pauseWhenHidden: false,
|
|
||||||
autoplay: false,
|
|
||||||
protocols: [],
|
|
||||||
audio: false,
|
|
||||||
videoBufferSize: 1024 * 1024 * 4,
|
|
||||||
|
|
||||||
// Override with user-specified options.
|
|
||||||
...this.jsmpegConfig?.options,
|
|
||||||
|
|
||||||
// Don't allow the player to internally reconnect, as it may re-use a
|
|
||||||
// URL with a (newly) invalid signature, e.g. during a Home Assistant
|
|
||||||
// restart.
|
|
||||||
reconnectInterval: 0,
|
|
||||||
onVideoDecode: () => {
|
|
||||||
// This is the only callback that is called after the dimensions
|
|
||||||
// are available. It's called on every frame decode, so just
|
|
||||||
// ignore any subsequent calls.
|
|
||||||
if (!videoDecoded && this._jsmpegCanvasElement) {
|
|
||||||
videoDecoded = true;
|
|
||||||
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
|
|
||||||
resolve(player);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset / destroy the player.
|
|
||||||
*/
|
|
||||||
protected _resetPlayer(): void {
|
|
||||||
if (this._refreshPlayerTimerID) {
|
|
||||||
window.clearTimeout(this._refreshPlayerTimerID);
|
|
||||||
this._refreshPlayerTimerID = undefined;
|
|
||||||
}
|
|
||||||
if (this._jsmpegVideoPlayer) {
|
|
||||||
try {
|
|
||||||
this._jsmpegVideoPlayer.destroy();
|
|
||||||
} catch (err) {
|
|
||||||
// Pass.
|
|
||||||
}
|
|
||||||
this._jsmpegVideoPlayer = undefined;
|
|
||||||
}
|
|
||||||
if (this._jsmpegCanvasElement) {
|
|
||||||
this._jsmpegCanvasElement.remove();
|
|
||||||
this._jsmpegCanvasElement = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component connected callback.
|
|
||||||
*/
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
if (this.isConnected) {
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component disconnected callback.
|
|
||||||
*/
|
|
||||||
disconnectedCallback(): void {
|
|
||||||
if (!this.isConnected) {
|
|
||||||
this._resetPlayer();
|
|
||||||
}
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the JSMPEG player.
|
|
||||||
*/
|
|
||||||
protected async _refreshPlayer(): Promise<void> {
|
|
||||||
this._resetPlayer();
|
|
||||||
|
|
||||||
this._jsmpegCanvasElement = document.createElement('canvas');
|
|
||||||
this._jsmpegCanvasElement.className = 'media';
|
|
||||||
|
|
||||||
if (!this.cameraConfig?.frigate.camera_name) {
|
|
||||||
return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
|
|
||||||
context: this.cameraConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await this._getURL();
|
|
||||||
if (url) {
|
|
||||||
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url);
|
|
||||||
this._refreshPlayerTimerID = window.setTimeout(() => {
|
|
||||||
this.requestUpdate();
|
|
||||||
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
|
||||||
} else {
|
|
||||||
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Master render method.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult | void {
|
|
||||||
const _render = async (): Promise<TemplateResult | void> => {
|
|
||||||
await this._refreshPlayer();
|
|
||||||
|
|
||||||
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
|
||||||
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
|
|
||||||
}
|
|
||||||
return html`${this._jsmpegCanvasElement}`;
|
|
||||||
};
|
|
||||||
return html`${until(
|
|
||||||
_render(),
|
|
||||||
renderProgressIndicator({
|
|
||||||
cardWideConfig: this.cardWideConfig,
|
|
||||||
}),
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return unsafeCSS(liveJSMPEGStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG;
|
|
||||||
'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard;
|
|
||||||
'frigate-card-live-ha': FrigateCardLiveFrigate;
|
|
||||||
'frigate-card-live-provider': FrigateCardLiveProvider;
|
'frigate-card-live-provider': FrigateCardLiveProvider;
|
||||||
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
||||||
'frigate-card-live': FrigateCardLive;
|
'frigate-card-live': FrigateCardLive;
|
||||||
@@ -29,7 +29,6 @@ import { dispatchFrigateCardErrorEvent } from './message.js';
|
|||||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
|
||||||
import './surround-basic.js';
|
import './surround-basic.js';
|
||||||
import './timeline-core.js';
|
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
|
||||||
interface ThumbnailViewContext {
|
interface ThumbnailViewContext {
|
||||||
@@ -122,6 +121,10 @@ export class FrigateCardSurround extends LitElement {
|
|||||||
* Called before each update.
|
* Called before each update.
|
||||||
*/
|
*/
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
|
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
||||||
|
import('./timeline-core.js');
|
||||||
|
}
|
||||||
|
|
||||||
// Once the component will certainly update, dispatch a media request. Only
|
// Once the component will certainly update, dispatch a media request. Only
|
||||||
// do so if properties relevant to the request have changed (as per their
|
// do so if properties relevant to the request have changed (as per their
|
||||||
// hasChanged).
|
// hasChanged).
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
|||||||
import { isEqual, throttle } from 'lodash-es';
|
import { isEqual, throttle } from 'lodash-es';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import { DataView, DataSet } from 'vis-data/esnext';
|
import { DataView, DataSet } from 'vis-data/esnext';
|
||||||
|
import type { DataGroupCollectionType, IdType } from 'vis-timeline/esnext';
|
||||||
import {
|
import {
|
||||||
DataGroupCollectionType,
|
|
||||||
IdType,
|
|
||||||
Timeline,
|
Timeline,
|
||||||
TimelineEventPropertiesResult,
|
TimelineEventPropertiesResult,
|
||||||
TimelineItem,
|
TimelineItem,
|
||||||
@@ -458,7 +457,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
this.hass,
|
this.hass,
|
||||||
this.dataManager,
|
this.dataManager,
|
||||||
this.cameras,
|
this.cameras,
|
||||||
this.view, {
|
this.view,
|
||||||
|
{
|
||||||
cameraIDs: new Set([String(properties.group)]),
|
cameraIDs: new Set([String(properties.group)]),
|
||||||
targetTime:
|
targetTime:
|
||||||
properties.what === 'background' ? properties.time : window.end,
|
properties.what === 'background' ? properties.time : window.end,
|
||||||
@@ -471,7 +471,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
this.hass,
|
this.hass,
|
||||||
this.dataManager,
|
this.dataManager,
|
||||||
this.cameras,
|
this.cameras,
|
||||||
this.view, {
|
this.view,
|
||||||
|
{
|
||||||
targetTime: window.end,
|
targetTime: window.end,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -483,12 +484,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
this.hass,
|
this.hass,
|
||||||
this.dataManager,
|
this.dataManager,
|
||||||
this.cameras,
|
this.cameras,
|
||||||
this.view, {
|
this.view,
|
||||||
|
{
|
||||||
cameraIDs: this._getAllCameraIDs(),
|
cameraIDs: this._getAllCameraIDs(),
|
||||||
start: startOfHour(properties.time),
|
start: startOfHour(properties.time),
|
||||||
end: endOfHour(properties.time),
|
end: endOfHour(properties.time),
|
||||||
targetTime: properties.time,
|
targetTime: properties.time,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
|||||||
import { DataManager } from '../utils/data-manager';
|
import { DataManager } from '../utils/data-manager';
|
||||||
import { View } from '../view';
|
import { View } from '../view';
|
||||||
import './surround.js';
|
import './surround.js';
|
||||||
import './timeline-core.js';
|
|
||||||
|
|
||||||
// This file is kept separate from timeline-core.ts to avoid a circular dependency:
|
// This file is kept separate from timeline-core.ts to avoid a circular dependency:
|
||||||
// FrigateCardTimeline ->
|
// FrigateCardTimeline ->
|
||||||
@@ -29,6 +28,13 @@ export class FrigateCardTimeline extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public dataManager?: DataManager;
|
public dataManager?: DataManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on first update.
|
||||||
|
*/
|
||||||
|
protected firstUpdated(): void {
|
||||||
|
import('./timeline-core.js');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
|
|||||||
@@ -240,7 +240,7 @@
|
|||||||
"info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides"
|
"info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides"
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"warning": "This card is in low profile mode so many defaults have changed to optimize performance",
|
"warning": "This card is in low profile mode so defaults have changed to optimize performance",
|
||||||
"features": {
|
"features": {
|
||||||
"editor_label": "Feature Options",
|
"editor_label": "Feature Options",
|
||||||
"animated_progress_indicator": "Animated Progress Indicator"
|
"animated_progress_indicator": "Animated Progress Indicator"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { DataSet, DataView } from 'vis-data/esnext';
|
import { DataSet, DataView } from 'vis-data/esnext';
|
||||||
import { IdType, TimelineItem } from 'vis-timeline/esnext';
|
import type { IdType, TimelineItem } from 'vis-timeline/esnext';
|
||||||
import { CAMERA_BIRDSEYE } from '../const.js';
|
import { CAMERA_BIRDSEYE } from '../const.js';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user