Initial draft of recordings support.

This commit is contained in:
Dermot Duffy
2022-06-04 11:04:38 -07:00
parent 2e80bdad0b
commit 7ebada9aa5
26 changed files with 1016 additions and 181 deletions
+2
View File
@@ -483,6 +483,7 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin
| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.|
| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
| `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.|
@@ -1617,6 +1618,7 @@ Reference: [Timeline Options](#timeline-options).
timeline:
clustering_threshold: 3
media: all
show_recordings: true
window_seconds: 3600
controls:
thumbnails:
+2 -2
View File
@@ -975,7 +975,7 @@ export class FrigateCard extends LitElement {
* @returns A boolean indicating whether the camera was changed.
*/
protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean {
if (!this._view) {
if (!this._view || !this._isAutomatedViewUpdateAllowed(true)) {
return false;
}
@@ -1001,7 +1001,7 @@ export class FrigateCard extends LitElement {
}
}
if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) {
if (triggerChanges) {
if (!this._triggers.size) {
this._changeView();
changedCamera = true;
+4 -3
View File
@@ -12,6 +12,7 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
import {
FrigateCardError,
FrigateConditional,
MenuButton,
MenuIcon,
@@ -21,7 +22,7 @@ import {
PictureElements
} from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { dispatchErrorMessageEvent } from './message.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
/* A note on picture element rendering:
*
@@ -108,7 +109,7 @@ export class FrigateCardElementsCore extends LitElement {
element.setConfig(config);
} catch (e) {
console.error(e, (e as Error).stack);
throw new Error(localize('error.invalid_elements_config'));
throw new FrigateCardError(localize('error.invalid_elements_config'));
}
return element;
}
@@ -125,7 +126,7 @@ export class FrigateCardElementsCore extends LitElement {
this._root = this._createRoot();
}
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
}
}
+34 -1
View File
@@ -612,6 +612,13 @@ export class FrigateCardLiveProvider extends LitElement {
this._providerRef.value?.unmute();
}
/**
* Seek the video.
*/
public seek(seconds: number): void {
this._providerRef.value?.seek(seconds);
}
protected _getResolvedProvider(): LiveProvider {
if (this.cameraConfig?.live_provider === 'auto') {
if (
@@ -711,6 +718,13 @@ export class FrigateCardLiveFrigate extends LitElement {
this._playerRef.value?.unmute();
}
/**
* Seek the video.
*/
public seek(seconds: number): void {
this._playerRef.value?.seek(seconds);
}
/**
* Master render method.
* @returns A rendered template.
@@ -810,6 +824,16 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
}
}
/**
* Seek the video.
*/
public seek(seconds: number): void {
const player = this._getPlayer();
if (player) {
player.currentTime = seconds;
}
}
/**
* Get the underlying video player.
* @returns The player or `null` if not found.
@@ -867,8 +891,9 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
return dispatchErrorMessageEvent(
this,
e instanceof FrigateCardError
? (e as FrigateCardError).message
? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
(e as FrigateCardError).context
);
}
return html`${webrtcElement}`;
@@ -964,6 +989,14 @@ export class FrigateCardLiveJSMPEG extends LitElement {
}
}
/**
* Seek the video (unsupported).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public seek(_seconds: number): void {
// JSMPEG does not support seeking.
}
/**
* Get a signed player URL.
* @returns A URL or null.
+17 -1
View File
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
import { TROUBLESHOOTING_URL } from '../const.js';
import { localize } from '../localize/localize.js';
import messageStyle from '../scss/message.scss';
import { Message } from '../types.js';
import { FrigateCardError, Message } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
@customElement('frigate-card-message')
@@ -140,3 +140,19 @@ export function dispatchErrorMessageEvent(
context: context,
});
}
/**
* Dispatch an event with an error message to show to the user.
* @param element The element to send the event.
* @param message The message to show.
*/
export function dispatchFrigateCardErrorEvent(
element: HTMLElement,
error: FrigateCardError
): void {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: error.message,
type: 'error',
context: error.context || ''
});
}
+4 -2
View File
@@ -12,6 +12,7 @@ import surroundThumbnailsStyle from '../scss/surround.scss';
import {
BrowseMediaQueryParameters,
FrigateBrowseMediaSource,
FrigateCardError,
FrigateCardView,
ThumbnailsControlConfig
} from '../types.js';
@@ -21,7 +22,7 @@ import {
multipleBrowseMediaQueryMerged
} from '../utils/ha/browse-media';
import { View } from '../view.js';
import { dispatchErrorMessageEvent } from './message.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
import './surround.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
@@ -69,7 +70,7 @@ export class FrigateCardSurround extends LitElement {
try {
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
} catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message);
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
}
if (getFirstTrueMediaChildIndex(parent) !== null) {
this.view
@@ -149,6 +150,7 @@ export class FrigateCardSurround extends LitElement {
view: this.targetView || 'event',
target: ev.detail.target,
childIndex: ev.detail.childIndex,
context: null,
})
.dispatchChangeEvent(this);
}}
+110 -14
View File
@@ -3,8 +3,14 @@ import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { localize } from '../localize/localize.js';
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss';
import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js';
import type {
FrigateBrowseMediaSource,
FrigateEvent,
FrigateRecording,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { prettifyTitle } from '../utils/basic.js';
import { getEventDurationString } from '../utils/ha/browse-media.js';
@@ -13,8 +19,49 @@ import { View } from '../view.js';
// The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@customElement('frigate-card-thumbnail-details')
export class FrigateCardThumbnailDetails extends LitElement {
@customElement('frigate-card-thumbnail-feature-event')
export class FrigateCardThumbnailFeatureEvent extends LitElement {
@property({ attribute: false })
public thumbnail?: string;
protected render(): TemplateResult | void {
return html`
${this.thumbnail
? html`<img src="${this.thumbnail}" />`
: html`<ha-icon
icon="mdi:image-off"
title=${localize('thumbnail.no_thumbnail')}
></ha-icon> `}
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureEventStyle);
}
}
@customElement('frigate-card-thumbnail-feature-recording')
export class FrigateCardThumbnailFeatureRecording extends LitElement {
@property({ attribute: false })
public date?: Date;
protected render(): TemplateResult | void {
if (!this.date) {
return;
}
return html`
<div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div>
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureRecordingStyle);
}
}
@customElement('frigate-card-thumbnail-details-event')
export class FrigateCardThumbnailDetailsEvent extends LitElement {
@property({ attribute: false })
public event?: FrigateEvent;
@@ -39,9 +86,29 @@ export class FrigateCardThumbnailDetails extends LitElement {
</div>`;
}
/**
* Get element styles.
*/
static get styles(): CSSResult {
return unsafeCSS(thumbnailDetailsStyle);
}
}
@customElement('frigate-card-thumbnail-details-recording')
export class FrigateCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false })
public recording?: FrigateRecording;
protected render(): TemplateResult | void {
if (!this.recording) {
return;
}
return html`<div class="left">
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
</div>
<div class="right">
<span class="larger">${this.recording.events}</span>
<span>${localize('recording.events')}</span>
</div>`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailDetailsStyle);
}
@@ -90,6 +157,7 @@ export class FrigateCardThumbnail extends LitElement {
*/
protected render(): TemplateResult | void {
let event: FrigateEvent | null = null;
let recording: FrigateRecording | null = null;
let thumbnail: string | null = null;
let label: string | null = null;
@@ -97,6 +165,7 @@ export class FrigateCardThumbnail extends LitElement {
if (this.target && this.target.children && this.childIndex !== undefined) {
const media = this.target.children[this.childIndex];
event = media.frigate?.event ?? null;
recording = media.frigate?.recording ?? null;
thumbnail = media.thumbnail;
label = media.title;
}
@@ -108,26 +177,37 @@ export class FrigateCardThumbnail extends LitElement {
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
label = this.label ? this.label : label;
if (!thumbnail) {
if (!event && !recording) {
return;
}
return html` <img
return html` ${event
? html`<frigate-card-thumbnail-feature-event
aria-label="${label ?? ''}"
src="${thumbnail}"
title="${label ?? ''}"
/>
.thumbnail=${thumbnail ?? undefined}
.label=${label ?? undefined}
></frigate-card-thumbnail-feature-event>`
: html`<frigate-card-thumbnail-feature-recording
aria-label="${label ?? ''}"
title="${label ?? ''}"
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
></frigate-card-thumbnail-feature-recording>`}
${this.controls && event?.retain_indefinitely
? html` <ha-icon
class="favorite"
icon="mdi:star"
title=${localize('thumbnail.retain_indefinitely')}
/>`
/></ha-icon>`
: ``}
${this.details && event
? html`<frigate-card-thumbnail-details
.event=${event}
></frigate-card-thumbnail-details>`
? html`<frigate-card-thumbnail-details-event
.event=${event ?? undefined}
></frigate-card-thumbnail-details-event>`
: this.details && recording
? html`<frigate-card-thumbnail-details-recording
.recording=${recording ?? undefined}
></frigate-card-thumbnail-details-recording>`
: html``}
${this.controls
? html`<ha-icon
@@ -136,6 +216,7 @@ export class FrigateCardThumbnail extends LitElement {
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (event) {
this.view
?.evolve({
view: 'timeline',
@@ -144,6 +225,21 @@ export class FrigateCardThumbnail extends LitElement {
context: {},
})
.dispatchChangeEvent(this);
} else if (recording) {
this.view
?.evolve({
view: 'timeline',
target: null,
childIndex: null,
context: {
window: {
start: fromUnixTime(recording.start_time),
end: fromUnixTime(recording.end_time),
},
},
})
.dispatchChangeEvent(this);
}
}}
></ha-icon>`
: ''}`;
+365 -71
View File
@@ -1,5 +1,15 @@
// TODO: In viewer, the seek is being applied to the 2nd media. Change away from play_time?
import { HomeAssistant } from 'custom-card-helpers';
import { add, fromUnixTime, sub } from 'date-fns';
import {
add,
endOfHour,
format,
fromUnixTime,
getUnixTime,
startOfHour,
sub
} from 'date-fns';
import {
CSSResultGroup,
html,
@@ -29,32 +39,44 @@ import timelineStyle from '../scss/timeline.scss';
import {
BrowseMediaQueryParameters,
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
frigateCardConfigDefaults,
FrigateCardError,
FrigateEvent,
TimelineConfig
} from '../types';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { dispatchFrigateCardEvent, prettifyTitle } from '../utils/basic';
import { getCameraTitle } from '../utils/camera.js';
import {
getRecordingSegments,
getRecordingsSummary,
RecordingSegments,
RecordingSummary
} from '../utils/frigate';
import {
createEventParentForChildren,
createVideoChild,
generateRecordingIdentifier,
getBrowseMediaQueryParameters,
isTrueMedia,
multipleBrowseMediaQuery
} from '../utils/ha/browse-media';
import { View, ViewContext } from '../view';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js';
import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
import './surround-thumbnails.js';
const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10;
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
interface FrigateCardGroupData {
id: string;
content: string;
}
interface FrigateCardTimelineItem extends TimelineItem {
event: FrigateEvent;
start: number;
end?: number;
event?: FrigateEvent;
source?: FrigateBrowseMediaSource;
}
@@ -68,12 +90,17 @@ interface TimelineViewContext extends ViewContext {
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
interface CameraRecordings {
segments: RecordingSegments;
summary: RecordingSummary;
}
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)');
/**
* A manager to maintain/fetch timeline events.
*/
class TimelineEventManager {
class TimelineDataManager {
protected _dataset = new DataSet<FrigateCardTimelineItem>();
// The earliest date managed.
@@ -87,7 +114,7 @@ class TimelineEventManager {
// The maximum allowable age of fetch data (will not fetch more frequently
// than this).
protected _maxAgeSeconds: number = TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS;
protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
@@ -183,9 +210,7 @@ class TimelineEventManager {
* @param end An optional end of the date range.
* @returns
*/
public hasCoverage(start: Date, end?: Date): boolean {
const now = new Date().getTime();
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
// Never fetched: no coverage.
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
return false;
@@ -194,7 +219,7 @@ class TimelineEventManager {
// If the most recent fetch is older than maxAgeSeconds: no coverage.
if (
this._maxAgeSeconds &&
now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
) {
return false;
}
@@ -219,7 +244,7 @@ class TimelineEventManager {
return false;
}
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
if (now - end.getTime() > this._maxAgeSeconds * 1000) {
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
return false;
}
@@ -237,21 +262,108 @@ class TimelineEventManager {
* @param end Fetch events that start earlier than this date.
* @returns `true` if events were fetched, `false` otherwise.
*/
public async fetchEventsIfNecessary(
public async fetchIfNecessary(
element: HTMLElement,
hass: HomeAssistant,
hass: ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>,
media: TimelineMediaType,
eventMedia: TimelineMediaType,
start: Date,
end: Date,
recordings?: boolean,
): Promise<boolean> {
if (this.hasCoverage(start, end)) {
const now = new Date();
if (this.hasCoverage(now, start, end)) {
return false;
}
await this._fetchEvents(element, hass, cameras, media, start, end);
// Cannot fetch the future.
end = end > now ? now : end;
if (!this._dateStart || start < this._dateStart) {
this._dateStart = start;
}
if (!this._dateEnd || end > this._dateEnd) {
this._dateEnd = end;
}
this._dateFetch = new Date();
await Promise.all([
// Events are always fetched for the maximum extent of the managed
// range. This is because events may change at any point in time
// (e.g. a long-running event that ends).
this._fetchEvents(
element,
hass,
cameras,
eventMedia,
this._dateStart,
this._dateEnd,
),
...(recordings ? [this._fetchRecordings(element, hass, cameras)] : []),
]);
return true;
}
/**
* Fetch recording hours for the timeline.
* @param element The element to send error events from.
* @param hass The HomeAssistant object.
* @param cameras The cameras map.
* @param start Fetch events that start later than this date.
* @param end Fetch events that start earlier than this date.
*/
protected async _fetchRecordings(
element: HTMLElement,
hass: ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>,
): Promise<void> {
const items: FrigateCardTimelineItem[] = [];
const now = new Date();
const storeRecordings = async (
camera: string,
config: CameraConfig,
): Promise<void> => {
if (!config.camera_name) {
return;
}
let summary: RecordingSummary;
try {
summary = await getRecordingsSummary(hass, config.client_id, config.camera_name);
} catch (e) {
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
for (const dayData of summary) {
for (const hourData of dayData.hours) {
const hour = add(dayData.day, { hours: hourData.hour });
const endHour = endOfHour(hour);
items.push({
id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`,
group: camera,
start: getUnixTime(startOfHour(hour)) * 1000,
// Don't let the recordings show off into the future (even though it
// is intended to be indicative of any recordings within that hour
// -- it still looks strange!)
end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000,
type: 'background',
content: '',
});
}
}
};
await Promise.all(
Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) =>
storeRecordings(camera, config),
),
);
this._dataset.update(items);
}
/**
* Fetch events for the timeline.
* @param element The element to send error events from.
@@ -265,34 +377,16 @@ class TimelineEventManager {
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
media: TimelineMediaType,
start?: Date,
end?: Date,
start: Date,
end: Date,
): Promise<void> {
if (!this._dateStart || (start && start < this._dateStart)) {
this._dateStart = start;
}
if (!this._dateEnd || (end && end > this._dateEnd)) {
this._dateEnd = end;
}
if (!this._dateStart || !this._dateEnd) {
return;
}
this._dateFetch = new Date();
const params: BrowseMediaQueryParameters[] = [];
cameras.forEach((cameraConfig, cameraID) => {
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
if (
this._dateEnd &&
this._dateStart &&
cameraConfig.camera_name !== CAMERA_BIRDSEYE
) {
if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) {
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
// Events are always fetched for the maximum extent of the managed
// range. This is because events may change at any point in time
// (e.g. a long-running event that ends).
before: this._dateEnd.getTime() / 1000,
after: this._dateStart.getTime() / 1000,
before: end.getTime() / 1000,
after: start.getTime() / 1000,
unlimited: true,
mediaType: mediaType as 'clips' | 'snapshots',
});
@@ -311,7 +405,7 @@ class TimelineEventManager {
try {
results = await multipleBrowseMediaQuery(hass, params);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
for (const [query, result] of results.entries()) {
@@ -325,7 +419,7 @@ class TimelineEventManager {
@customElement('frigate-card-timeline')
export class FrigateCardTimeline extends LitElement {
@property({ attribute: false })
protected hass?: HomeAssistant;
protected hass?: ExtendedHomeAssistant;
@property({ attribute: false })
protected view?: Readonly<View>;
@@ -371,7 +465,7 @@ export class FrigateCardTimeline extends LitElement {
@customElement('frigate-card-timeline-core')
export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false })
protected hass?: HomeAssistant;
protected hass?: ExtendedHomeAssistant;
@property({ attribute: false })
protected view?: Readonly<View>;
@@ -382,12 +476,17 @@ export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false })
protected timelineConfig?: TimelineConfig;
protected _events = new TimelineEventManager({
protected _data = new TimelineDataManager({
tooltipCallback: this._getTooltip.bind(this),
});
protected _refTimeline: Ref<HTMLElement> = createRef();
protected _timeline?: Timeline;
// Need a way to separate when a user clicks (to pan the timeline) vs when a
// user clicks (to choose a recording (non-event) to play). On pan,
// _wasDragged will be set to true, and the click subsequently ignored.
protected _wasDragged = false;
/**
* Get a tooltip for a given timeline event.
* @param source The FrigateBrowseMediaSource in question.
@@ -446,12 +545,206 @@ export class FrigateCardTimelineCore extends LitElement {
></div>`;
}
/**
* Get the number of seconds to seek into a video stream consisting of the
* provided segments to reach the target time provided.
* @param time Target time.
* @param segments A RecordingSegments object.
* @returns
*/
protected _getSeekTime(time: Date, segments: RecordingSegments): number | null {
if (!segments.length) {
return null;
}
const target = getUnixTime(time);
const hourStart = getUnixTime(startOfHour(time));
let seekSeconds = 0;
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
for (const segment of segments) {
if (segment.start_time > target) {
break;
}
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
const end = segment.end_time > target ? target : segment.end_time;
seekSeconds += end - start;
}
return seekSeconds;
}
/**
* Create recording objects.
* @param results A map of camera ID to a CameraRecordings object.
* @param time The target time for the recordings.
* @param onlyMatchingHour If `true` only shows the hour matching the target
* for the provided cameras, otherwise shows all hours.
* @returns
*/
protected _createRecordingChildren(
results: Map<string, CameraRecordings>,
time: Date,
onlyMatchingHour: boolean,
): FrigateBrowseMediaSource[] {
const children: FrigateBrowseMediaSource[] = [];
const processedCameras: Set<string> = new Set();
for (const [camera, recording] of results.entries()) {
const config = this.cameras?.get(camera);
if (!config?.camera_name) {
continue;
}
// There is a single set of recordings for a given Frigate camera name.
// Zones on that same camera do not get separate recordings. The card may
// have multiple instances of the same camera for different zoness, so
// need to enforce uniqueness here.
const uniqueID = `${config.client_id}/${config.camera_name}`;
if (processedCameras.has(uniqueID)) {
continue;
}
processedCameras.add(uniqueID);
const seekSeconds = this._getSeekTime(time, recording.segments);
if (seekSeconds === null) {
continue;
}
for (const dayData of recording.summary) {
for (const hourData of dayData.hours) {
const hour = add(dayData.day, { hours: hourData.hour });
const startHour = startOfHour(hour);
const endHour = endOfHour(hour);
const isMatchingHour = time >= startHour && time <= endHour;
if (!onlyMatchingHour || isMatchingHour) {
children.push(
createVideoChild(
`${prettifyTitle(config.camera_name)} ${format(
hour,
'yyyy-MM-dd HH:mm',
)}`,
generateRecordingIdentifier({
clientId: config.client_id,
year: dayData.day.getFullYear(),
month: dayData.day.getMonth() + 1,
day: dayData.day.getDate(),
hour: hourData.hour,
cameraName: config.camera_name,
}),
{
recording: {
camera: config.camera_name,
start_time: getUnixTime(startHour),
end_time: getUnixTime(endHour),
events: hourData.events,
...(isMatchingHour && { play_time: seekSeconds }),
},
},
),
);
}
}
}
}
return children;
}
/**
* Change the view to a recording.
* @param time The time of the recording to show.
* @param camera An optional camera to show a recording of, otherwise all
* cameras are shown at the given time.
*/
protected async _changeViewToRecording(time: Date, camera?: string): Promise<void> {
if (!this.hass) {
return;
}
const before = endOfHour(time);
const after = startOfHour(time);
const results: Map<string, CameraRecordings> = new Map();
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
if (!config || !config.camera_name || !this.hass) {
return;
}
try {
const cameraResults = await Promise.all([
getRecordingSegments(
this.hass,
config.client_id,
config.camera_name,
before,
after,
),
getRecordingsSummary(this.hass, config.client_id, config.camera_name),
]);
results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] });
} catch (e) {}
};
const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])];
await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera))));
const children = this._createRecordingChildren(results, time, !camera);
if (!children.length) {
return;
}
let childIndex = 0;
if (camera) {
childIndex = children.findIndex(
(child) =>
child.frigate?.recording &&
child.frigate.recording.start_time * 1000 === after.getTime(),
);
if (childIndex < 0) {
return;
}
}
this.view
?.evolve({
view: 'event',
target: createEventParentForChildren(localize('common.recordings'), children),
childIndex: childIndex,
})
.dispatchChangeEvent(this);
}
/**
* Called whenever the range is in the process of being changed.
* @param properties
*/
protected _timelineRangeChangeHandler(
properties: TimelineEventPropertiesResult,
): void {
if (properties.event) {
// When a human changes the range, an event will be set.
this._wasDragged = true;
}
}
/**
* Called whenever the timeline is clicked.
* @param properties The properties of the timeline click event.
*/
protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
if (properties.what === 'item') {
if (properties.what && ['item', 'background'].includes(properties.what)) {
// Prevent interaction with items on the timeline from activating card
// wide actions.
stopEventFromActivatingCardWideActions(properties.event);
}
if (!this._wasDragged && properties.what) {
if (['background', 'group-label'].includes(properties.what)) {
this._changeViewToRecording(properties.time, String(properties.group));
} else if (properties.what === 'axis') {
this._changeViewToRecording(properties.time);
}
}
this._wasDragged = false;
}
/**
@@ -468,14 +761,15 @@ export class FrigateCardTimelineCore extends LitElement {
return;
}
if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
this._events
.fetchEventsIfNecessary(
this._data
.fetchIfNecessary(
this,
this.hass,
this.cameras,
this.timelineConfig.media,
properties.start,
properties.end,
this.timelineConfig.show_recordings,
)
.then(() => {
if (this._timeline) {
@@ -507,7 +801,7 @@ export class FrigateCardTimelineCore extends LitElement {
const childIndex = data.items.length
? this.view.target.children.findIndex(
(child) => child.frigate?.event.id === data.items[0],
(child) => child.frigate?.event?.id === data.items[0],
)
: null;
@@ -560,8 +854,8 @@ export class FrigateCardTimelineCore extends LitElement {
const selected = this._timeline.getSelection();
let childIndex = -1;
const children: FrigateBrowseMediaSource[] = [];
this._events.dataset.get({ order: sortEvent }).forEach((item) => {
if (item.source) {
this._data.dataset.get({ order: sortEvent }).forEach((item) => {
if (item.event && item.source) {
children.push(item.source);
if (selected.includes(item.event.id)) {
childIndex = children.length - 1;
@@ -572,9 +866,8 @@ export class FrigateCardTimelineCore extends LitElement {
return null;
}
const target = createEventParentForChildren('Timeline events', children);
return {
target: target,
target: createEventParentForChildren('Timeline events', children),
childIndex: childIndex < 0 ? null : childIndex,
};
}
@@ -695,12 +988,14 @@ export class FrigateCardTimelineCore extends LitElement {
// Never include the target media in a cluster, and never group
// different object types together (e.g. person and car).
return (
[first.type, second.type].every((type) => type !== 'background') &&
first.type === second.type &&
!!first.id &&
first.id !== this.view?.media?.frigate?.event?.id &&
!!second.id &&
second.id != this.view?.media?.frigate?.event?.id &&
(<FrigateCardTimelineItem>first).event.label ===
(<FrigateCardTimelineItem>second).event.label
(<FrigateCardTimelineItem>first).event?.label ===
(<FrigateCardTimelineItem>second).event?.label
);
},
}
@@ -750,13 +1045,7 @@ export class FrigateCardTimelineCore extends LitElement {
* Update the timeline from the view object.
*/
protected async _updateTimelineFromView(): Promise<void> {
if (
!this.hass ||
!this.cameras ||
!this.view ||
!this._timeline ||
!this.timelineConfig
) {
if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
return;
}
@@ -765,15 +1054,20 @@ export class FrigateCardTimelineCore extends LitElement {
? this._getStartEndFromEvent(event)
: this._getStartEnd();
await this._events.fetchEventsIfNecessary(
await this._data.fetchIfNecessary(
this,
this.hass,
this.cameras,
this.timelineConfig.media,
windowStart,
windowEnd,
this.timelineConfig.show_recordings,
);
if (!this._timeline) {
return;
}
this._timeline.setSelection(event ? [event.id] : [], {
focus: false,
animation: {
@@ -807,9 +1101,9 @@ export class FrigateCardTimelineCore extends LitElement {
// Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included
// in a cluster.
const item = this._events.dataset.get(event.id);
const item = this._data.dataset.get(event.id);
if (item) {
this._events.dataset.updateOnly(item);
this._data.dataset.updateOnly(item);
}
}
} else {
@@ -825,7 +1119,7 @@ export class FrigateCardTimelineCore extends LitElement {
// -> New view dispatched (to load thumbnails into outer carousel).
// -> New view received ... [loop]
const currentContext = this.view.context as TimelineViewContext | null;
if (currentContext?.dateFetch !== this._events.lastFetchDate) {
if (currentContext?.dateFetch !== this._data.lastFetchDate) {
const thumbnails = this._generateThumbnails();
this.view
?.evolve({
@@ -851,8 +1145,8 @@ export class FrigateCardTimelineCore extends LitElement {
} else if (currentContext?.window) {
newContext.window = currentContext.window;
}
if (this._events.lastFetchDate) {
newContext.dateFetch = this._events.lastFetchDate;
if (this._data.lastFetchDate) {
newContext.dateFetch = this._data.lastFetchDate;
}
return newContext || null;
}
@@ -865,7 +1159,7 @@ export class FrigateCardTimelineCore extends LitElement {
super.updated(changedProperties);
if (changedProperties.has('cameras')) {
this._events.clear();
this._data.clear();
this._timeline?.destroy();
this._timeline = undefined;
}
@@ -888,14 +1182,14 @@ export class FrigateCardTimelineCore extends LitElement {
this._timeline = new Timeline(
this._refTimeline.value,
this._events.dataset,
this._data.dataset,
groups,
options,
);
this._timeline.on('select', this._timelineSelectHandler.bind(this));
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
this._timeline.on('click', this._timelineClickHandler.bind(this));
this._timeline.on('doubleclick', this._timelineClickHandler.bind(this));
this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
}
}
+44 -10
View File
@@ -22,6 +22,7 @@ import type {
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateCardMediaPlayer,
MediaShowInfo,
TransitionEffect,
ViewerConfig
@@ -36,8 +37,8 @@ import {
multipleBrowseMediaQueryMerged,
overrideMultiBrowseMediaQueryParameters
} from '../utils/ha/browse-media.js';
import { createMediaShowInfo } from '../utils/media-info.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { createMediaShowInfo } from '../utils/media-info.js';
import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
@@ -125,6 +126,8 @@ export class FrigateCardViewer extends LitElement {
}
}
const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
@customElement('frigate-card-viewer-carousel')
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
@property({ attribute: false })
@@ -168,11 +171,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
++i
) {
if (isTrueMedia(target.children[i])) {
await resolveMedia(
this.hass,
target.children[i],
this.resolvedMediaCache,
);
await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
}
}
},
@@ -278,6 +277,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
};
}
/**
* The the HLS player on a slide (or current slide if not provided.)
* @param slide An optional slide.
* @returns The FrigateCardMediaPlayer or null if not found.
*/
protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null {
if (this._carousel) {
if (!slide) {
slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()];
}
return slide?.querySelector(
FRIGATE_CARD_HLS_SELECTOR,
) as FrigateCardMediaPlayer | null;
}
return null;
}
/**
* Get the Embla plugins to use.
* @returns An EmblaOptionsType object or undefined for no options.
@@ -291,7 +307,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}),
}),
AutoMediaPlugin({
playerSelector: 'frigate-card-ha-hls-player',
playerSelector: FRIGATE_CARD_HLS_SELECTOR,
...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play,
}),
@@ -511,9 +527,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
const img = slide.querySelector('img') as HTMLImageElement;
// Frigate >= 0.9.0+ clips.
const hls_player = slide.querySelector(
'frigate-card-ha-hls-player',
) as HTMLElement & { url: string };
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
url: string;
};
if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
@@ -661,6 +677,24 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
: ``} `;
}
/**
* Fire a media show event when a slide is selected.
*/
protected _selectSlideMediaShowHandler(): void {
super._selectSlideMediaShowHandler();
// If this is a recording and play is desired to be started from a
// particular point, seek to that point.
if (this.view?.media?.frigate?.recording?.play_time) {
const player = this._getPlayer();
if (player) {
player.seek(this.view.media.frigate.recording.play_time);
// TODO: Fix this bug.
console.info(`Seeking on ${this.view.media.media_content_id}`);
}
}
}
protected _renderMediaItem(
mediaToRender: FrigateBrowseMediaSource,
slideIndex: number,
+1
View File
@@ -118,6 +118,7 @@ export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
+5
View File
@@ -80,6 +80,7 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
CONF_TIMELINE_MEDIA,
CONF_TIMELINE_SHOW_RECORDINGS,
CONF_TIMELINE_WINDOW_SECONDS,
CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE,
@@ -1223,6 +1224,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_TIMELINE_MEDIA,
this._timelineMediaTypes,
)}
${this._renderSwitch(
CONF_TIMELINE_SHOW_RECORDINGS,
defaults.timeline.show_recordings,
)}
${this._renderOptionSelector(
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
this._thumbnailModes,
+13 -4
View File
@@ -7,7 +7,8 @@
"no_clips": "No clips",
"no_snapshot": "No recent snapshot",
"no_clip": "No recent clip",
"live": "Live"
"live": "Live",
"recordings": "Recordings"
},
"config": {
"cameras": {
@@ -109,8 +110,8 @@
"thumbnails": {
"mode": "Event Viewer thumbnails mode",
"size": "Event Viewer thumbnails size in pixels",
"show_details": "Show event details with thumbnails",
"show_controls": "Show event controls with thumbnails",
"show_details": "Show details with thumbnails",
"show_controls": "Show controls with thumbnails",
"modes": {
"below": "Thumbnails below the media",
"above": "Thumbnails above the media",
@@ -229,6 +230,7 @@
"window_seconds": "The default length of the timeline view in seconds",
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
"media": "The media the timeline displays",
"show_recordings": "Show recordings",
"medias": {
"all": "All media types",
"clips": "Clips",
@@ -292,13 +294,20 @@
"in_progress": "In Progress",
"score": "Score"
},
"recording": {
"events": "Events"
},
"thumbnail": {
"retain_indefinitely": "Event will be indefinitely retained",
"timeline": "See event in timeline"
"timeline": "See event in timeline",
"no_thumbnail": "No thumbnail available"
},
"error": {
"undecodable_response": "Could not decode response from Home Assistant for request",
"empty_response": "Received empty response from Home Assistant for request",
"invalid_response": "Received invalid response from Home Assistant for request",
"failed_response": "Failed to receive response from Home Assistant for request",
"failed_sign": "Could not sign Home Assistant URL",
"invalid_keys": "Invalid keys",
"unknown": "Unknown error",
"troubleshooting": "Check troubleshooting",
+7
View File
@@ -77,6 +77,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
this._player?.unmute();
}
/**
* Seek the video (unsupported).
*/
public seek(seconds: number): void {
this._player?.seek(seconds);
}
/**
* Master render method.
* @returns A rendered template.
+9
View File
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-hls-player').then(() => {
}
}
/**
* Seek the video.
*/
public seek(seconds: number): void {
if (this._video) {
this._video.currentTime = seconds;
}
}
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
+9
View File
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
}
}
/**
* Seek the video.
*/
public seek(seconds: number): void {
if (this._video) {
this._video.currentTime = seconds;
}
}
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
+8
View File
@@ -2,6 +2,11 @@
height: 100%;
width: 100%;
display: block;
// Ensure error messages are selectable.
user-select: text;
// Safari only has prefixed support.
-webkit-user-select: text;
}
div.wrapper {
@@ -20,6 +25,7 @@ div.message {
div.message div.contents {
padding: 10px;
height: 100%;
max-width: 100%;
}
div.message div.icon {
@@ -37,4 +43,6 @@ div.message div.icon {
.message pre {
margin-top: 20px;
white-space: pre-wrap;
word-break: break-all;
}
+1
View File
@@ -32,6 +32,7 @@
box-shadow: 0px 0px 20px 5px black;
transition: all 0.2s ease-out;
opacity: 0.8;
aspect-ratio: 1 / 1;
}
.controls.thumbnails:hover {
opacity: 1 !important;
+7 -2
View File
@@ -8,11 +8,15 @@
column-gap: 5%;
}
div.right, div.left {
div.right,
div.left {
display: flex;
flex-direction: column;
justify-content: center;
}
div.right {
align-items: center;
}
div.left {
flex: 1;
@@ -36,6 +40,7 @@ span.heading {
font-weight: bold;
}
.larger {
div.larger,
span.larger {
font-size: 1.5rem;
}
+37
View File
@@ -0,0 +1,37 @@
:host {
display: block;
height: 100%;
overflow: hidden;
}
img, ha-icon {
border-radius: var(--ha-card-border-radius, 4px);
max-width: var(--frigate-card-thumbnail-size-max);
max-height: var(--frigate-card-thumbnail-size-max);
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
// should be clipped to fill the thumbnail div whilst maintaining
// aspect-ratio.
object-fit: cover;
// Restrict images to a maximum of thumbnail size.
aspect-ratio: 1 / 1;
height: 100%;
transition: transform 0.2s linear;
}
ha-icon {
--mdc-icon-size: 50%;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid rgba(255,255,255,0.3);
box-sizing: border-box;
}
img:hover {
transform: scale(1.04);
}
+29
View File
@@ -0,0 +1,29 @@
:host {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
max-width: var(--frigate-card-thumbnail-size-max);
max-height: var(--frigate-card-thumbnail-size-max);
border: 1px solid rgba(255,255,255,0.3);
border-radius: var(--ha-card-border-radius, 4px);
box-sizing: border-box;
transition: transform 0.2s linear;
padding: 10px;
}
:host(:hover) {
transform: scale(1.04);
}
div.title {
font-size: 1.5rem;
}
+8 -25
View File
@@ -19,39 +19,22 @@
background-color: var(--primary-background-color, black);
}
img {
border-radius: var(--ha-card-border-radius, 4px);
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
// should be clipped to fill the thumbnail div whilst maintaining
// aspect-ratio.
object-fit: cover;
// Restrict images to a maximum of thumbnail size.
aspect-ratio: 1 / 1;
height: 100%;
max-width: var(--frigate-card-thumbnail-size-max);
max-height: var(--frigate-card-thumbnail-size-max);
transition: transform 0.2s linear;
}
img:hover {
transform: scale(1.04);
ha-icon {
position: absolute;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
}
ha-icon.favorite {
position: absolute;
color: gold;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
opacity: 0.8;
}
ha-icon.timeline {
position: absolute;
color: var(--primary-color);
right: 0px;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
}
frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording {
flex: 1;
}
+11 -3
View File
@@ -46,10 +46,18 @@ div.timeline.right-margin {
color: var(--primary-text-color);
background-color: var(--primary-color);
}
.vis-item.vis-background {
background-color: rgba(255, 255, 255, 0.1);
}
.vis-item:hover {
// Float icons upwards when the user hovers over them.
z-index: 2;
.vis-item:not(.vis-background) {
cursor: pointer;
}
.vis-item.vis-background, .vis-labelset, .vis-time-axis {
cursor: crosshair;
}
.vis-item:active {
cursor: unset;
}
.vis-item.vis-box {
+48 -8
View File
@@ -83,7 +83,14 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
export class FrigateCardError extends Error {}
export class FrigateCardError extends Error {
context?: unknown;
constructor(message: string, context?: unknown) {
super(message);
this.context = context;
}
}
/**
* Action Types (for "Picture Elements" / Menu)
@@ -400,7 +407,10 @@ const cameraConfigSchema = z
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy),
trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities),
trigger_by_entities: z
.string()
.array()
.default(cameraConfigDefault.trigger_by_entities),
})
.default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
@@ -508,7 +518,7 @@ const viewConfigDefault = {
scan: {
enabled: false,
show_trigger_status: true,
}
},
};
const viewConfigSchema = z
.object({
@@ -525,10 +535,14 @@ const viewConfigSchema = z
update_entities: z.string().array().optional(),
render_entities: z.string().array().optional(),
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
scan: z.object({
scan: z
.object({
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
}).default(viewConfigDefault.scan)
show_trigger_status: z
.boolean()
.default(viewConfigDefault.scan.show_trigger_status),
})
.default(viewConfigDefault.scan),
})
.merge(actionsSchema)
.default(viewConfigDefault);
@@ -970,6 +984,7 @@ const timelineConfigDefault = {
clustering_threshold: 3,
media: 'all' as const,
window_seconds: 60 * 60,
show_recordings: true,
controls: {
thumbnails: {
mode: 'left' as const,
@@ -995,6 +1010,7 @@ const timelineConfigSchema = z
.max(24 * 60 * 60)
.optional()
.default(timelineConfigDefault.window_seconds),
show_recordings: z.boolean().optional().default(timelineConfigDefault.show_recordings),
controls: z
.object({
thumbnails: thumbnailsControlSchema
@@ -1130,6 +1146,15 @@ export interface BrowseMediaQueryParameters {
cameraID?: string;
}
export interface BrowseRecordingQueryParameters {
clientId: string;
cameraName: string;
year: number;
month: number;
day: number;
hour: number;
}
export interface BrowseMediaNeighbors {
previous: FrigateBrowseMediaSource | null;
previousIndex: number | null;
@@ -1165,6 +1190,7 @@ export interface FrigateCardMediaPlayer {
pause(): void;
mute(): void;
unmute(): void;
seek(seconds: number): void;
}
export interface CardHelpers {
@@ -1180,7 +1206,9 @@ export interface CardHelpers {
*/
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
export const MEDIA_CLASS_VIDEO = 'video' as const;
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
export const MEDIA_TYPE_VIDEO = 'video' as const;
// Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types
@@ -1212,10 +1240,22 @@ export interface FrigateEvent {
retain_indefinitely?: boolean;
}
export interface FrigateRecording {
camera: string;
start_time: number;
end_time: number;
events: number;
// The number of seconds at which this recording should be initially played
// from.
play_time?: number;
}
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
children?: FrigateBrowseMediaSource[] | null;
frigate?: {
event: FrigateEvent;
event?: FrigateEvent;
recording?: FrigateRecording;
};
}
@@ -1274,7 +1314,7 @@ export type Entity = z.infer<typeof entitySchema>;
export const extendedEntitySchema = entitySchema.extend({
// Extended entity results.
unique_id: z.string().optional(),
})
});
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
export const entityListSchema = entitySchema.array();
+76
View File
@@ -0,0 +1,76 @@
import { z } from 'zod';
import { ExtendedHomeAssistant } from '../types';
import { homeAssistantHTTPRequest } from './ha';
const recordingSummaryHourSchema = z.object({
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
duration: z.number().min(0),
events: z.number().min(0),
});
const recordingSummarySchema = z
.object({
day: z.preprocess((arg) => {
// Must provide the hour:minute:second on parsing or Javascript will
// assume UTC midnight.
return typeof arg === 'string' ? new Date(`${arg} 00:00:00`) : arg;
}, z.date()),
events: z.number(),
hours: recordingSummaryHourSchema.array(),
})
.array();
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
const recordingSegmentSchema = z.object({
start_time: z.number(),
end_time: z.number(),
id: z.string(),
});
const recordingSegmentsSchema = recordingSegmentSchema.array();
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
/**
* Get the recordings summary.
* @param hass The Home Assistant object.
* @param client_id The Frigate client_id.
* @param camera_name The Frigate camera name.
* @returns A RecordingSummary object.
*/
export const getRecordingsSummary = async (
hass: ExtendedHomeAssistant,
client_id: string,
camera_name: string,
): Promise<RecordingSummary> => {
return await homeAssistantHTTPRequest(
hass,
recordingSummarySchema,
`/api/frigate/${client_id}/${camera_name}/recordings/summary`,
);
};
/**
* Get the recording segments..
* @param hass The Home Assistant object.
* @param client_id The Frigate client_id.
* @param camera_name The Frigate camera name.
* @param before The segment low watermark.
* @param after The segment high watermark.
* @returns A RecordingSegments object.
*/
export const getRecordingSegments = async (
hass: ExtendedHomeAssistant,
client_id: string,
camera_name: string,
before: Date,
after: Date,
): Promise<RecordingSegments> => {
return await homeAssistantHTTPRequest(
hass,
recordingSegmentsSchema,
`/api/frigate/${client_id}/${camera_name}/recordings`,
new URLSearchParams({
before: String(before.getTime() / 1000),
after: String(after.getTime() / 1000),
}),
);
};
+66 -6
View File
@@ -8,15 +8,23 @@ import {
import { homeAssistantWSRequest } from '.';
import {
dispatchErrorMessageEvent,
dispatchFrigateCardErrorEvent,
dispatchMessageEvent
} from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import {
BrowseMediaQueryParameters,
BrowseRecordingQueryParameters,
CameraConfig,
FrigateBrowseMediaSource,
frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_PLAYLIST
frigateBrowseMediaSourceSchema,
FrigateCardError,
FrigateEvent,
FrigateRecording,
MEDIA_CLASS_PLAYLIST,
MEDIA_CLASS_VIDEO,
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_VIDEO
} from '../../types.js';
import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js';
@@ -27,7 +35,7 @@ import { getCameraTitle } from '../camera.js';
* @returns The `event_id` or `null` if not successfully parsed.
*/
export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
return media.frigate?.event.id ?? null;
return media.frigate?.event?.id ?? null;
};
/**
@@ -36,7 +44,7 @@ export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
return media.frigate?.event.start_time ?? null;
return media.frigate?.event?.start_time ?? null;
};
/**
@@ -336,7 +344,7 @@ export const fetchLatestMediaAndDispatchViewChange = async (
try {
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
@@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
try {
parent = await browseMedia(hass, child.media_content_id);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
view
@@ -409,6 +417,38 @@ export const createEventParentForChildren = (
};
};
/**
* Given a media video child with a given media_content_id.
* @param title The title to use for the child.
* @param media_con
* @param children The children media items.
* @returns A single parent containing the children.
*/
export const createVideoChild = (
title: string,
mediaContentID: string,
options?: {
thumbnail?: string;
recording?: FrigateRecording;
},
): FrigateBrowseMediaSource => {
return {
title: title,
media_class: MEDIA_CLASS_VIDEO,
media_content_type: MEDIA_TYPE_VIDEO,
media_content_id: mediaContentID,
can_play: true,
can_expand: false,
thumbnail: options?.thumbnail ?? null,
children: null,
...(options?.recording && {
frigate: {
recording: options.recording,
},
}),
};
};
/**
* Convenience function to convert a timestamp to hours, minutes and seconds
* string. Heavily inspired by, and returning the same format as, the Frigate
@@ -436,3 +476,23 @@ export function getEventDurationString(event: FrigateEvent): string {
duration += `${seconds}s`;
return duration;
}
/**
* Generate a recording identifier.
* @param hass The HomeAssistant object.
* @param params The recording parameters to use in the identifer.
* @returns A recording identifier.
*/
export const generateRecordingIdentifier = (
params: BrowseRecordingQueryParameters,
): string => {
return [
'media-source://frigate',
params.clientId,
'recordings',
`${params.year}-${String(params.month).padStart(2, '0')}`,
String(params.day).padStart(2, '0'),
String(params.hour).padStart(2, '0'),
params.cameraName,
].join('/');
};
+79 -9
View File
@@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js';
import {
CardHelpers, ExtendedHomeAssistant,
CardHelpers,
ExtendedHomeAssistant,
FrigateCardError,
SignedPath,
signedPathSchema,
StateParameters
@@ -36,12 +38,16 @@ export async function homeAssistantWSRequest<T>(
const parseResult = schema.safeParse(response);
if (!parseResult.success) {
const keys = getParseErrorKeys<T>(parseResult.error);
const error_message =
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
localize('error.invalid_keys') +
`: '${keys}'`;
console.warn(error_message);
throw new Error(error_message);
const error_message = localize('error.invalid_response');
console.warn(
`${error_message}: ${JSON.stringify(request)}. ${localize(
'error.invalid_keys',
)}: ${keys}`,
);
throw new FrigateCardError(error_message, {
request: request,
invalid_keys: keys,
});
}
return parseResult.data;
}
@@ -74,6 +80,70 @@ export async function homeAssistantSignPath(
return hass.hassUrl(response.path);
}
/**
* Make a HomeAssistant HTTP 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 homeAssistantHTTPRequest<T>(
hass: ExtendedHomeAssistant,
schema: ZodSchema<T>,
url: string,
params?: URLSearchParams,
): Promise<T> {
let signResponse: string | null | undefined;
try {
signResponse = await homeAssistantSignPath(hass, url);
} catch (e) {
console.warn(e);
}
if (!signResponse) {
throw new FrigateCardError(localize('error.failed_sign'), {
url: url.toString(),
});
}
const signedURL = new URL(signResponse);
if (params) {
for (const [key, value] of params.entries()) {
signedURL.searchParams.append(key, value);
}
}
const response = await fetch(signedURL.toString());
if (!response.ok) {
throw new FrigateCardError(localize('error.failed_response'), {
url: signedURL.toString(),
status: response.status,
statusText: response.statusText,
});
}
let raw_json;
try {
raw_json = await response.json();
} catch (e) {
console.warn(e);
throw new FrigateCardError(localize('error.undecodable_response'), {
url: signedURL.toString(),
});
}
try {
return schema.parse(raw_json);
} catch (e) {
console.warn(e);
throw new FrigateCardError(localize('error.invalid_response'), {
url: signedURL.toString(),
response: raw_json,
});
}
}
interface HassStateDifference {
entity: string;
oldState?: HassEntity;
@@ -310,7 +380,7 @@ export const isTriggeredState = (state?: HassEntity): boolean => {
* Get entities from the HASS object.
* @param hass
* @param domain
* @returns
* @returns A list of entities ids.
*/
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) {
@@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin
);
entities.sort();
return entities;
}
};