) => {
wrapMediaLoadedEventForCarousel(slideIndex, e);
diff --git a/src/const.ts b/src/const.ts
index ca56f40e..ac6b6b6d 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -84,6 +84,17 @@ export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
+ `${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA =
+ `${CONF_MEDIA_VIEWER}.controls.timeline.media` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
+ `${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS =
+ `${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
+ `${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
+
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
@@ -115,6 +126,16 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
+export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
+ `${CONF_LIVE}.control s.timeline.clustering_threshold` as const;
+export const CONF_LIVE_CONTROLS_TIMELINE_MEDIA =
+ `${CONF_LIVE}.controls.timeline.media` as const;
+export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
+ `${CONF_LIVE}.controls.timeline.mode` as const;
+export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS =
+ `${CONF_LIVE}.controls.timeline.show_recordings` as const;
+export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
+ `${CONF_LIVE}.controls.timeline.window_seconds` as const;
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_LIVE}.controls.title.duration_seconds` as const;
diff --git a/src/editor.ts b/src/editor.ts
index b5f92116..a5367816 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -54,6 +54,11 @@ import {
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
+ CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
+ CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
+ CONF_LIVE_CONTROLS_TIMELINE_MODE,
+ CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
+ CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
CONF_LIVE_CONTROLS_TITLE_MODE,
CONF_LIVE_DRAGGABLE,
@@ -76,6 +81,11 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
CONF_MEDIA_VIEWER_DRAGGABLE,
@@ -136,8 +146,10 @@ const MENU_CAMERAS_WEBRTC = 'cameras.webrtc';
const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls';
const MENU_IMAGE_LAYOUT = 'image.layout';
const MENU_LIVE_CONTROLS = 'live.controls';
+const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
const MENU_LIVE_LAYOUT = 'live.layout';
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
+const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
const MENU_TIMELINE_CONTROLS = 'timeline.controls';
const MENU_OPTIONS = 'options';
@@ -421,6 +433,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'fill', label: localize('config.common.layout.fits.fill') },
];
+ protected _miniTimelineModes: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'none', label: localize('config.timeline.mini.modes.none') },
+ { value: 'above', label: localize('config.timeline.mini.modes.above') },
+ { value: 'below', label: localize('config.timeline.mini.modes.below') },
+ ];
+
public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
@@ -784,7 +803,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
/**
* Render a media layout section.
- * @param domain The submenu domain.
+ * @param domain The submenu domain.
* @param labelPath The path to the label.
* @param configPathFit The path to the fit config.
* @param configPathPositionX The path to the position.x config.
@@ -819,6 +838,71 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
);
}
+ /**
+ * Render the core timeline controls (mini or full timeline),
+ * @param configPathWindowSeconds Timeline window config path.
+ * @param configPathClusteringThreshold Clustering threshold config path.
+ * @param configPathTimelineMedia Timeline media config path.
+ * @param configPathShowRecordings Show recordings config path.
+ * @param defaultShowRecordings Default value of show_recordings.
+ * @returns A rendered template.
+ */
+ protected _renderTimelineCoreControls(
+ configPathWindowSeconds: string,
+ configPathClusteringThreshold: string,
+ configPathTimelineMedia: string,
+ configPathShowRecordings: string,
+ defaultShowRecordings: boolean,
+ ): TemplateResult {
+ return html` ${this._renderNumberInput(configPathWindowSeconds, {
+ label: localize(`config.${CONF_TIMELINE_WINDOW_SECONDS}`),
+ })}
+ ${this._renderNumberInput(configPathClusteringThreshold, {
+ label: localize(`config.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`),
+ })}
+ ${this._renderOptionSelector(configPathTimelineMedia, this._timelineMediaTypes, {
+ label: localize(`config.${CONF_TIMELINE_MEDIA}`),
+ })}
+ ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, {
+ label: localize(`config.${CONF_TIMELINE_SHOW_RECORDINGS}`),
+ })}`;
+ }
+
+ /**
+ * Render the mini timeline controls.
+ * @param domain The submenu domain.
+ * @param configPathWindowSeconds Timeline window config path.
+ * @param configPathClusteringThreshold Clustering threshold config path.
+ * @param configPathTimelineMedia Timeline media config path.
+ * @param configPathShowRecordings Show recordings config path.
+ * @returns A rendered template.
+ */
+ protected _renderMiniTimeline(
+ domain: string,
+ configPathMode: string,
+ configPathWindowSeconds: string,
+ configPathClusteringThreshold: string,
+ configPathTimelineMedia: string,
+ configPathShowRecordings: string,
+ ): TemplateResult | void {
+ return this._putInSubmenu(
+ domain,
+ true,
+ 'config.timeline.mini.options',
+ { name: 'mdi:chart-gantt' },
+ html` ${this._renderOptionSelector(configPathMode, this._miniTimelineModes, {
+ label: localize('config.timeline.mini.mode'),
+ })}
+ ${this._renderTimelineCoreControls(
+ configPathWindowSeconds,
+ configPathClusteringThreshold,
+ configPathTimelineMedia,
+ configPathShowRecordings,
+ frigateCardConfigDefaults.mini_timeline.show_recordings,
+ )}`,
+ );
+ }
+
/**
* Render a camera section.
* @param cameras The full array of cameras.
@@ -1312,6 +1396,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
max: 60,
},
)}
+ ${this._renderMiniTimeline(
+ MENU_LIVE_CONTROLS_TIMELINE,
+ CONF_LIVE_CONTROLS_TIMELINE_MODE,
+ CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
+ CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
+ CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
+ CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
+ )}
`,
)}
${this._renderMediaLayout(
@@ -1429,6 +1521,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
{ min: 0, max: 60 },
)}
+ ${this._renderMiniTimeline(
+ MENU_MEDIA_VIEWER_CONTROLS_TIMELINE,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
+ )}
`,
)}
${this._renderMediaLayout(
@@ -1458,13 +1558,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderOptionSetHeader('timeline')}
${this._expandedMenus[MENU_OPTIONS] === 'timeline'
? html`
- ${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)}
- ${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)}
- ${this._renderOptionSelector(
+ ${this._renderTimelineCoreControls(
+ CONF_TIMELINE_WINDOW_SECONDS,
+ CONF_TIMELINE_CLUSTERING_THRESHOLD,
CONF_TIMELINE_MEDIA,
- this._timelineMediaTypes,
- )}
- ${this._renderSwitch(
CONF_TIMELINE_SHOW_RECORDINGS,
defaults.timeline.show_recordings,
)}
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index fb13f480..ad366f0d 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -161,11 +161,11 @@
"thumbnails": {
"mode": "Media Viewer thumbnails mode",
"modes": {
- "above": "Thumbnails above the media",
- "below": "Thumbnails below the media",
- "left": "Thumbnails in a drawer left of the media",
+ "above": "Thumbnails above",
+ "below": "Thumbnails below",
+ "left": "Thumbnails in a drawer to the left",
"none": "No thumbnails",
- "right": "Thumbnails in a drawer right of the media"
+ "right": "Thumbnails in a drawer to the right"
},
"show_details": "Show details with thumbnails",
"show_favorite_control": "Show favorite control on thumbnails",
@@ -254,6 +254,15 @@
"size": "Timeline thumbnails size in pixels"
}
},
+ "mini": {
+ "options": "Mini Timeline",
+ "mode": "Mode",
+ "modes": {
+ "none": "None",
+ "above": "Above",
+ "below": "Below"
+ }
+ },
"media": "The media the timeline displays",
"medias": {
"all": "All media types",
@@ -362,7 +371,8 @@
"duration": "Duration",
"in_progress": "In Progress",
"score": "Score",
- "start": "Start"
+ "start": "Start",
+ "seek": "Seek"
},
"recording": {
"events": "Events",
@@ -371,7 +381,11 @@
"thumbnail": {
"no_thumbnail": "No thumbnail available",
"retain_indefinitely": "Event will be indefinitely retained",
- "timeline": "See event in timeline"
+ "timeline": "See event/recording in timeline"
+ },
+ "timeline": {
+ "lock": "Lock timeline to a single event",
+ "unlock": "Unlock timeline"
},
"elements": {
"ptz": {
diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts
index fa910c91..3f0152d6 100644
--- a/src/patches/ha-hls-player.ts
+++ b/src/patches/ha-hls-player.ts
@@ -25,6 +25,8 @@ customElements.whenDefined('ha-hls-player').then(() => {
@query('#video')
protected _video: HTMLVideoElement;
+ protected _controlsVisibilityTimerID: number | null = null;
+
/**
* Play the video.
*/
@@ -65,7 +67,20 @@ customElements.whenDefined('ha-hls-player').then(() => {
*/
public seek(seconds: number): void {
if (this._video) {
+ // Hide the controls while programatically seeking, and make them
+ // visible again a short time after the last seek (controls are annoying
+ // during timeline seeking)
+ this._video.controls = false;
+
this._video.currentTime = seconds;
+
+ if (this._controlsVisibilityTimerID !== null) {
+ window.clearTimeout(this._controlsVisibilityTimerID);
+ }
+ this._controlsVisibilityTimerID = window.setTimeout(() => {
+ this._video.controls = true;
+ this._controlsVisibilityTimerID = null;
+ }, 1000);
}
}
@@ -112,7 +127,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
});
declare global {
- interface HTMLElementTagNameMap {
- "frigate-card-ha-hls-player": FrigateCardHaHlsPlayer
- }
+ interface HTMLElementTagNameMap {
+ 'frigate-card-ha-hls-player': FrigateCardHaHlsPlayer;
+ }
}
diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss
index 6c5288cf..a86b3070 100644
--- a/src/scss/drawer.scss
+++ b/src/scss/drawer.scss
@@ -37,7 +37,7 @@ div.control-surround {
ha-icon.control {
color: var(--secondary-color, white);
background-color: rgba(0, 0, 0, 0.7);
- opacity: 0.7;
+ opacity: 0.5;
pointer-events: all;
--mdc-icon-size: #{$drawer-icon-size};
diff --git a/src/scss/surround-basic.scss b/src/scss/surround-basic.scss
new file mode 100644
index 00000000..d6ff998a
--- /dev/null
+++ b/src/scss/surround-basic.scss
@@ -0,0 +1,21 @@
+:host {
+ width: 100%;
+ height: 100%;
+
+ // Share the screen space with thumbnails that may be above/below.
+ display: flex;
+ flex-direction: column;
+
+ // Set the drawer relative to this host.
+ position: relative;
+
+ // Hide any content outside the main pane (e.g. side drawers) to ensure the
+ // user cannot scroll across to the drawers without opening them.
+ overflow: hidden;
+}
+
+::slotted:not([name]) {
+ // Expand the main body to fill available content not otherwise used by the
+ // surround.
+ flex: 1;
+}
diff --git a/src/scss/surround-thumbnails.scss b/src/scss/surround-thumbnails.scss
deleted file mode 100644
index f0f64c07..00000000
--- a/src/scss/surround-thumbnails.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-:host {
- width: 100%;
- height: 100%;
- display: block;
-}
\ No newline at end of file
diff --git a/src/scss/surround.scss b/src/scss/surround.scss
index d6ff998a..f0f64c07 100644
--- a/src/scss/surround.scss
+++ b/src/scss/surround.scss
@@ -1,21 +1,5 @@
:host {
width: 100%;
height: 100%;
-
- // Share the screen space with thumbnails that may be above/below.
- display: flex;
- flex-direction: column;
-
- // Set the drawer relative to this host.
- position: relative;
-
- // Hide any content outside the main pane (e.g. side drawers) to ensure the
- // user cannot scroll across to the drawers without opening them.
- overflow: hidden;
-}
-
-::slotted:not([name]) {
- // Expand the main body to fill available content not otherwise used by the
- // surround.
- flex: 1;
-}
+ display: block;
+}
\ No newline at end of file
diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss
index f8ea64e7..44a521f5 100644
--- a/src/scss/thumbnail-details.scss
+++ b/src/scss/thumbnail-details.scss
@@ -13,6 +13,8 @@ div.left {
display: flex;
flex-direction: column;
justify-content: center;
+ font-size: 0.8rem;
+ line-height: normal;
}
div.right {
align-items: center;
@@ -42,5 +44,5 @@ span.heading {
div.larger,
span.larger {
- font-size: 1.5rem;
+ font-size: 1.4rem;
}
diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss
index 04df7733..84590fd3 100644
--- a/src/scss/thumbnail-feature-event.scss
+++ b/src/scss/thumbnail-feature-event.scss
@@ -30,4 +30,5 @@ ha-icon {
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.3);
box-sizing: border-box;
+ opacity: 0.2;
}
diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss
index f83a47e1..a6851b2a 100644
--- a/src/scss/timeline-core.scss
+++ b/src/scss/timeline-core.scss
@@ -4,10 +4,6 @@
:host {
width: 100%;
- height: 100%;
- background-color: var(--card-background-color);
- padding-bottom: 5px;
-
// Share the screen space with thumbnails that may be above/below.
display: flex;
flex-direction: column;
@@ -27,14 +23,6 @@ frigate-card-thumbnail[details] {
div.timeline {
flex: 1;
}
-div.timeline.left-margin {
- // Clearance for the drawer button.
- margin-left: calc(drawer.$drawer-icon-size + 1px);
-}
-div.timeline.right-margin {
- // Clearance for the drawer button.
- margin-right: calc(drawer.$drawer-icon-size + 1px);
-}
.vis-text {
color: var(--primary-text-color) !important;
@@ -68,6 +56,14 @@ div.timeline.right-margin {
opacity: 0.1;
}
+// If there are no timeline groups shown (e.g. mini mode with a single camera),
+// ensure the background (recordings) always span the full height. Otherwise, in
+// cases where there are no events, the background is incorrectly rendered too
+// short by visjs.
+:host(:not([groups])) .vis-item.vis-background {
+ min-height: 100%;
+}
+
.vis-item:not(.vis-background) {
cursor: pointer;
}
@@ -127,3 +123,21 @@ div.vis-tooltip {
// Use browser default font-family for tooltips.
font-family: unset;
}
+
+.target_bar {
+ border-left: 2px solid var(--primary-color);
+ opacity: 0.7;
+ box-shadow: 0px 0px 3px 1px var(--primary-color);
+
+ // Prevent the mouse interacting with the custom time.
+ pointer-events: none;
+}
+
+ha-icon.lock {
+ position: absolute;
+ right: 2px;
+ bottom: 2px;
+ color: var(--primary-color);
+ z-index: 10;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 9749f550..8141c19d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -26,6 +26,8 @@ export const THUMBNAIL_WIDTH_MIN = 75;
* Internal types.
*/
+export type ClipsOrSnapshots = 'clips' | 'snapshots';
+
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'live',
'clip',
@@ -38,6 +40,7 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
+ 'recording',
// Media: A generic piece of media (could be clip, snapshot, recording).
'media',
@@ -568,10 +571,12 @@ export type PictureElements = z.infer;
*/
const mediaLayoutConfigSchema = z.object({
fit: z.enum(['contain', 'cover', 'fill']).optional(),
- position: z.object({
- x: z.number().min(0).max(100).optional(),
- y: z.number().min(0).max(100).optional(),
- }).optional(),
+ position: z
+ .object({
+ x: z.number().min(0).max(100).optional(),
+ y: z.number().min(0).max(100).optional(),
+ })
+ .optional(),
});
export type MediaLayoutConfig = z.infer;
@@ -655,6 +660,44 @@ const thumbnailsControlSchema = z.object({
});
export type ThumbnailsControlConfig = z.infer;
+/**
+ * Core/Mini timeline controls configuration section.
+ */
+
+const timelineCoreConfigDefault = {
+ clustering_threshold: 3,
+ media: 'all' as const,
+ window_seconds: 60 * 60,
+ show_recordings: true,
+};
+
+const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']);
+export type TimelineMedia = z.infer;
+
+const timelineCoreConfigSchema = z.object({
+ clustering_threshold: z
+ .number()
+ .optional()
+ .default(timelineCoreConfigDefault.clustering_threshold),
+ media: timelineMediaSchema.optional().default(timelineCoreConfigDefault.media),
+ window_seconds: z
+ .number()
+ .min(1 * 60)
+ .max(24 * 60 * 60)
+ .optional()
+ .default(timelineCoreConfigDefault.window_seconds),
+ show_recordings: z
+ .boolean()
+ .optional()
+ .default(timelineCoreConfigDefault.show_recordings),
+});
+export type TimelineCoreConfig = z.infer;
+
+const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
+ mode: z.enum(['none', 'above', 'below']),
+});
+export type MiniTimelineControlConfig = z.infer;
+
/**
* Next/Previous Control configuration section.
*/
@@ -787,6 +830,7 @@ const liveOverridableConfigSchema = z
.default(liveConfigDefault.controls.thumbnails.media),
})
.default(liveConfigDefault.controls.thumbnails),
+ timeline: miniTimelineConfigSchema.optional(),
title: titleControlConfigSchema
.extend({
mode: titleControlConfigSchema.shape.mode.default(
@@ -989,6 +1033,7 @@ const viewerConfigSchema = z
),
})
.default(viewerConfigDefault.controls.thumbnails),
+ timeline: miniTimelineConfigSchema.optional(),
title: titleControlConfigSchema
.extend({
mode: titleControlConfigSchema.shape.mode.default(
@@ -1082,10 +1127,7 @@ const dimensionsConfigSchema = z
* Timeline configuration section.
*/
const timelineConfigDefault = {
- clustering_threshold: 3,
- media: 'all' as const,
- window_seconds: 60 * 60,
- show_recordings: true,
+ ...timelineCoreConfigDefault,
controls: {
thumbnails: {
mode: 'left' as const,
@@ -1096,26 +1138,9 @@ const timelineConfigDefault = {
},
},
};
-const timelineConfigSchema = z
- .object({
- clustering_threshold: z
- .number()
- .optional()
- .default(timelineConfigDefault.clustering_threshold),
- media: z
- .enum(['all', 'clips', 'snapshots'])
- .optional()
- .default(timelineConfigDefault.media),
- window_seconds: z
- .number()
- .min(1 * 60)
- .max(24 * 60 * 60)
- .optional()
- .default(timelineConfigDefault.window_seconds),
- show_recordings: z
- .boolean()
- .optional()
- .default(timelineConfigDefault.show_recordings),
+
+const timelineConfigSchema = timelineCoreConfigSchema
+ .extend({
controls: z
.object({
thumbnails: thumbnailsControlSchema
@@ -1215,6 +1240,7 @@ export const frigateCardConfigDefaults = {
event_gallery: galleryConfigDefault,
image: imageConfigDefault,
timeline: timelineConfigDefault,
+ mini_timeline: timelineCoreConfigDefault,
};
const menuButtonSchema = z.discriminatedUnion('type', [
@@ -1347,31 +1373,12 @@ interface BrowseMediaSource {
children?: BrowseMediaSource[] | null;
}
-export interface FrigateEvent {
- camera: string;
- end_time?: number;
- false_positive: boolean;
- has_clip: boolean;
- has_snapshot: boolean;
- id: string;
- label: string;
- start_time: number;
- top_score: number;
- zones: string[];
- retain_indefinitely?: boolean;
-}
-
export interface FrigateRecording {
+ // Frigate camera name (may not be unique)
camera: string;
start_time: number;
end_time: number;
events: number;
-
- // Specifies the point at which this recording should be played, the
- // seek_time is the date of the desired play point, and seek_seconds is the
- // number of seconds to seek to reach that point.
- seek_time?: number;
- seek_seconds?: number;
}
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
@@ -1379,9 +1386,28 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource {
frigate?: {
event?: FrigateEvent;
recording?: FrigateRecording;
+ cameraID?: string;
};
}
+export const frigateEventSchema = z.object({
+ camera: z.string(),
+ end_time: z.number().nullable(),
+ false_positive: z.boolean().nullable(),
+ has_clip: z.boolean(),
+ has_snapshot: z.boolean(),
+ id: z.string(),
+ label: z.string(),
+ start_time: z.number(),
+ top_score: z.number(),
+ zones: z.string().array(),
+ retain_indefinitely: z.boolean().optional(),
+});
+export type FrigateEvent = z.infer;
+
+export const frigateEventsSchema = frigateEventSchema.array();
+export type FrigateEvents = z.infer;
+
export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy(
() =>
z.object({
@@ -1396,19 +1422,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.
children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(),
frigate: z
.object({
- event: z.object({
- camera: z.string(),
- end_time: z.number().nullable(),
- false_positive: z.boolean().nullable(),
- has_clip: z.boolean(),
- has_snapshot: z.boolean(),
- id: z.string(),
- label: z.string(),
- start_time: z.number(),
- top_score: z.number(),
- zones: z.string().array(),
- retain_indefinitely: z.boolean().optional(),
- }),
+ event: frigateEventSchema,
})
.optional(),
}),
diff --git a/src/utils/basic.ts b/src/utils/basic.ts
index 31504bbc..d44142fd 100644
--- a/src/utils/basic.ts
+++ b/src/utils/basic.ts
@@ -1,3 +1,4 @@
+import { format } from 'date-fns';
import { isEqual } from 'lodash-es';
import { FrigateCardError } from '../types';
@@ -85,3 +86,28 @@ export function errorToConsole(e: Error, func?: CallableFunction): void {
export const isHoverableDevice = (): boolean => window.matchMedia(
'(hover: hover) and (pointer: fine)',
).matches;
+
+/**
+ * Format a date object to RFC3339.
+ * @param date A Date object.
+ * @returns A date and time.
+ */
+export const formatDateAndTime = (date: Date): string => {
+ return format(date, 'yyyy-MM-dd HH:mm');
+}
+
+/**
+ * Run a function in idle periods. If idle callbacks are not supported (e.g.
+ * Safari) the callback is run immediately.
+ * @param func The function to call.
+ * @param timeout The maximum number of seconds to wait.
+ */
+export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
+ if (window.requestIdleCallback) {
+ window.requestIdleCallback(func, {
+ ...(timeout && { timeout: timeout})
+ });
+ } else {
+ func();
+ }
+}
\ No newline at end of file
diff --git a/src/utils/camera.ts b/src/utils/camera.ts
index 81c5d1ec..8ef70904 100644
--- a/src/utils/camera.ts
+++ b/src/utils/camera.ts
@@ -72,3 +72,38 @@ export function getCameraIcon(
): string {
return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
}
+
+/**
+ * Get all cameras that depend on a given camera.
+ * @param cameras Cameras map.
+ * @param camera Name of the target camera.
+ * @returns A set of query parameters.
+ */
+export const getAllDependentCameras = (
+ cameras: Map,
+ camera?: string,
+): Set => {
+ const cameraIDs: Set = new Set();
+ const getDependentCameras = (camera: string): void => {
+ const cameraConfig = cameras.get(camera);
+ if (cameraConfig) {
+ cameraIDs.add(camera);
+ const dependentCameras: Set = new Set();
+ (cameraConfig.dependencies.cameras || []).forEach((item) =>
+ dependentCameras.add(item),
+ );
+ if (cameraConfig.dependencies.all_cameras) {
+ cameras.forEach((_, key) => dependentCameras.add(key));
+ }
+ for (const eventCameraID of dependentCameras) {
+ if (!cameraIDs.has(eventCameraID)) {
+ getDependentCameras(eventCameraID);
+ }
+ }
+ }
+ };
+ if (camera) {
+ getDependentCameras(camera);
+ }
+ return cameraIDs;
+};
diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts
index 02953681..ade6cf3a 100644
--- a/src/utils/frigate.ts
+++ b/src/utils/frigate.ts
@@ -1,7 +1,21 @@
import { HomeAssistant } from 'custom-card-helpers';
+import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
+import differenceInHours from 'date-fns/differenceInHours';
+import differenceInMinutes from 'date-fns/differenceInMinutes';
+import differenceInSeconds from 'date-fns/differenceInSeconds';
+import fromUnixTime from 'date-fns/fromUnixTime';
import { z } from 'zod';
import { localize } from '../localize/localize';
-import { CameraConfig, ExtendedHomeAssistant, FrigateCardError } from '../types';
+import {
+ BrowseRecordingQueryParameters,
+ ClipsOrSnapshots,
+ ExtendedHomeAssistant,
+ FrigateCardError,
+ FrigateEvent,
+ FrigateEvents,
+ frigateEventsSchema,
+} from '../types';
+import { formatDateAndTime, prettifyTitle } from './basic';
import { homeAssistantWSRequest } from './ha';
export const FRIGATE_ICON_SVG_PATH =
@@ -35,7 +49,7 @@ const recordingSummarySchema = z
.object({
day: z.preprocess((arg) => {
// Must provide the hour:minute:second on parsing or Javascript will
- // assume UTC midnight.
+ // assume *UTC* midnight.
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
}, z.date()),
events: z.number(),
@@ -74,9 +88,9 @@ export const getRecordingsSummary = async (
hass,
recordingSummarySchema,
{
- type: "frigate/recordings/summary",
+ type: 'frigate/recordings/summary',
instance_id: client_id,
- camera: camera_name
+ camera: camera_name,
},
true,
);
@@ -102,7 +116,7 @@ export const getRecordingSegments = async (
hass,
recordingSegmentsSchema,
{
- type: "frigate/recordings/get",
+ type: 'frigate/recordings/get',
instance_id: client_id,
camera: camera_name,
before: Math.floor(before.getTime() / 1000),
@@ -145,26 +159,151 @@ export async function retainEvent(
}
}
+export interface FrigateGetEventsParameters {
+ instance_id?: string;
+ camera?: string;
+ label?: string;
+ zone?: string;
+ after?: number;
+ before?: number;
+ limit?: number;
+ has_clip?: boolean;
+ has_snapshot?: boolean;
+}
+
/**
- * Get an id that unique identifies a particular camera (not zone, object, etc)
- * within a particular Frigate instance. ID will not (necessarily) be unique
- * within the card.
- * @param cameraConfig The camera config.
+ * Get events over websocket. May throw.
+ * @param hass The Home Assistant object.
+ * @param params The events search parameters.
+ * @returns An array of 'FrigateEvent's.
*/
-export const getUniqueFrigateCameraID = (config: CameraConfig): string => {
- return [config.frigate.client_id, config.frigate.camera_name].join('/');
+export const getEvents = async (
+ hass: HomeAssistant,
+ params?: FrigateGetEventsParameters,
+): Promise => {
+ return await homeAssistantWSRequest(
+ hass,
+ frigateEventsSchema,
+ {
+ type: 'frigate/events/get',
+ ...params,
+ },
+ true,
+ );
};
/**
- * Get an id that unique identifies a source of Frigate events. ID will not
- * (necessarily) be unique within the card.
- * @param cameraConfig The camera config.
+ * Get multiple sets of events.
+ * @param hass The Home Assistant object.
+ * @param params A Map of parameters keyed on any key.
+ * @returns A Map of key -> events.
*/
-export const getUniqueFrigateCameraEventsID = (config: CameraConfig): string => {
+export const getEventsMultiple = async (
+ hass: HomeAssistant,
+ params: Map,
+): Promise