Merge pull request #1086 from dermotduffy/ribbon

Add support for new ribbon style timeline
This commit is contained in:
Dermot Duffy
2023-04-14 18:28:48 -07:00
committed by GitHub
13 changed files with 154 additions and 71 deletions
+13 -3
View File
@@ -515,11 +515,14 @@ live:
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
| `style` | `ribbon` | :white_check_mark: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). |
| `window_seconds` | `3600` | :white_check_mark: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | | `window_seconds` | `3600` | :white_check_mark: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
| `clustering_threshold` | `3` | :white_check_mark: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| | `clustering_threshold` | `3` | :white_check_mark: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
| `media` | `all` | :white_check_mark: | 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.| | `media` | `all` | :white_check_mark: | 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` | :white_check_mark: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).| | `show_recordings` | `true` | :white_check_mark: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
**Caution**: 🚩 For optimal UX, keep the settings for the mini-timeline in the `live` and `media_viewer` identical. Dragging the timeline may cause the card to change between the `live` view and `media_viewer` based views as the user pans between the past and present -- if the settings are different the timeline must "reset".
<a name="live-controls-title"></a> <a name="live-controls-title"></a>
#### Live Controls: Title #### Live Controls: Title
@@ -609,11 +612,14 @@ media_viewer:
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
| `style` | `ribbon` | :heavy_multiplication_x: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). |
| `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. | | `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.| | `clustering_threshold` | `3` | :heavy_multiplication_x: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
| `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.| | `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).| | `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
**Caution**: 🚩 For optimal UX, keep the settings for the mini-timeline in the `live` and `media_viewer` identical. Dragging the timeline may cause the card to change between the `live` view and `media_viewer` based views as the user pans between the past and present -- if the settings are different the timeline must "reset".
#### Media Viewer Controls: Title #### Media Viewer Controls: Title
All configuration is under: All configuration is under:
@@ -717,8 +723,9 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
| `style` | `stack` | :heavy_multiplication_x: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). |
| `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. | | `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.| | `clustering_threshold` | `3` | :heavy_multiplication_x: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. |
| `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.| | `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).| | `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.| | `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
@@ -1766,6 +1773,7 @@ live:
show_timeline_control: true show_timeline_control: true
mode: none mode: none
timeline: timeline:
style: ribbon
mode: none mode: none
clustering_threshold: 3 clustering_threshold: 3
media: all media: all
@@ -1824,6 +1832,7 @@ media_viewer:
show_favorite_control: true show_favorite_control: true
show_timeline_control: true show_timeline_control: true
timeline: timeline:
style: ribbon
mode: none mode: none
clustering_threshold: 3 clustering_threshold: 3
media: all media: all
@@ -2285,6 +2294,7 @@ Reference: [Timeline Options](#timeline-options).
```yaml ```yaml
timeline: timeline:
style: stack
clustering_threshold: 3 clustering_threshold: 3
media: all media: all
show_recordings: true show_recordings: true
+34 -33
View File
@@ -1,6 +1,8 @@
import add from 'date-fns/add'; import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour';
import differenceInSeconds from 'date-fns/differenceInSeconds'; import differenceInSeconds from 'date-fns/differenceInSeconds';
import endOfDay from 'date-fns/endOfDay';
import endOfHour from 'date-fns/endOfHour';
import startOfDay from 'date-fns/startOfDay';
import startOfHour from 'date-fns/startOfHour'; import startOfHour from 'date-fns/startOfHour';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { import {
@@ -26,6 +28,9 @@ import {
TimelineOptionsCluster, TimelineOptionsCluster,
TimelineWindow, TimelineWindow,
} from 'vis-timeline/esnext'; } from 'vis-timeline/esnext';
import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineCoreStyle from '../scss/timeline-core.scss';
import { import {
@@ -42,27 +47,23 @@ import {
contentsChanged, contentsChanged,
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
isHoverableDevice, isHoverableDevice,
setOrRemoveAttribute,
} from '../utils/basic'; } from '../utils/basic';
import { import {
createQueriesForRecordingsView, createQueriesForRecordingsView,
executeMediaQueryForView, executeMediaQueryForView,
findClosestMediaIndex, findBestMediaIndex,
} from '../utils/media-to-view'; } from '../utils/media-to-view';
import { CameraManager } from '../camera-manager/manager';
import { EventMediaQueries, MediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { dispatchMessageEvent } from './message.js';
import './thumbnail.js';
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { rangesOverlap } from '../camera-manager/range'; import { EventMediaQueries, MediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { View } from '../view/view'; import { View } from '../view/view';
import { MediaQuery } from '../camera-manager/types';
import './date-picker.js'; import './date-picker.js';
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
import startOfDay from 'date-fns/startOfDay'; import { dispatchMessageEvent } from './message.js';
import endOfDay from 'date-fns/endOfDay'; import './thumbnail.js';
interface FrigateCardGroupData { interface FrigateCardGroupData {
id: string; id: string;
@@ -450,10 +451,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.view || !this.view ||
!this.hass || !this.hass ||
!this.cameraManager || !this.cameraManager ||
this._panBehavior === 'pan' || this._panBehavior === 'pan'
// Skip range changes that do not have hammerjs pan directions associated
// with them, as these outliers cause media matching issues below.
!properties.event.additionalEvent
) { ) {
return; return;
} }
@@ -466,10 +464,9 @@ export class FrigateCardTimelineCore extends LitElement {
.clone() .clone()
.resetSelectedResult() .resetSelectedResult()
.selectBestResult((media) => .selectBestResult((media) =>
findClosestMediaIndex( findBestMediaIndex(
media, media,
targetTime, targetTime,
properties.event.additionalEvent === 'panright' ? 'end' : 'start',
), ),
); );
@@ -609,9 +606,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if (view?.queryResults?.hasResults()) { if (view?.queryResults?.hasResults()) {
view.mergeInContext( view.mergeInContext({ mediaViewer: { seek: properties.time } });
{ mediaViewer: { seek: properties.time }
});
} }
view?.mergeInContext(this._getTimelineContext()); view?.mergeInContext(this._getTimelineContext());
@@ -840,6 +835,7 @@ export class FrigateCardTimelineCore extends LitElement {
*/ */
protected _isClustering(): boolean { protected _isClustering(): boolean {
return ( return (
this.timelineConfig?.style === 'stack' &&
!!this.timelineConfig?.clustering_threshold && !!this.timelineConfig?.clustering_threshold &&
this.timelineConfig.clustering_threshold > 0 this.timelineConfig.clustering_threshold > 0
); );
@@ -854,7 +850,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
const defaultWindow = this._getDefaultStartEnd(); const defaultWindow = this._getDefaultStartEnd();
const stack = this.timelineConfig.style === 'stack';
// Configuration for the Timeline, see: // Configuration for the Timeline, see:
// https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options
return { return {
@@ -898,7 +894,15 @@ export class FrigateCardTimelineCore extends LitElement {
maxHeight: '100%', maxHeight: '100%',
zoomMax: 1 * 24 * 60 * 60 * 1000, zoomMax: 1 * 24 * 60 * 60 * 1000,
zoomMin: 1 * 1000, zoomMin: 1 * 1000,
margin: {
item: {
// In ribbon mode, a 20px item is reduced to 6px, so need to add a
// 14px margin to ensure items line up with subgroups.
vertical: stack ? 10 : 24,
},
},
selectable: true, selectable: true,
stack: stack,
start: defaultWindow.start, start: defaultWindow.start,
end: defaultWindow.end, end: defaultWindow.end,
groupHeightMode: 'auto', groupHeightMode: 'auto',
@@ -988,7 +992,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
const mediaID = media?.getID(); const mediaID = media?.getID();
if (!this._pointerHeld && media && mediaID && this._isClustering()) { if (media && mediaID && this._isClustering()) {
// Hack: Clustering may not update unless the dataset changes, artifically // Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included // update the dataset to ensure the newly selected item cannot be included
// in a cluster. Only do this when the pointer is not held to avoid // in a cluster. Only do this when the pointer is not held to avoid
@@ -1094,11 +1098,9 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if (changedProps.has('timelineConfig')) { if (changedProps.has('timelineConfig')) {
if (this.timelineConfig?.show_recordings) { setOrRemoveAttribute(this, !!this.timelineConfig?.show_recordings, 'recordings');
this.setAttribute('recordings', ''); setOrRemoveAttribute(this, this.timelineConfig?.style === 'ribbon', 'ribbon');
} else { setOrRemoveAttribute(this, this.timelineConfig?.style === 'stack', 'stack');
this.removeAttribute('recordings');
}
} }
if ( if (
@@ -1128,6 +1130,7 @@ export class FrigateCardTimelineCore extends LitElement {
this._timeline?.destroy(); this._timeline?.destroy();
this._timeline = undefined; this._timeline = undefined;
this._targetBarVisible = false; this._targetBarVisible = false;
this._pointerHeld = null;
} }
/** /**
@@ -1167,7 +1170,8 @@ export class FrigateCardTimelineCore extends LitElement {
const options = this._getOptions(); const options = this._getOptions();
if (options) { if (options) {
createdTimeline = true; createdTimeline = true;
if (this.mini && groups.length === 1) { const noGroups = this.mini && groups.length === 1;
if (noGroups) {
// In a mini timeline, if there's only one group don't bother grouping // In a mini timeline, if there's only one group don't bother grouping
// at all. // at all.
this._timeline = new Timeline( this._timeline = new Timeline(
@@ -1175,7 +1179,6 @@ export class FrigateCardTimelineCore extends LitElement {
this._timelineSource.dataset, this._timelineSource.dataset,
options, options,
) as Timeline; ) as Timeline;
this.removeAttribute('groups');
} else { } else {
this._timeline = new Timeline( this._timeline = new Timeline(
this._refTimeline.value, this._refTimeline.value,
@@ -1183,8 +1186,8 @@ export class FrigateCardTimelineCore extends LitElement {
groups, groups,
options, options,
) as Timeline; ) as Timeline;
this.setAttribute('groups', '');
} }
setOrRemoveAttribute(this, !noGroups, 'groups');
this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this));
this._timeline.on('click', this._timelineClickHandler.bind(this)); this._timeline.on('click', this._timelineClickHandler.bind(this));
@@ -1208,18 +1211,16 @@ export class FrigateCardTimelineCore extends LitElement {
} }
} }
if (changedProperties.has('view')) {
if (createdTimeline) { if (createdTimeline) {
// If the timeline was just created, give it one frame to draw itself. // If the timeline was just created, give it one frame to draw itself.
// Failure to do so may result in subsequent calls to // Failure to do so may result in subsequent calls to
// `this._timeline.setwindow()` being entirely ignored. Example case: // `this._timeline.setwindow()` being entirely ignored. Example case:
// Clicking the timeline control on a recording thumbnail. // Clicking the timeline control on a recording thumbnail.
window.requestAnimationFrame(this._updateTimelineFromView.bind(this)); window.requestAnimationFrame(this._updateTimelineFromView.bind(this));
} else { } else if (changedProperties.has('view')) {
this._updateTimelineFromView(); this._updateTimelineFromView();
} }
} }
}
/** /**
* Return compiled CSS styles. * Return compiled CSS styles.
+5
View File
@@ -128,6 +128,8 @@ export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
`${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const; `${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS = export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS =
`${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const; `${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE =
`${CONF_MEDIA_VIEWER}.controls.timeline.style` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS = export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const; `${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
@@ -172,6 +174,8 @@ export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
`${CONF_LIVE}.controls.timeline.mode` as const; `${CONF_LIVE}.controls.timeline.mode` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS = export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS =
`${CONF_LIVE}.controls.timeline.show_recordings` as const; `${CONF_LIVE}.controls.timeline.show_recordings` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_STYLE =
`${CONF_LIVE}.controls.timeline.style` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS = export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${CONF_LIVE}.controls.timeline.window_seconds` as const; `${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_MODE = `${CONF_LIVE}.controls.title.mode` as const;
@@ -202,6 +206,7 @@ export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const; `${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` 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_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const; `${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
+21 -1
View File
@@ -144,6 +144,9 @@ import {
MEDIA_CHUNK_SIZE_MAX, MEDIA_CHUNK_SIZE_MAX,
CONF_DIMENSIONS_MAX_HEIGHT, CONF_DIMENSIONS_MAX_HEIGHT,
CONF_DIMENSIONS_MIN_HEIGHT, CONF_DIMENSIONS_MIN_HEIGHT,
CONF_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
} from './const.js'; } from './const.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import frigate_card_editor_style from './scss/editor.scss'; import frigate_card_editor_style from './scss/editor.scss';
@@ -462,6 +465,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'snapshots', label: localize('config.common.timeline.medias.snapshots') }, { value: 'snapshots', label: localize('config.common.timeline.medias.snapshots') },
]; ];
protected _timelineStyleTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'ribbon', label: localize('config.common.timeline.styles.ribbon') },
{ value: 'stack', label: localize('config.common.timeline.styles.stack') },
];
protected _darkModes: EditorSelectOption[] = [ protected _darkModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'on', label: localize('config.view.dark_modes.on') }, { value: 'on', label: localize('config.view.dark_modes.on') },
@@ -969,6 +978,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
/** /**
* Render the core timeline controls (mini or full timeline), * Render the core timeline controls (mini or full timeline),
* @param configPathStyle Timeline style config path.
* @param configPathWindowSeconds Timeline window config path. * @param configPathWindowSeconds Timeline window config path.
* @param configPathClusteringThreshold Clustering threshold config path. * @param configPathClusteringThreshold Clustering threshold config path.
* @param configPathTimelineMedia Timeline media config path. * @param configPathTimelineMedia Timeline media config path.
@@ -977,13 +987,18 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
* @returns A rendered template. * @returns A rendered template.
*/ */
protected _renderTimelineCoreControls( protected _renderTimelineCoreControls(
configPathStyle: string,
configPathWindowSeconds: string, configPathWindowSeconds: string,
configPathClusteringThreshold: string, configPathClusteringThreshold: string,
configPathTimelineMedia: string, configPathTimelineMedia: string,
configPathShowRecordings: string, configPathShowRecordings: string,
defaultShowRecordings: boolean, defaultShowRecordings: boolean,
): TemplateResult { ): TemplateResult {
return html` ${this._renderNumberInput(configPathWindowSeconds, { return html`
${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, {
label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
})}
${this._renderNumberInput(configPathWindowSeconds, {
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`), label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
})} })}
${this._renderNumberInput(configPathClusteringThreshold, { ${this._renderNumberInput(configPathClusteringThreshold, {
@@ -1009,6 +1024,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
protected _renderMiniTimeline( protected _renderMiniTimeline(
domain: string, domain: string,
configPathMode: string, configPathMode: string,
configPathStyle: string,
configPathWindowSeconds: string, configPathWindowSeconds: string,
configPathClusteringThreshold: string, configPathClusteringThreshold: string,
configPathTimelineMedia: string, configPathTimelineMedia: string,
@@ -1024,6 +1040,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
label: localize('config.common.controls.timeline.mode'), label: localize('config.common.controls.timeline.mode'),
})} })}
${this._renderTimelineCoreControls( ${this._renderTimelineCoreControls(
configPathStyle,
configPathWindowSeconds, configPathWindowSeconds,
configPathClusteringThreshold, configPathClusteringThreshold,
configPathTimelineMedia, configPathTimelineMedia,
@@ -1811,6 +1828,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMiniTimeline( ${this._renderMiniTimeline(
MENU_LIVE_CONTROLS_TIMELINE, MENU_LIVE_CONTROLS_TIMELINE,
CONF_LIVE_CONTROLS_TIMELINE_MODE, CONF_LIVE_CONTROLS_TIMELINE_MODE,
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_LIVE_CONTROLS_TIMELINE_MEDIA, CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
@@ -1916,6 +1934,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMiniTimeline( ${this._renderMiniTimeline(
MENU_MEDIA_VIEWER_CONTROLS_TIMELINE, MENU_MEDIA_VIEWER_CONTROLS_TIMELINE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
@@ -1952,6 +1971,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._expandedMenus[MENU_OPTIONS] === 'timeline' ${this._expandedMenus[MENU_OPTIONS] === 'timeline'
? html` <div class="values"> ? html` <div class="values">
${this._renderTimelineCoreControls( ${this._renderTimelineCoreControls(
CONF_TIMELINE_STYLE,
CONF_TIMELINE_WINDOW_SECONDS, CONF_TIMELINE_WINDOW_SECONDS,
CONF_TIMELINE_CLUSTERING_THRESHOLD, CONF_TIMELINE_CLUSTERING_THRESHOLD,
CONF_TIMELINE_MEDIA, CONF_TIMELINE_MEDIA,
+5
View File
@@ -176,6 +176,11 @@
"snapshots": "Snapshots" "snapshots": "Snapshots"
}, },
"show_recordings": "Show recordings", "show_recordings": "Show recordings",
"style": "Timeline style",
"styles": {
"ribbon": "Events on a single ribbon",
"stack": "Stacked & clustered events"
},
"window_seconds": "The default length of the timeline view in seconds" "window_seconds": "The default length of the timeline view in seconds"
} }
}, },
+5
View File
@@ -176,6 +176,11 @@
"snapshots": "Istantanee" "snapshots": "Istantanee"
}, },
"show_recordings": "Mostra registrazioni", "show_recordings": "Mostra registrazioni",
"style": "",
"styles": {
"ribbon": "",
"stack": ""
},
"window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi"
} }
}, },
+5
View File
@@ -176,6 +176,11 @@
"snapshots": "Instantâneos" "snapshots": "Instantâneos"
}, },
"show_recordings": "Mostrar gravações", "show_recordings": "Mostrar gravações",
"style": "",
"styles": {
"ribbon": "",
"stack": ""
},
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos" "window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
} }
}, },
+11
View File
@@ -42,6 +42,17 @@ div.timeline {
color: var(--primary-text-color); color: var(--primary-text-color);
} }
:host([ribbon]:not([groups])) .vis-item:not(.vis-background) {
// When a ribbon timeline is used without groups, we need to add extra margin
// to the top to center the bar vertically within the mini-timeline.
margin-top: 7px;
}
:host([ribbon]) .vis-item:not(.vis-background) {
// Ribbon timelines have a much shorter 'bar'.
height: 6px;
}
.vis-item { .vis-item {
border-color: var(--primary-color); border-color: var(--primary-color);
background: none; background: none;
+6
View File
@@ -832,6 +832,7 @@ const timelineCoreConfigDefault = {
media: 'all' as const, media: 'all' as const,
window_seconds: 60 * 60, window_seconds: 60 * 60,
show_recordings: true, show_recordings: true,
style: 'stack' as const,
}; };
const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']); const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']);
@@ -851,15 +852,20 @@ const timelineCoreConfigSchema = z.object({
.boolean() .boolean()
.optional() .optional()
.default(timelineCoreConfigDefault.show_recordings), .default(timelineCoreConfigDefault.show_recordings),
style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style),
}); });
export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>; export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>;
const miniTimelineConfigDefault = { const miniTimelineConfigDefault = {
...timelineCoreConfigDefault, ...timelineCoreConfigDefault,
mode: 'none' as const, mode: 'none' as const,
// Mini-timeline defaults to ribbon style.
style: 'ribbon' as const,
}; };
const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode),
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
}); });
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>; export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
+21 -1
View File
@@ -199,4 +199,24 @@ export const sleep = async (seconds: number) => {
export const isValidDate = (date: Date): boolean => { export const isValidDate = (date: Date): boolean => {
return !isNaN(date.getTime()); return !isNaN(date.getTime());
} };
/**
* Set or remove an attribute on a HTMLElement.
* @param element The element.
* @param set If `true` sets the attribute, otherwise removes it.
* @param name The attribute name.
* @param value An optional value to set the attribute to.
*/
export const setOrRemoveAttribute = (
element: HTMLElement,
set: boolean,
name: string,
value?: string,
): void => {
if (set) {
element.setAttribute(name, value ?? '');
} else {
element.removeAttribute(name);
}
};
+12 -20
View File
@@ -171,7 +171,7 @@ export const executeMediaQueryForView = async (
if (options?.select === 'time' && options?.targetTime) { if (options?.select === 'time' && options?.targetTime) {
queryResults.selectBestResult((media) => queryResults.selectBestResult((media) =>
findClosestMediaIndex(media, options.targetTime as Date), findBestMediaIndex(media, options.targetTime as Date),
); );
viewerContext = { viewerContext = {
mediaViewer: { mediaViewer: {
@@ -193,39 +193,31 @@ export const executeMediaQueryForView = async (
}; };
/** /**
* Find the closest matching media object. * Find the longest matching media object that contains a given targetTime.
* Longest is chosen to give the most stability to the media viewer.
* @param mediaArray The media. * @param mediaArray The media.
* @param targetTime The target time used to find the relevant child. * @param targetTime The target time used to find the relevant child.
* @param refPoint Whether to find based on the start or end of the
* event/recording. If not specified, the first match is returned rather than
* the best match.
* @returns The childindex or null if no matching child is found. * @returns The childindex or null if no matching child is found.
*/ */
export const findClosestMediaIndex = ( export const findBestMediaIndex = (
mediaArray: ViewMedia[], mediaArray: ViewMedia[],
targetTime: Date, targetTime: Date
refPoint?: 'start' | 'end',
): number | null => { ): number | null => {
let bestMatch: let bestMatch:
| { | {
index: number; index: number;
delta: number; duration: number;
} }
| undefined; | undefined;
for (const [i, media] of mediaArray.entries()) { for (const [i, media] of mediaArray.entries()) {
if (media.includesTime(targetTime)) {
const start = media.getStartTime(); const start = media.getStartTime();
const end = media.getEndTime(); const end = media.getUsableEndTime();
if (!refPoint || !start || !end) {
return i; if (media.includesTime(targetTime) && start && end) {
} const duration = end.getTime() - start.getTime();
const delta = if (!bestMatch || duration > bestMatch.duration) {
refPoint === 'end' bestMatch = { index: i, duration: duration };
? end.getTime() - targetTime.getTime()
: targetTime.getTime() - start.getTime();
if (!bestMatch || delta < bestMatch.delta) {
bestMatch = { index: i, delta: delta };
} }
} }
} }
+2 -2
View File
@@ -147,8 +147,8 @@ export class TimelineDataSource {
content: '', content: '',
media: media, media: media,
start: startTime.getTime(), start: startTime.getTime(),
type: endTime ? 'range' : 'point', type: 'range',
...(endTime && { end: endTime.getTime() }), end: endTime?.getTime() ?? startTime.getTime(),
}); });
} }
} }
+6 -3
View File
@@ -1,8 +1,8 @@
export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
export enum VideoContentType { export enum VideoContentType {
MP4 = "mp4", MP4 = 'mp4',
HLS = "hls", HLS = 'hls',
} }
export class ViewMedia { export class ViewMedia {
@@ -34,6 +34,9 @@ export class ViewMedia {
public getEndTime(): Date | null { public getEndTime(): Date | null {
return null; return null;
} }
public getUsableEndTime(): Date | null {
return this.getEndTime() ?? (this.inProgress() ? new Date() : this.getStartTime());
}
public inProgress(): boolean | null { public inProgress(): boolean | null {
return null; return null;
} }
@@ -51,7 +54,7 @@ export class ViewMedia {
} }
public includesTime(seek: Date): boolean { public includesTime(seek: Date): boolean {
const startTime = this.getStartTime(); const startTime = this.getStartTime();
const endTime = this.getEndTime() ?? startTime; const endTime = this.getUsableEndTime();
return !!startTime && !!endTime && seek >= startTime && seek <= endTime; return !!startTime && !!endTime && seek >= startTime && seek <= endTime;
} }