Merge pull request #158 from dermotduffy/download

Add media download support
This commit is contained in:
Dermot Duffy
2021-10-29 20:29:18 -07:00
committed by GitHub
15 changed files with 723 additions and 297 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ lovelace:
| Option | Default | Description | | Option | Default | Description |
| ------------- | --------------------------------------------- | - | | ------------- | --------------------------------------------- | - |
| `menu_mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.| | `menu_mode` | `hidden-top` | The menu mode to show by default. See [menu modes](#menu-modes) below.|
| `menu_buttons.{frigate, live, clips, snapshots, frigate_ui, fullscreen}` | `true` | Whether or not to show these builtin actions in the card menu. | | `menu_buttons.{frigate, live, clips, snapshots, download, frigate_ui, fullscreen}` | `true` | Whether or not to show these builtin actions in the card menu. |
| `controls.nextprev` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | | `controls.nextprev` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . |
| `dimensions.aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| | `dimensions.aspect_ratio_mode` | `dynamic` | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.|
| `dimensions.aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `<W>:<H>` or `<W>/<H>`. See [aspect ratios](#aspect-ratios) below.| | `dimensions.aspect_ratio` | `16:9` | The aspect ratio to use. Acceptable values: `<W>:<H>` or `<W>/<H>`. See [aspect ratios](#aspect-ratios) below.|
+122
View File
@@ -0,0 +1,122 @@
import type { BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant } from './types.js';
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from './common.js';
import { browseMediaSourceSchema } from './types.js';
import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
dayjs.extend(dayjs_custom_parse_format);
export class BrowseMediaUtil {
/**
* Return the Frigate event_id given a BrowseMediaSource object.
* @param media The event to extract the id from.
* @returns The `event_id` or `null` if not successfully parsed.
*/
static extractEventID(media: BrowseMediaSource): string | null {
const result = media.media_content_id.match(
/^media-source:\/\/frigate\/.*\/(?<id>[.0-9]+-[a-zA-Z0-9]+)$/);
return result && result.groups ? result.groups['id'] : null;
}
/**
* Return the event start time given a BrowseMediaSource object.
* @param browseMedia The media object to extract the start time from.
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
static extractEventStartTime(
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;
}
/**
* Determine if a BrowseMediaSource object is truly a media item (vs a folder).
* @param media The media object.
* @returns `true` if it's truly a media item, `false` otherwise.
*/
static isTrueMedia(media: BrowseMediaSource): boolean {
return !media.can_expand;
}
/**
* From a BrowseMediaSource item extract the first true media item from the
* children (i.e. a clip/snapshot, not a folder).
* @param media The media object with children.
* @returns The first true media item found.
*/
static getFirstTrueMediaChildIndex(
media: BrowseMediaSource | null,
): number | null {
if (!media || !media.children) {
return null;
}
const index = media.children.findIndex((child) => this.isTrueMedia(child));
return index >= 0 ? index : null;
}
//
/**
* Browse Frigate media with a media content id. May throw.
* @param hass The HomeAssistant object.
* @param media_content_id The media content id to browse.
* @returns A BrowseMediaSource object or null on malformed.
*/
static async 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.
/**
* Browse Frigate media with a media query. May throw.
* @param hass The HomeAssistant object.
* @param params The search parameters to use to search for media.
* @returns A BrowseMediaSource object or null on malformed.
*/
static async browseMediaQuery(
hass: HomeAssistant & ExtendedHomeAssistant,
params: BrowseMediaQueryParameters,
): Promise<BrowseMediaSource | null> {
return this.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('/'),
);
}
}
+216 -39
View File
@@ -26,7 +26,7 @@ import type {
Entity, Entity,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardConfig, FrigateCardConfig,
MediaLoadInfo, MediaShowInfo,
MenuButton, MenuButton,
Message, Message,
} from './types.js'; } from './types.js';
@@ -35,7 +35,12 @@ import { CARD_VERSION, REPO_URL } from './const.js';
import { FrigateCardElements } from './components/elements.js'; import { FrigateCardElements } from './components/elements.js';
import { FrigateCardMenu, MENU_HEIGHT } from './components/menu.js'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu.js';
import { View } from './view.js'; import { View } from './view.js';
import { homeAssistantWSRequest, shouldUpdateBasedOnHass } from './common.js'; import {
homeAssistantSignPath,
homeAssistantWSRequest,
isValidMediaShowInfo,
shouldUpdateBasedOnHass,
} from './common.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import { renderMessage, renderProgressIndicator } from './components/message.js'; import { renderMessage, renderProgressIndicator } from './components/message.js';
@@ -52,9 +57,7 @@ import './patches/ha-hls-player.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { ResolvedMediaCache } from './resolved-media.js'; import { ResolvedMediaCache } from './resolved-media.js';
import { BrowseMediaUtil } from './browse-media-util.js';
const MEDIA_HEIGHT_CUTOFF = 50;
const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF;
/** A note on media callbacks: /** A note on media callbacks:
* *
@@ -90,7 +93,9 @@ console.info(
documentationURL: REPO_URL, documentationURL: REPO_URL,
}); });
// Main FrigateCard class. /**
* Main FrigateCard class.
*/
@customElement('frigate-card') @customElement('frigate-card')
export class FrigateCard extends LitElement { export class FrigateCard extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -118,7 +123,7 @@ export class FrigateCard extends LitElement {
protected _entitiesToMonitor: string[] = []; protected _entitiesToMonitor: string[] = [];
// Information about the most recently loaded media item. // Information about the most recently loaded media item.
protected _mediaInfo: MediaLoadInfo | null = null; protected _mediaShowInfo: MediaShowInfo | null = null;
// Array of dynamic menu buttons to be added to menu. // Array of dynamic menu buttons to be added to menu.
protected _dynamicMenuButtons: MenuButton[] = []; protected _dynamicMenuButtons: MenuButton[] = [];
@@ -137,6 +142,9 @@ export class FrigateCard extends LitElement {
// A cache of resolved media URLs/mimetypes for use in the whole card. // A cache of resolved media URLs/mimetypes for use in the whole card.
protected _resolvedMediaCache = new ResolvedMediaCache(); protected _resolvedMediaCache = new ResolvedMediaCache();
/**
* Set the Home Assistant object.
*/
set hass(hass: HomeAssistant & ExtendedHomeAssistant) { set hass(hass: HomeAssistant & ExtendedHomeAssistant) {
this._hass = hass; this._hass = hass;
@@ -153,12 +161,20 @@ export class FrigateCard extends LitElement {
} }
} }
// Get the configuration element. /**
* Get the card editor element.
* @returns A LovelaceCardEditor element.
*/
public static async getConfigElement(): Promise<LovelaceCardEditor> { public static async getConfigElement(): Promise<LovelaceCardEditor> {
return document.createElement('frigate-card-editor'); return document.createElement('frigate-card-editor');
} }
// Get a stub basic config using the first available camera of any kind. /**
* Get a stub basic config using the first available camera of any kind.
* @param _hass The Home Assistant object.
* @param entities The entities available to Home Assistant.
* @returns A valid stub card configuration.
*/
public static getStubConfig( public static getStubConfig(
_hass: HomeAssistant, _hass: HomeAssistant,
entities: string[], entities: string[],
@@ -169,6 +185,10 @@ export class FrigateCard extends LitElement {
} as FrigateCardConfig; } as FrigateCardConfig;
} }
/**
* Get the menu buttons to display.
* @returns An array of menu buttons.
*/
protected _getMenuButtons(): MenuButton[] { protected _getMenuButtons(): MenuButton[] {
const buttons: MenuButton[] = []; const buttons: MenuButton[] = [];
@@ -215,6 +235,14 @@ export class FrigateCard extends LitElement {
emphasize: this._view.is('image'), emphasize: this._view.is('image'),
}); });
} }
if (this._view.isViewerView() && (this.config.menu_buttons?.download ?? true)) {
buttons.push({
type: 'internal-menu-icon',
card_action: 'download',
title: localize('menu.download'),
icon: 'mdi:download',
});
}
if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) {
buttons.push({ buttons.push({
type: 'internal-menu-icon', type: 'internal-menu-icon',
@@ -234,6 +262,10 @@ export class FrigateCard extends LitElement {
return buttons.concat(this._dynamicMenuButtons); return buttons.concat(this._dynamicMenuButtons);
} }
/**
* Add a dynamic (elements) menu button.
* @param button The button to add.
*/
public _addDynamicMenuButton(button: MenuButton): void { public _addDynamicMenuButton(button: MenuButton): void {
if (!this._dynamicMenuButtons.includes(button)) { if (!this._dynamicMenuButtons.includes(button)) {
this._dynamicMenuButtons = [...this._dynamicMenuButtons, button]; this._dynamicMenuButtons = [...this._dynamicMenuButtons, button];
@@ -241,6 +273,10 @@ export class FrigateCard extends LitElement {
this._menu.buttons = this._getMenuButtons(); this._menu.buttons = this._getMenuButtons();
} }
/**
* Remove a dynamic (elements) menu button that was previously added.
* @param target The button to remove.
*/
public _removeDynamicMenuButton(target: MenuButton): void { public _removeDynamicMenuButton(target: MenuButton): void {
this._dynamicMenuButtons = this._dynamicMenuButtons.filter( this._dynamicMenuButtons = this._dynamicMenuButtons.filter(
(button) => button != target, (button) => button != target,
@@ -248,6 +284,10 @@ export class FrigateCard extends LitElement {
this._menu.buttons = this._getMenuButtons(); this._menu.buttons = this._getMenuButtons();
} }
/**
* Get the Frigate camera name through a variety of means.
* @returns The Frigate camera name or null if unavailable.
*/
protected async _getFrigateCameraName(): Promise<string | null> { protected async _getFrigateCameraName(): Promise<string | null> {
// No camera name specified, apply two heuristics in this order: // No camera name specified, apply two heuristics in this order:
// - Get the entity information and pull out the camera name from the unique_id. // - Get the entity information and pull out the camera name from the unique_id.
@@ -293,6 +333,11 @@ export class FrigateCard extends LitElement {
return null; return null;
} }
/**
* Get configuration parse errors.
* @param error The ZodError object from parsing.
* @returns An array of string error paths.
*/
protected _getParseErrorPaths<T>(error: z.ZodError<T>): string[] { protected _getParseErrorPaths<T>(error: z.ZodError<T>): string[] {
/* Zod errors involving unions are complex, as Zod may not be able to tell /* Zod errors involving unions are complex, as Zod may not be able to tell
* where the 'real' error is vs simply a union option not matching. This * where the 'real' error is vs simply a union option not matching. This
@@ -327,8 +372,12 @@ export class FrigateCard extends LitElement {
return contenders; return contenders;
} }
// Convert an array of strings and indices into a more human readable string, /**
// e.g. [a, 1, b, 2] => 'a[1] -> b[2]' * Convert an array of strings and indices into a more human readable string,
* e.g. [a, 1, b, 2] => 'a[1] -> b[2]'
* @param path An array of strings and numbers.
* @returns A single string.
*/
protected _getParseErrorPathString(path: (string | number)[]): string { protected _getParseErrorPathString(path: (string | number)[]): string {
let out = ''; let out = '';
for (let i = 0; i < path.length; i++) { for (let i = 0; i < path.length; i++) {
@@ -344,7 +393,10 @@ export class FrigateCard extends LitElement {
return out; return out;
} }
// Set the object configuration. /**
* Set the card configuration.
* @param inputConfig The card configuration.
*/
public setConfig(inputConfig: FrigateCardConfig): void { public setConfig(inputConfig: FrigateCardConfig): void {
if (!inputConfig) { if (!inputConfig) {
throw new Error(localize('error.invalid_configuration:')); throw new Error(localize('error.invalid_configuration:'));
@@ -390,7 +442,11 @@ export class FrigateCard extends LitElement {
this._changeView(e.detail); this._changeView(e.detail);
} }
// Determine whether the card should be updated. /**
* Determine whether the card should be updated.
* @param changedProps The changed properties if any.
* @returns True if the card should be updated.
*/
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
if (!this.config) { if (!this.config) {
return false; return false;
@@ -415,6 +471,65 @@ export class FrigateCard extends LitElement {
return true; return true;
} }
/**
* Download media being displayed in the viewer.
*/
protected async _downloadViewerMedia(): Promise<void> {
if (!this._hass || !this._view.isViewerView()) {
// Should not occur.
return;
}
if (!this._view.media) {
this._setMessageAndUpdate({
message: localize('error.download_no_media'),
type: 'error',
})
return;
}
const event_id = BrowseMediaUtil.extractEventID(this._view.media);
if (!event_id) {
this._setMessageAndUpdate({
message: localize('error.download_no_event_id'),
type: 'error',
})
return;
}
const path =
`/api/frigate/${this.config.frigate_client_id}` +
`/notifications/${event_id}/` +
`${this._view.isClipRelatedView() ? 'clip.mp4': 'snapshot.jpg'}` +
`?download=true`;
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(this._hass, path);
} catch (e) {
console.error(e, (e as Error).stack);
}
if (!response) {
this._setMessageAndUpdate({
message: localize('error.download_sign_failed'),
type: 'error',
})
return;
}
// Use the HTML5 download attribute to prevent a new window from temporarily
// opening.
const link = document.createElement('a');
link.setAttribute('download', '');
link.href = response;
link.click();
link.remove();
}
/**
* Handle a menu button being clicked.
* @param action The action to be called from the clicked button.
* @param button The button that was clicked.
*/
protected _menuActionHandler(action: string, button: MenuButton): void { protected _menuActionHandler(action: string, button: MenuButton): void {
if (button.type != 'internal-menu-icon') { if (button.type != 'internal-menu-icon') {
handleAction(this, this._hass as HomeAssistant, button, action); handleAction(this, this._hass as HomeAssistant, button, action);
@@ -431,6 +546,9 @@ export class FrigateCard extends LitElement {
case 'snapshots': case 'snapshots':
this._changeView(new View({ view: button.card_action })); this._changeView(new View({ view: button.card_action }));
break; break;
case 'download':
this._downloadViewerMedia();
break;
case 'frigate_ui': case 'frigate_ui':
const frigate_url = this._getFrigateURLFromContext(); const frigate_url = this._getFrigateURLFromContext();
if (frigate_url) { if (frigate_url) {
@@ -447,7 +565,10 @@ export class FrigateCard extends LitElement {
} }
} }
// Get the Frigate UI url. /**
* Get the Frigate UI URL from context.
* @returns The URL or null if unavailable.
*/
protected _getFrigateURLFromContext(): string | null { protected _getFrigateURLFromContext(): string | null {
if (!this.config.frigate_url) { if (!this.config.frigate_url) {
return null; return null;
@@ -460,7 +581,9 @@ export class FrigateCard extends LitElement {
return `${this.config.frigate_url}/events?camera=${this._frigateCameraName}`; return `${this.config.frigate_url}/events?camera=${this._frigateCameraName}`;
} }
// Record interactions with the card. /**
* Handle interaction with the card.
*/
protected _interactionHandler(): void { protected _interactionHandler(): void {
if (!this.config.view_timeout) { if (!this.config.view_timeout) {
return; return;
@@ -474,6 +597,10 @@ export class FrigateCard extends LitElement {
}, this.config.view_timeout * 1000); }, this.config.view_timeout * 1000);
} }
/**
* Render the card menu.
* @returns A rendered template.
*/
protected _renderMenu(): TemplateResult | void { protected _renderMenu(): TemplateResult | void {
const classes = { const classes = {
'hover-menu': this.config.menu_mode.startsWith('hover-'), 'hover-menu': this.config.menu_mode.startsWith('hover-'),
@@ -523,6 +650,11 @@ export class FrigateCard extends LitElement {
this._mediaPlaying = false; this._mediaPlaying = false;
} }
/**
* Set the message to display and trigger an update.
* @param message The message to display.
* @param skipUpdate If true an update request is skipped.
*/
protected _setMessageAndUpdate(message: Message, skipUpdate?: boolean): void { protected _setMessageAndUpdate(message: Message, skipUpdate?: boolean): void {
// Register the first message, or prioritize errors if there's pre-render competition. // Register the first message, or prioritize errors if there's pre-render competition.
if (!this._message || (message.type == 'error' && this._message.type != 'error')) { if (!this._message || (message.type == 'error' && this._message.type != 'error')) {
@@ -533,44 +665,63 @@ export class FrigateCard extends LitElement {
} }
} }
/**
* Handle a message event to render to the user.
* @param e The message event.
*/
protected _messageHandler(e: CustomEvent<Message>): void { protected _messageHandler(e: CustomEvent<Message>): void {
return this._setMessageAndUpdate(e.detail); return this._setMessageAndUpdate(e.detail);
} }
protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void { /**
const mediaInfo = e.detail; * Handle a new piece of media being shown.
* @param e Event with MediaShowInfo details for the media.
*/
protected _mediaShowHandler(e: CustomEvent<MediaShowInfo>): void {
const mediaShowInfo = e.detail;
// In Safari, with WebRTC, 0x0 is occasionally returned during loading, // In Safari, with WebRTC, 0x0 is occasionally returned during loading,
// so treat anything less than a safety cutoff as bogus. // so treat anything less than a safety cutoff as bogus.
if (mediaInfo.height < MEDIA_HEIGHT_CUTOFF || mediaInfo.width < MEDIA_WIDTH_CUTOFF) { if (!isValidMediaShowInfo(mediaShowInfo)) {
return; return;
} }
let requestRefresh = false; let requestRefresh = false;
if ( if (
this._isAspectRatioEnforced() && this._isAspectRatioEnforced() &&
(mediaInfo.width != this._mediaInfo?.width || (mediaShowInfo.width != this._mediaShowInfo?.width ||
mediaInfo.height != this._mediaInfo?.height) mediaShowInfo.height != this._mediaShowInfo?.height)
) { ) {
requestRefresh = true; requestRefresh = true;
} }
this._mediaInfo = mediaInfo; this._mediaShowInfo = mediaShowInfo;
if (requestRefresh) { if (requestRefresh) {
this.requestUpdate(); this.requestUpdate();
} }
} }
/**
* Handler called when fullscreen is toggled.
*/
protected _fullScreenHandler(): void { protected _fullScreenHandler(): void {
// Re-render after a change to fullscreen mode to take advantage of // Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked // the expanded screen real-estate (vs staying in aspect-ratio locked
// modes). // modes).
this.requestUpdate(); this.requestUpdate();
} }
/**
* Component connected callback.
*/
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
if (screenfull.isEnabled) { if (screenfull.isEnabled) {
screenfull.on('change', this._fullScreenHandler.bind(this)); screenfull.on('change', this._fullScreenHandler.bind(this));
} }
} }
/**
* Component disconnected callback.
*/
disconnectedCallback(): void { disconnectedCallback(): void {
if (screenfull.isEnabled) { if (screenfull.isEnabled) {
screenfull.off('change', this._fullScreenHandler.bind(this)); screenfull.off('change', this._fullScreenHandler.bind(this));
@@ -578,6 +729,10 @@ export class FrigateCard extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
/**
* Determine if the aspect ratio should be enforced given the current view and
* context.
*/
protected _isAspectRatioEnforced(): boolean { protected _isAspectRatioEnforced(): boolean {
const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic';
@@ -593,14 +748,19 @@ export class FrigateCard extends LitElement {
); );
} }
/**
* Get the aspect ratio padding required to enforce the aspect ratio (if it is
* required).
* @returns A padding percentage.
*/
protected _getAspectRatioPadding(): number | null { protected _getAspectRatioPadding(): number | null {
if (!this._isAspectRatioEnforced()) { if (!this._isAspectRatioEnforced()) {
return null; return null;
} }
const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic';
if (aspect_ratio_mode == 'dynamic' && this._mediaInfo) { if (aspect_ratio_mode == 'dynamic' && this._mediaShowInfo) {
return (this._mediaInfo.height / this._mediaInfo.width) * 100; return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100;
} }
const default_aspect_ratio = this.config.dimensions?.aspect_ratio; const default_aspect_ratio = this.config.dimensions?.aspect_ratio;
@@ -611,7 +771,9 @@ export class FrigateCard extends LitElement {
} }
} }
// Render the call (master render method). /**
* Master render method for the card.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (this.config.show_warning) { if (this.config.show_warning) {
return this._showWarning(localize('common.show_warning')); return this._showWarning(localize('common.show_warning'));
@@ -643,8 +805,8 @@ export class FrigateCard extends LitElement {
screenfull.isEnabled && screenfull.isEnabled &&
screenfull.isFullscreen && screenfull.isFullscreen &&
this._view.isMediaView() && this._view.isMediaView() &&
this._mediaInfo && this._mediaShowInfo &&
this._mediaInfo.width / this._mediaInfo.height < this._mediaShowInfo.width / this._mediaShowInfo.height <
window.innerWidth / window.innerHeight window.innerWidth / window.innerHeight
) { ) {
// If the menu is outside the media (i.e. above/below) allow space for it. // If the menu is outside the media (i.e. above/below) allow space for it.
@@ -652,7 +814,7 @@ export class FrigateCard extends LitElement {
? MENU_HEIGHT ? MENU_HEIGHT
: 0; : 0;
innerStyle['max-width'] = `calc(${ innerStyle['max-width'] = `calc(${
(100 * this._mediaInfo.width) / this._mediaInfo.height (100 * this._mediaShowInfo.width) / this._mediaShowInfo.height
}vh - ${allowance}px )`; }vh - ${allowance}px )`;
} }
@@ -680,6 +842,9 @@ export class FrigateCard extends LitElement {
</ha-card>`; </ha-card>`;
} }
/**
* Sub-render method for the card.
*/
protected _render(): TemplateResult | void { protected _render(): TemplateResult | void {
if (!this._hass) { if (!this._hass) {
return html``; return html``;
@@ -703,8 +868,7 @@ export class FrigateCard extends LitElement {
hidden: this.config.live_preload && !this._view.isGalleryView(), hidden: this.config.live_preload && !this._view.isGalleryView(),
}; };
const viewerClasses = { const viewerClasses = {
hidden: hidden: this.config.live_preload && !this._view.isViewerView(),
this.config.live_preload && !['clip', 'snapshot'].includes(this._view.view),
}; };
const liveClasses = { const liveClasses = {
hidden: this.config.live_preload && this._view.view != 'live', hidden: this.config.live_preload && this._view.view != 'live',
@@ -720,7 +884,7 @@ export class FrigateCard extends LitElement {
? html` <frigate-card-image ? html` <frigate-card-image
.image=${this.config.image} .image=${this.config.image}
class="${classMap(imageClasses)}" class="${classMap(imageClasses)}"
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:message=${this._messageHandler} @frigate-card:message=${this._messageHandler}
> >
</frigate-card-image>` </frigate-card-image>`
@@ -736,7 +900,7 @@ export class FrigateCard extends LitElement {
> >
</frigate-card-gallery>` </frigate-card-gallery>`
: ``} : ``}
${!this._message && (this._view.is('clip') || this._view.is('snapshot')) ${!this._message && this._view.isViewerView()
? html` <frigate-card-viewer ? html` <frigate-card-viewer
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
@@ -747,7 +911,7 @@ export class FrigateCard extends LitElement {
.lazyLoad=${this.config.event_viewer?.lazy_load ?? true} .lazyLoad=${this.config.event_viewer?.lazy_load ?? true}
class="${classMap(viewerClasses)}" class="${classMap(viewerClasses)}"
@frigate-card:change-view=${this._changeViewHandler} @frigate-card:change-view=${this._changeViewHandler}
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler} @frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler} @frigate-card:message=${this._messageHandler}
@@ -764,7 +928,7 @@ export class FrigateCard extends LitElement {
.config=${this.config} .config=${this.config}
.frigateCameraName=${this._frigateCameraName} .frigateCameraName=${this._frigateCameraName}
class="${classMap(liveClasses)}" class="${classMap(liveClasses)}"
@frigate-card:media-load=${this._mediaLoadHandler} @frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:pause=${this._pauseHandler} @frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler} @frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler} @frigate-card:message=${this._messageHandler}
@@ -799,12 +963,20 @@ export class FrigateCard extends LitElement {
`; `;
} }
// Show a warning card. /**
* Show a warning card.
* @param warning The warning message.
* @returns A rendered template.
*/
private _showWarning(warning: string): TemplateResult { private _showWarning(warning: string): TemplateResult {
return html` <hui-warning> ${warning} </hui-warning> `; return html` <hui-warning> ${warning} </hui-warning> `;
} }
// Show an error card. /**
* Show an error card.
* @param error The error message.
* @returns A rendered template.
*/
private _showError(error: string): TemplateResult { private _showError(error: string): TemplateResult {
const errorCard = document.createElement('hui-error-card'); const errorCard = document.createElement('hui-error-card');
errorCard.setConfig({ errorCard.setConfig({
@@ -816,15 +988,20 @@ export class FrigateCard extends LitElement {
return html` ${errorCard} `; return html` ${errorCard} `;
} }
// Return compiled CSS styles (thus safe to use with unsafeCSS). /**
* Return compiled CSS styles (thus safe to use with unsafeCSS).
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(cardStyle); return unsafeCSS(cardStyle);
} }
// Get the Lovelace card size. /**
* Get the Lovelace card size.
* @returns The Lovelace card size in units of 50px.
*/
public getCardSize(): number { public getCardSize(): number {
if (this._mediaInfo) { if (this._mediaShowInfo) {
return this._mediaInfo.height / 50; return this._mediaShowInfo.height / 50;
} }
return 6; return 6;
} }
+129 -79
View File
@@ -2,20 +2,34 @@ import { ZodSchema, z } from 'zod';
import { MessageBase } from 'home-assistant-js-websocket'; import { MessageBase } from 'home-assistant-js-websocket';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import type { import {
BrowseMediaQueryParameters,
BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MediaLoadInfo, MediaShowInfo,
Message, Message,
SignedPath,
signedPathSchema,
} from './types.js'; } from './types.js';
import { browseMediaSourceSchema } from './types.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
/**
* Get the keys that didn't parse from a ZodError.
* @param error The zoderror to extract the keys from.
* @returns An array of error keys.
*/
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] { export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
const errors = error.format(); const errors = error.format();
return Object.keys(errors).filter((v) => !v.startsWith('_')); return Object.keys(errors).filter((v) => !v.startsWith('_'));
} }
/**
* Make a HomeAssistant websocket request. May throw.
* @param hass The HomeAssistant object to send the request with.
* @param schema The expected Zod schema of the response.
* @param request The request to make.
* @returns The parsed valid response or null on malformed.
*/
export async function homeAssistantWSRequest<T>( export async function homeAssistantWSRequest<T>(
hass: HomeAssistant & ExtendedHomeAssistant, hass: HomeAssistant & ExtendedHomeAssistant,
schema: ZodSchema<T>, schema: ZodSchema<T>,
@@ -43,66 +57,41 @@ export async function homeAssistantWSRequest<T>(
return parseResult.data; return parseResult.data;
} }
export function isTrueMedia(media: BrowseMediaSource): boolean { /**
return !media.can_expand; * Request that HA sign a path. May throw.
} * @param hass The HomeAssistant object used to request the signature.
* @param path The path to sign.
// From a BrowseMediaSource item extract the first true media item (i.e. a * @param expires An optional number of seconds to sign the path for.
// clip/snapshot, not a folder). * @returns The signed URL, or null if the response was malformed.
export function getFirstTrueMediaChildIndex( */
media: BrowseMediaSource | null, export async function homeAssistantSignPath(
): number | null {
if (!media || !media.children) {
return null;
}
for (let i = 0; i < media.children.length; i++) {
if (isTrueMedia(media.children[i])) {
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, hass: HomeAssistant & ExtendedHomeAssistant,
params: BrowseMediaQueryParameters, path: string,
): Promise<BrowseMediaSource | null> { expires?: number,
return browseMedia( ): Promise<string | null> {
const request = {
type: 'auth/sign_path',
path: path,
expires: expires,
};
const response = await homeAssistantWSRequest<SignedPath>(
hass, hass,
// Defined in: signedPathSchema,
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py request,
[
'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('/'),
); );
if (!response) {
return null;
}
return hass.hassUrl(response.path);
} }
export function dispatchEvent<T>(element: HTMLElement, name: string, detail?: T): void { /**
* Dispatch a Frigate Card event.
* @param element The element to send the event.
* @param name The name of the Frigate card event to send.
* @param detail An optional detail object to attach.
*/
export function dispatchFrigateCardEvent<T>(element: HTMLElement, name: string, detail?: T): void {
element.dispatchEvent( element.dispatchEvent(
new CustomEvent<T>(`frigate-card:${name}`, { new CustomEvent<T>(`frigate-card:${name}`, {
bubbles: true, bubbles: true,
@@ -112,18 +101,28 @@ export function dispatchEvent<T>(element: HTMLElement, name: string, detail?: T)
); );
} }
/**
* Dispatch a Frigate card play event.
* @param element The element to send the event.
*/
export function dispatchPlayEvent(element: HTMLElement): void { export function dispatchPlayEvent(element: HTMLElement): void {
dispatchEvent(element, 'play'); dispatchFrigateCardEvent(element, 'play');
} }
/**
* Dispatch a Frigate card pause event.
* @param element The element to send the event.
*/
export function dispatchPauseEvent(element: HTMLElement): void { export function dispatchPauseEvent(element: HTMLElement): void {
dispatchEvent(element, 'pause'); dispatchFrigateCardEvent(element, 'pause');
} }
export function dispatchMediaLoadEvent( /**
element: HTMLElement, * Create a MediaShowInfo object.
source: Event | HTMLElement, * @param source An event or HTMLElement that should be used as a source.
): void { * @returns A new MediaShowInfo object or null if one could not be created.
*/
export function createMediaShowInfo(source: Event | HTMLElement): MediaShowInfo | null {
let target: HTMLElement | EventTarget; let target: HTMLElement | EventTarget;
if (source instanceof Event) { if (source instanceof Event) {
target = source.composedPath()[0]; target = source.composedPath()[0];
@@ -132,46 +131,88 @@ export function dispatchMediaLoadEvent(
} }
if (target instanceof HTMLImageElement) { if (target instanceof HTMLImageElement) {
dispatchEvent<MediaLoadInfo>(element, 'media-load', { return {
width: (target as HTMLImageElement).naturalWidth, width: (target as HTMLImageElement).naturalWidth,
height: (target as HTMLImageElement).naturalHeight, height: (target as HTMLImageElement).naturalHeight,
}); };
} else if (target instanceof HTMLVideoElement) { } else if (target instanceof HTMLVideoElement) {
dispatchEvent<MediaLoadInfo>(element, 'media-load', { return {
width: (target as HTMLVideoElement).videoWidth, width: (target as HTMLVideoElement).videoWidth,
height: (target as HTMLVideoElement).videoHeight, height: (target as HTMLVideoElement).videoHeight,
}); };
} else if (target instanceof HTMLCanvasElement) { } else if (target instanceof HTMLCanvasElement) {
dispatchEvent<MediaLoadInfo>(element, 'media-load', { return {
width: (target as HTMLCanvasElement).width, width: (target as HTMLCanvasElement).width,
height: (target as HTMLCanvasElement).height, height: (target as HTMLCanvasElement).height,
}); };
}
return null;
}
/**
* Dispatch a Frigate card media show event.
* @param element The element to send the event.
* @param source An event or HTMLElement that should be used as a source.
*/
export function dispatchMediaShowEvent(
element: HTMLElement,
source: Event | HTMLElement,
): void {
const mediaShowInfo = createMediaShowInfo(source);
if (mediaShowInfo) {
dispatchExistingMediaShowInfoAsEvent(element, mediaShowInfo);
} }
} }
/**
* Dispatch a pre-existing MediaShowInfo object as an event.
* @param element The element to send the event.
* @param mediaShowInfo The MediaShowInfo object to send.
*/
export function dispatchExistingMediaShowInfoAsEvent(
element: HTMLElement,
mediaShowInfo: MediaShowInfo,
): void {
dispatchFrigateCardEvent<MediaShowInfo>(element, 'media-show', mediaShowInfo);
}
/**
* Dispatch an event with a message to show to the user.
* @param element The element to send the event.
* @param message The message to show.
* @param icon An optional icon to attach to the message.
*/
export function dispatchMessageEvent( export function dispatchMessageEvent(
element: HTMLElement, element: HTMLElement,
message: string, message: string,
icon?: string, icon?: string,
): void { ): void {
dispatchEvent<Message>(element, 'message', { dispatchFrigateCardEvent<Message>(element, 'message', {
message: message, message: message,
type: 'info', type: 'info',
icon: icon, icon: icon,
}); });
} }
export function dispatchErrorMessageEvent( /**
element: HTMLElement, * Dispatch an event with an error message to show to the user.
message: string, * @param element The element to send the event.
): void { * @param message The message to show.
dispatchEvent<Message>(element, 'message', { */
export function dispatchErrorMessageEvent(element: HTMLElement, message: string): void {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: message, message: message,
type: 'error', type: 'error',
}); });
} }
// Determine whether the card should be updated based on Home Assistant changes. /**
* Determine whether the card should be updated based on Home Assistant changes.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @returns A boolean indicating whether or not to allow an update.
*/
export function shouldUpdateBasedOnHass( export function shouldUpdateBasedOnHass(
newHass: HomeAssistant | null, newHass: HomeAssistant | null,
oldHass: HomeAssistant | undefined, oldHass: HomeAssistant | undefined,
@@ -198,3 +239,12 @@ export function shouldUpdateBasedOnHass(
} }
return false; return false;
} }
/**
* Determine if a MediaShowInfo object is valid/acceptable.
* @param info The MediaShowInfo object.
* @returns True if the object is valid, false otherwise.
*/
export function isValidMediaShowInfo(info: MediaShowInfo): boolean {
return info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF;
}
+8 -4
View File
@@ -10,7 +10,7 @@ import {
MenuStateIcon, MenuStateIcon,
PictureElements, PictureElements,
} from '../types.js'; } from '../types.js';
import { dispatchErrorMessageEvent, dispatchEvent } from '../common.js'; import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js';
import elementsStyle from '../scss/elements.scss'; import elementsStyle from '../scss/elements.scss';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
@@ -134,7 +134,11 @@ export class FrigateCardElements extends LitElement {
protected _menuRemoveHandler(ev: Event): void { protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as // Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element). // there is no parent of the disconnected element).
dispatchEvent<MenuButton>(this, 'menu-remove', (ev as CustomEvent).detail); dispatchFrigateCardEvent<MenuButton>(
this,
'menu-remove',
(ev as CustomEvent).detail,
);
} }
protected _menuAddHandler(ev: Event): void { protected _menuAddHandler(ev: Event): void {
@@ -274,13 +278,13 @@ export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
if (this._config) { if (this._config) {
dispatchEvent<T>(this, 'menu-add', this._config); dispatchFrigateCardEvent<T>(this, 'menu-add', this._config);
} }
} }
disconnectedCallback(): void { disconnectedCallback(): void {
if (this._config) { if (this._config) {
dispatchEvent<T>(this, 'menu-remove', this._config); dispatchFrigateCardEvent<T>(this, 'menu-remove', this._config);
} }
super.disconnectedCallback(); super.disconnectedCallback();
} }
+8 -11
View File
@@ -1,28 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { until } from 'lit/directives/until.js';
import type { import type {
BrowseMediaSource, BrowseMediaSource,
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
ExtendedHomeAssistant, ExtendedHomeAssistant,
} from '../types.js'; } from '../types.js';
import { BrowseMediaUtil } from '../browse-media-util.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { import {
browseMedia,
browseMediaQuery,
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchMessageEvent, dispatchMessageEvent,
getFirstTrueMediaChildIndex,
} from '../common.js'; } from '../common.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { renderProgressIndicator } from './message.js'; import { renderProgressIndicator } from './message.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { styleMap } from 'lit/directives/style-map.js';
const MAX_THUMBNAIL_WIDTH = 175; const MAX_THUMBNAIL_WIDTH = 175;
const DEFAULT_COLUMNS = 5; const DEFAULT_COLUMNS = 5;
@@ -79,15 +76,15 @@ export class FrigateCardGallery extends LitElement {
let parent: BrowseMediaSource | null; let parent: BrowseMediaSource | null;
try { try {
if (this.view.target) { if (this.view.target) {
parent = await browseMedia(this.hass, this.view.target.media_content_id); parent = await BrowseMediaUtil.browseMedia(this.hass, this.view.target.media_content_id);
} else { } else {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); parent = await BrowseMediaUtil.browseMediaQuery(this.hass, this.browseMediaQueryParameters);
} }
} catch (e: any) { } catch (e: any) {
return dispatchErrorMessageEvent(this, e.message); return dispatchErrorMessageEvent(this, e.message);
} }
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { if (!parent || !parent.children || BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) == null) {
return dispatchMessageEvent( return dispatchMessageEvent(
this, this,
this._getMediaType() == 'clips' this._getMediaType() == 'clips'
@@ -149,7 +146,7 @@ export class FrigateCardGallery extends LitElement {
src="${child.thumbnail}" src="${child.thumbnail}"
@click=${() => { @click=${() => {
new View({ new View({
view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot', view: this._getMediaType() == 'clips' ? 'clip-specific' : 'snapshot-specific',
target: parent ?? undefined, target: parent ?? undefined,
childIndex: index, childIndex: index,
previous: this.view ?? undefined, previous: this.view ?? undefined,
+2 -2
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { dispatchMediaLoadEvent } from '../common.js'; import { dispatchMediaShowEvent } from '../common.js';
import imageStyle from '../scss/image.scss'; import imageStyle from '../scss/image.scss';
import defaultImage from '../images/frigate-bird-in-sky.jpg' import defaultImage from '../images/frigate-bird-in-sky.jpg'
@@ -15,7 +15,7 @@ export class FrigateCardImage extends LitElement {
return html` <img return html` <img
src=${this.image || defaultImage} src=${this.image || defaultImage}
@load=${(e) => { @load=${(e) => {
dispatchMediaLoadEvent(this, e); dispatchMediaShowEvent(this, e);
}} }}
>`; >`;
} }
+15 -18
View File
@@ -1,19 +1,17 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
import { HomeAssistant } from 'custom-card-helpers';
import { signedPathSchema } from '../types.js';
import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { import {
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchMediaLoadEvent, dispatchMediaShowEvent,
dispatchMessageEvent, dispatchMessageEvent,
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
homeAssistantWSRequest, homeAssistantSignPath,
} from '../common.js'; } from '../common.js';
import { renderProgressIndicator } from '../components/message.js'; import { renderProgressIndicator } from '../components/message.js';
@@ -142,7 +140,7 @@ export class FrigateCardLiveWebRTC extends LitElement {
if (onloadedmetadata) { if (onloadedmetadata) {
onloadedmetadata.call(video, e); onloadedmetadata.call(video, e);
} }
dispatchMediaLoadEvent(this, video); dispatchMediaShowEvent(this, video);
}; };
video.onplay = (e) => { video.onplay = (e) => {
if (onplay) { if (onplay) {
@@ -184,21 +182,20 @@ export class FrigateCardLiveJSMPEG extends LitElement {
return null; return null;
} }
const request = { let response: string | null | undefined;
type: 'auth/sign_path',
path: `/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`,
expires: URL_SIGN_EXPIRY_SECONDS,
};
// Sign the path so it includes an authSig parameter.
let response;
try { try {
response = await homeAssistantWSRequest(this.hass, signedPathSchema, request); response = await homeAssistantSignPath(
this.hass,
`/api/frigate/${this.clientId}` + `/jsmpeg/${this.cameraName}`,
URL_SIGN_EXPIRY_SECONDS);
} catch (err) { } catch (err) {
console.warn(err); console.warn(err);
return null; return null;
} }
const url = this.hass.hassUrl(response.path); if (!response) {
return url.replace(/^http/i, 'ws'); return null;
}
return response.replace(/^http/i, 'ws');
} }
protected _createJSMPEGPlayer(): JSMpeg.VideoElement { protected _createJSMPEGPlayer(): JSMpeg.VideoElement {
@@ -229,7 +226,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
// ignore any subsequent calls. // ignore any subsequent calls.
if (!videoDecoded && this._jsmpegCanvasElement) { if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true; videoDecoded = true;
dispatchMediaLoadEvent(this, this._jsmpegCanvasElement); dispatchMediaShowEvent(this, this._jsmpegCanvasElement);
} }
}, },
}, },
+118 -78
View File
@@ -6,47 +6,40 @@ import {
unsafeCSS, unsafeCSS,
PropertyValues, PropertyValues,
} from 'lit'; } from 'lit';
import { BrowseMediaUtil } from '../browse-media-util.js';
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { ifDefined } from 'lit-html/directives/if-defined.js'; import { ifDefined } from 'lit-html/directives/if-defined.js';
import { until } from 'lit/directives/until.js';
import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
import type { import type {
BrowseMediaNeighbors, BrowseMediaNeighbors,
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
BrowseMediaSource, BrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MediaShowInfo,
NextPreviousControlStyle, NextPreviousControlStyle,
} from '../types.js'; } from '../types.js';
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
import { localize } from '../localize/localize.js'; import { View } from '../view.js';
import { import {
browseMediaQuery, createMediaShowInfo,
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchMediaLoadEvent,
dispatchMessageEvent, dispatchMessageEvent,
dispatchPauseEvent, dispatchPauseEvent,
dispatchPlayEvent, dispatchPlayEvent,
getFirstTrueMediaChildIndex, dispatchExistingMediaShowInfoAsEvent,
isTrueMedia, isValidMediaShowInfo,
} from '../common.js'; } from '../common.js';
import { localize } from '../localize/localize.js';
import { View } from '../view.js';
import { renderProgressIndicator } from '../components/message.js'; import { renderProgressIndicator } from '../components/message.js';
import './next-prev-control.js'; import './next-prev-control.js';
import viewerStyle from '../scss/viewer.scss'; import viewerStyle from '../scss/viewer.scss';
const IMG_TRANSPARENT_1x1 = const IMG_EMPTY = 'data:,';
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
// Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format);
@customElement('frigate-card-viewer') @customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement { export class FrigateCardViewer extends LitElement {
@@ -71,10 +64,6 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected lazyLoad?: boolean; protected lazyLoad?: boolean;
protected render(): TemplateResult | void {
return html`${until(this._render(), renderProgressIndicator())}`;
}
/** /**
* Resolve all the given media for a target. * Resolve all the given media for a target.
* @param target The target to resolve media from. * @param target The target to resolve media from.
@@ -89,7 +78,7 @@ export class FrigateCardViewer extends LitElement {
let errorFree = true; let errorFree = true;
for (let i = 0; target.children && i < (target.children || []).length; ++i) { for (let i = 0; target.children && i < (target.children || []).length; ++i) {
if (isTrueMedia(target.children[i])) { if (BrowseMediaUtil.isTrueMedia(target.children[i])) {
errorFree &&= !!(await ResolvedMediaUtil.resolveMedia( errorFree &&= !!(await ResolvedMediaUtil.resolveMedia(
this.hass, this.hass,
target.children[i], target.children[i],
@@ -100,6 +89,10 @@ export class FrigateCardViewer extends LitElement {
return errorFree; return errorFree;
} }
protected render(): TemplateResult | void {
return html`${until(this._render(), renderProgressIndicator())}`;
}
/** /**
* Asyncronously render the element. * Asyncronously render the element.
* @returns A template to render. * @returns A template to render.
@@ -110,16 +103,18 @@ export class FrigateCardViewer extends LitElement {
} }
let autoplay = true; let autoplay = true;
let view = this.view;
if (!view.target) { if (this.view.is('clip') || this.view.is('snapshot')) {
let parent: BrowseMediaSource | null = null; let parent: BrowseMediaSource | null = null;
try { try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); parent = await BrowseMediaUtil.browseMediaQuery(
this.hass,
this.browseMediaQueryParameters,
);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
const childIndex = getFirstTrueMediaChildIndex(parent); const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) { if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent( return dispatchMessageEvent(
this, this,
@@ -129,11 +124,8 @@ export class FrigateCardViewer extends LitElement {
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off', this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
); );
} }
view = new View({ this.view.target = parent;
view: this.view.view, this.view.childIndex = childIndex;
target: parent,
childIndex: childIndex,
});
// In this block, no clip has been manually selected, so this is loading // 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 // the most recent clip on card load. In this mode, autoplay of the clip
@@ -143,12 +135,12 @@ export class FrigateCardViewer extends LitElement {
autoplay = this.autoplayClip ?? true; autoplay = this.autoplayClip ?? true;
} }
if (view.target && !(await this._resolveAllMediaForTarget(view.target))) { if (this.view.target && !(await this._resolveAllMediaForTarget(this.view.target))) {
return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); return dispatchErrorMessageEvent(this, localize('error.could_not_resolve'));
} }
return html` <frigate-card-viewer-core return html` <frigate-card-viewer-core
.view=${view} .view=${this.view}
.nextPreviousControlStyle=${this.nextPreviousControlStyle} .nextPreviousControlStyle=${this.nextPreviousControlStyle}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
.autoplayClip=${autoplay} .autoplayClip=${autoplay}
@@ -198,6 +190,10 @@ export class FrigateCardViewerCore extends LitElement {
// (Folders are not media items that can be rendered). // (Folders are not media items that can be rendered).
protected _slideToChild: Record<number, number> = {}; protected _slideToChild: Record<number, number> = {};
// A "map" from slide number to MediaShowInfo object or null if the slide has
// been lazy loaded, but the MediaShowInfo object is not yet available.
protected _mediaShowInfo: Record<number, MediaShowInfo | null> = {};
/** /**
* The updated lifecycle callback for this element. * The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render. * @param changedProperties The properties that were changed in this render.
@@ -233,8 +229,9 @@ export class FrigateCardViewerCore extends LitElement {
this._carousel = EmblaCarousel(carouselNode, { this._carousel = EmblaCarousel(carouselNode, {
startIndex: isNaN(startIndex) ? undefined : startIndex, startIndex: isNaN(startIndex) ? undefined : startIndex,
}); });
// Update views based on slide selections. // Update views and dispatch media-show events based on slide selections.
this._carousel.on('select', this._slideSelectHandler.bind(this)); this._carousel.on('select', this._selectSlideSetViewHandler.bind(this));
this._carousel.on('select', this._selectSlideMediaShowHandler.bind(this));
// Lazily load media that is displayed. These handlers are registered // Lazily load media that is displayed. These handlers are registered
// regardless of the value of this.lazyLoad to allow that value to change // regardless of the value of this.lazyLoad to allow that value to change
@@ -245,28 +242,6 @@ export class FrigateCardViewerCore extends LitElement {
} }
} }
/**
* Get the event start time from a media object.
* @param browseMedia The media object to extract the start time from.
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
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 true media items from the current view. * Get the previous and next true media items from the current view.
* @returns A BrowseMediaNeighbors with indices and objects of true media * @returns A BrowseMediaNeighbors with indices and objects of true media
@@ -286,7 +261,7 @@ export class FrigateCardViewerCore extends LitElement {
let prevIndex: number | null = null; let prevIndex: number | null = null;
for (let i = this.view.childIndex - 1; i >= 0; i--) { for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = this.view.target.children[i]; const media = this.view.target.children[i];
if (media && isTrueMedia(media)) { if (media && BrowseMediaUtil.isTrueMedia(media)) {
prevIndex = i; prevIndex = i;
break; break;
} }
@@ -296,7 +271,7 @@ export class FrigateCardViewerCore extends LitElement {
let nextIndex: number | null = null; let nextIndex: number | null = null;
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = this.view.target.children[i]; const media = this.view.target.children[i];
if (media && isTrueMedia(media)) { if (media && BrowseMediaUtil.isTrueMedia(media)) {
nextIndex = i; nextIndex = i;
break; break;
} }
@@ -330,7 +305,7 @@ export class FrigateCardViewerCore extends LitElement {
return null; return null;
} }
const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot); const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot);
if (!snapshotStartTime) { if (!snapshotStartTime) {
return null; return null;
} }
@@ -348,10 +323,10 @@ export class FrigateCardViewerCore extends LitElement {
let latest: number | null = null; let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) { for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i]; const child = this.view.target.children[i];
if (!isTrueMedia(child)) { if (!BrowseMediaUtil.isTrueMedia(child)) {
continue; continue;
} }
const startTime = this._extractEventStartTimeFromBrowseMedia(child); const startTime = BrowseMediaUtil.extractEventStartTime(child);
if (startTime && (earliest === null || startTime < earliest)) { if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime; earliest = startTime;
@@ -367,7 +342,7 @@ export class FrigateCardViewerCore extends LitElement {
let clips: BrowseMediaSource | null; let clips: BrowseMediaSource | null;
try { try {
clips = await browseMediaQuery(this.hass, { clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
...this.browseMediaQueryParameters, ...this.browseMediaQueryParameters,
mediaType: 'clips', mediaType: 'clips',
before: latest, before: latest,
@@ -384,13 +359,13 @@ export class FrigateCardViewerCore extends LitElement {
for (let i = 0; i < clips.children.length; i++) { for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i]; const child = clips.children[i];
if (!isTrueMedia(child)) { if (!BrowseMediaUtil.isTrueMedia(child)) {
continue; continue;
} }
const clipStartTime = this._extractEventStartTimeFromBrowseMedia(child); const clipStartTime = BrowseMediaUtil.extractEventStartTime(child);
if (clipStartTime && clipStartTime === snapshotStartTime) { if (clipStartTime && clipStartTime === snapshotStartTime) {
return new View({ return new View({
view: 'clip', view: 'clip-specific',
target: clips, target: clips,
childIndex: i, childIndex: i,
previous: this.view, previous: this.view,
@@ -403,12 +378,12 @@ export class FrigateCardViewerCore extends LitElement {
/** /**
* Handle the user selecting a new slide in the carousel. * Handle the user selecting a new slide in the carousel.
*/ */
protected _slideSelectHandler(): void { protected _selectSlideSetViewHandler(): void {
if (!this._carousel || !this.view) { if (!this._carousel || !this.view) {
return; return;
} }
// Update the childIndex in the view (without re-render) // Update the childIndex in the view.
const slidesInView = this._carousel.slidesInView(true); const slidesInView = this._carousel.slidesInView(true);
if (slidesInView.length) { if (slidesInView.length) {
const childIndex = this._slideToChild[slidesInView[0]]; const childIndex = this._slideToChild[slidesInView[0]];
@@ -434,8 +409,6 @@ export class FrigateCardViewerCore extends LitElement {
/** /**
* Lazily load media in the carousel. * Lazily load media in the carousel.
* @param eventName The Embla event name that triggered this load.
* // TODO delete eventName above?
*/ */
protected _lazyLoadMediaHandler(): void { protected _lazyLoadMediaHandler(): void {
if (!this.lazyLoad || !this._carousel) { if (!this.lazyLoad || !this._carousel) {
@@ -459,6 +432,12 @@ export class FrigateCardViewerCore extends LitElement {
} }
slidesToLoad.forEach((index) => { slidesToLoad.forEach((index) => {
// Only lazy loads slides that are not already loaded.
if (index in this._mediaShowInfo) {
return;
}
this._mediaShowInfo[index] = null;
const slide = slides[index]; const slide = slides[index];
// Snapshots. // Snapshots.
@@ -505,7 +484,7 @@ export class FrigateCardViewerCore extends LitElement {
this._slideToChild = {}; this._slideToChild = {};
for (let i = 0; i < this.view.target.children?.length; ++i) { for (let i = 0; i < this.view.target.children?.length; ++i) {
const slide = this._renderMediaItem(this.view.target.children[i]); const slide = this._renderMediaItem(this.view.target.children[i], slides.length);
if (slide) { if (slide) {
this._slideToChild[slides.length] = i; this._slideToChild[slides.length] = i;
slides.push(slide); slides.push(slide);
@@ -541,15 +520,72 @@ export class FrigateCardViewerCore extends LitElement {
</div>`; </div>`;
} }
/**
* Fire a media show event when a slide is selected.
*/
protected _selectSlideMediaShowHandler(): void {
if (!this._carousel || !this.view) {
return;
}
this._carousel.slidesInView(true).forEach((slideIndex) => {
if (slideIndex in this._mediaShowInfo) {
const mediaShowInfo = this._mediaShowInfo[slideIndex];
if (mediaShowInfo) {
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
}
}
});
}
/**
* Handle a media-show event that is generated by a child component, saving the
* contents for future use when the relevant slide is shown.
* @param slideIndex The relevant slide index.
* @param event The media-show event from the child component.
*/
protected _mediaShowEventHandler(
slideIndex: number,
event: CustomEvent<MediaShowInfo>,
): void {
// Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown.
event.stopPropagation();
this._mediaShowInfoHandler(slideIndex, event.detail);
}
/**
* Handle a MediaShowInfo object that is generated on media load, by saving it
* for future, or immediate use, when the relevant slide is displayed.
* @param slideIndex The relevant slide index.
* @param mediaShowInfo The MediaShowInfo object generated by the media.
*/
protected _mediaShowInfoHandler(
slideIndex: number,
mediaShowInfo?: MediaShowInfo | null,
): void {
// isValidMediaShowInfo is used to weed out the initial load of the
// transparent 1x1 placeholders.
if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) {
this._mediaShowInfo[slideIndex] = mediaShowInfo;
if (this._carousel && this._carousel?.slidesInView(true).includes(slideIndex)) {
dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo);
}
}
}
/** /**
* Render a given media item. * Render a given media item.
* @param mediaToRender The media item to render. * @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered. * @returns A template or void if the item could not be rendered.
*/ */
protected _renderMediaItem(mediaToRender: BrowseMediaSource): TemplateResult | void { protected _renderMediaItem(
mediaToRender: BrowseMediaSource,
slideIndex: number,
): TemplateResult | void {
// media that can be expanded (folders) cannot be resolved to a single media // media that can be expanded (folders) cannot be resolved to a single media
// item, skip them. // item, skip them.
if (!this.view || !isTrueMedia(mediaToRender)) { if (!this.view || !BrowseMediaUtil.isTrueMedia(mediaToRender)) {
return; return;
} }
@@ -560,7 +596,7 @@ export class FrigateCardViewerCore extends LitElement {
return html` return html`
<div class="embla__slide"> <div class="embla__slide">
${this.view.is('clip') ${this.view.isClipRelatedView()
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl' ? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
? html`<frigate-card-ha-hls-player ? html`<frigate-card-ha-hls-player
.hass=${this.hass} .hass=${this.hass}
@@ -572,6 +608,8 @@ export class FrigateCardViewerCore extends LitElement {
playsinline playsinline
allow-exoplayer allow-exoplayer
?autoplay="${this.autoplayClip}" ?autoplay="${this.autoplayClip}"
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)}
> >
</frigate-card-ha-hls-player>` </frigate-card-ha-hls-player>`
: html`<video : html`<video
@@ -580,7 +618,9 @@ export class FrigateCardViewerCore extends LitElement {
controls controls
playsinline playsinline
?autoplay="${this.autoplayClip}" ?autoplay="${this.autoplayClip}"
@loadedmetadata="${(e) => dispatchMediaLoadEvent(this, e)}" @loadedmetadata="${(e: Event) => {
this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}}"
@play=${() => dispatchPlayEvent(this)} @play=${() => dispatchPlayEvent(this)}
@pause=${() => dispatchPauseEvent(this)} @pause=${() => dispatchPauseEvent(this)}
> >
@@ -591,7 +631,7 @@ export class FrigateCardViewerCore extends LitElement {
/> />
</video>` </video>`
: html`<img : html`<img
src=${ifDefined(this.lazyLoad ? IMG_TRANSPARENT_1x1 : resolvedMedia.url)} src=${ifDefined(this.lazyLoad ? IMG_EMPTY : resolvedMedia.url)}
data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)} data-src=${ifDefined(this.lazyLoad ? resolvedMedia.url : undefined)}
title="${mediaToRender.title}" title="${mediaToRender.title}"
@click=${() => { @click=${() => {
@@ -603,9 +643,9 @@ export class FrigateCardViewerCore extends LitElement {
}); });
} }
}} }}
@load=${(e) => { @load="${(e: Event) => {
dispatchMediaLoadEvent(this, e); this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}} }}"
/>`} />`}
</div> </div>
`; `;
+12
View File
@@ -411,6 +411,18 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
></ha-switch> ></ha-switch>
</ha-formfield> </ha-formfield>
<br /> <br />
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('menu.download')}
>
<ha-switch
.checked=${this._config?.menu_buttons?.download ?? true}
.configValue=${'menu_buttons.download'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<br />
<ha-formfield <ha-formfield
.label=${localize('editor.show_button') + .label=${localize('editor.show_button') +
': ' + ': ' +
+6 -2
View File
@@ -46,7 +46,8 @@
"snapshot": "Latest Snapshot", "snapshot": "Latest Snapshot",
"frigate_ui": "Frigate User Interface", "frigate_ui": "Frigate User Interface",
"fullscreen": "Fullscreen", "fullscreen": "Fullscreen",
"image": "Static Image" "image": "Static Image",
"download": "Download event media"
}, },
"control": { "control": {
"nextprev": "Media Next & Previous Controls", "nextprev": "Media Next & Previous Controls",
@@ -104,6 +105,9 @@
"could_not_render_elements": "Could not render picture elements", "could_not_render_elements": "Could not render picture elements",
"invalid_elements_config": "Invalid picture elements configuration", "invalid_elements_config": "Invalid picture elements configuration",
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
"jsmpeg_no_player": "Could not start JSMPEG player" "jsmpeg_no_player": "Could not start JSMPEG player",
"download_no_media": "No media to download",
"download_no_event_id": "Could not extract Frigate event id from media",
"download_sign_failed": "Could not sign media URL for download"
} }
} }
+2 -2
View File
@@ -11,7 +11,7 @@
import { TemplateResult, html } from 'lit'; import { TemplateResult, html } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { dispatchMediaLoadEvent } from '../common.js'; import { dispatchMediaShowEvent } from '../common.js';
customElements.whenDefined('ha-camera-stream').then(() => { customElements.whenDefined('ha-camera-stream').then(() => {
// ======================================================================================== // ========================================================================================
@@ -53,7 +53,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
if (typeof this._elementResized != 'undefined') { if (typeof this._elementResized != 'undefined') {
this._elementResized(); this._elementResized();
} }
dispatchMediaLoadEvent(this, e); dispatchMediaShowEvent(this, e);
}} }}
.src=${ .src=${
(typeof this._connected == 'undefined' || (typeof this._connected == 'undefined' ||
+2 -2
View File
@@ -14,7 +14,7 @@ import {
html, html,
} from 'lit'; } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { dispatchMediaLoadEvent, dispatchPauseEvent, dispatchPlayEvent } from '../common.js'; import { dispatchMediaShowEvent, dispatchPauseEvent, dispatchPlayEvent } from '../common.js';
customElements.whenDefined("ha-hls-player").then(() => { customElements.whenDefined("ha-hls-player").then(() => {
@customElement("frigate-card-ha-hls-player") @customElement("frigate-card-ha-hls-player")
@@ -33,7 +33,7 @@ customElements.whenDefined("ha-hls-player").then(() => {
?controls=${this.controls} ?controls=${this.controls}
@loadeddata=${(e) => { @loadeddata=${(e) => {
this._elementResized(); this._elementResized();
dispatchMediaLoadEvent(this, e); dispatchMediaShowEvent(this, e);
}} }}
@pause=${() => dispatchPauseEvent(this)} @pause=${() => dispatchPauseEvent(this)}
@play=${() => dispatchPlayEvent(this)} @play=${() => dispatchPlayEvent(this)}
+68 -49
View File
@@ -20,15 +20,23 @@ declare global {
* Internal types. * Internal types.
*/ */
export const FRIGATE_CARD_VIEWS = [ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'live', 'live', // Live view.
'clip', 'clip', // Most recent clip.
'clips', 'clips', // Clips gallery.
'snapshot', 'snapshot', // Most recent snapshot.
'snapshots', 'snapshots', // Snapshots gallery.
'image' 'image', // Static image.
] as const; ] as const;
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
const FRIGATE_CARD_VIEWS_INTERNAL = [
'clip-specific', // A specific clip.
'snapshot-specific', // A specific snapshot.
] as const;
export type FrigateCardView =
| typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]
| typeof FRIGATE_CARD_VIEWS_INTERNAL[number];
export const FRIGATE_MENU_MODES = [ export const FRIGATE_MENU_MODES = [
'none', 'none',
@@ -126,7 +134,8 @@ const stateBadgeIconSchema = elementsBaseSchema.merge(
z.object({ z.object({
type: z.literal('state-badge'), type: z.literal('state-badge'),
entity: z.string(), entity: z.string(),
})); }),
);
// https://www.home-assistant.io/lovelace/picture-elements/#state-icon // https://www.home-assistant.io/lovelace/picture-elements/#state-icon
const stateIconSchema = elementsBaseSchema.merge( const stateIconSchema = elementsBaseSchema.merge(
@@ -135,7 +144,8 @@ const stateIconSchema = elementsBaseSchema.merge(
entity: z.string(), entity: z.string(),
icon: z.string().optional(), icon: z.string().optional(),
state_color: z.boolean().default(true), state_color: z.boolean().default(true),
})); }),
);
// https://www.home-assistant.io/lovelace/picture-elements/#state-label // https://www.home-assistant.io/lovelace/picture-elements/#state-label
const stateLabelSchema = elementsBaseSchema.merge( const stateLabelSchema = elementsBaseSchema.merge(
@@ -145,19 +155,19 @@ const stateLabelSchema = elementsBaseSchema.merge(
attribute: z.string().optional(), attribute: z.string().optional(),
prefix: z.string().optional(), prefix: z.string().optional(),
suffix: z.string().optional(), suffix: z.string().optional(),
})); }),
);
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button // https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
const serviceCallButtonSchema = const serviceCallButtonSchema = elementsBaseSchema.merge(
elementsBaseSchema.merge(z z.object({
.object({ type: z.literal('service-button'),
type: z.literal('service-button'), // Title is required for service button.
// Title is required for service button. title: z.string(),
title: z.string(), service: z.string(),
service: z.string(), service_data: z.object({}).passthrough().optional(),
service_data: z.object({}).passthrough().optional(), }),
}) );
)
// https://www.home-assistant.io/lovelace/picture-elements/#icon // https://www.home-assistant.io/lovelace/picture-elements/#icon
const iconSchema = elementsBaseSchema.merge( const iconSchema = elementsBaseSchema.merge(
@@ -165,7 +175,8 @@ const iconSchema = elementsBaseSchema.merge(
type: z.literal('icon'), type: z.literal('icon'),
icon: z.string(), icon: z.string(),
entity: z.string().optional(), entity: z.string().optional(),
})); }),
);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element // https://www.home-assistant.io/lovelace/picture-elements/#image-element
const imageSchema = elementsBaseSchema.merge( const imageSchema = elementsBaseSchema.merge(
@@ -179,32 +190,37 @@ const imageSchema = elementsBaseSchema.merge(
filter: z.string().optional(), filter: z.string().optional(),
state_filter: z.object({}).passthrough().optional(), state_filter: z.object({}).passthrough().optional(),
aspect_ratio: z.string().optional(), aspect_ratio: z.string().optional(),
})); }),
);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element // https://www.home-assistant.io/lovelace/picture-elements/#image-element
const conditionalSchema = z.object({ const conditionalSchema = z.object({
type: z.literal('conditional'), type: z.literal('conditional'),
conditions: z.object({ conditions: z
.object({
entity: z.string(), entity: z.string(),
state: z.string().optional(), state: z.string().optional(),
state_not: z.string().optional(), state_not: z.string().optional(),
}).array(), })
elements: z.lazy(() => pictureElementsSchema), .array(),
}); elements: z.lazy(() => pictureElementsSchema),
});
// https://www.home-assistant.io/lovelace/picture-elements/#custom-elements // https://www.home-assistant.io/lovelace/picture-elements/#custom-elements
const customSchema = z.object({ const customSchema = z
.object({
// Insist that Frigate card custom elements are handled by other schemas. // Insist that Frigate card custom elements are handled by other schemas.
type: z.string().superRefine((val, ctx) => { type: z.string().superRefine((val, ctx) => {
if (!val.match(/^custom:(?!frigate-card).+/)) { if (!val.match(/^custom:(?!frigate-card).+/)) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.invalid_type, code: z.ZodIssueCode.invalid_type,
expected: "string", expected: 'string',
received: "string", received: 'string',
}); });
} }
}) }),
}).passthrough(); })
.passthrough();
/** /**
* Custom Element Types * Custom Element Types
@@ -213,13 +229,15 @@ const customSchema = z.object({
export const menuIconSchema = iconSchema.merge( export const menuIconSchema = iconSchema.merge(
z.object({ z.object({
type: z.literal('custom:frigate-card-menu-icon'), type: z.literal('custom:frigate-card-menu-icon'),
})); }),
);
export type MenuIcon = z.infer<typeof menuIconSchema>; export type MenuIcon = z.infer<typeof menuIconSchema>;
export const menuStateIconSchema = stateIconSchema.merge( export const menuStateIconSchema = stateIconSchema.merge(
z.object({ z.object({
type: z.literal('custom:frigate-card-menu-state-icon'), type: z.literal('custom:frigate-card-menu-state-icon'),
})); }),
);
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>; export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
const frigateConditionalSchema = z.object({ const frigateConditionalSchema = z.object({
@@ -231,7 +249,6 @@ const frigateConditionalSchema = z.object({
}); });
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>; export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
// 'internalMenuIconSchema' is excluded to disallow the user from manually // 'internalMenuIconSchema' is excluded to disallow the user from manually
// changing the internal menu buttons. // changing the internal menu buttons.
const pictureElementSchema = z.union([ const pictureElementSchema = z.union([
@@ -259,7 +276,7 @@ export const frigateCardConfigSchema = z.object({
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_USER_SPECIFIED).optional().default('live'),
view_timeout: z view_timeout: z
.number() .number()
.or( .or(
@@ -283,9 +300,11 @@ export const frigateCardConfigSchema = z.object({
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),
event_viewer: z.object({ event_viewer: z
lazy_load: z.boolean().default(true), .object({
}).optional(), lazy_load: z.boolean().default(true),
})
.optional(),
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 menu_buttons: z
.object({ .object({
@@ -294,6 +313,7 @@ export const frigateCardConfigSchema = z.object({
clips: z.boolean().default(true), clips: z.boolean().default(true),
snapshots: z.boolean().default(true), snapshots: z.boolean().default(true),
image: z.boolean().default(false), image: z.boolean().default(false),
download: z.boolean().default(true),
frigate_ui: z.boolean().default(true), frigate_ui: z.boolean().default(true),
fullscreen: z.boolean().default(true), fullscreen: z.boolean().default(true),
}) })
@@ -333,14 +353,13 @@ export const frigateCardConfigSchema = z.object({
export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>; export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
// Schema for card (non-user configured) menu icons. // Schema for card (non-user configured) menu icons.
const internalMenuIconSchema = z const internalMenuIconSchema = z.object({
.object({ type: z.literal('internal-menu-icon'),
type: z.literal('internal-menu-icon'), title: z.string(),
title: z.string(), icon: z.string().optional(),
icon: z.string().optional(), emphasize: z.boolean().default(false).optional(),
emphasize: z.boolean().default(false).optional(), card_action: z.string(),
card_action: z.string(), });
});
const menuButtonSchema = z.union([ const menuButtonSchema = z.union([
menuIconSchema, menuIconSchema,
@@ -370,7 +389,7 @@ export interface BrowseMediaNeighbors {
nextIndex: number | null; nextIndex: number | null;
} }
export interface MediaLoadInfo { export interface MediaShowInfo {
width: number; width: number;
height: number; height: number;
} }
+13 -9
View File
@@ -1,4 +1,5 @@
import type { BrowseMediaSource, FrigateCardView } from './types.js'; import type { BrowseMediaSource, FrigateCardView } from './types.js';
import { dispatchFrigateCardEvent } from './common.js';
export interface ViewParameters { export interface ViewParameters {
view?: FrigateCardView; view?: FrigateCardView;
@@ -38,18 +39,27 @@ export class View {
return !this.isGalleryView(); return !this.isGalleryView();
} }
/**
* Determine if a view is for the media viewer.
*/
public isViewerView(): boolean {
return ['clip', 'clip-specific', 'snapshot', 'snapshot-specific'].includes(
this.view,
);
}
/** /**
* Determine if a view is related to a clip or clips. * Determine if a view is related to a clip or clips.
*/ */
public isClipRelatedView(): boolean { public isClipRelatedView(): boolean {
return ['clip', 'clips'].includes(this.view); return ['clip', 'clips', 'clip-specific'].includes(this.view);
} }
/** /**
* Determine if a view is related to a snapshot or snapshots. * Determine if a view is related to a snapshot or snapshots.
*/ */
public isSnapshotRelatedView(): boolean { public isSnapshotRelatedView(): boolean {
return ['snapshot', 'snapshots'].includes(this.view); return ['snapshot', 'snapshots', 'snapshot-specific'].includes(this.view);
} }
/** /**
@@ -70,12 +80,6 @@ export class View {
* @param node The element dispatching the event. * @param node The element dispatching the event.
*/ */
public dispatchChangeEvent(node: HTMLElement): void { public dispatchChangeEvent(node: HTMLElement): void {
node.dispatchEvent( dispatchFrigateCardEvent(node, 'change-view', this);
new CustomEvent<View>('frigate-card:change-view', {
bubbles: true,
composed: true,
detail: this,
}),
);
} }
} }