Implement downloading and correct MediaShow events in carousel.
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user