Merge pull request #80 from dermotduffy/lit-refactor
Refactor card into multiple separate Lit webcomponents
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
"@babel/plugin-proposal-class-properties": "^7.14.5",
|
||||||
"@babel/plugin-proposal-decorators": "^7.15.4",
|
"@babel/plugin-proposal-decorators": "^7.15.4",
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
|
"@rollup/plugin-multi-entry": "^4.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
||||||
"@typescript-eslint/parser": "^4.30.0",
|
"@typescript-eslint/parser": "^4.30.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
|
|||||||
+4
-2
@@ -6,6 +6,7 @@ import { terser } from 'rollup-plugin-terser';
|
|||||||
import serve from 'rollup-plugin-serve';
|
import serve from 'rollup-plugin-serve';
|
||||||
import json from '@rollup/plugin-json';
|
import json from '@rollup/plugin-json';
|
||||||
import styles from 'rollup-plugin-styles';
|
import styles from 'rollup-plugin-styles';
|
||||||
|
import multi from '@rollup/plugin-multi-entry';
|
||||||
|
|
||||||
const dev = process.env.ROLLUP_WATCH;
|
const dev = process.env.ROLLUP_WATCH;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const serveopts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const plugins = [
|
const plugins = [
|
||||||
|
multi(),
|
||||||
styles({
|
styles({
|
||||||
modules: false,
|
modules: false,
|
||||||
// Behavior of inject mode, without actually injecting style
|
// Behavior of inject mode, without actually injecting style
|
||||||
@@ -42,9 +44,9 @@ const plugins = [
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
input: 'src/frigate-hass-card.ts',
|
input: ['src/card.ts'],
|
||||||
output: {
|
output: {
|
||||||
dir: 'dist',
|
file: 'dist/frigate-hass-card.js',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
},
|
},
|
||||||
plugins: [...plugins],
|
plugins: [...plugins],
|
||||||
|
|||||||
+431
@@ -0,0 +1,431 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import {
|
||||||
|
CSSResultGroup,
|
||||||
|
LitElement,
|
||||||
|
PropertyValues,
|
||||||
|
TemplateResult,
|
||||||
|
html,
|
||||||
|
unsafeCSS,
|
||||||
|
} from 'lit';
|
||||||
|
import { customElement, property, query, state } from 'lit/decorators';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import {
|
||||||
|
HomeAssistant,
|
||||||
|
LovelaceCardEditor,
|
||||||
|
fireEvent,
|
||||||
|
getLovelace,
|
||||||
|
stateIcon,
|
||||||
|
} from 'custom-card-helpers';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MenuButton,
|
||||||
|
frigateCardConfigSchema,
|
||||||
|
} from './types';
|
||||||
|
import type {
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateCardConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
import { CARD_VERSION } from './const';
|
||||||
|
import { FrigateCardMenu } from './components/menu';
|
||||||
|
import { View } from './view';
|
||||||
|
import { getParseErrorKeys } from './common';
|
||||||
|
import { localize } from './localize/localize';
|
||||||
|
|
||||||
|
import './editor';
|
||||||
|
import './components/gallery';
|
||||||
|
import './components/live';
|
||||||
|
import './components/menu';
|
||||||
|
import './components/message';
|
||||||
|
import './components/viewer';
|
||||||
|
|
||||||
|
import cardStyle from './scss/card.scss';
|
||||||
|
|
||||||
|
/* eslint no-console: 0 */
|
||||||
|
console.info(
|
||||||
|
`%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `,
|
||||||
|
'color: pink; font-weight: bold; background: black',
|
||||||
|
'color: white; font-weight: bold; background: dimgray',
|
||||||
|
);
|
||||||
|
|
||||||
|
// This puts your card into the UI card picker dialog
|
||||||
|
(window as any).customCards = (window as any).customCards || [];
|
||||||
|
(window as any).customCards.push({
|
||||||
|
type: 'frigate-card',
|
||||||
|
name: localize('common.frigate_card'),
|
||||||
|
description: localize('common.frigate_card_description'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine whether the card should be updated based on Home Assistant changes.
|
||||||
|
function shouldUpdateBasedOnHass(
|
||||||
|
newHass: HomeAssistant | null,
|
||||||
|
oldHass: HomeAssistant | undefined,
|
||||||
|
entities: string[] | null,
|
||||||
|
): boolean {
|
||||||
|
if (!newHass || !entities) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!entities.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldHass) {
|
||||||
|
for (let i = 0; i < entities.length; i++) {
|
||||||
|
const entity = entities[i];
|
||||||
|
if (!entity) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (oldHass.states[entity] !== newHass.states[entity]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main FrigateCard class.
|
||||||
|
@customElement('frigate-card')
|
||||||
|
export class FrigateCard extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected _hass: (HomeAssistant & ExtendedHomeAssistant) | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
public config!: FrigateCardConfig;
|
||||||
|
|
||||||
|
protected _interactionTimerID: number | null = null;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected _view: View = new View();
|
||||||
|
|
||||||
|
@query('frigate-card-menu')
|
||||||
|
_menu!: FrigateCardMenu;
|
||||||
|
|
||||||
|
// Whether or not there is an active clip being played.
|
||||||
|
protected _clipPlaying = false;
|
||||||
|
|
||||||
|
// A small cache to avoid needing to create a new list of entities every time
|
||||||
|
// a hass update arrives.
|
||||||
|
protected _entitiesToMonitor: string[] | null = null;
|
||||||
|
|
||||||
|
set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
|
||||||
|
this._hass = hass;
|
||||||
|
this._updateMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the configuration element.
|
||||||
|
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||||
|
return document.createElement('frigate-card-editor');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a stub basic config.
|
||||||
|
public static getStubConfig(): Record<string, string> {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _updateMenu(): void {
|
||||||
|
// Manually set hass in the menu. This is to allow the menu to update,
|
||||||
|
// without necessarily re-rendering the entire card (re-rendering interrupts
|
||||||
|
// clip playing).
|
||||||
|
if (!this._menu || !this._hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._menu.buttons = this._getMenuButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getMenuButtons(): Map<string, MenuButton> {
|
||||||
|
const buttons: Map<string, MenuButton> = new Map();
|
||||||
|
|
||||||
|
if (this.config.menu_buttons?.frigate ?? true) {
|
||||||
|
buttons.set('frigate', { description: localize('menu.frigate') });
|
||||||
|
}
|
||||||
|
if (this.config.menu_buttons?.live ?? true) {
|
||||||
|
buttons.set('live', {
|
||||||
|
icon: 'mdi:cctv',
|
||||||
|
description: localize('menu.live'),
|
||||||
|
emphasize: this._view.is('live'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.config.menu_buttons?.clips ?? true) {
|
||||||
|
buttons.set('clips', {
|
||||||
|
icon: 'mdi:filmstrip',
|
||||||
|
description: localize('menu.clips'),
|
||||||
|
emphasize: this._view.is('clips'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.config.menu_buttons?.snapshots ?? true) {
|
||||||
|
buttons.set('snapshots', {
|
||||||
|
icon: 'mdi:camera',
|
||||||
|
description: localize('menu.snapshots'),
|
||||||
|
emphasize: this._view.is('snapshots'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) {
|
||||||
|
buttons.set('frigate_ui', {
|
||||||
|
icon: 'mdi:web',
|
||||||
|
description: localize('menu.frigate_ui'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entities = this.config.entities || [];
|
||||||
|
for (let i = 0; this._hass && i < entities.length; i++) {
|
||||||
|
if (!entities[i].show) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entity = entities[i].entity;
|
||||||
|
const state = this._hass.states[entity];
|
||||||
|
buttons.set(entity, {
|
||||||
|
description: state.attributes.friendly_name || entity,
|
||||||
|
emphasize: ['on', 'active', 'home'].includes(state.state),
|
||||||
|
icon: entities[i].icon || stateIcon(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the object configuration.
|
||||||
|
public setConfig(inputConfig: FrigateCardConfig): void {
|
||||||
|
if (!inputConfig) {
|
||||||
|
throw new Error(localize('error.invalid_configuration:'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const keys = getParseErrorKeys(parseResult.error);
|
||||||
|
throw new Error(localize('error.invalid_configuration') + ': ' + keys.join(', '));
|
||||||
|
}
|
||||||
|
const config = parseResult.data;
|
||||||
|
|
||||||
|
if (config.test_gui) {
|
||||||
|
getLovelace().setEditMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.frigate_camera_name) {
|
||||||
|
// No camera name specified, so just assume it's the same as the entity name.
|
||||||
|
if (config.camera_entity.includes('.')) {
|
||||||
|
config.frigate_camera_name = config.camera_entity.split('.', 2)[1];
|
||||||
|
} else {
|
||||||
|
throw new Error(localize('error.invalid_configuration') + ': camera_entity');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
this._entitiesToMonitor = [
|
||||||
|
...(this.config.entities || []).map((entity) => entity.entity),
|
||||||
|
this.config.camera_entity,
|
||||||
|
];
|
||||||
|
this._changeView();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _changeView(view?: View | undefined): void {
|
||||||
|
if (view === undefined) {
|
||||||
|
this._view = new View({ view: this.config.view_default });
|
||||||
|
} else {
|
||||||
|
this._view = view;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _changeViewHandler(e: CustomEvent<View>): void {
|
||||||
|
this._changeView(e.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whether the card should be updated.
|
||||||
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
|
if (!this.config) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (changedProps.has('config')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldHass = changedProps.get('_hass') as HomeAssistant | undefined;
|
||||||
|
if (oldHass) {
|
||||||
|
// Home Assistant pumps a lot of updates through. Re-rendering the card is
|
||||||
|
// necessary at times (e.g. to update the 'clip' view as new clips
|
||||||
|
// arrive), but also is a jarring experience for the user (e.g. if they
|
||||||
|
// are browsing the mini-gallery). Do not allow re-rendering from a Home
|
||||||
|
// Assistant update if there's been recent interaction (e.g. clicks on the
|
||||||
|
// card) or if there is a clip active playing.
|
||||||
|
if (this._interactionTimerID || this._clipPlaying) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _menuActionHandler(name: string): void {
|
||||||
|
switch (name) {
|
||||||
|
case 'frigate':
|
||||||
|
this._changeView();
|
||||||
|
break;
|
||||||
|
case 'live':
|
||||||
|
case 'clips':
|
||||||
|
case 'snapshots':
|
||||||
|
this._changeView(new View({ view: name }));
|
||||||
|
break;
|
||||||
|
case 'frigate_ui':
|
||||||
|
const frigate_url = this._getFrigateURLFromContext();
|
||||||
|
if (frigate_url) {
|
||||||
|
window.open(frigate_url);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// If it's unknown, it's assumed to be an entity_id.
|
||||||
|
fireEvent(this, 'hass-more-info', { entityId: name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Frigate UI url.
|
||||||
|
protected _getFrigateURLFromContext(): string | null {
|
||||||
|
if (!this.config.frigate_url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (this._view.is('live')) {
|
||||||
|
return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`;
|
||||||
|
}
|
||||||
|
return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updated(): void {
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
// DOM elements are not always present until after updateComplete promise
|
||||||
|
// is resolved. Note that children of children (i.e. the underlying video
|
||||||
|
// element) is not always present even when the promise returns, so
|
||||||
|
// capture the event at the upper shadow root instead.
|
||||||
|
const hls_player = this.renderRoot
|
||||||
|
?.querySelector('ha-card')
|
||||||
|
?.querySelector('ha-hls-player');
|
||||||
|
|
||||||
|
if (hls_player) {
|
||||||
|
hls_player.shadowRoot?.addEventListener(
|
||||||
|
'play',
|
||||||
|
() => {
|
||||||
|
this._clipPlaying = true;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
hls_player.shadowRoot?.addEventListener(
|
||||||
|
'pause',
|
||||||
|
() => {
|
||||||
|
this._clipPlaying = true;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record interactions with the card.
|
||||||
|
protected _interactionHandler(): void {
|
||||||
|
if (!this.config.view_timeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._interactionTimerID) {
|
||||||
|
window.clearTimeout(this._interactionTimerID);
|
||||||
|
}
|
||||||
|
this._interactionTimerID = window.setTimeout(() => {
|
||||||
|
this._interactionTimerID = null;
|
||||||
|
this._changeView();
|
||||||
|
}, this.config.view_timeout * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _renderMenu(): TemplateResult | void {
|
||||||
|
const classes = {
|
||||||
|
'hover-menu': this.config.menu_mode.startsWith('hover-'),
|
||||||
|
};
|
||||||
|
return html`
|
||||||
|
<frigate-card-menu
|
||||||
|
class="${classMap(classes)}"
|
||||||
|
.actionCallback=${this._menuActionHandler.bind(this)}
|
||||||
|
.menuMode=${this.config.menu_mode}
|
||||||
|
.buttons=${this._getMenuButtons()}
|
||||||
|
></frigate-card-menu>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters {
|
||||||
|
return {
|
||||||
|
mediaType: this._view.view == 'clips' ? 'clips' : 'snapshots',
|
||||||
|
clientId: this.config.frigate_client_id,
|
||||||
|
// frigate_camera_name cannot be null, it will be set to a default value
|
||||||
|
// in setConfig if not specified in the configuration.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
cameraName: this.config.frigate_camera_name!,
|
||||||
|
label: this.config.label,
|
||||||
|
zone: this.config.zone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the call (master render method).
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (this.config.show_warning) {
|
||||||
|
return this._showWarning(localize('common.show_warning'));
|
||||||
|
}
|
||||||
|
if (this.config.show_error) {
|
||||||
|
return this._showError(localize('common.show_error'));
|
||||||
|
}
|
||||||
|
return html` <ha-card @click=${this._interactionHandler}>
|
||||||
|
${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
|
||||||
|
<div class="container_16_9 outer">
|
||||||
|
<div class="frigate-card-contents">
|
||||||
|
${this._view.is('clips') || this._view.is('snapshots')
|
||||||
|
? html` <frigate-card-gallery
|
||||||
|
.hass=${this._hass}
|
||||||
|
.view=${this._view}
|
||||||
|
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
|
||||||
|
@frigate-card:change-view=${this._changeViewHandler}
|
||||||
|
>
|
||||||
|
</frigate-card-gallery>`
|
||||||
|
: ``}
|
||||||
|
${this._view.is('clip') || this._view.is('snapshot')
|
||||||
|
? html` <frigate-card-viewer
|
||||||
|
.hass=${this._hass}
|
||||||
|
.view=${this._view}
|
||||||
|
.browseMediaQueryParameters=${this._getBrowseMediaQueryParameters()}
|
||||||
|
.nextPreviousControlStyle=${this.config.controls?.nextprev ?? 'thumbnails'}
|
||||||
|
.autoplayClip=${this.config.autoplay_clip}
|
||||||
|
@frigate-card:change-view=${this._changeViewHandler}
|
||||||
|
>
|
||||||
|
</frigate-card-viewer>`
|
||||||
|
: ``}
|
||||||
|
${this._view.is('live')
|
||||||
|
? html` <frigate-card-live
|
||||||
|
.hass=${this._hass}
|
||||||
|
.config=${this.config}
|
||||||
|
>
|
||||||
|
</frigate-card-live>`
|
||||||
|
: ``}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${this.config.menu_mode != 'above' ? this._renderMenu() : ''}
|
||||||
|
</ha-card>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a warning card.
|
||||||
|
private _showWarning(warning: string): TemplateResult {
|
||||||
|
return html` <hui-warning> ${warning} </hui-warning> `;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show an error card.
|
||||||
|
private _showError(error: string): TemplateResult {
|
||||||
|
const errorCard = document.createElement('hui-error-card');
|
||||||
|
errorCard.setConfig({
|
||||||
|
type: 'error',
|
||||||
|
error,
|
||||||
|
origConfig: this.config,
|
||||||
|
});
|
||||||
|
|
||||||
|
return html` ${errorCard} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return compiled CSS styles (thus safe to use with unsafeCSS).
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(cardStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Lovelace card size.
|
||||||
|
public getCardSize(): number {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
import { ZodSchema, z } from 'zod';
|
||||||
|
import { MessageBase } from 'home-assistant-js-websocket';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { localize } from './localize/localize';
|
||||||
|
import type {
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
|
BrowseMediaSource,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
} from './types';
|
||||||
|
import { browseMediaSourceSchema } from './types';
|
||||||
|
|
||||||
|
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
|
||||||
|
const errors = error.format();
|
||||||
|
return Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function homeAssistantWSRequest<T>(
|
||||||
|
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||||
|
schema: ZodSchema<T>,
|
||||||
|
request: MessageBase,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const response = await hass.callWS<T>(request);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
const error_message = `${localize('error.empty_response')}: ${JSON.stringify(
|
||||||
|
request,
|
||||||
|
)}`;
|
||||||
|
console.warn(error_message);
|
||||||
|
throw new Error(error_message);
|
||||||
|
}
|
||||||
|
const parseResult = schema.safeParse(response);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const keys = getParseErrorKeys<T>(parseResult.error);
|
||||||
|
const error_message =
|
||||||
|
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
|
||||||
|
localize('error.invalid_keys') +
|
||||||
|
`: '${keys}'`;
|
||||||
|
console.warn(error_message);
|
||||||
|
throw new Error(error_message);
|
||||||
|
}
|
||||||
|
return parseResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From a BrowseMediaSource item extract the first true media item (i.e. a
|
||||||
|
// clip/snapshot, not a folder).
|
||||||
|
export function getFirstTrueMediaChildIndex(
|
||||||
|
media: BrowseMediaSource | null,
|
||||||
|
): number | null {
|
||||||
|
if (!media || !media.children) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < media.children.length; i++) {
|
||||||
|
if (!media.children[i].can_expand) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse Frigate media with a media content id.
|
||||||
|
export async function browseMedia(
|
||||||
|
hass: (HomeAssistant & ExtendedHomeAssistant) | null,
|
||||||
|
media_content_id: string,
|
||||||
|
): Promise<BrowseMediaSource | null> {
|
||||||
|
if (!hass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const request = {
|
||||||
|
type: 'media_source/browse_media',
|
||||||
|
media_content_id: media_content_id,
|
||||||
|
};
|
||||||
|
return homeAssistantWSRequest(hass, browseMediaSourceSchema, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse Frigate media with query parameters.
|
||||||
|
export async function browseMediaQuery(
|
||||||
|
hass: HomeAssistant & ExtendedHomeAssistant,
|
||||||
|
params: BrowseMediaQueryParameters,
|
||||||
|
): Promise<BrowseMediaSource | null> {
|
||||||
|
return browseMedia(
|
||||||
|
hass,
|
||||||
|
// Defined in:
|
||||||
|
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
|
||||||
|
[
|
||||||
|
'media-source://frigate',
|
||||||
|
params.clientId,
|
||||||
|
'event-search',
|
||||||
|
params.mediaType,
|
||||||
|
'', // Name/Title to render (not necessary here)
|
||||||
|
params.after ? String(params.after) : '',
|
||||||
|
params.before ? String(params.before) : '',
|
||||||
|
params.cameraName,
|
||||||
|
params.label,
|
||||||
|
params.zone,
|
||||||
|
].join('/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchPlayEvent(node: HTMLElement): void {
|
||||||
|
node.dispatchEvent(
|
||||||
|
new CustomEvent<void>('frigate-card:play', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchPauseEvent(node: HTMLElement): void {
|
||||||
|
node.dispatchEvent(
|
||||||
|
new CustomEvent<void>('frigate-card:pause', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators';
|
||||||
|
import { until } from 'lit/directives/until.js';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BrowseMediaSource,
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import { View } from '../view';
|
||||||
|
import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common';
|
||||||
|
import { localize } from '../localize/localize';
|
||||||
|
import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message';
|
||||||
|
|
||||||
|
import galleryStyle from '../scss/gallery.scss';
|
||||||
|
|
||||||
|
@customElement('frigate-card-gallery')
|
||||||
|
export class FrigateCardGallery extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected view!: View;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected browseMediaQueryParameters!: BrowseMediaQueryParameters;
|
||||||
|
|
||||||
|
protected _getMediaType(): 'clips' | 'snapshots' {
|
||||||
|
return this.view?.view == 'clips' ? 'clips' : 'snapshots';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html`${until(this._render(), renderProgressIndicator())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _render(): Promise<TemplateResult> {
|
||||||
|
let parent: BrowseMediaSource | null;
|
||||||
|
try {
|
||||||
|
if (this.view.target) {
|
||||||
|
parent = await browseMedia(this.hass, this.view.target.media_content_id);
|
||||||
|
} else {
|
||||||
|
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return renderErrorMessage(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) {
|
||||||
|
return renderMessage(
|
||||||
|
this._getMediaType() == 'clips'
|
||||||
|
? localize('common.no_clips')
|
||||||
|
: localize('common.no_snapshots'),
|
||||||
|
this._getMediaType() == 'clips' ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html` <ul class="mdc-image-list frigate-card-gallery">
|
||||||
|
${this.view && this.view.previous
|
||||||
|
? html`<li class="mdc-image-list__item">
|
||||||
|
<div class="mdc-image-list__image-aspect-container">
|
||||||
|
<div class="mdc-image-list__image">
|
||||||
|
<ha-card
|
||||||
|
@click=${() => {
|
||||||
|
if (this.view && this.view.previous) {
|
||||||
|
this.view.previous.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
outlined=""
|
||||||
|
class="frigate-card-gallery-folder"
|
||||||
|
>
|
||||||
|
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
|
||||||
|
</ha-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>`
|
||||||
|
: ''}
|
||||||
|
${parent.children.map(
|
||||||
|
(child, index) =>
|
||||||
|
html` <li class="mdc-image-list__item">
|
||||||
|
<div class="mdc-image-list__image-aspect-container">
|
||||||
|
${child.can_expand
|
||||||
|
? html`<div class="mdc-image-list__image">
|
||||||
|
<ha-card
|
||||||
|
@click=${() => {
|
||||||
|
new View({
|
||||||
|
view: this._getMediaType(),
|
||||||
|
target: child,
|
||||||
|
previous: this.view ?? undefined,
|
||||||
|
}).dispatchChangeEvent(this);
|
||||||
|
}}
|
||||||
|
outlined=""
|
||||||
|
class="frigate-card-gallery-folder"
|
||||||
|
>
|
||||||
|
<div>${child.title}</div>
|
||||||
|
</ha-card>
|
||||||
|
</div>`
|
||||||
|
: child.thumbnail
|
||||||
|
? html`<img
|
||||||
|
title="${child.title}"
|
||||||
|
class="mdc-image-list__image"
|
||||||
|
src="${child.thumbnail}"
|
||||||
|
@click=${() => {
|
||||||
|
new View({
|
||||||
|
view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot',
|
||||||
|
target: parent ?? undefined,
|
||||||
|
childIndex: index,
|
||||||
|
previous: this.view ?? undefined,
|
||||||
|
}).dispatchChangeEvent(this);
|
||||||
|
}}
|
||||||
|
/>`
|
||||||
|
: ``}
|
||||||
|
</div>
|
||||||
|
</li>`,
|
||||||
|
)}
|
||||||
|
</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(galleryStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators';
|
||||||
|
import { until } from 'lit/directives/until.js';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
|
||||||
|
import { signedPathSchema } from '../types';
|
||||||
|
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types';
|
||||||
|
|
||||||
|
import { localize } from '../localize/localize';
|
||||||
|
import { homeAssistantWSRequest } from '../common';
|
||||||
|
import {
|
||||||
|
renderMessage,
|
||||||
|
renderErrorMessage,
|
||||||
|
renderProgressIndicator,
|
||||||
|
} from '../components/message';
|
||||||
|
|
||||||
|
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||||
|
|
||||||
|
import liveStyle from '../scss/live.scss';
|
||||||
|
|
||||||
|
@customElement('frigate-card-live')
|
||||||
|
export class FrigateCardViewer extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected config!: FrigateCardConfig;
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html`${until(this._render(), renderProgressIndicator())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _render(): Promise<TemplateResult> {
|
||||||
|
return html` ${this.config.live_provider == 'frigate'
|
||||||
|
? html` <frigate-card-live-frigate
|
||||||
|
.hass=${this.hass}
|
||||||
|
.cameraEntity=${this.config.camera_entity}
|
||||||
|
>
|
||||||
|
</frigate-card-live-frigate>`
|
||||||
|
: this.config.live_provider == 'webrtc'
|
||||||
|
? html`<frigate-card-live-webrtc
|
||||||
|
.hass=${this.hass}
|
||||||
|
.webRTCConfig=${this.config.webrtc || {}}
|
||||||
|
>
|
||||||
|
</frigate-card-live-webrtc>`
|
||||||
|
: html` <frigate-card-live-jsmpeg
|
||||||
|
.hass=${this.hass}
|
||||||
|
.cameraName=${this.config.frigate_camera_name}
|
||||||
|
.clientId=${this.config.frigate_client_id}
|
||||||
|
>
|
||||||
|
</frigate-card-live-jsmpeg>`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-live-frigate')
|
||||||
|
export class FrigateCardViewerFrigate extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected cameraEntity!: string;
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!(this.cameraEntity in this.hass.states)) {
|
||||||
|
return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off');
|
||||||
|
}
|
||||||
|
return html` <ha-camera-stream
|
||||||
|
.hass=${this.hass}
|
||||||
|
.stateObj=${this.hass.states[this.cameraEntity]}
|
||||||
|
.controls=${true}
|
||||||
|
.muted=${true}
|
||||||
|
>
|
||||||
|
</ha-camera-stream>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a wrapper for the WebRTC element
|
||||||
|
// - https://github.com/AlexxIT/WebRTC
|
||||||
|
@customElement('frigate-card-live-webrtc')
|
||||||
|
export class FrigateCardViewerWebRTC extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected webRTCConfig!: Record<string, unknown>;
|
||||||
|
|
||||||
|
protected _webRTCElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
protected _createWebRTC(): TemplateResult | void {
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const webrtcElement = customElements.get('webrtc-camera') as any;
|
||||||
|
if (webrtcElement) {
|
||||||
|
const webrtc = new webrtcElement();
|
||||||
|
webrtc.setConfig(this.webRTCConfig);
|
||||||
|
webrtc.hass = this.hass;
|
||||||
|
this._webRTCElement = webrtc;
|
||||||
|
} else {
|
||||||
|
throw new Error(localize('error.missing_webrtc'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!this._webRTCElement) {
|
||||||
|
try {
|
||||||
|
this._createWebRTC();
|
||||||
|
} catch (e) {
|
||||||
|
return renderErrorMessage((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html`${this._webRTCElement}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-live-jsmpeg')
|
||||||
|
export class FrigateCardViewerJSMPEG extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected cameraName!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected clientId!: string;
|
||||||
|
|
||||||
|
protected _jsmpegCanvasElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
protected _jsmpegVideoPlayer: any | null = null;
|
||||||
|
|
||||||
|
protected async _getURL(): Promise<string | null> {
|
||||||
|
if (!this.hass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
type: 'auth/sign_path',
|
||||||
|
path: `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`,
|
||||||
|
};
|
||||||
|
// Sign the path so it includes an authSig parameter.
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await homeAssistantWSRequest(this.hass, signedPathSchema, request);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = this.hass.hassUrl(response.path);
|
||||||
|
return url.replace(/^http/i, 'ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
if (this._jsmpegVideoPlayer) {
|
||||||
|
this._jsmpegVideoPlayer.destroy();
|
||||||
|
this._jsmpegVideoPlayer = null;
|
||||||
|
}
|
||||||
|
this._jsmpegCanvasElement = null;
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html`${until(this._render(), renderProgressIndicator())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _render(): Promise<TemplateResult> {
|
||||||
|
if (!this._jsmpegCanvasElement) {
|
||||||
|
this._jsmpegCanvasElement = document.createElement('canvas');
|
||||||
|
this._jsmpegCanvasElement.className = 'media';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._jsmpegVideoPlayer) {
|
||||||
|
const jsmpeg_url = await this._getURL();
|
||||||
|
|
||||||
|
if (!jsmpeg_url) {
|
||||||
|
return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the html canvas node only after the JSMPEG video has loaded and
|
||||||
|
// is playing, to reduce the amount of time the user is staring at a blank
|
||||||
|
// white canvas (instead they get the progress spinner until this promise
|
||||||
|
// resolves).
|
||||||
|
return new Promise<TemplateResult>((resolve) => {
|
||||||
|
this._jsmpegVideoPlayer = new JSMpeg.VideoElement(
|
||||||
|
this,
|
||||||
|
jsmpeg_url,
|
||||||
|
{
|
||||||
|
canvas: this._jsmpegCanvasElement,
|
||||||
|
hooks: {
|
||||||
|
play: () => {
|
||||||
|
resolve(html`${this._jsmpegCanvasElement}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ protocols: [], videoBufferSize: 1024 * 1024 * 4 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return html`${this._jsmpegCanvasElement}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(liveStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
|
||||||
|
import { MenuButton } from '../types';
|
||||||
|
import type { FrigateMenuMode } from '../types';
|
||||||
|
|
||||||
|
import menuStyle from '../scss/menu.scss';
|
||||||
|
|
||||||
|
type FrigateCardMenuCallback = (name: string) => void;
|
||||||
|
|
||||||
|
// A menu for the Frigate card.
|
||||||
|
@customElement('frigate-card-menu')
|
||||||
|
export class FrigateCardMenu extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected menuMode: FrigateMenuMode = 'hidden-top';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected expand = false;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected actionCallback: FrigateCardMenuCallback | null = null;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public buttons: Map<string, MenuButton> = new Map();
|
||||||
|
|
||||||
|
// Call the callback.
|
||||||
|
protected _callAction(name: string): void {
|
||||||
|
if (this.menuMode.startsWith('hidden-')) {
|
||||||
|
if (name == 'frigate') {
|
||||||
|
this.expand = !this.expand;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Collapse menu after the user clicks on something.
|
||||||
|
this.expand = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.actionCallback) {
|
||||||
|
this.actionCallback(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a menu button.
|
||||||
|
protected _renderButton(name: string, button: MenuButton): TemplateResult {
|
||||||
|
const classes = {
|
||||||
|
button: true,
|
||||||
|
emphasize: button.emphasize ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return html` <ha-icon-button
|
||||||
|
class="${classMap(classes)}"
|
||||||
|
icon=${button.icon || 'mdi:gesture-tap-button'}
|
||||||
|
title=${button.description}
|
||||||
|
@click=${() => this._callAction(name)}
|
||||||
|
></ha-icon-button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the Frigate menu button.
|
||||||
|
protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult {
|
||||||
|
const icon =
|
||||||
|
this.menuMode.startsWith('hidden-') && !this.expand
|
||||||
|
? 'mdi:alpha-f-box-outline'
|
||||||
|
: 'mdi:alpha-f-box';
|
||||||
|
|
||||||
|
return this._renderButton(name, Object.assign({}, button, { icon: icon }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the menu.
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
// If the menu is off, or if it's in hidden mode but there's no button to
|
||||||
|
// unhide it, just show nothing.
|
||||||
|
if (
|
||||||
|
this.menuMode == 'none' ||
|
||||||
|
(this.menuMode.startsWith('hidden-') && !this.buttons.get('frigate'))
|
||||||
|
) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = {
|
||||||
|
'frigate-card-menu': true,
|
||||||
|
'overlay-hidden':
|
||||||
|
this.menuMode.startsWith('hidden-') ||
|
||||||
|
this.menuMode.startsWith('overlay-') ||
|
||||||
|
this.menuMode.startsWith('hover-'),
|
||||||
|
'expanded-horizontal':
|
||||||
|
(this.menuMode.startsWith('overlay-') ||
|
||||||
|
this.menuMode.startsWith('hover-') ||
|
||||||
|
this.expand) &&
|
||||||
|
(this.menuMode.endsWith('-top') || this.menuMode.endsWith('-bottom')),
|
||||||
|
'expanded-vertical':
|
||||||
|
(this.menuMode.startsWith('overlay-') ||
|
||||||
|
this.menuMode.startsWith('hover-') ||
|
||||||
|
this.expand) &&
|
||||||
|
(this.menuMode.endsWith('-left') || this.menuMode.endsWith('-right')),
|
||||||
|
full: ['above', 'below'].includes(this.menuMode),
|
||||||
|
left: this.menuMode.endsWith('-left'),
|
||||||
|
right: this.menuMode.endsWith('-right'),
|
||||||
|
top: this.menuMode.endsWith('-top'),
|
||||||
|
bottom: this.menuMode.endsWith('-bottom'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class=${classMap(classes)}>
|
||||||
|
${Array.from(this.buttons.keys()).map((name) => {
|
||||||
|
const button = this.buttons.get(name);
|
||||||
|
if (button) {
|
||||||
|
return name === 'frigate'
|
||||||
|
? this._renderFrigateButton(name, button)
|
||||||
|
: this._renderButton(name, button);
|
||||||
|
}
|
||||||
|
return html``;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return compiled CSS styles (thus safe to use with unsafeCSS).
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(menuStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators';
|
||||||
|
|
||||||
|
import { localize } from '../localize/localize';
|
||||||
|
|
||||||
|
import messageStyle from '../scss/message.scss';
|
||||||
|
|
||||||
|
const URL_TROUBLESHOOTING =
|
||||||
|
'https://github.com/dermotduffy/frigate-hass-card#troubleshooting';
|
||||||
|
|
||||||
|
@customElement('frigate-card-message')
|
||||||
|
export class FrigateCardMessage extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected message = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected icon = 'mdi:information-outline';
|
||||||
|
|
||||||
|
// Render the menu.
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html` <div class="message">
|
||||||
|
<span>
|
||||||
|
<ha-icon icon="${this.icon}"> </ha-icon>
|
||||||
|
${this.message ? html` ${this.message}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(messageStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-error-message')
|
||||||
|
export class FrigateCardErrorMessage extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected error = '';
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html` <frigate-card-message
|
||||||
|
.message=${html` ${this.error}.
|
||||||
|
<a href="${URL_TROUBLESHOOTING}"> ${localize('error.troubleshooting')} </a>.`}
|
||||||
|
.icon=${'mdi:alert-circle'}
|
||||||
|
>
|
||||||
|
</frigate-card-message>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-progress-indicator')
|
||||||
|
export class FrigateCardProgressIndicator extends LitElement {
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html` <div class="message">
|
||||||
|
<ha-circular-progress active="true" size="large"> </ha-circular-progress>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(messageStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderErrorMessage(error: string): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<frigate-card-error-message .error=${error}></frigate-card-error-message>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMessage(message: string, icon: string): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<frigate-card-message .message=${message} .icon=${icon}></frigate-card-message>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderProgressIndicator(): TemplateResult {
|
||||||
|
return html` <frigate-card-progress-indicator> </frigate-card-progress-indicator> `;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators';
|
||||||
|
import { classMap } from 'lit/directives/class-map';
|
||||||
|
|
||||||
|
import { BrowseMediaSource, NextPreviousControlStyle } from '../types';
|
||||||
|
|
||||||
|
import { View } from '../view';
|
||||||
|
|
||||||
|
import controlStyle from '../scss/next-previous-control.scss';
|
||||||
|
|
||||||
|
@customElement('frigate-card-next-previous-control')
|
||||||
|
export class FrigateCardMessage extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected control!: "next" | "previous";
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected controlStyle!: NextPreviousControlStyle;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected parent!: BrowseMediaSource;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected childIndex!: number;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected view!: View;
|
||||||
|
|
||||||
|
protected _changeView(): void {
|
||||||
|
new View({
|
||||||
|
view: this.view.view,
|
||||||
|
target: this.parent,
|
||||||
|
childIndex: this.childIndex,
|
||||||
|
}).dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() : TemplateResult {
|
||||||
|
if (this.controlStyle == 'none' || !this.parent.children) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
const target = this.parent.children[this.childIndex];
|
||||||
|
if (!target) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = {
|
||||||
|
controls: true,
|
||||||
|
previous: this.control == "previous",
|
||||||
|
next: this.control == "next",
|
||||||
|
thumbnails: this.controlStyle == "thumbnails",
|
||||||
|
chevrons: this.controlStyle == "chevrons",
|
||||||
|
button: this.controlStyle == "chevrons",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.controlStyle == "chevrons") {
|
||||||
|
return html` <ha-icon-button
|
||||||
|
icon=${this.control == "previous" ? 'mdi:chevron-left' : 'mdi:chevron-right'}
|
||||||
|
class="${classMap(classes)}"
|
||||||
|
title=${target.title}
|
||||||
|
@click=${this._changeView}
|
||||||
|
></ha-icon-button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.thumbnail) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
return html`<img
|
||||||
|
src="${target.thumbnail}"
|
||||||
|
class="${classMap(classes)}"
|
||||||
|
title="${target.title}"
|
||||||
|
@click=${this._changeView}
|
||||||
|
/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(controlStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators';
|
||||||
|
import { until } from 'lit/directives/until.js';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
|
||||||
|
|
||||||
|
import { resolvedMediaSchema } from '../types';
|
||||||
|
import type {
|
||||||
|
BrowseMediaNeighbors,
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
|
BrowseMediaSource,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
NextPreviousControlStyle,
|
||||||
|
ResolvedMedia,
|
||||||
|
} from '../types';
|
||||||
|
import { localize } from '../localize/localize';
|
||||||
|
import {
|
||||||
|
browseMediaQuery,
|
||||||
|
dispatchPauseEvent,
|
||||||
|
dispatchPlayEvent,
|
||||||
|
getFirstTrueMediaChildIndex,
|
||||||
|
homeAssistantWSRequest,
|
||||||
|
} from '../common';
|
||||||
|
|
||||||
|
import { View } from '../view';
|
||||||
|
import {
|
||||||
|
renderMessage,
|
||||||
|
renderErrorMessage,
|
||||||
|
renderProgressIndicator,
|
||||||
|
} from '../components/message';
|
||||||
|
|
||||||
|
import './next-prev-control';
|
||||||
|
|
||||||
|
import viewerStyle from '../scss/viewer.scss';
|
||||||
|
|
||||||
|
// Load dayjs plugin(s).
|
||||||
|
dayjs.extend(dayjs_custom_parse_format);
|
||||||
|
|
||||||
|
@customElement('frigate-card-viewer')
|
||||||
|
export class FrigateCardViewer extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass!: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected view!: View;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected browseMediaQueryParameters!: BrowseMediaQueryParameters;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected nextPreviousControlStyle!: NextPreviousControlStyle;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected autoplayClip!: boolean;
|
||||||
|
|
||||||
|
protected async _resolveMedia(
|
||||||
|
mediaSource: BrowseMediaSource | null,
|
||||||
|
): Promise<ResolvedMedia | null> {
|
||||||
|
if (!mediaSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const request = {
|
||||||
|
type: 'media_source/resolve_media',
|
||||||
|
media_content_id: mediaSource.media_content_id,
|
||||||
|
};
|
||||||
|
return homeAssistantWSRequest(this.hass, resolvedMediaSchema, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _extractEventStartTimeFromBrowseMedia(
|
||||||
|
browseMedia: BrowseMediaSource,
|
||||||
|
): number | null {
|
||||||
|
// Example: 2021-08-27 20:57:22 [10s, Person 76%]
|
||||||
|
const result = browseMedia.title.match(/^(?<iso_datetime>.+) \[/);
|
||||||
|
if (result && result.groups) {
|
||||||
|
const iso_datetime_str = result.groups['iso_datetime'];
|
||||||
|
if (iso_datetime_str) {
|
||||||
|
const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true);
|
||||||
|
if (iso_datetime.isValid()) {
|
||||||
|
return iso_datetime.unix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the previous and next real media items, given the index
|
||||||
|
protected _getMediaNeighbors(
|
||||||
|
parent: BrowseMediaSource,
|
||||||
|
index: number | null,
|
||||||
|
): BrowseMediaNeighbors | null {
|
||||||
|
if (index == null || !parent.children) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work backwards from the index to get the previous real media.
|
||||||
|
let prevIndex: number | null = null;
|
||||||
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
|
const media = parent.children[i];
|
||||||
|
if (media && !media.can_expand) {
|
||||||
|
prevIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work forwards from the index to get the next real media.
|
||||||
|
let nextIndex: number | null = null;
|
||||||
|
for (let i = index + 1; i < parent.children.length; i++) {
|
||||||
|
const media = parent.children[i];
|
||||||
|
if (media && !media.can_expand) {
|
||||||
|
nextIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previousIndex: prevIndex,
|
||||||
|
previous: prevIndex != null ? parent.children[prevIndex] : null,
|
||||||
|
nextIndex: nextIndex,
|
||||||
|
next: nextIndex != null ? parent.children[nextIndex] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a clip at the same time as a snapshot.
|
||||||
|
protected async _findRelatedClips(
|
||||||
|
snapshot: BrowseMediaSource | null,
|
||||||
|
): Promise<BrowseMediaSource | null> {
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
|
||||||
|
if (startTime) {
|
||||||
|
try {
|
||||||
|
// Fetch clips within the same second (same camera/zone/label, etc).
|
||||||
|
const clipsAtSameTime = await browseMediaQuery(this.hass, {
|
||||||
|
...this.browseMediaQueryParameters,
|
||||||
|
before: startTime + 1,
|
||||||
|
after: startTime,
|
||||||
|
});
|
||||||
|
if (clipsAtSameTime) {
|
||||||
|
const index = getFirstTrueMediaChildIndex(clipsAtSameTime);
|
||||||
|
if (index != null && clipsAtSameTime.children?.length) {
|
||||||
|
return clipsAtSameTime.children[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// Pass. This is best effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html`${until(this._render(), renderProgressIndicator())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _render(): Promise<TemplateResult> {
|
||||||
|
let autoplay = true;
|
||||||
|
|
||||||
|
let parent: BrowseMediaSource | null = null;
|
||||||
|
let childIndex: number | null = null;
|
||||||
|
let mediaToRender: BrowseMediaSource | null = null;
|
||||||
|
|
||||||
|
if (this.view.target) {
|
||||||
|
parent = this.view.target;
|
||||||
|
childIndex = this.view.childIndex ?? null;
|
||||||
|
mediaToRender = this.view.media ?? null;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
|
||||||
|
} catch (e) {
|
||||||
|
return renderErrorMessage((e as Error).message);
|
||||||
|
}
|
||||||
|
childIndex = getFirstTrueMediaChildIndex(parent);
|
||||||
|
if (!parent || !parent.children || childIndex == null) {
|
||||||
|
return renderMessage(
|
||||||
|
this.view.is('clip')
|
||||||
|
? localize('common.no_clip')
|
||||||
|
: localize('common.no_snapshot'),
|
||||||
|
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mediaToRender = parent.children[childIndex];
|
||||||
|
|
||||||
|
// In this block, no clip has been manually selected, so this is loading
|
||||||
|
// the most recent clip on card load. In this mode, autoplay of the clip
|
||||||
|
// may be disabled by configuration. If does not make sense to disable
|
||||||
|
// autoplay when the user has explicitly picked an event to play in the
|
||||||
|
// gallery.
|
||||||
|
autoplay = this.autoplayClip;
|
||||||
|
}
|
||||||
|
const resolvedMedia = await this._resolveMedia(mediaToRender);
|
||||||
|
if (!mediaToRender || !resolvedMedia) {
|
||||||
|
// Home Assistant could not resolve media item.
|
||||||
|
return renderErrorMessage(localize('error.could_not_resolve'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const neighbors = this._getMediaNeighbors(parent, childIndex);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${neighbors?.previousIndex != null
|
||||||
|
? html`<frigate-card-next-previous-control
|
||||||
|
.control=${'previous'}
|
||||||
|
.controlStyle=${this.nextPreviousControlStyle}
|
||||||
|
.parent=${parent}
|
||||||
|
.childIndex=${neighbors.previousIndex}
|
||||||
|
.view=${this.view}
|
||||||
|
></frigate-card-next-previous-control>`
|
||||||
|
: ``}
|
||||||
|
${this.view.is('clip')
|
||||||
|
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
|
||||||
|
? html`<ha-hls-player
|
||||||
|
.hass=${this.hass}
|
||||||
|
.url=${resolvedMedia.url}
|
||||||
|
title="${mediaToRender.title}"
|
||||||
|
muted
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
allow-exoplayer
|
||||||
|
?autoplay="${autoplay}"
|
||||||
|
>
|
||||||
|
</ha-hls-player>`
|
||||||
|
: html`<video
|
||||||
|
title="${mediaToRender.title}"
|
||||||
|
muted
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
?autoplay="${autoplay}"
|
||||||
|
@play=${() => dispatchPlayEvent(this)}
|
||||||
|
@pause=${() => dispatchPauseEvent(this)}
|
||||||
|
>
|
||||||
|
<source src="${resolvedMedia.url}" type="${resolvedMedia.mime_type}" />
|
||||||
|
</video>`
|
||||||
|
: html`<img
|
||||||
|
src=${resolvedMedia.url}
|
||||||
|
title="${mediaToRender.title}"
|
||||||
|
@click=${() => {
|
||||||
|
// Get clips potentially related to this snapshot.
|
||||||
|
this._findRelatedClips(mediaToRender).then((relatedClip) => {
|
||||||
|
if (relatedClip) {
|
||||||
|
new View({
|
||||||
|
view: 'clip',
|
||||||
|
target: relatedClip,
|
||||||
|
}).dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>`}
|
||||||
|
${neighbors?.nextIndex != null
|
||||||
|
? html`<frigate-card-next-previous-control
|
||||||
|
.control=${'next'}
|
||||||
|
.controlStyle=${this.nextPreviousControlStyle}
|
||||||
|
.parent=${parent}
|
||||||
|
.childIndex=${neighbors.nextIndex}
|
||||||
|
.view=${this.view}
|
||||||
|
></frigate-card-next-previous-control>`
|
||||||
|
: ``}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(viewerStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -84,6 +84,7 @@
|
|||||||
"could_not_resolve": "Could not resolve media URL",
|
"could_not_resolve": "Could not resolve media URL",
|
||||||
"no_live_camera": "No live camera",
|
"no_live_camera": "No live camera",
|
||||||
"invalid_configuration": "Invalid configuration",
|
"invalid_configuration": "Invalid configuration",
|
||||||
"missing_webrtc": "WebRTC component not found"
|
"missing_webrtc": "WebRTC component not found",
|
||||||
|
"internal": "Internal error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-93
@@ -1,7 +1,3 @@
|
|||||||
@use "@material/image-list/mdc-image-list";
|
|
||||||
@use "@material/image-list";
|
|
||||||
@use './common.scss';
|
|
||||||
|
|
||||||
.container_16_9 {
|
.container_16_9 {
|
||||||
/* 16:9 Aspect Ratio. When Safari supports 'aspect-ratio' this should not be
|
/* 16:9 Aspect Ratio. When Safari supports 'aspect-ratio' this should not be
|
||||||
necessary */
|
necessary */
|
||||||
@@ -22,7 +18,12 @@
|
|||||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Support the 'hover' menu mode. */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.frigate-card-contents::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The 'hover' menu mode is styling applied outside of the menu itself */
|
||||||
.hover-menu {
|
.hover-menu {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
@@ -34,47 +35,6 @@
|
|||||||
opacity: 1.0;
|
opacity: 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frigate-card-contents img.media,video.media,canvas.media {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
|
||||||
.frigate-card-contents::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-card-contents .attention {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-card-image-list {
|
|
||||||
@include image-list.standard-columns(5, 1px);
|
|
||||||
@include image-list.shape-radius(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-card .frigate-card-image-list-folder {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-align: center;
|
|
||||||
border-style: 2px solid;
|
|
||||||
opacity: 0.7;
|
|
||||||
color: var(--secondary-text-color, white);
|
|
||||||
border-color: var(--secondary-text-color, black);
|
|
||||||
background-color: var(--primary-background-color, black);
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
video, img {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-card {
|
ha-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -85,55 +45,8 @@ ha-card {
|
|||||||
position: relative;
|
position: relative;
|
||||||
color: var(--secondary-text-color, white);
|
color: var(--secondary-text-color, white);
|
||||||
background-color: var(--secondary-background-color, black);
|
background-color: var(--secondary-background-color, black);
|
||||||
|
|
||||||
transform-style: preserve-3d; /* Safari brings video elements forward without this */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ha-card a {
|
ha-card a {
|
||||||
color: var(--primary-text-color, white);
|
color: var(--primary-text-color, white);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Don't drop shadow or have radius for nested webrtc card */
|
|
||||||
webrtc-camera ha-card {
|
|
||||||
box-shadow: none;
|
|
||||||
border-radius: 0px;
|
|
||||||
background-color: var(--secondary-background-color, black);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-media-controls {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.frigate-media-controls.previous {
|
|
||||||
left: 45px;
|
|
||||||
}
|
|
||||||
.frigate-media-controls.next {
|
|
||||||
right: 45px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-media-controls.chevrons {
|
|
||||||
top: calc(50% - (40px / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-media-controls.thumbnails {
|
|
||||||
border-radius: 50%;
|
|
||||||
height: 48px;
|
|
||||||
top: calc(50% - (48px / 2));
|
|
||||||
box-shadow: 0px 0px 30px 1px black;
|
|
||||||
transition: all .2s ease;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.frigate-media-controls.thumbnails:hover {
|
|
||||||
opacity: 1 !important;
|
|
||||||
height: 72px;
|
|
||||||
top: calc(50% - (72px / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-media-controls.previous.thumbnails:hover {
|
|
||||||
left: 33px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frigate-media-controls.next.thumbnails:hover {
|
|
||||||
right: 33px;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@use "@material/image-list/mdc-image-list";
|
||||||
|
@use "@material/image-list";
|
||||||
|
|
||||||
|
.frigate-card-gallery {
|
||||||
|
@include image-list.standard-columns(5, 1px);
|
||||||
|
@include image-list.shape-radius(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
ha-card.frigate-card-gallery-folder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
border-style: 2px solid;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--secondary-text-color, white);
|
||||||
|
border-color: var(--secondary-text-color, black);
|
||||||
|
background-color: var(--primary-background-color, black);
|
||||||
|
padding: 10px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Don't drop shadow or have radius for nested webrtc card */
|
||||||
|
webrtc-camera ha-card {
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0px;
|
||||||
|
background-color: var(--secondary-background-color, black);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@use './common.scss';
|
@use './button.scss';
|
||||||
|
|
||||||
.frigate-card-menu {
|
.frigate-card-menu {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.message {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@use './button.scss';
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.controls.previous {
|
||||||
|
left: 45px;
|
||||||
|
}
|
||||||
|
.controls.next {
|
||||||
|
right: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls.chevrons {
|
||||||
|
top: calc(50% - (40px / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls.thumbnails {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 48px;
|
||||||
|
top: calc(50% - (48px / 2));
|
||||||
|
box-shadow: 0px 0px 30px 1px black;
|
||||||
|
transition: all .2s ease;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.controls.thumbnails:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
height: 72px;
|
||||||
|
top: calc(50% - (72px / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls.previous.thumbnails:hover {
|
||||||
|
left: 33px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls.next.thumbnails:hover {
|
||||||
|
right: 33px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ha-hls-player {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
img,video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
+41
-18
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers';
|
||||||
LovelaceCard,
|
|
||||||
LovelaceCardEditor,
|
|
||||||
} from 'custom-card-helpers';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -43,12 +40,17 @@ export const FRIGATE_MENU_MODES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
|
export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
|
||||||
|
|
||||||
|
export const NEXT_PREVIOUS_CONTROL_STYLES = ['none', 'thumbnails', 'chevrons'] as const;
|
||||||
|
export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[number];
|
||||||
|
|
||||||
|
export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const;
|
||||||
|
export type LiveProvider = typeof LIVE_PROVIDERS[number];
|
||||||
|
|
||||||
export const frigateCardConfigSchema = z.object({
|
export const frigateCardConfigSchema = z.object({
|
||||||
camera_entity: z.string(),
|
camera_entity: z.string(),
|
||||||
// No URL validation to allow relative URLs within HA (e.g. addons).
|
// No URL validation to allow relative URLs within HA (e.g. addons).
|
||||||
frigate_url: z.string().optional(),
|
frigate_url: z.string().optional(),
|
||||||
frigate_client_id: z.string().optional().default("frigate"),
|
frigate_client_id: z.string().optional().default('frigate'),
|
||||||
frigate_camera_name: z.string().optional(),
|
frigate_camera_name: z.string().optional(),
|
||||||
view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'),
|
view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'),
|
||||||
view_timeout: z
|
view_timeout: z
|
||||||
@@ -59,31 +61,42 @@ export const frigateCardConfigSchema = z.object({
|
|||||||
.regex(/^\d+$/)
|
.regex(/^\d+$/)
|
||||||
.transform((val) => Number(val)),
|
.transform((val) => Number(val)),
|
||||||
)
|
)
|
||||||
.optional().default(180),
|
.optional()
|
||||||
live_provider: z.enum(['frigate', 'frigate-jsmpeg', 'webrtc']).default('frigate'),
|
.default(180),
|
||||||
webrtc: z.object({
|
live_provider: z.enum(LIVE_PROVIDERS).default('frigate'),
|
||||||
|
webrtc: z
|
||||||
|
.object({
|
||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
}).passthrough().optional(),
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
zone: z.string().optional(),
|
zone: z.string().optional(),
|
||||||
autoplay_clip: z.boolean().default(false),
|
autoplay_clip: z.boolean().default(false),
|
||||||
menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'),
|
menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'),
|
||||||
menu_buttons: z.object({
|
menu_buttons: z
|
||||||
|
.object({
|
||||||
frigate: z.boolean().default(true),
|
frigate: z.boolean().default(true),
|
||||||
live: z.boolean().default(true),
|
live: z.boolean().default(true),
|
||||||
clips: z.boolean().default(true),
|
clips: z.boolean().default(true),
|
||||||
snapshots: z.boolean().default(true),
|
snapshots: z.boolean().default(true),
|
||||||
frigate_ui: z.boolean().default(true),
|
frigate_ui: z.boolean().default(true),
|
||||||
}).optional(),
|
})
|
||||||
entities: z.object({
|
.optional(),
|
||||||
|
entities: z
|
||||||
|
.object({
|
||||||
entity: z.string(),
|
entity: z.string(),
|
||||||
show: z.boolean().default(true),
|
show: z.boolean().default(true),
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
}).array().optional(),
|
})
|
||||||
controls: z.object({
|
.array()
|
||||||
nextprev: z.enum(['thumbnails', 'chevrons', 'none']).default('thumbnails'),
|
.optional(),
|
||||||
}).optional(),
|
controls: z
|
||||||
|
.object({
|
||||||
|
nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
|
||||||
// Stock lovelace card config.
|
// Stock lovelace card config.
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
@@ -103,6 +116,16 @@ export interface ExtendedHomeAssistant {
|
|||||||
hassUrl(path?): string;
|
hassUrl(path?): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowseMediaQueryParameters {
|
||||||
|
mediaType: 'clips' | 'snapshots';
|
||||||
|
clientId: string;
|
||||||
|
cameraName: string;
|
||||||
|
label?: string;
|
||||||
|
zone?: string;
|
||||||
|
before?: number;
|
||||||
|
after?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media Browser API types.
|
* Media Browser API types.
|
||||||
*/
|
*/
|
||||||
@@ -119,7 +142,7 @@ export interface BrowseMediaSource {
|
|||||||
can_play: boolean;
|
can_play: boolean;
|
||||||
can_expand: boolean;
|
can_expand: boolean;
|
||||||
children_media_class: string | null;
|
children_media_class: string | null;
|
||||||
thumbnail: string | null
|
thumbnail: string | null;
|
||||||
children?: BrowseMediaSource[] | null;
|
children?: BrowseMediaSource[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +157,7 @@ export const browseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(()
|
|||||||
children_media_class: z.string().nullable(),
|
children_media_class: z.string().nullable(),
|
||||||
thumbnail: z.string().nullable(),
|
thumbnail: z.string().nullable(),
|
||||||
children: z.array(browseMediaSourceSchema).nullable().optional(),
|
children: z.array(browseMediaSourceSchema).nullable().optional(),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
|
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import type { BrowseMediaSource, FrigateCardView } from './types';
|
||||||
|
|
||||||
|
export interface ViewParameters {
|
||||||
|
view?: FrigateCardView;
|
||||||
|
target?: BrowseMediaSource;
|
||||||
|
childIndex?: number;
|
||||||
|
previous?: View;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class View {
|
||||||
|
view: FrigateCardView;
|
||||||
|
target?: BrowseMediaSource;
|
||||||
|
childIndex?: number;
|
||||||
|
previous?: View;
|
||||||
|
|
||||||
|
constructor(params?: ViewParameters) {
|
||||||
|
this.view = params?.view || 'live';
|
||||||
|
this.target = params?.target;
|
||||||
|
this.childIndex = params?.childIndex;
|
||||||
|
this.previous = params?.previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
public is(name: string): boolean {
|
||||||
|
return this.view == name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get media(): BrowseMediaSource | undefined {
|
||||||
|
if (this.target) {
|
||||||
|
if (this.target.children && this.childIndex !== undefined) {
|
||||||
|
return this.target.children[this.childIndex];
|
||||||
|
}
|
||||||
|
return this.target;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispatchChangeEvent(node: HTMLElement): void {
|
||||||
|
node.dispatchEvent(
|
||||||
|
new CustomEvent<View>('frigate-card:change-view', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: this,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user