diff --git a/src/card.ts b/src/card.ts index b0a17de8..98d200df 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1276,6 +1276,7 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .view=${this._view} .cameraConfig=${cameraConfig} + .timelineConfig=${this._getConfig().timeline} > ` : ``} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 13fa510f..21d26fd8 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -8,6 +8,7 @@ import { CameraConfig, ExtendedHomeAssistant, GalleryConfig, + THUMBNAIL_WIDTH_MAX, frigateCardConfigDefaults, } from '../types.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; @@ -17,8 +18,6 @@ import { stopEventFromActivatingCardWideActions } from '../common.js'; import galleryStyle from '../scss/gallery.scss'; -const MAX_THUMBNAIL_WIDTH = 175; - @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @property({ attribute: false }) @@ -124,7 +123,7 @@ export class FrigateCardGalleryCore extends LitElement { this._columns = Math.max( this.galleryConfig?.min_columns ?? frigateCardConfigDefaults.event_gallery.min_columns, - Math.ceil(this.clientWidth / MAX_THUMBNAIL_WIDTH), + Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX), ); } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index b4b2cc50..a0e0b53c 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -8,12 +8,18 @@ import { } from 'lit'; import { DataSet } from 'vis-data/esnext'; import { HomeAssistant } from 'custom-card-helpers'; -import { Timeline, TimelineOptions } from 'vis-timeline/esnext'; +import { Timeline, TimelineOptions, TimelineOptionsCluster } from 'vis-timeline/esnext'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref'; import { BrowseMediaUtil } from '../browse-media-util'; -import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types'; +import { + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + TimelineConfig, + frigateCardConfigDefaults, +} from '../types'; import { View } from '../view'; import { contentsChanged, dispatchFrigateCardEvent } from '../common.js'; import { renderProgressIndicator } from './message'; @@ -38,6 +44,23 @@ export class FrigateCardTimelineEvent extends LitElement { @property({ attribute: true }) protected label?: string; + @property({ attribute: true, type: Number }) + protected thumbnail_size?: number; + + /** + * Ensure there is a cached value before an update. + * @param _changedProps The changed properties + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected willUpdate(_changedProps: PropertyValues): void { + if (this.thumbnail_size !== undefined) { + this.style.setProperty( + '--frigate-card-timeline-thumbnail-size', + `${this.thumbnail_size}px`, + ); + } + } + protected render(): TemplateResult | void { if (!this.thumbnail) { return; @@ -72,6 +95,17 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) protected cameraConfig?: CameraConfig; + /** + * Set the Home Assistant object. + */ + set timelineConfig(timelineConfig: TimelineConfig) { + this._timelineConfig = timelineConfig; + this._setOptions(); + } + + @state() + protected _timelineConfig?: TimelineConfig; + @state({ hasChanged: contentsChanged }) protected _timelineOptions?: TimelineOptions; @@ -79,14 +113,6 @@ export class FrigateCardTimeline extends LitElement { protected _timeline?: Timeline; protected _boundTimelineSelectHandler = this._timelineSelectHandler.bind(this); - protected _resizeObserver: ResizeObserver; - - constructor() { - super(); - this._resizeObserver = new ResizeObserver(this._setOptions.bind(this)); - this._setOptions(); - } - /** * Master render method. * @returns A rendered template. @@ -127,7 +153,6 @@ export class FrigateCardTimeline extends LitElement { 'frigate-card:timeline-select', this._boundTimelineSelectHandler, ); - this._resizeObserver.observe(this); } /** @@ -138,7 +163,6 @@ export class FrigateCardTimeline extends LitElement { 'frigate-card:timeline-select', this._boundTimelineSelectHandler, ); - this._resizeObserver.disconnect(); super.disconnectedCallback(); } @@ -172,6 +196,10 @@ export class FrigateCardTimeline extends LitElement { media_id=${source.media_content_id} thumbnail="${source.thumbnail}" label="${source.title}" + thumbnail_size="${ + this._timelineConfig?.controls.thumbnails.size_pixels ?? + frigateCardConfigDefaults.timeline.controls.thumbnails.size_pixels + }" > `; } @@ -204,24 +232,48 @@ export class FrigateCardTimeline extends LitElement { * Handle timeline resize. */ protected _setOptions(): void { + if (!this._timelineConfig) { + return; + } + + const thumbnailConfig = + this._timelineConfig?.controls.thumbnails ?? + frigateCardConfigDefaults.timeline.controls.thumbnails; + const gap = 5; + // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options this._timelineOptions = { - cluster: { - showStipes: true, - maxItems: 3, - }, + cluster: thumbnailConfig.clustering_threshold > 0 + ? { + showStipes: true, + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: thumbnailConfig.clustering_threshold, + } + : false as TimelineOptionsCluster, minHeight: '100%', maxHeight: '100%', margin: { - item: 50, - axis: 75 + 10, + item: thumbnailConfig.size_pixels - thumbnailConfig.overlap_pixels + gap, + axis: thumbnailConfig.size_pixels + gap, }, xss: { disabled: false, filterOptions: { whiteList: { - 'frigate-card-timeline-event': ['thumbnail', 'label', 'media_id'], + 'frigate-card-timeline-event': [ + 'thumbnail', + 'label', + 'media_id', + 'thumbnail_size', + ], div: ['title'], }, }, @@ -234,7 +286,9 @@ export class FrigateCardTimeline extends LitElement { * @param changedProps The changed properties if any. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected updated(_changedProperties: PropertyValues): void { + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (this._timelineRef.value) { if (this._timeline) { this._timeline.destroy(); diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss index b59a6cf7..7d03c7ff 100644 --- a/src/scss/timeline-event.scss +++ b/src/scss/timeline-event.scss @@ -2,11 +2,13 @@ display: block; width: 100%; height: 100%; + + --frigate-card-timeline-thumbnail-size: 75px; } img { - width: 75px; - height: 75px; + width: var(--frigate-card-timeline-thumbnail-size); + height: var(--frigate-card-timeline-thumbnail-size); display: block; border-radius: 5px; transition: transform 0.2s linear; diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 4862e731..253fdd29 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -4,12 +4,18 @@ width: 100%; height: 100%; display: block; + background-color: var(--card-background-color); + padding-bottom: 5px; } div.timeline { height: 100%; } +.vis-text { + color: var(--primary-text-color) !important; +} + .vis-timeline { border: hidden; } @@ -20,6 +26,7 @@ div.timeline { } .vis-item:hover { + // Float icons upwards when the user hovers over them. z-index: 2; } diff --git a/src/types.ts b/src/types.ts index 30a33f25..df8a8f79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,13 +26,13 @@ declare global { */ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ - 'live', // Live view. - 'clip', // Most recent clip. - 'clips', // Clips gallery. - 'snapshot', // Most recent snapshot. - 'snapshots',// Snapshots gallery. - 'image', // Static image. - 'timeline', // Event timeline. + 'live', + 'clip', + 'clips', + 'snapshot', + 'snapshots', + 'image', + 'timeline', ] as const; export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; @@ -59,6 +59,9 @@ export type LiveProvider = typeof LIVE_PROVIDERS[number]; export class FrigateCardError extends Error {} +// The maximum width thumbnail Frigate returns +export const THUMBNAIL_WIDTH_MAX = 175; + /** * Action Types (for "Picture Elements" / Menu) */ @@ -738,6 +741,45 @@ const dimensionsConfigSchema = z }) .default(dimensionsConfigDefault); +/** + * Timeline configuration section. + */ +const timelineConfigDefault = { + controls: { + thumbnails: { + size_pixels: 75, + overlap_pixels: 25, + clustering_threshold: 3, + }, + }, +}; +const timelineConfigSchema = z + .object({ + controls: z + .object({ + thumbnails: z + .object({ + size_pixels: z + .number() + .min(50) + .max(THUMBNAIL_WIDTH_MAX) + .default(timelineConfigDefault.controls.thumbnails.size_pixels), + overlap_pixels: z + .number() + .min(0) + .max(THUMBNAIL_WIDTH_MAX) + .default(timelineConfigDefault.controls.thumbnails.overlap_pixels), + clustering_threshold: z + .number() + .default(timelineConfigDefault.controls.thumbnails.clustering_threshold), + }) + .default(timelineConfigDefault.controls.thumbnails), + }) + .default(timelineConfigDefault.controls), + }) + .default(timelineConfigDefault); +export type TimelineConfig = z.infer; + /** * Configuration overrides */ @@ -781,6 +823,7 @@ export const frigateCardConfigSchema = z.object({ image: imageConfigSchema, elements: pictureElementsSchema, dimensions: dimensionsConfigSchema, + timeline: timelineConfigSchema, // Configuration overrides. overrides: overridesSchema, @@ -804,6 +847,7 @@ export const frigateCardConfigDefaults = { event_viewer: viewerConfigDefault, event_gallery: galleryConfigDefault, image: imageConfigDefault, + timeline: timelineConfigDefault, }; const menuButtonSchema = z.discriminatedUnion('type', [ @@ -913,20 +957,22 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. children_media_class: z.string().nullable().optional(), thumbnail: z.string().nullable(), children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), - frigate: z.object({ - event: z.object({ - camera: z.string(), - end_time: z.number().nullable(), - false_positive: z.boolean(), - 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(), - }), - }).optional(), + frigate: z + .object({ + event: z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean(), + 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(), + }), + }) + .optional(), }), );