diff --git a/README.md b/README.md index b67cc4d9..039d0dfb 100644 --- a/README.md +++ b/README.md @@ -515,11 +515,14 @@ live: | 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. | -| `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.| | `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". + #### Live Controls: Title @@ -609,11 +612,14 @@ media_viewer: | 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. | -| `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.| | `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 All configuration is under: @@ -717,8 +723,9 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin | 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. | -| `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.| | `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.| @@ -1766,6 +1773,7 @@ live: show_timeline_control: true mode: none timeline: + style: ribbon mode: none clustering_threshold: 3 media: all @@ -1824,6 +1832,7 @@ media_viewer: show_favorite_control: true show_timeline_control: true timeline: + style: ribbon mode: none clustering_threshold: 3 media: all @@ -2285,6 +2294,7 @@ Reference: [Timeline Options](#timeline-options). ```yaml timeline: + style: stack clustering_threshold: 3 media: all show_recordings: true diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 189a33b2..73e6bd6a 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -1,6 +1,8 @@ import add from 'date-fns/add'; -import endOfHour from 'date-fns/endOfHour'; 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 sub from 'date-fns/sub'; import { @@ -26,6 +28,9 @@ import { TimelineOptionsCluster, TimelineWindow, } 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 timelineCoreStyle from '../scss/timeline-core.scss'; import { @@ -42,27 +47,23 @@ import { contentsChanged, dispatchFrigateCardEvent, isHoverableDevice, + setOrRemoveAttribute, } from '../utils/basic'; import { createQueriesForRecordingsView, executeMediaQueryForView, - findClosestMediaIndex, + findBestMediaIndex, } 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 { ViewMedia } from '../view/media'; 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 { MediaQuery } from '../camera-manager/types'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; -import startOfDay from 'date-fns/startOfDay'; -import endOfDay from 'date-fns/endOfDay'; +import { dispatchMessageEvent } from './message.js'; +import './thumbnail.js'; interface FrigateCardGroupData { id: string; @@ -450,10 +451,7 @@ export class FrigateCardTimelineCore extends LitElement { !this.view || !this.hass || !this.cameraManager || - 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 + this._panBehavior === 'pan' ) { return; } @@ -466,10 +464,9 @@ export class FrigateCardTimelineCore extends LitElement { .clone() .resetSelectedResult() .selectBestResult((media) => - findClosestMediaIndex( + findBestMediaIndex( media, targetTime, - properties.event.additionalEvent === 'panright' ? 'end' : 'start', ), ); @@ -609,9 +606,7 @@ export class FrigateCardTimelineCore extends LitElement { } if (view?.queryResults?.hasResults()) { - view.mergeInContext( - { mediaViewer: { seek: properties.time } - }); + view.mergeInContext({ mediaViewer: { seek: properties.time } }); } view?.mergeInContext(this._getTimelineContext()); @@ -840,6 +835,7 @@ export class FrigateCardTimelineCore extends LitElement { */ protected _isClustering(): boolean { return ( + this.timelineConfig?.style === 'stack' && !!this.timelineConfig?.clustering_threshold && this.timelineConfig.clustering_threshold > 0 ); @@ -854,7 +850,7 @@ export class FrigateCardTimelineCore extends LitElement { } const defaultWindow = this._getDefaultStartEnd(); - + const stack = this.timelineConfig.style === 'stack'; // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options return { @@ -898,7 +894,15 @@ export class FrigateCardTimelineCore extends LitElement { maxHeight: '100%', zoomMax: 1 * 24 * 60 * 60 * 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, + stack: stack, start: defaultWindow.start, end: defaultWindow.end, groupHeightMode: 'auto', @@ -988,7 +992,7 @@ export class FrigateCardTimelineCore extends LitElement { } 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 // 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 @@ -1094,11 +1098,9 @@ export class FrigateCardTimelineCore extends LitElement { } if (changedProps.has('timelineConfig')) { - if (this.timelineConfig?.show_recordings) { - this.setAttribute('recordings', ''); - } else { - this.removeAttribute('recordings'); - } + setOrRemoveAttribute(this, !!this.timelineConfig?.show_recordings, 'recordings'); + setOrRemoveAttribute(this, this.timelineConfig?.style === 'ribbon', 'ribbon'); + setOrRemoveAttribute(this, this.timelineConfig?.style === 'stack', 'stack'); } if ( @@ -1128,6 +1130,7 @@ export class FrigateCardTimelineCore extends LitElement { this._timeline?.destroy(); this._timeline = undefined; this._targetBarVisible = false; + this._pointerHeld = null; } /** @@ -1167,7 +1170,8 @@ export class FrigateCardTimelineCore extends LitElement { const options = this._getOptions(); if (options) { 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 // at all. this._timeline = new Timeline( @@ -1175,7 +1179,6 @@ export class FrigateCardTimelineCore extends LitElement { this._timelineSource.dataset, options, ) as Timeline; - this.removeAttribute('groups'); } else { this._timeline = new Timeline( this._refTimeline.value, @@ -1183,8 +1186,8 @@ export class FrigateCardTimelineCore extends LitElement { groups, options, ) as Timeline; - this.setAttribute('groups', ''); } + setOrRemoveAttribute(this, !noGroups, 'groups'); this._timeline.on('rangechanged', this._timelineRangeChangedHandler.bind(this)); this._timeline.on('click', this._timelineClickHandler.bind(this)); @@ -1208,16 +1211,14 @@ export class FrigateCardTimelineCore extends LitElement { } } - if (changedProperties.has('view')) { - if (createdTimeline) { - // If the timeline was just created, give it one frame to draw itself. - // Failure to do so may result in subsequent calls to - // `this._timeline.setwindow()` being entirely ignored. Example case: - // Clicking the timeline control on a recording thumbnail. - window.requestAnimationFrame(this._updateTimelineFromView.bind(this)); - } else { - this._updateTimelineFromView(); - } + if (createdTimeline) { + // If the timeline was just created, give it one frame to draw itself. + // Failure to do so may result in subsequent calls to + // `this._timeline.setwindow()` being entirely ignored. Example case: + // Clicking the timeline control on a recording thumbnail. + window.requestAnimationFrame(this._updateTimelineFromView.bind(this)); + } else if (changedProperties.has('view')) { + this._updateTimelineFromView(); } } diff --git a/src/const.ts b/src/const.ts index 7ae897e3..9e79c0b7 100644 --- a/src/const.ts +++ b/src/const.ts @@ -128,6 +128,8 @@ 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_STYLE = + `${CONF_MEDIA_VIEWER}.controls.timeline.style` as const; export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS = `${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; export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS = `${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 = `${CONF_LIVE}.controls.timeline.window_seconds` 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; 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_STYLE = `${CONF_TIMELINE}.style` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = `${CONF_TIMELINE}.controls.thumbnails.mode` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = diff --git a/src/editor.ts b/src/editor.ts index 6f9779a0..2e637c7d 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -144,6 +144,9 @@ import { MEDIA_CHUNK_SIZE_MAX, CONF_DIMENSIONS_MAX_HEIGHT, CONF_DIMENSIONS_MIN_HEIGHT, + CONF_TIMELINE_STYLE, + CONF_LIVE_CONTROLS_TIMELINE_STYLE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, } from './const.js'; import { localize } from './localize/localize.js'; 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') }, ]; + 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[] = [ { value: '', label: '' }, { 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), + * @param configPathStyle Timeline style config path. * @param configPathWindowSeconds Timeline window config path. * @param configPathClusteringThreshold Clustering threshold config path. * @param configPathTimelineMedia Timeline media config path. @@ -977,13 +987,18 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * @returns A rendered template. */ protected _renderTimelineCoreControls( + configPathStyle: string, configPathWindowSeconds: string, configPathClusteringThreshold: string, configPathTimelineMedia: string, configPathShowRecordings: string, defaultShowRecordings: boolean, ): 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}`), })} ${this._renderNumberInput(configPathClusteringThreshold, { @@ -1009,6 +1024,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _renderMiniTimeline( domain: string, configPathMode: string, + configPathStyle: string, configPathWindowSeconds: string, configPathClusteringThreshold: string, configPathTimelineMedia: string, @@ -1024,6 +1040,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor label: localize('config.common.controls.timeline.mode'), })} ${this._renderTimelineCoreControls( + configPathStyle, configPathWindowSeconds, configPathClusteringThreshold, configPathTimelineMedia, @@ -1811,6 +1828,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderMiniTimeline( MENU_LIVE_CONTROLS_TIMELINE, CONF_LIVE_CONTROLS_TIMELINE_MODE, + CONF_LIVE_CONTROLS_TIMELINE_STYLE, CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, CONF_LIVE_CONTROLS_TIMELINE_MEDIA, @@ -1916,6 +1934,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderMiniTimeline( MENU_MEDIA_VIEWER_CONTROLS_TIMELINE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA, @@ -1952,6 +1971,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._expandedMenus[MENU_OPTIONS] === 'timeline' ? html`
${this._renderTimelineCoreControls( + CONF_TIMELINE_STYLE, CONF_TIMELINE_WINDOW_SECONDS, CONF_TIMELINE_CLUSTERING_THRESHOLD, CONF_TIMELINE_MEDIA, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 06bc05a8..c4d70334 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -176,6 +176,11 @@ "snapshots": "Snapshots" }, "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" } }, diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index bc7ef5aa..500571fd 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -176,6 +176,11 @@ "snapshots": "Istantanee" }, "show_recordings": "Mostra registrazioni", + "style": "", + "styles": { + "ribbon": "", + "stack": "" + }, "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" } }, diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 7cceea8e..cbb19e24 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -176,6 +176,11 @@ "snapshots": "Instantâneos" }, "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" } }, diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 98b686db..3079bc1c 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -42,6 +42,17 @@ div.timeline { 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 { border-color: var(--primary-color); background: none; diff --git a/src/types.ts b/src/types.ts index 9ef4e25f..70161d95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -832,6 +832,7 @@ const timelineCoreConfigDefault = { media: 'all' as const, window_seconds: 60 * 60, show_recordings: true, + style: 'stack' as const, }; const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']); @@ -851,15 +852,20 @@ const timelineCoreConfigSchema = z.object({ .boolean() .optional() .default(timelineCoreConfigDefault.show_recordings), + style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), }); export type TimelineCoreConfig = z.infer; const miniTimelineConfigDefault = { ...timelineCoreConfigDefault, mode: 'none' as const, + + // Mini-timeline defaults to ribbon style. + style: 'ribbon' as const, }; const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), + style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), }); export type MiniTimelineControlConfig = z.infer; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 29872a47..b4eff688 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -199,4 +199,24 @@ export const sleep = async (seconds: number) => { export const isValidDate = (date: Date): boolean => { return !isNaN(date.getTime()); -} \ No newline at end of file +}; + +/** + * 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); + } +}; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 76f3830a..655b5ca9 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -171,7 +171,7 @@ export const executeMediaQueryForView = async ( if (options?.select === 'time' && options?.targetTime) { queryResults.selectBestResult((media) => - findClosestMediaIndex(media, options.targetTime as Date), + findBestMediaIndex(media, options.targetTime as Date), ); viewerContext = { 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 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. */ -export const findClosestMediaIndex = ( +export const findBestMediaIndex = ( mediaArray: ViewMedia[], - targetTime: Date, - refPoint?: 'start' | 'end', + targetTime: Date ): number | null => { let bestMatch: | { index: number; - delta: number; + duration: number; } | undefined; for (const [i, media] of mediaArray.entries()) { - if (media.includesTime(targetTime)) { - const start = media.getStartTime(); - const end = media.getEndTime(); - if (!refPoint || !start || !end) { - return i; - } - const delta = - refPoint === 'end' - ? end.getTime() - targetTime.getTime() - : targetTime.getTime() - start.getTime(); - if (!bestMatch || delta < bestMatch.delta) { - bestMatch = { index: i, delta: delta }; + const start = media.getStartTime(); + const end = media.getUsableEndTime(); + + if (media.includesTime(targetTime) && start && end) { + const duration = end.getTime() - start.getTime(); + if (!bestMatch || duration > bestMatch.duration) { + bestMatch = { index: i, duration: duration }; } } } diff --git a/src/utils/timeline-source.ts b/src/utils/timeline-source.ts index 343ad79e..f2646457 100644 --- a/src/utils/timeline-source.ts +++ b/src/utils/timeline-source.ts @@ -147,8 +147,8 @@ export class TimelineDataSource { content: '', media: media, start: startTime.getTime(), - type: endTime ? 'range' : 'point', - ...(endTime && { end: endTime.getTime() }), + type: 'range', + end: endTime?.getTime() ?? startTime.getTime(), }); } } diff --git a/src/view/media.ts b/src/view/media.ts index 758ed34b..abb62371 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -1,8 +1,8 @@ export type ViewMediaType = 'clip' | 'snapshot' | 'recording'; export enum VideoContentType { - MP4 = "mp4", - HLS = "hls", + MP4 = 'mp4', + HLS = 'hls', } export class ViewMedia { @@ -34,6 +34,9 @@ export class ViewMedia { public getEndTime(): Date | null { return null; } + public getUsableEndTime(): Date | null { + return this.getEndTime() ?? (this.inProgress() ? new Date() : this.getStartTime()); + } public inProgress(): boolean | null { return null; } @@ -51,7 +54,7 @@ export class ViewMedia { } public includesTime(seek: Date): boolean { const startTime = this.getStartTime(); - const endTime = this.getEndTime() ?? startTime; + const endTime = this.getUsableEndTime(); return !!startTime && !!endTime && seek >= startTime && seek <= endTime; }