diff --git a/README.md b/README.md index 2ad37a81..eed4af83 100644 --- a/README.md +++ b/README.md @@ -632,6 +632,7 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | | `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| +| `pan_mode` | `pan`| :white_check_mark: | If set to `pan` then dragging the timeline will have no effect on the media. If set to `seek` will open media for that time and seek to that position . If set to `seek-in-media` will seek within the opened media only. If set to `seek-in-camera` will seek within the selected camera only. | | `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 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. | @@ -814,7 +815,9 @@ media_viewer: | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| +| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the +`left` or `right` of the media or to hide it entirely (`none`).| +| `pan_mode` | `pan`| :white_check_mark: | If set to `pan` then dragging the timeline will have no effect on the media. If set to `seek` will open media for that time and seek to that position . If set to `seek-in-media` will seek within the opened media only. If set to `seek-in-camera` will seek within the selected camera only. | | `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 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. | @@ -2139,6 +2142,7 @@ live: timeline: style: ribbon mode: none + pan_mode: pan clustering_threshold: 3 events_media_type: all show_recordings: true @@ -2232,6 +2236,7 @@ media_viewer: timeline: style: ribbon mode: none + pan_mode: pan clustering_threshold: 3 events_media_type: all show_recordings: true diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index a7ed952d..ba20b89b 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -39,6 +39,7 @@ import { FrigateCardView, ThumbnailsControlBaseConfig, TimelineCoreConfig, + TimelinePanMode, } from '../config/types'; import { localize } from '../localize/localize'; import timelineCoreStyle from '../scss/timeline-core.scss'; @@ -86,11 +87,9 @@ interface TimelineRangeChange extends TimelineWindow { interface TimelineViewContext { window?: TimelineWindow; - panBehavior?: TimelinePanBehavior; } type TimelineItemClickAction = 'play' | 'select'; -type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media' | 'seek-in-camera'; declare module 'view' { interface ViewContext { @@ -214,7 +213,7 @@ export class FrigateCardTimelineCore extends LitElement { public itemClickAction?: TimelineItemClickAction; @state() - protected _panBehavior: TimelinePanBehavior = 'seek'; + protected _panMode: TimelinePanMode | null = null; protected _targetBarVisible = false; @@ -288,20 +287,22 @@ export class FrigateCardTimelineCore extends LitElement { } const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); + const panMode = this._getEffectivePanMode(); + const panTitle = - this._panBehavior === 'pan' - ? localize('timeline.pan_behavior.pan') - : this._panBehavior === 'seek' - ? localize('timeline.pan_behavior.seek') - : this._panBehavior === 'seek-in-media' - ? localize('timeline.pan_behavior.seek-in-media') - : localize('timeline.pan_behavior.seek-in-camera'); + panMode === 'pan' + ? localize('config.common.controls.timeline.pan_modes.pan') + : panMode === 'seek' + ? localize('config.common.controls.timeline.pan_modes.seek') + : panMode === 'seek-in-media' + ? localize('config.common.controls.timeline.pan_modes.seek-in-media') + : localize('config.common.controls.timeline.pan_modes.seek-in-camera'); const panIcon = - this._panBehavior === 'pan' + panMode === 'pan' ? 'mdi:pan-horizontal' - : this._panBehavior === 'seek' + : panMode === 'seek' ? 'mdi:filmstrip-box-multiple' - : this._panBehavior === 'seek-in-media' + : panMode === 'seek-in-media' ? 'mdi:play-box-lock' : 'mdi:camera-lock'; @@ -318,12 +319,12 @@ export class FrigateCardTimelineCore extends LitElement { ? html` { - this._panBehavior = - this._panBehavior === 'pan' + this._panMode = + panMode === 'pan' ? 'seek' - : this._panBehavior === 'seek' + : panMode === 'seek' ? 'seek-in-media' - : this._panBehavior === 'seek-in-media' + : panMode === 'seek-in-media' ? 'seek-in-camera' : 'pan'; }} @@ -404,15 +405,15 @@ export class FrigateCardTimelineCore extends LitElement { return; } + const panMode = this._getEffectivePanMode(); const targetBarOn = this._shouldSupportSeeking() && - (this._panBehavior === 'seek' || - this._panBehavior === 'seek-in-camera' || - (this._panBehavior === 'seek-in-media' && + (panMode === 'seek' || + ((panMode === 'seek-in-camera' || panMode === 'seek-in-media') && this._timeline.getSelection().some((id) => { const item = this._timelineSource?.dataset?.get(id); return ( - this._panBehavior !== 'seek-in-camera' || + panMode !== 'seek-in-camera' || item?.media?.getCameraID() === this.view?.camera, item && item.start && @@ -473,6 +474,7 @@ export class FrigateCardTimelineCore extends LitElement { ): Promise { const results = this.view?.queryResults; const media = results?.getResults(); + const panMode = this._getEffectivePanMode(); if ( !media || !results || @@ -480,7 +482,7 @@ export class FrigateCardTimelineCore extends LitElement { !this.view || !this.hass || !this.cameraManager || - this._panBehavior === 'pan' + panMode === 'pan' ) { return; } @@ -488,7 +490,7 @@ export class FrigateCardTimelineCore extends LitElement { const canSeek = this._shouldSupportSeeking(); let newResults: MediaQueriesResults | null = null; - if (this._panBehavior === 'seek') { + if (panMode === 'seek') { newResults = results .clone() .resetSelectedResult() @@ -499,7 +501,7 @@ export class FrigateCardTimelineCore extends LitElement { main: true, }, ); - } else if (this._panBehavior === 'seek-in-camera') { + } else if (panMode === 'seek-in-camera') { newResults = results .clone() .resetSelectedResult() @@ -507,7 +509,7 @@ export class FrigateCardTimelineCore extends LitElement { cameraID: this.view.camera, }) .promoteCameraSelectionToMainSelection(this.view.camera); - } else if (this._panBehavior === 'seek-in-media') { + } else if (panMode === 'seek-in-media') { newResults = results; } @@ -531,6 +533,10 @@ export class FrigateCardTimelineCore extends LitElement { .dispatchChangeEvent(this); } + protected _getEffectivePanMode(): TimelinePanMode { + return this._panMode ?? this.timelineConfig?.pan_mode ?? 'pan'; + } + /** * Called whenever the timeline is clicked. * @param properties The properties of the timeline click event. @@ -988,10 +994,6 @@ export class FrigateCardTimelineCore extends LitElement { : null; const context = this.view.context?.timeline; - if (context && context.panBehavior) { - this._panBehavior = context.panBehavior; - } - if (context && context.window) { desiredWindow = context.window; } else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { @@ -1107,7 +1109,6 @@ export class FrigateCardTimelineCore extends LitElement { return { timeline: { ...this.view?.context?.timeline, - panBehavior: this._panBehavior, ...(newWindow && { window: newWindow }), }, }; diff --git a/src/config/types.ts b/src/config/types.ts index c6086fa0..d241c793 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -727,8 +727,17 @@ const timelineCoreConfigDefault = { window_seconds: 60 * 60, show_recordings: true, style: 'stack' as const, + pan_mode: 'pan' as const, }; +export const timelinePanModeSchema = z.enum([ + 'pan', + 'seek', + 'seek-in-media', + 'seek-in-camera', +]); +export type TimelinePanMode = z.infer; + const timelineCoreConfigSchema = z.object({ clustering_threshold: z .number() @@ -748,6 +757,9 @@ const timelineCoreConfigSchema = z.object({ .optional() .default(timelineCoreConfigDefault.show_recordings), style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), + pan_mode: timelinePanModeSchema + .optional() + .default(timelineCoreConfigDefault.pan_mode), }); export type TimelineCoreConfig = z.infer; diff --git a/src/const.ts b/src/const.ts index 1dd10870..fdb6237c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -163,6 +163,8 @@ export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE = `${CONF_MEDIA_VIEWER}.controls.timeline.events_media_type` 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_PAN_MODE = + `${CONF_MEDIA_VIEWER}.controls.timeline.pan_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 = @@ -219,6 +221,8 @@ export const CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE = `${CONF_LIVE}.controls.timeline.events_media_type` as const; export const CONF_LIVE_CONTROLS_TIMELINE_MODE = `${CONF_LIVE}.controls.timeline.mode` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE = + `${CONF_LIVE}.controls.timeline.pan_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 = diff --git a/src/editor.ts b/src/editor.ts index b9f9493a..415d5e56 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -91,6 +91,7 @@ import { CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE, CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, + CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, @@ -140,6 +141,7 @@ import { CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, @@ -719,6 +721,26 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; + protected _timelinePanModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'pan', + label: localize('config.common.controls.timeline.pan_modes.pan'), + }, + { + value: 'seek', + label: localize('config.common.controls.timeline.pan_modes.seek'), + }, + { + value: 'seek-in-media', + label: localize('config.common.controls.timeline.pan_modes.seek-in-media'), + }, + { + value: 'seek-in-camera', + label: localize('config.common.controls.timeline.pan_modes.seek-in-camera'), + }, + ]; + 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 @@ -1197,30 +1219,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor configPathTimelineEventsMediaType: string, configPathShowRecordings: string, defaultShowRecordings: boolean, + configPathPanMode?: string, ): TemplateResult { - return html` ${this._renderOptionSelector( - configPathStyle, - this._timelineStyleTypes, - { + 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, { - label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`), - })} - ${this._renderOptionSelector( - configPathTimelineEventsMediaType, - this._timelineEventsMediaTypes, - { - label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`), - }, - )} - ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, { - label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`), - })}`; + })} + ${configPathPanMode + ? this._renderOptionSelector(configPathPanMode, this._timelinePanModes, { + label: localize(`config.common.controls.timeline.pan_mode`), + }) + : ``} + ${this._renderNumberInput(configPathWindowSeconds, { + label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`), + })} + ${this._renderNumberInput(configPathClusteringThreshold, { + label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`), + })} + ${this._renderOptionSelector( + configPathTimelineEventsMediaType, + this._timelineEventsMediaTypes, + { + label: localize(`config.common.${CONF_TIMELINE_EVENTS_MEDIA_TYPE}`), + }, + )} + ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, { + label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`), + })} + `; } /** @@ -1241,6 +1267,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor configPathTimelineEventsMediaType: string, configPathShowRecordings: string, showRecordingsDefault: boolean, + configPathPanMode: string, ): TemplateResult | void { return this._putInSubmenu( domain, @@ -1257,6 +1284,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor configPathTimelineEventsMediaType, configPathShowRecordings, showRecordingsDefault, + configPathPanMode, )}`, ); } @@ -2208,6 +2236,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, this._defaults.live.controls.timeline.show_recordings, + CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE, )} ${this._putInSubmenu( MENU_LIVE_CONTROLS_PTZ, @@ -2394,6 +2423,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, this._defaults.media_viewer.controls.timeline.show_recordings, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, )} `, )} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 6e41fb24..25407e9c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -173,6 +173,13 @@ "above": "Above", "below": "Below", "none": "None" + }, + "pan_mode": "Default pan mode", + "pan_modes": { + "pan": "Pan", + "seek": "Pan seeks across all media", + "seek-in-camera": "Pan seeks within selected camera only", + "seek-in-media": "Pan seeks within selected media item only" } }, "title": { @@ -589,12 +596,6 @@ "timeline": "See media in timeline" }, "timeline": { - "pan_behavior": { - "pan": "Pan", - "seek": "Pan seeks across all media", - "seek-in-camera": "Pan seeks within selected camera only", - "seek-in-media": "Pan seeks within selected media item only" - }, "select_date": "Choose date" } -} \ No newline at end of file +} diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 76f7fa6c..94cb26f5 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -28,8 +28,8 @@ "editor_label": "Opzioni di dipendenza" }, "dimensions": { - "editor_label": "", "aspect_ratio": "", + "editor_label": "", "layout": { "fit": "Adatta al layout", "fits": { @@ -172,6 +172,13 @@ "above": "sopra", "below": "sotto", "none": "sessuna" + }, + "pan_mode": "", + "pan_modes": { + "pan": "", + "seek": "", + "seek-in-camera": "", + "seek-in-media": "" } }, "title": { @@ -577,12 +584,6 @@ "timeline": "Vedi evento nella timeline" }, "timeline": { - "pan_behavior": { - "pan": "", - "seek": "", - "seek-in-camera": "", - "seek-in-media": "" - }, "select_date": "Scegli la data" } } diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 0243437f..85ee08a3 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -28,8 +28,8 @@ "editor_label": "Opções de dependência" }, "dimensions": { - "editor_label": "", "aspect_ratio": "", + "editor_label": "", "layout": { "fit": "Ajuste de layout", "fits": { @@ -172,6 +172,13 @@ "above": "Acima", "below": "Abaixo", "none": "Nenhum" + }, + "pan_mode": "", + "pan_modes": { + "pan": "", + "seek": "", + "seek-in-camera": "", + "seek-in-media": "" } }, "title": { @@ -587,12 +594,6 @@ "timeline": "Ver evento na linha do tempo" }, "timeline": { - "pan_behavior": { - "pan": "", - "seek": "", - "seek-in-camera": "", - "seek-in-media": "" - }, "select_date": "Escolha a data" } } diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 00d9b704..859fa85f 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -28,8 +28,8 @@ "editor_label": "Opções de dependência" }, "dimensions": { - "editor_label": "", "aspect_ratio": "", + "editor_label": "", "layout": { "fit": "Fit", "fits": { @@ -102,9 +102,9 @@ "editor_label": "Opções de activação", "entities": "Activar a partir de outras entidades", "events": { + "clips": "", "editor_label": "", "events": "", - "clips": "", "snapshots": "" }, "motion": "Activar detectando automaticamente o sensor de movimento", @@ -172,6 +172,13 @@ "above": "Por cima", "below": "Abaixo", "none": "Nenhum" + }, + "pan_mode": "", + "pan_modes": { + "pan": "", + "seek": "", + "seek-in-camera": "", + "seek-in-media": "" } }, "title": { @@ -569,12 +576,6 @@ "timeline": "Ver evento na linha do tempo" }, "timeline": { - "pan_behavior": { - "pan": "", - "seek": "", - "seek-in-camera": "", - "seek-in-media": "" - }, "select_date": "Selecionar a data" } } diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index c3b2d4c2..9ce96b3e 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -91,6 +91,7 @@ describe('config defaults', () => { clustering_threshold: 3, events_media_type: 'all', mode: 'none', + pan_mode: 'pan', show_recordings: true, style: 'ribbon', window_seconds: 3600, @@ -147,6 +148,7 @@ describe('config defaults', () => { clustering_threshold: 3, events_media_type: 'all', mode: 'none', + pan_mode: 'pan', show_recordings: true, style: 'ribbon', window_seconds: 3600, @@ -271,6 +273,7 @@ describe('config defaults', () => { }, }, events_media_type: 'all', + pan_mode: 'pan', show_recordings: true, style: 'stack', window_seconds: 3600, diff --git a/vite.config.ts b/vite.config.ts index 29404bb3..2c0b04a2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -80,13 +80,13 @@ export default defineConfig({ // Expected thresholds for anything that does not have 100% coverage // yet. - statements: 10.67, + statements: 10.69, branches: 4.9, functions: 7.81, - lines: 10.92, + lines: 10.94, ...calculateFullCoverageThresholds(), }, }, }, -}); +}); \ No newline at end of file