Split live providers into chunks.

This commit is contained in:
Dermot Duffy
2022-10-09 08:39:04 -07:00
parent 4dbde2895f
commit 0d09286c8c
13 changed files with 642 additions and 576 deletions
+1 -1
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+114
View File
@@ -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;
}
}
+262
View File
@@ -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;
}
}
+202
View File
@@ -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;
+4 -1
View File
@@ -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).
+8 -6
View File
@@ -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 (
+7 -1
View File
@@ -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.
+1 -1
View File
@@ -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 -1
View File
@@ -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,