Use media_browser websockets.
This commit is contained in:
+16
-9
@@ -145,12 +145,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
})}
|
||||
</paper-listbox>
|
||||
</paper-dropdown-menu>
|
||||
<paper-input
|
||||
label="Frigate URL (Required)"
|
||||
.value=${this._config?.frigate_url || ''}
|
||||
.configValue=${'frigate_url'}
|
||||
@value-changed=${this._valueChanged}
|
||||
></paper-input>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -221,6 +215,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
})}
|
||||
</paper-listbox>
|
||||
</paper-dropdown-menu>
|
||||
<paper-input
|
||||
label="Frigate client id (Optional, for >1 Frigate server)"
|
||||
.value=${this._config?.frigate_client_id || ''}
|
||||
.configValue=${'frigate_client_id'}
|
||||
@value-changed=${this._valueChanged}
|
||||
></paper-input>
|
||||
<paper-input
|
||||
label="View timeout (seconds)"
|
||||
prevent-invalid-input
|
||||
@@ -231,6 +231,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
.configValue=${'view_timeout'}
|
||||
@value-changed=${this._valueChanged}
|
||||
></paper-input>
|
||||
<paper-input
|
||||
label="Frigate URL (Optional, for Frigate UI button)"
|
||||
.value=${this._config?.frigate_url || ''}
|
||||
.configValue=${'frigate_url'}
|
||||
@value-changed=${this._valueChanged}
|
||||
></paper-input>
|
||||
<ha-formfield .label=${`Autoplay latest clip`}>
|
||||
<ha-switch
|
||||
.checked=${this._config?.autoplay_clip === true}
|
||||
@@ -341,6 +347,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
return;
|
||||
}
|
||||
const target = ev.target;
|
||||
const value = target.value?.trim();
|
||||
let key: string = target.configValue;
|
||||
|
||||
if (!key) {
|
||||
@@ -361,12 +368,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
key = parts[1];
|
||||
}
|
||||
|
||||
if (target.value !== undefined && objectTarget[key] === target.value) {
|
||||
if (value !== undefined && objectTarget[key] === value) {
|
||||
return;
|
||||
} else if (target.value === '') {
|
||||
} else if (value === '') {
|
||||
delete objectTarget[key];
|
||||
} else {
|
||||
objectTarget[key] = target.checked !== undefined ? target.checked : target.value;
|
||||
objectTarget[key] = target.checked !== undefined ? target.checked : value;
|
||||
}
|
||||
this._config = newConfig;
|
||||
fireEvent(this, 'config-changed', { config: this._config });
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
|
||||
.frigate-card-contents {
|
||||
width: 100%;
|
||||
|
||||
max-height: 277px; /* Max Lovelace 16/9 video height */
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.frigate-card-viewer {
|
||||
width: 100%;
|
||||
max-height: 277px; /* Max Lovelace 16/9 video height */
|
||||
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.frigate-card-gallery {
|
||||
overflow: auto;
|
||||
max-height: 277px; /* Max Lovelace 16/9 video height */
|
||||
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
||||
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
@@ -26,6 +34,8 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0% 5% 0% 5%;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
+227
-154
@@ -1,3 +1,4 @@
|
||||
// TODO Don't show button if no Frigate url.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
LitElement,
|
||||
@@ -27,30 +28,32 @@ import frigate_card_style from './frigate-hass-card.scss';
|
||||
import frigate_card_menu_style from './frigate-hass-card-menu.scss';
|
||||
|
||||
import {
|
||||
browseMediaSourceSchema,
|
||||
frigateCardConfigSchema,
|
||||
frigateGetEventsResponseSchema,
|
||||
FrigateMenuMode,
|
||||
resolvedMediaSchema,
|
||||
} from './types';
|
||||
import type {
|
||||
BrowseMediaSource,
|
||||
ControlVideosParameters,
|
||||
FrigateCardView,
|
||||
FrigateCardConfig,
|
||||
FrigateEvent,
|
||||
FrigateGetEventsResponse,
|
||||
GetEventsParameters,
|
||||
ControlVideosParameters,
|
||||
FrigateMenuMode,
|
||||
MediaBeingShown,
|
||||
ResolvedMedia,
|
||||
} from './types';
|
||||
import { CARD_VERSION } from './const';
|
||||
import { localize } from './localize/localize';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs_utc from 'dayjs/plugin/utc';
|
||||
import dayjs_timezone from 'dayjs/plugin/timezone';
|
||||
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
|
||||
|
||||
import { z, ZodSchema } from 'zod';
|
||||
import { MessageBase } from 'home-assistant-js-websocket';
|
||||
|
||||
const URL_TROUBLESHOOTING =
|
||||
'https://github.com/dermotduffy/frigate-hass-card#troubleshooting';
|
||||
|
||||
// Load dayjs plugins.
|
||||
dayjs.extend(dayjs_timezone);
|
||||
dayjs.extend(dayjs_utc);
|
||||
// Load dayjs plugin(s).
|
||||
dayjs.extend(dayjs_custom_parse_format);
|
||||
|
||||
/* eslint no-console: 0 */
|
||||
console.info(
|
||||
@@ -282,17 +285,23 @@ export class FrigateCard extends LitElement {
|
||||
|
||||
// Event specifically requested to be shown by the user.
|
||||
@property({ attribute: false })
|
||||
protected _requestedEvent: FrigateEvent | null = null;
|
||||
protected _requestedMediaSource: BrowseMediaSource | null = null;
|
||||
|
||||
// Event actually being shown to the user. This may be different from
|
||||
// _requestedEvent when no particular event is requested (e.g. most recent) --
|
||||
// in that case the requestedEvent will be null, but _eventBeingShown will be
|
||||
// the actual event shown.
|
||||
protected _eventBeingShown: FrigateEvent | null = null;
|
||||
// Media (both browse item & resolved media) actually being shown to the user.
|
||||
// This may be different from _requestedMediaSource when no particular event is
|
||||
// requested (e.g. 'clip' view that views the most recent) -- in that case the
|
||||
// requestedEvent will be null, but _mediaBeingShown will be the actual event
|
||||
// shown.
|
||||
protected _mediaBeingShown: MediaBeingShown | null = null;
|
||||
|
||||
// Whether or not there is an active clip being played.
|
||||
protected _clipPlaying = false;
|
||||
|
||||
protected _getParseErrorKeys(error: z.ZodError): string[] {
|
||||
const errors = error.format();
|
||||
return Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||
}
|
||||
|
||||
// Set the object configuration.
|
||||
public setConfig(inputConfig: FrigateCardConfig): void {
|
||||
if (!inputConfig) {
|
||||
@@ -301,8 +310,7 @@ export class FrigateCard extends LitElement {
|
||||
|
||||
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
|
||||
if (!parseResult.success) {
|
||||
const errors = parseResult.error.format();
|
||||
const keys = Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||
const keys = this._getParseErrorKeys(parseResult.error);
|
||||
throw new Error(localize('common.invalid_configuration') + ': ' + keys.join(', '));
|
||||
}
|
||||
const config = parseResult.data;
|
||||
@@ -339,19 +347,19 @@ export class FrigateCard extends LitElement {
|
||||
|
||||
protected _changeView(
|
||||
view?: FrigateCardView | undefined,
|
||||
event?: FrigateEvent | undefined,
|
||||
mediaSource?: BrowseMediaSource | undefined,
|
||||
): void {
|
||||
if (view !== undefined) {
|
||||
this._viewMode = view;
|
||||
} else {
|
||||
this._viewMode = this.config.view_default;
|
||||
if (['clip', 'snapshot'].includes(this.config.view_default)) {
|
||||
this._requestedEvent = null;
|
||||
this._requestedMediaSource = null;
|
||||
}
|
||||
}
|
||||
this._eventBeingShown = null;
|
||||
if (event !== undefined) {
|
||||
this._requestedEvent = event;
|
||||
this._mediaBeingShown = null;
|
||||
if (mediaSource !== undefined) {
|
||||
this._requestedMediaSource = mediaSource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,50 +387,72 @@ export class FrigateCard extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get FrigateEvents from the Frigate server API.
|
||||
protected async _getEvents({
|
||||
has_clip = false,
|
||||
has_snapshot = false,
|
||||
limit = 100,
|
||||
}: GetEventsParameters): Promise<FrigateGetEventsResponse> {
|
||||
let url = `${this.config.frigate_url}/api/events?camera=${this.config.frigate_camera_name}`;
|
||||
if (has_clip) {
|
||||
url += `&has_clip=1`;
|
||||
}
|
||||
if (has_snapshot) {
|
||||
url += `&has_snapshot=1`;
|
||||
}
|
||||
if (limit > 0) {
|
||||
url += `&limit=${limit}`;
|
||||
// Make a websocket request to Home Assistant.
|
||||
protected async _makeWSRequest<T>(
|
||||
schema: ZodSchema<T>,
|
||||
request: MessageBase,
|
||||
): Promise<T | null> {
|
||||
if (!this._hass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.config.label) {
|
||||
url += `&label=${this.config.label}`;
|
||||
}
|
||||
if (this.config.zone) {
|
||||
url += `&zone=${this.config.zone}`;
|
||||
}
|
||||
const response = await this._hass.callWS<T>(request);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
let raw_json;
|
||||
try {
|
||||
raw_json = await response.json();
|
||||
} catch (e: any) {
|
||||
console.warn(e);
|
||||
throw new Error(`Could not JSON decode Frigate API response: ${e}`);
|
||||
}
|
||||
try {
|
||||
return frigateGetEventsResponseSchema.parse(raw_json);
|
||||
} catch (e: any) {
|
||||
console.warn(e);
|
||||
throw new Error(`Frigate events were malformed: ${e}`);
|
||||
}
|
||||
} else {
|
||||
const error_message = `Frigate API request failed with status: ${response.status}`;
|
||||
if (!response) {
|
||||
const error_message = `Received empty response from Home Assistant for request ${JSON.stringify(
|
||||
request,
|
||||
)}`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
const parseResult = schema.safeParse(response);
|
||||
if (!parseResult.success) {
|
||||
const keys = this._getParseErrorKeys(parseResult.error);
|
||||
const error_message =
|
||||
`Received invalid response from Home Assistant for request ${JSON.stringify(
|
||||
request,
|
||||
)}, ` + `invalid keys were '${keys}'`;
|
||||
console.warn(error_message);
|
||||
throw new Error(error_message);
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
// Browse Frigate media.
|
||||
protected async _browseMedia(
|
||||
want_clips?: boolean,
|
||||
before?: number,
|
||||
after?: number,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
// Defined in:
|
||||
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
|
||||
const request = {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: [
|
||||
'media-source://frigate',
|
||||
this.config.frigate_client_id,
|
||||
'event-search',
|
||||
want_clips ? 'clips' : 'snapshots',
|
||||
'', // Name/Title to render (not necessary here)
|
||||
after ? String(after) : '',
|
||||
before ? String(before) : '',
|
||||
this.config.frigate_camera_name,
|
||||
this.config.label,
|
||||
this.config.zone,
|
||||
].join('/'),
|
||||
};
|
||||
return this._makeWSRequest(browseMediaSourceSchema, request);
|
||||
}
|
||||
|
||||
// Resolve Frigate media identifier to a real URL.
|
||||
protected async _resolveMedia(
|
||||
mediaSource: BrowseMediaSource,
|
||||
): Promise<ResolvedMedia | null> {
|
||||
const request = {
|
||||
type: 'media_source/resolve_media',
|
||||
media_content_id: mediaSource.media_content_id,
|
||||
};
|
||||
return this._makeWSRequest(resolvedMediaSchema, request);
|
||||
}
|
||||
|
||||
// Render an attention grabbing icon.
|
||||
@@ -442,41 +472,23 @@ export class FrigateCard extends LitElement {
|
||||
protected _renderError(error: string): TemplateResult {
|
||||
return this._renderAttentionIcon(
|
||||
'mdi:alert-circle',
|
||||
html`${error}. See <a href="${URL_TROUBLESHOOTING}">troubleshooting</a></span>.`,
|
||||
html`${
|
||||
error ? `${error} .` : `Unknown error`
|
||||
}Check <a href="${URL_TROUBLESHOOTING}">troubleshooting</a></span>.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a human-readable title from an event.
|
||||
// MediaBrowser title: 2021-08-12 19:20:14 [10s, Person 76%]
|
||||
protected _getEventTitle(event: FrigateEvent): string {
|
||||
const date = dayjs.unix(event.end_time).tz('UTC').local();
|
||||
|
||||
const iso_datetime = date.format('YYYY-MM-DD HH:mm:ss');
|
||||
const duration = Math.trunc(
|
||||
event.end_time > event.start_time ? event.end_time - event.start_time : 0,
|
||||
);
|
||||
const score = Math.trunc(event.top_score * 100);
|
||||
|
||||
// Capitalize the label.
|
||||
const label = event.label.charAt(0).toUpperCase() + event.label.slice(1);
|
||||
|
||||
return `${iso_datetime} [${duration}s, ${label} ${score}%]`;
|
||||
}
|
||||
|
||||
// Render Frigate events into a card gallery.
|
||||
protected async _renderEvents(): Promise<TemplateResult> {
|
||||
const want_clips = this._viewMode == 'clips';
|
||||
let events;
|
||||
let media;
|
||||
try {
|
||||
events = await this._getEvents({
|
||||
has_clip: want_clips,
|
||||
has_snapshot: !want_clips,
|
||||
});
|
||||
media = await this._browseMedia(want_clips);
|
||||
} catch (e: any) {
|
||||
return this._renderError(e.message);
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
const firstMediaItem = this._getFirstTrueMediaItem(media);
|
||||
if (!firstMediaItem) {
|
||||
return this._renderAttentionIcon(
|
||||
want_clips ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
||||
want_clips ? 'No clips' : 'No snapshots',
|
||||
@@ -484,20 +496,22 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
|
||||
return html` <ul class="mdc-image-list frigate-card-image-list">
|
||||
${events.map(
|
||||
(event) => html` <li class="mdc-image-list__item">
|
||||
<div class="mdc-image-list__image-aspect-container">
|
||||
<img
|
||||
data-toggle="tooltip"
|
||||
title="${this._getEventTitle(event)}"
|
||||
class="mdc-image-list__image"
|
||||
src="data:image/png;base64,${event.thumbnail}"
|
||||
@click=${() => {
|
||||
this._changeView(want_clips ? 'clip' : 'snapshot', event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>`,
|
||||
${media.children.map((mediaSource) =>
|
||||
mediaSource.can_expand
|
||||
? ''
|
||||
: html` <li class="mdc-image-list__item">
|
||||
<div class="mdc-image-list__image-aspect-container">
|
||||
<img
|
||||
data-toggle="tooltip"
|
||||
title="${mediaSource.title}"
|
||||
class="mdc-image-list__image"
|
||||
src="${mediaSource.thumbnail}"
|
||||
@click=${() => {
|
||||
this._changeView(want_clips ? 'clip' : 'snapshot', mediaSource);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>`,
|
||||
)}
|
||||
</ul>`;
|
||||
}
|
||||
@@ -584,7 +598,11 @@ export class FrigateCard extends LitElement {
|
||||
this._changeView(name);
|
||||
break;
|
||||
case 'frigate-ui':
|
||||
window.open(this._getFrigateURLFromContext());
|
||||
const frigate_url = this._getFrigateURLFromContext();
|
||||
if (frigate_url) {
|
||||
window.open(frigate_url);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'motion':
|
||||
if (this.config.motion_entity) {
|
||||
@@ -596,46 +614,76 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected _getFrigateURLFromContext(): string {
|
||||
if (this._eventBeingShown) {
|
||||
return `${this.config.frigate_url}/events/${this._eventBeingShown.id}`;
|
||||
// Extract the Frigate event id from the resolved media. Unfortunately, there
|
||||
// is no way to attach metadata to BrowseMediaSource so this must suffice.
|
||||
protected _extractEventIDFromResolvedMedia(
|
||||
resolvedMedia: ResolvedMedia,
|
||||
): string | null {
|
||||
// Example: /api/frigate/frigate/clips/camera-1630123639.21596-l1y9af.jpg?authSig=[large_string]
|
||||
const result = resolvedMedia.url.match(/-(?<id>[\w]+)\.(jpg|m3u8|mp4)($|\?)/i);
|
||||
if (result && result.groups) {
|
||||
return result.groups['id'] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 Frigate UI url.
|
||||
protected _getFrigateURLFromContext(): string | null {
|
||||
if (!this.config.frigate_url) {
|
||||
return null;
|
||||
}
|
||||
if (this._mediaBeingShown) {
|
||||
const eventID = this._extractEventIDFromResolvedMedia(
|
||||
this._mediaBeingShown.resolvedMedia,
|
||||
);
|
||||
if (eventID) {
|
||||
return `${this.config.frigate_url}/events/${eventID}`;
|
||||
}
|
||||
}
|
||||
return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`;
|
||||
}
|
||||
|
||||
protected _getClipURLFromEvent(event: FrigateEvent): string | null {
|
||||
if (!event.has_clip) {
|
||||
return null;
|
||||
}
|
||||
return `${this.config.frigate_url}/vod/event/${event.id}/index.m3u8`;
|
||||
}
|
||||
|
||||
protected _getSnapshotURLFromEvent(event: FrigateEvent): string | null {
|
||||
if (!event.has_snapshot) {
|
||||
return null;
|
||||
}
|
||||
return `${this.config.frigate_url}/clips/${event.camera}-${event.id}.jpg`;
|
||||
// From a BrowseMediaSource item extract the first true media item (i.e. a
|
||||
// clip/snapshot, not a folder).
|
||||
protected _getFirstTrueMediaItem(media: BrowseMediaSource): BrowseMediaSource | null {
|
||||
return media.children?.find((mediaSource) => !mediaSource.can_expand) || null;
|
||||
}
|
||||
|
||||
// Render the player for a saved clip.
|
||||
protected async _renderClipPlayer(): Promise<TemplateResult> {
|
||||
let event: FrigateEvent, events: FrigateGetEventsResponse;
|
||||
let mediaSource: BrowseMediaSource;
|
||||
let autoplay = true;
|
||||
if (this._requestedEvent) {
|
||||
event = this._requestedEvent;
|
||||
if (this._requestedMediaSource) {
|
||||
mediaSource = this._requestedMediaSource;
|
||||
} else {
|
||||
let media;
|
||||
try {
|
||||
events = await this._getEvents({
|
||||
has_clip: true,
|
||||
limit: 1,
|
||||
});
|
||||
media = await this._browseMedia(true);
|
||||
} catch (e: any) {
|
||||
return this._renderError(e.message);
|
||||
}
|
||||
if (!events.length) {
|
||||
return this._renderAttentionIcon('mdi:camera-off', 'No recent clip');
|
||||
const firstMediaItem = this._getFirstTrueMediaItem(media);
|
||||
if (!firstMediaItem) {
|
||||
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent clip');
|
||||
}
|
||||
event = events[0];
|
||||
mediaSource = firstMediaItem;
|
||||
|
||||
// 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
|
||||
@@ -645,19 +693,21 @@ export class FrigateCard extends LitElement {
|
||||
autoplay = this.config.autoplay_clip;
|
||||
}
|
||||
|
||||
const clipURL = this._getClipURLFromEvent(event);
|
||||
if (!clipURL) {
|
||||
// Frigate has returned an event without a clip, even though it was
|
||||
// specifically asked only for events with clips.
|
||||
return this._renderAttentionIcon('mdi:camera-off', 'No recent clip');
|
||||
const resolvedMedia = await this._resolveMedia(mediaSource);
|
||||
if (!resolvedMedia) {
|
||||
// Home Assistant could not resolve media item.
|
||||
return this._renderError('Could not resolve clip URL');
|
||||
}
|
||||
|
||||
this._eventBeingShown = event;
|
||||
this._mediaBeingShown = {
|
||||
browseMedia: mediaSource,
|
||||
resolvedMedia: resolvedMedia,
|
||||
};
|
||||
|
||||
return html`
|
||||
<ha-hls-player
|
||||
.hass=${this._hass}
|
||||
.url=${clipURL}
|
||||
.url=${resolvedMedia.url}
|
||||
class="frigate-card-viewer"
|
||||
muted
|
||||
controls
|
||||
@@ -698,42 +748,65 @@ export class FrigateCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Get a clip at the same time as a snapshot.
|
||||
protected async _findRelatedClips(
|
||||
snapshot: BrowseMediaSource,
|
||||
): Promise<BrowseMediaSource | null> {
|
||||
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
|
||||
if (startTime) {
|
||||
try {
|
||||
// Fetch clips within the same second (same camera/zone/label, etc).
|
||||
const clipsAtSameTime = await this._browseMedia(true, startTime + 1, startTime);
|
||||
if (clipsAtSameTime) {
|
||||
return this._getFirstTrueMediaItem(clipsAtSameTime);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Pass. This is best effort.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render a snapshot.
|
||||
protected async _renderSnapshotViewer(): Promise<TemplateResult> {
|
||||
let event: FrigateEvent, events: FrigateGetEventsResponse;
|
||||
if (this._requestedEvent) {
|
||||
event = this._requestedEvent;
|
||||
let mediaSource: BrowseMediaSource;
|
||||
if (this._requestedMediaSource) {
|
||||
mediaSource = this._requestedMediaSource;
|
||||
} else {
|
||||
let media;
|
||||
try {
|
||||
events = await this._getEvents({
|
||||
has_snapshot: true,
|
||||
limit: 1,
|
||||
});
|
||||
media = await this._browseMedia(false);
|
||||
} catch (e: any) {
|
||||
return this._renderError(e.message);
|
||||
}
|
||||
if (!events.length) {
|
||||
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent snapshots');
|
||||
const firstMediaItem = this._getFirstTrueMediaItem(media);
|
||||
if (!firstMediaItem) {
|
||||
return this._renderAttentionIcon('mdi:camera-off', 'No recent snapshots');
|
||||
}
|
||||
event = events[0];
|
||||
mediaSource = firstMediaItem;
|
||||
}
|
||||
|
||||
const snapshotURL = this._getSnapshotURLFromEvent(event);
|
||||
if (!snapshotURL) {
|
||||
// Frigate has returned an event without a snapshot, even though it was
|
||||
// specifically asked only for events with snapshots.
|
||||
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent snapshots');
|
||||
const resolvedMedia = await this._resolveMedia(mediaSource);
|
||||
if (!resolvedMedia) {
|
||||
// Home Assistant could not resolve media item.
|
||||
return this._renderError('Could not resolve snapshot URL');
|
||||
}
|
||||
|
||||
this._eventBeingShown = event;
|
||||
this._mediaBeingShown = {
|
||||
browseMedia: mediaSource,
|
||||
resolvedMedia: resolvedMedia,
|
||||
};
|
||||
|
||||
return html` <img
|
||||
class="frigate-card-viewer"
|
||||
src="${snapshotURL}"
|
||||
src="${resolvedMedia.url}"
|
||||
@click=${() => {
|
||||
if (event.has_clip) {
|
||||
this._changeView('clip', event);
|
||||
}
|
||||
// Get clips potentially related to this snapshot.
|
||||
this._findRelatedClips(mediaSource).then((relatedClip) => {
|
||||
if (relatedClip) {
|
||||
this._changeView('clip', relatedClip);
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>`;
|
||||
}
|
||||
|
||||
+44
-24
@@ -36,7 +36,9 @@ export type FrigateMenuMode = typeof FRIGATE_MENU_MODES[number];
|
||||
export const frigateCardConfigSchema = z.object({
|
||||
camera_entity: z.string(),
|
||||
motion_entity: z.string().optional(),
|
||||
frigate_url: z.string().url(),
|
||||
// No URL validation to allow relative URLs within HA (e.g. addons).
|
||||
frigate_url: z.string().optional(),
|
||||
frigate_client_id: z.string().optional().default("frigate"),
|
||||
frigate_camera_name: z.string().optional(),
|
||||
view_default: z.enum(FRIGATE_CARD_VIEWS).optional().default('live'),
|
||||
|
||||
@@ -64,36 +66,54 @@ export const frigateCardConfigSchema = z.object({
|
||||
});
|
||||
export type FrigateCardConfig = z.infer<typeof frigateCardConfigSchema>;
|
||||
|
||||
export interface GetEventsParameters {
|
||||
has_clip?: boolean;
|
||||
has_snapshot?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ControlVideosParameters {
|
||||
stop: boolean;
|
||||
control_live?: boolean;
|
||||
control_clip?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaBeingShown {
|
||||
browseMedia: BrowseMediaSource;
|
||||
resolvedMedia: ResolvedMedia;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frigate API types.
|
||||
* Media Browser API types.
|
||||
*/
|
||||
|
||||
export const frigateEventSchema = z.object({
|
||||
camera: z.string(),
|
||||
end_time: z.number(),
|
||||
false_positive: z.boolean(),
|
||||
has_clip: z.boolean(),
|
||||
has_snapshot: z.boolean(),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
start_time: z.number(),
|
||||
thumbnail: z.string(),
|
||||
top_score: z.number(),
|
||||
zones: z.string().array(),
|
||||
});
|
||||
export type FrigateEvent = z.infer<typeof frigateEventSchema>;
|
||||
// Recursive type, cannot use type interference:
|
||||
// See: https://github.com/colinhacks/zod#recursive-types
|
||||
//
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/__init__.py
|
||||
export interface BrowseMediaSource {
|
||||
title: string;
|
||||
media_class: string;
|
||||
media_content_type: string;
|
||||
media_content_id: string;
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
children_media_class: string | null;
|
||||
thumbnail: string | null
|
||||
children?: BrowseMediaSource[] | null;
|
||||
}
|
||||
|
||||
export const frigateGetEventsResponseSchema = z.array(frigateEventSchema);
|
||||
export type FrigateGetEventsResponse = z.infer<typeof frigateGetEventsResponseSchema>;
|
||||
export const browseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(() =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
media_class: z.string(),
|
||||
media_content_type: z.string(),
|
||||
media_content_id: z.string(),
|
||||
can_play: z.boolean(),
|
||||
can_expand: z.boolean(),
|
||||
children_media_class: z.string().nullable(),
|
||||
thumbnail: z.string().nullable(),
|
||||
children: z.array(browseMediaSourceSchema).nullable().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
|
||||
export const resolvedMediaSchema = z.object({
|
||||
url: z.string(),
|
||||
mime_type: z.string(),
|
||||
});
|
||||
export type ResolvedMedia = z.infer<typeof resolvedMediaSchema>;
|
||||
Reference in New Issue
Block a user