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, 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:
* *
@@ -118,7 +121,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[] = [];
@@ -215,6 +218,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',
@@ -415,6 +426,57 @@ export class FrigateCard extends LitElement {
return true; 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 { 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 +493,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) {
@@ -537,23 +602,24 @@ export class FrigateCard extends LitElement {
return this._setMessageAndUpdate(e.detail); return this._setMessageAndUpdate(e.detail);
} }
protected _mediaLoadHandler(e: CustomEvent<MediaLoadInfo>): void { protected _mediaShowHandler(e: CustomEvent<MediaShowInfo>): void {
const mediaInfo = e.detail; 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;
} }
console.info(`Media show: ${JSON.stringify(mediaShowInfo)}`);
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();
} }
@@ -599,8 +665,8 @@ export class FrigateCard extends LitElement {
} }
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;
@@ -643,8 +709,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 +718,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 )`;
} }
@@ -703,8 +769,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 +785,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 +801,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 +812,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 +829,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}
@@ -823,8 +888,8 @@ export class FrigateCard extends LitElement {
// Get the Lovelace card size. // Get the Lovelace card size.
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);
} }
}, },
}, },
+112 -71
View File
@@ -6,36 +6,33 @@ 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';
@@ -45,9 +42,6 @@ import viewerStyle from '../scss/viewer.scss';
const IMG_TRANSPARENT_1x1 = const IMG_TRANSPARENT_1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; '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 {
@property({ attribute: false }) @property({ attribute: false })
@@ -89,7 +83,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],
@@ -110,16 +104,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 +125,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 +136,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 +191,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 +230,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 +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. * 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 +262,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 +272,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 +306,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 +324,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 +343,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 +360,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 +379,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 +410,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 +433,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 +485,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 +521,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 {
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. * 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 +597,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 +609,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 +619,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)}
> >
@@ -603,9 +644,9 @@ export class FrigateCardViewerCore extends LitElement {
}); });
} }
}} }}
@load=${(e) => { @load="${(e: Event) => {
dispatchMediaLoadEvent(this, e); this._mediaShowInfoHandler(slideIndex, createMediaShowInfo(e));
}} }}"
/>`} />`}
</div> </div>
`; `;
+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)}
+52 -33
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(), })
.array(),
elements: z.lazy(() => pictureElementsSchema), 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
.object({
lazy_load: z.boolean().default(true), lazy_load: z.boolean().default(true),
}).optional(), })
.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,8 +353,7 @@ 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(),
@@ -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,
}),
);
} }
} }