Implement downloading and correct MediaShow events in carousel.

This commit is contained in:
Dermot Duffy
2021-10-29 16:36:36 -07:00
parent 715810dcc9
commit 66ae262654
13 changed files with 582 additions and 275 deletions
+126
View File
@@ -0,0 +1,126 @@
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;
}
for (let i = 0; i < media.children.length; i++) {
if (this.isTrueMedia(media.children[i])) {
return i;
}
}
return 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('/'),
);
}
}
+90 -25
View File
@@ -26,7 +26,7 @@ import type {
Entity,
ExtendedHomeAssistant,
FrigateCardConfig,
MediaLoadInfo,
MediaShowInfo,
MenuButton,
Message,
} from './types.js';
@@ -35,7 +35,12 @@ import { CARD_VERSION, REPO_URL } from './const.js';
import { FrigateCardElements } from './components/elements.js';
import { FrigateCardMenu, MENU_HEIGHT } from './components/menu.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 { renderMessage, renderProgressIndicator } from './components/message.js';
@@ -52,9 +57,7 @@ import './patches/ha-hls-player.js';
import cardStyle from './scss/card.scss';
import { ResolvedMediaCache } from './resolved-media.js';
const MEDIA_HEIGHT_CUTOFF = 50;
const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF;
import { BrowseMediaUtil } from './browse-media-util.js';
/** A note on media callbacks:
*
@@ -118,7 +121,7 @@ export class FrigateCard extends LitElement {
protected _entitiesToMonitor: string[] = [];
// 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.
protected _dynamicMenuButtons: MenuButton[] = [];
@@ -215,6 +218,14 @@ export class FrigateCard extends LitElement {
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) {
buttons.push({
type: 'internal-menu-icon',
@@ -415,6 +426,57 @@ export class FrigateCard extends LitElement {
return true;
}
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();
}
protected _menuActionHandler(action: string, button: MenuButton): void {
if (button.type != 'internal-menu-icon') {
handleAction(this, this._hass as HomeAssistant, button, action);
@@ -431,6 +493,9 @@ export class FrigateCard extends LitElement {
case 'snapshots':
this._changeView(new View({ view: button.card_action }));
break;
case 'download':
this._downloadViewerMedia();
break;
case 'frigate_ui':
const frigate_url = this._getFrigateURLFromContext();
if (frigate_url) {
@@ -537,23 +602,24 @@ export class FrigateCard extends LitElement {
return this._setMessageAndUpdate(e.detail);
}
protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void {
const mediaInfo = e.detail;
protected _mediaShowHandler(e: CustomEvent<MediaShowInfo>): void {
const mediaShowInfo = e.detail;
// In Safari, with WebRTC, 0x0 is occasionally returned during loading,
// 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;
}
console.info(`Media show: ${JSON.stringify(mediaShowInfo)}`);
let requestRefresh = false;
if (
this._isAspectRatioEnforced() &&
(mediaInfo.width != this._mediaInfo?.width ||
mediaInfo.height != this._mediaInfo?.height)
(mediaShowInfo.width != this._mediaShowInfo?.width ||
mediaShowInfo.height != this._mediaShowInfo?.height)
) {
requestRefresh = true;
}
this._mediaInfo = mediaInfo;
this._mediaShowInfo = mediaShowInfo;
if (requestRefresh) {
this.requestUpdate();
}
@@ -599,8 +665,8 @@ export class FrigateCard extends LitElement {
}
const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic';
if (aspect_ratio_mode == 'dynamic' && this._mediaInfo) {
return (this._mediaInfo.height / this._mediaInfo.width) * 100;
if (aspect_ratio_mode == 'dynamic' && this._mediaShowInfo) {
return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100;
}
const default_aspect_ratio = this.config.dimensions?.aspect_ratio;
@@ -643,8 +709,8 @@ export class FrigateCard extends LitElement {
screenfull.isEnabled &&
screenfull.isFullscreen &&
this._view.isMediaView() &&
this._mediaInfo &&
this._mediaInfo.width / this._mediaInfo.height <
this._mediaShowInfo &&
this._mediaShowInfo.width / this._mediaShowInfo.height <
window.innerWidth / window.innerHeight
) {
// If the menu is outside the media (i.e. above/below) allow space for it.
@@ -652,7 +718,7 @@ export class FrigateCard extends LitElement {
? MENU_HEIGHT
: 0;
innerStyle['max-width'] = `calc(${
(100 * this._mediaInfo.width) / this._mediaInfo.height
(100 * this._mediaShowInfo.width) / this._mediaShowInfo.height
}vh - ${allowance}px )`;
}
@@ -703,8 +769,7 @@ export class FrigateCard extends LitElement {
hidden: this.config.live_preload && !this._view.isGalleryView(),
};
const viewerClasses = {
hidden:
this.config.live_preload && !['clip', 'snapshot'].includes(this._view.view),
hidden: this.config.live_preload && !this._view.isViewerView(),
};
const liveClasses = {
hidden: this.config.live_preload && this._view.view != 'live',
@@ -720,7 +785,7 @@ export class FrigateCard extends LitElement {
? html` <frigate-card-image
.image=${this.config.image}
class="${classMap(imageClasses)}"
@frigate-card:media-load=${this._mediaLoadHandler}
@frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:message=${this._messageHandler}
>
</frigate-card-image>`
@@ -736,7 +801,7 @@ export class FrigateCard extends LitElement {
>
</frigate-card-gallery>`
: ``}
${!this._message && (this._view.is('clip') || this._view.is('snapshot'))
${!this._message && this._view.isViewerView()
? html` <frigate-card-viewer
.hass=${this._hass}
.view=${this._view}
@@ -747,7 +812,7 @@ export class FrigateCard extends LitElement {
.lazyLoad=${this.config.event_viewer?.lazy_load ?? true}
class="${classMap(viewerClasses)}"
@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:play=${this._playHandler}
@frigate-card:message=${this._messageHandler}
@@ -764,7 +829,7 @@ export class FrigateCard extends LitElement {
.config=${this.config}
.frigateCameraName=${this._frigateCameraName}
class="${classMap(liveClasses)}"
@frigate-card:media-load=${this._mediaLoadHandler}
@frigate-card:media-show=${this._mediaShowHandler}
@frigate-card:pause=${this._pauseHandler}
@frigate-card:play=${this._playHandler}
@frigate-card:message=${this._messageHandler}
@@ -823,8 +888,8 @@ export class FrigateCard extends LitElement {
// Get the Lovelace card size.
public getCardSize(): number {
if (this._mediaInfo) {
return this._mediaInfo.height / 50;
if (this._mediaShowInfo) {
return this._mediaShowInfo.height / 50;
}
return 6;
}
+129 -79
View File
@@ -2,20 +2,34 @@ import { ZodSchema, z } from 'zod';
import { MessageBase } from 'home-assistant-js-websocket';
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from './localize/localize.js';
import type {
BrowseMediaQueryParameters,
BrowseMediaSource,
import {
ExtendedHomeAssistant,
MediaLoadInfo,
MediaShowInfo,
Message,
SignedPath,
signedPathSchema,
} 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[] {
const errors = error.format();
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>(
hass: HomeAssistant & ExtendedHomeAssistant,
schema: ZodSchema<T>,
@@ -43,66 +57,41 @@ export async function homeAssistantWSRequest<T>(
return parseResult.data;
}
export function isTrueMedia(media: BrowseMediaSource): boolean {
return !media.can_expand;
}
// 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 (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(
/**
* Request that HA sign a path. May throw.
* @param hass The HomeAssistant object used to request the signature.
* @param path The path to sign.
* @param expires An optional number of seconds to sign the path for.
* @returns The signed URL, or null if the response was malformed.
*/
export async function homeAssistantSignPath(
hass: HomeAssistant & ExtendedHomeAssistant,
params: BrowseMediaQueryParameters,
): Promise<BrowseMediaSource | null> {
return browseMedia(
path: string,
expires?: number,
): Promise<string | null> {
const request = {
type: 'auth/sign_path',
path: path,
expires: expires,
};
const response = await homeAssistantWSRequest<SignedPath>(
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('/'),
signedPathSchema,
request,
);
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(
new CustomEvent<T>(`frigate-card:${name}`, {
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 {
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 {
dispatchEvent(element, 'pause');
dispatchFrigateCardEvent(element, 'pause');
}
export function dispatchMediaLoadEvent(
element: HTMLElement,
source: Event | HTMLElement,
): void {
/**
* Create a MediaShowInfo object.
* @param source An event or HTMLElement that should be used as a source.
* @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;
if (source instanceof Event) {
target = source.composedPath()[0];
@@ -132,46 +131,88 @@ export function dispatchMediaLoadEvent(
}
if (target instanceof HTMLImageElement) {
dispatchEvent<MediaLoadInfo>(element, 'media-load', {
return {
width: (target as HTMLImageElement).naturalWidth,
height: (target as HTMLImageElement).naturalHeight,
});
};
} else if (target instanceof HTMLVideoElement) {
dispatchEvent<MediaLoadInfo>(element, 'media-load', {
return {
width: (target as HTMLVideoElement).videoWidth,
height: (target as HTMLVideoElement).videoHeight,
});
};
} else if (target instanceof HTMLCanvasElement) {
dispatchEvent<MediaLoadInfo>(element, 'media-load', {
return {
width: (target as HTMLCanvasElement).width,
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(
element: HTMLElement,
message: string,
icon?: string,
): void {
dispatchEvent<Message>(element, 'message', {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: message,
type: 'info',
icon: icon,
});
}
export function dispatchErrorMessageEvent(
element: HTMLElement,
message: string,
): void {
dispatchEvent<Message>(element, 'message', {
/**
* Dispatch an event with an error message to show to the user.
* @param element The element to send the event.
* @param message The message to show.
*/
export function dispatchErrorMessageEvent(element: HTMLElement, message: string): void {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: message,
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(
newHass: HomeAssistant | null,
oldHass: HomeAssistant | undefined,
@@ -198,3 +239,12 @@ export function shouldUpdateBasedOnHass(
}
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,
PictureElements,
} from '../types.js';
import { dispatchErrorMessageEvent, dispatchEvent } from '../common.js';
import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js';
import elementsStyle from '../scss/elements.scss';
import { localize } from '../localize/localize.js';
@@ -134,7 +134,11 @@ export class FrigateCardElements extends LitElement {
protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as
// 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 {
@@ -274,13 +278,13 @@ export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
dispatchEvent<T>(this, 'menu-add', this._config);
dispatchFrigateCardEvent<T>(this, 'menu-add', this._config);
}
}
disconnectedCallback(): void {
if (this._config) {
dispatchEvent<T>(this, 'menu-remove', this._config);
dispatchFrigateCardEvent<T>(this, 'menu-remove', this._config);
}
super.disconnectedCallback();
}
+8 -11
View File
@@ -1,28 +1,25 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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 { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { until } from 'lit/directives/until.js';
import type {
BrowseMediaSource,
BrowseMediaQueryParameters,
ExtendedHomeAssistant,
} from '../types.js';
import { BrowseMediaUtil } from '../browse-media-util.js';
import { View } from '../view.js';
import {
browseMedia,
browseMediaQuery,
dispatchErrorMessageEvent,
dispatchMessageEvent,
getFirstTrueMediaChildIndex,
} from '../common.js';
import { localize } from '../localize/localize.js';
import { renderProgressIndicator } from './message.js';
import galleryStyle from '../scss/gallery.scss';
import { styleMap } from 'lit/directives/style-map.js';
const MAX_THUMBNAIL_WIDTH = 175;
const DEFAULT_COLUMNS = 5;
@@ -79,15 +76,15 @@ export class FrigateCardGallery extends LitElement {
let parent: BrowseMediaSource | null;
try {
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 {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
parent = await BrowseMediaUtil.browseMediaQuery(this.hass, this.browseMediaQueryParameters);
}
} catch (e: any) {
return dispatchErrorMessageEvent(this, e.message);
}
if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) {
if (!parent || !parent.children || BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) == null) {
return dispatchMessageEvent(
this,
this._getMediaType() == 'clips'
@@ -149,7 +146,7 @@ export class FrigateCardGallery extends LitElement {
src="${child.thumbnail}"
@click=${() => {
new View({
view: this._getMediaType() == 'clips' ? 'clip' : 'snapshot',
view: this._getMediaType() == 'clips' ? 'clip-specific' : 'snapshot-specific',
target: parent ?? undefined,
childIndex: index,
previous: this.view ?? undefined,
+2 -2
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { dispatchMediaLoadEvent } from '../common.js';
import { dispatchMediaShowEvent } from '../common.js';
import imageStyle from '../scss/image.scss';
import defaultImage from '../images/frigate-bird-in-sky.jpg'
@@ -15,7 +15,7 @@ export class FrigateCardImage extends LitElement {
return html` <img
src=${this.image || defaultImage}
@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 type { ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.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 {
dispatchErrorMessageEvent,
dispatchMediaLoadEvent,
dispatchMediaShowEvent,
dispatchMessageEvent,
dispatchPauseEvent,
dispatchPlayEvent,
homeAssistantWSRequest,
homeAssistantSignPath,
} from '../common.js';
import { renderProgressIndicator } from '../components/message.js';
@@ -142,7 +140,7 @@ export class FrigateCardLiveWebRTC extends LitElement {
if (onloadedmetadata) {
onloadedmetadata.call(video, e);
}
dispatchMediaLoadEvent(this, video);
dispatchMediaShowEvent(this, video);
};
video.onplay = (e) => {
if (onplay) {
@@ -184,21 +182,20 @@ export class FrigateCardLiveJSMPEG extends LitElement {
return null;
}
const request = {
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;
let response: string | null | undefined;
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) {
console.warn(err);
return null;
}
const url = this.hass.hassUrl(response.path);
return url.replace(/^http/i, 'ws');
if (!response) {
return null;
}
return response.replace(/^http/i, 'ws');
}
protected _createJSMPEGPlayer(): JSMpeg.VideoElement {
@@ -229,7 +226,7 @@ export class FrigateCardLiveJSMPEG extends LitElement {
// ignore any subsequent calls.
if (!videoDecoded && this._jsmpegCanvasElement) {
videoDecoded = true;
dispatchMediaLoadEvent(this, this._jsmpegCanvasElement);
dispatchMediaShowEvent(this, this._jsmpegCanvasElement);
}
},
},
+112 -71
View File
@@ -6,36 +6,33 @@ import {
unsafeCSS,
PropertyValues,
} from 'lit';
import { BrowseMediaUtil } from '../browse-media-util.js';
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js';
import { until } from 'lit/directives/until.js';
import type {
BrowseMediaNeighbors,
BrowseMediaQueryParameters,
BrowseMediaSource,
ExtendedHomeAssistant,
MediaShowInfo,
NextPreviousControlStyle,
} from '../types.js';
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
import { localize } from '../localize/localize.js';
import { View } from '../view.js';
import {
browseMediaQuery,
createMediaShowInfo,
dispatchErrorMessageEvent,
dispatchMediaLoadEvent,
dispatchMessageEvent,
dispatchPauseEvent,
dispatchPlayEvent,
getFirstTrueMediaChildIndex,
isTrueMedia,
dispatchExistingMediaShowInfoAsEvent,
isValidMediaShowInfo,
} from '../common.js';
import { View } from '../view.js';
import { localize } from '../localize/localize.js';
import { renderProgressIndicator } from '../components/message.js';
import './next-prev-control.js';
@@ -45,9 +42,6 @@ import viewerStyle from '../scss/viewer.scss';
const IMG_TRANSPARENT_1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
// Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format);
@customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement {
@property({ attribute: false })
@@ -89,7 +83,7 @@ export class FrigateCardViewer extends LitElement {
let errorFree = true;
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(
this.hass,
target.children[i],
@@ -110,16 +104,18 @@ export class FrigateCardViewer extends LitElement {
}
let autoplay = true;
let view = this.view;
if (!view.target) {
if (this.view.is('clip') || this.view.is('snapshot')) {
let parent: BrowseMediaSource | null = null;
try {
parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters);
parent = await BrowseMediaUtil.browseMediaQuery(
this.hass,
this.browseMediaQueryParameters,
);
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
this,
@@ -129,11 +125,8 @@ export class FrigateCardViewer extends LitElement {
this.view.is('clip') ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
view = new View({
view: this.view.view,
target: parent,
childIndex: childIndex,
});
this.view.target = parent;
this.view.childIndex = 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
@@ -143,12 +136,12 @@ export class FrigateCardViewer extends LitElement {
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 html` <frigate-card-viewer-core
.view=${view}
.view=${this.view}
.nextPreviousControlStyle=${this.nextPreviousControlStyle}
.resolvedMediaCache=${this.resolvedMediaCache}
.autoplayClip=${autoplay}
@@ -198,6 +191,10 @@ export class FrigateCardViewerCore extends LitElement {
// (Folders are not media items that can be rendered).
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.
* @param changedProperties The properties that were changed in this render.
@@ -233,8 +230,9 @@ export class FrigateCardViewerCore extends LitElement {
this._carousel = EmblaCarousel(carouselNode, {
startIndex: isNaN(startIndex) ? undefined : startIndex,
});
// Update views based on slide selections.
this._carousel.on('select', this._slideSelectHandler.bind(this));
// Update views and dispatch media-show events based on slide selections.
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
// regardless of the value of this.lazyLoad to allow that value to change
@@ -245,28 +243,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.
* @returns A BrowseMediaNeighbors with indices and objects of true media
@@ -286,7 +262,7 @@ export class FrigateCardViewerCore extends LitElement {
let prevIndex: number | null = null;
for (let i = this.view.childIndex - 1; i >= 0; i--) {
const media = this.view.target.children[i];
if (media && isTrueMedia(media)) {
if (media && BrowseMediaUtil.isTrueMedia(media)) {
prevIndex = i;
break;
}
@@ -296,7 +272,7 @@ export class FrigateCardViewerCore extends LitElement {
let nextIndex: number | null = null;
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
const media = this.view.target.children[i];
if (media && isTrueMedia(media)) {
if (media && BrowseMediaUtil.isTrueMedia(media)) {
nextIndex = i;
break;
}
@@ -330,7 +306,7 @@ export class FrigateCardViewerCore extends LitElement {
return null;
}
const snapshotStartTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot);
if (!snapshotStartTime) {
return null;
}
@@ -348,10 +324,10 @@ export class FrigateCardViewerCore extends LitElement {
let latest: number | null = null;
for (let i = 0; i < this.view.target.children.length; i++) {
const child = this.view.target.children[i];
if (!isTrueMedia(child)) {
if (!BrowseMediaUtil.isTrueMedia(child)) {
continue;
}
const startTime = this._extractEventStartTimeFromBrowseMedia(child);
const startTime = BrowseMediaUtil.extractEventStartTime(child);
if (startTime && (earliest === null || startTime < earliest)) {
earliest = startTime;
@@ -367,7 +343,7 @@ export class FrigateCardViewerCore extends LitElement {
let clips: BrowseMediaSource | null;
try {
clips = await browseMediaQuery(this.hass, {
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
...this.browseMediaQueryParameters,
mediaType: 'clips',
before: latest,
@@ -384,13 +360,13 @@ export class FrigateCardViewerCore extends LitElement {
for (let i = 0; i < clips.children.length; i++) {
const child = clips.children[i];
if (!isTrueMedia(child)) {
if (!BrowseMediaUtil.isTrueMedia(child)) {
continue;
}
const clipStartTime = this._extractEventStartTimeFromBrowseMedia(child);
const clipStartTime = BrowseMediaUtil.extractEventStartTime(child);
if (clipStartTime && clipStartTime === snapshotStartTime) {
return new View({
view: 'clip',
view: 'clip-specific',
target: clips,
childIndex: i,
previous: this.view,
@@ -403,12 +379,12 @@ export class FrigateCardViewerCore extends LitElement {
/**
* Handle the user selecting a new slide in the carousel.
*/
protected _slideSelectHandler(): void {
protected _selectSlideSetViewHandler(): void {
if (!this._carousel || !this.view) {
return;
}
// Update the childIndex in the view (without re-render)
// Update the childIndex in the view.
const slidesInView = this._carousel.slidesInView(true);
if (slidesInView.length) {
const childIndex = this._slideToChild[slidesInView[0]];
@@ -434,8 +410,6 @@ export class FrigateCardViewerCore extends LitElement {
/**
* Lazily load media in the carousel.
* @param eventName The Embla event name that triggered this load.
* // TODO delete eventName above?
*/
protected _lazyLoadMediaHandler(): void {
if (!this.lazyLoad || !this._carousel) {
@@ -459,6 +433,12 @@ export class FrigateCardViewerCore extends LitElement {
}
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];
// Snapshots.
@@ -505,7 +485,7 @@ export class FrigateCardViewerCore extends LitElement {
this._slideToChild = {};
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) {
this._slideToChild[slides.length] = i;
slides.push(slide);
@@ -541,15 +521,72 @@ export class FrigateCardViewerCore extends LitElement {
</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 {
this._mediaShowInfoHandler(slideIndex, event.detail);
// 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();
}
/**
* 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.
* @param mediaToRender The media item to render.
* @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
// item, skip them.
if (!this.view || !isTrueMedia(mediaToRender)) {
if (!this.view || !BrowseMediaUtil.isTrueMedia(mediaToRender)) {
return;
}
@@ -560,7 +597,7 @@ export class FrigateCardViewerCore extends LitElement {
return html`
<div class="embla__slide">
${this.view.is('clip')
${this.view.isClipRelatedView()
? resolvedMedia?.mime_type.toLowerCase() == 'application/x-mpegurl'
? html`<frigate-card-ha-hls-player
.hass=${this.hass}
@@ -572,6 +609,8 @@ export class FrigateCardViewerCore extends LitElement {
playsinline
allow-exoplayer
?autoplay="${this.autoplayClip}"
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)}
>
</frigate-card-ha-hls-player>`
: html`<video
@@ -580,7 +619,9 @@ export class FrigateCardViewerCore extends LitElement {
controls
playsinline
?autoplay="${this.autoplayClip}"
@loadedmetadata="${(e) => dispatchMediaLoadEvent(this, e)}"
@loadedmetadata="${(e: Event) => {
this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}}"
@play=${() => dispatchPlayEvent(this)}
@pause=${() => dispatchPauseEvent(this)}
>
@@ -603,9 +644,9 @@ export class FrigateCardViewerCore extends LitElement {
});
}
}}
@load=${(e) => {
dispatchMediaLoadEvent(this, e);
}}
@load="${(e: Event) => {
this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}}"
/>`}
</div>
`;
+6 -2
View File
@@ -46,7 +46,8 @@
"snapshot": "Latest Snapshot",
"frigate_ui": "Frigate User Interface",
"fullscreen": "Fullscreen",
"image": "Static Image"
"image": "Static Image",
"download": "Download event media"
},
"control": {
"nextprev": "Media Next & Previous Controls",
@@ -104,6 +105,9 @@
"could_not_render_elements": "Could not render picture elements",
"invalid_elements_config": "Invalid picture elements configuration",
"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 { customElement } from 'lit/decorators.js';
import { dispatchMediaLoadEvent } from '../common.js';
import { dispatchMediaShowEvent } from '../common.js';
customElements.whenDefined('ha-camera-stream').then(() => {
// ========================================================================================
@@ -53,7 +53,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
if (typeof this._elementResized != 'undefined') {
this._elementResized();
}
dispatchMediaLoadEvent(this, e);
dispatchMediaShowEvent(this, e);
}}
.src=${
(typeof this._connected == 'undefined' ||
+2 -2
View File
@@ -14,7 +14,7 @@ import {
html,
} from 'lit';
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(() => {
@customElement("frigate-card-ha-hls-player")
@@ -33,7 +33,7 @@ customElements.whenDefined("ha-hls-player").then(() => {
?controls=${this.controls}
@loadeddata=${(e) => {
this._elementResized();
dispatchMediaLoadEvent(this, e);
dispatchMediaShowEvent(this, e);
}}
@pause=${() => dispatchPauseEvent(this)}
@play=${() => dispatchPlayEvent(this)}
+54 -35
View File
@@ -20,15 +20,23 @@ declare global {
* Internal types.
*/
export const FRIGATE_CARD_VIEWS = [
'live',
'clip',
'clips',
'snapshot',
'snapshots',
'image'
const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'live', // Live view.
'clip', // Most recent clip.
'clips', // Clips gallery.
'snapshot', // Most recent snapshot.
'snapshots', // Snapshots gallery.
'image', // Static image.
] 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 = [
'none',
@@ -126,7 +134,8 @@ const stateBadgeIconSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('state-badge'),
entity: z.string(),
}));
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#state-icon
const stateIconSchema = elementsBaseSchema.merge(
@@ -135,7 +144,8 @@ const stateIconSchema = elementsBaseSchema.merge(
entity: z.string(),
icon: z.string().optional(),
state_color: z.boolean().default(true),
}));
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#state-label
const stateLabelSchema = elementsBaseSchema.merge(
@@ -145,19 +155,19 @@ const stateLabelSchema = elementsBaseSchema.merge(
attribute: z.string().optional(),
prefix: z.string().optional(),
suffix: z.string().optional(),
}));
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
const serviceCallButtonSchema =
elementsBaseSchema.merge(z
.object({
const serviceCallButtonSchema = elementsBaseSchema.merge(
z.object({
type: z.literal('service-button'),
// Title is required for service button.
title: z.string(),
service: z.string(),
service_data: z.object({}).passthrough().optional(),
})
)
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#icon
const iconSchema = elementsBaseSchema.merge(
@@ -165,7 +175,8 @@ const iconSchema = elementsBaseSchema.merge(
type: z.literal('icon'),
icon: z.string(),
entity: z.string().optional(),
}));
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
const imageSchema = elementsBaseSchema.merge(
@@ -179,32 +190,37 @@ const imageSchema = elementsBaseSchema.merge(
filter: z.string().optional(),
state_filter: z.object({}).passthrough().optional(),
aspect_ratio: z.string().optional(),
}));
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
const conditionalSchema = z.object({
type: z.literal('conditional'),
conditions: z.object({
conditions: z
.object({
entity: z.string(),
state: z.string().optional(),
state_not: z.string().optional(),
}).array(),
})
.array(),
elements: z.lazy(() => pictureElementsSchema),
});
});
// 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.
type: z.string().superRefine((val, ctx) => {
if (!val.match(/^custom:(?!frigate-card).+/)) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_type,
expected: "string",
received: "string",
expected: 'string',
received: 'string',
});
}
}),
})
}).passthrough();
.passthrough();
/**
* Custom Element Types
@@ -213,13 +229,15 @@ const customSchema = z.object({
export const menuIconSchema = iconSchema.merge(
z.object({
type: z.literal('custom:frigate-card-menu-icon'),
}));
}),
);
export type MenuIcon = z.infer<typeof menuIconSchema>;
export const menuStateIconSchema = stateIconSchema.merge(
z.object({
type: z.literal('custom:frigate-card-menu-state-icon'),
}));
}),
);
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
const frigateConditionalSchema = z.object({
@@ -231,7 +249,6 @@ const frigateConditionalSchema = z.object({
});
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
// 'internalMenuIconSchema' is excluded to disallow the user from manually
// changing the internal menu buttons.
const pictureElementSchema = z.union([
@@ -259,7 +276,7 @@ export const frigateCardConfigSchema = z.object({
frigate_url: z.string().optional(),
frigate_client_id: z.string().optional().default('frigate'),
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
.number()
.or(
@@ -283,9 +300,11 @@ export const frigateCardConfigSchema = z.object({
label: z.string().optional(),
zone: z.string().optional(),
autoplay_clip: z.boolean().default(false),
event_viewer: z.object({
event_viewer: z
.object({
lazy_load: z.boolean().default(true),
}).optional(),
})
.optional(),
menu_mode: z.enum(FRIGATE_MENU_MODES).optional().default('hidden-top'),
menu_buttons: z
.object({
@@ -294,6 +313,7 @@ export const frigateCardConfigSchema = z.object({
clips: z.boolean().default(true),
snapshots: z.boolean().default(true),
image: z.boolean().default(false),
download: z.boolean().default(true),
frigate_ui: 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>;
// Schema for card (non-user configured) menu icons.
const internalMenuIconSchema = z
.object({
const internalMenuIconSchema = z.object({
type: z.literal('internal-menu-icon'),
title: z.string(),
icon: z.string().optional(),
emphasize: z.boolean().default(false).optional(),
card_action: z.string(),
});
});
const menuButtonSchema = z.union([
menuIconSchema,
@@ -370,7 +389,7 @@ export interface BrowseMediaNeighbors {
nextIndex: number | null;
}
export interface MediaLoadInfo {
export interface MediaShowInfo {
width: number;
height: number;
}
+13 -9
View File
@@ -1,4 +1,5 @@
import type { BrowseMediaSource, FrigateCardView } from './types.js';
import { dispatchFrigateCardEvent } from './common.js';
export interface ViewParameters {
view?: FrigateCardView;
@@ -38,18 +39,27 @@ export class View {
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.
*/
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.
*/
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.
*/
public dispatchChangeEvent(node: HTMLElement): void {
node.dispatchEvent(
new CustomEvent<View>('frigate-card:change-view', {
bubbles: true,
composed: true,
detail: this,
}),
);
dispatchFrigateCardEvent(node, 'change-view', this);
}
}