From b3bb56298adf8cc0fd6e24c19bd45066b8ba85a7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 8 Apr 2023 12:51:10 -0700 Subject: [PATCH] Modify timeline to allow pan into past/future. --- src/components/timeline-core.ts | 120 +++++++++++++++++------------- src/localize/languages/en.json | 9 ++- src/localize/languages/it.json | 9 ++- src/localize/languages/pt-BR.json | 9 ++- src/utils/media-to-view.ts | 30 +++++--- src/view/media.ts | 2 +- 6 files changed, 106 insertions(+), 73 deletions(-) diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 9c94b2f2..feaafc64 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -76,9 +76,11 @@ interface TimelineRangeChange extends TimelineWindow { interface TimelineViewContext { window?: TimelineWindow; + panBehavior?: TimelinePanBehavior; } type TimelineItemClickAction = 'play' | 'select'; +type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media'; declare module 'view' { interface ViewContext { @@ -197,7 +199,7 @@ export class FrigateCardTimelineCore extends LitElement { public itemClickAction?: TimelineItemClickAction; @state() - protected _locked = false; + protected _panBehavior: TimelinePanBehavior = 'seek'; protected _targetBarVisible = false; @@ -272,9 +274,18 @@ export class FrigateCardTimelineCore extends LitElement { } const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); - const lockTitle = this._locked - ? localize('timeline.unlock') - : localize('timeline.lock'); + const panTitle = + this._panBehavior === 'pan' + ? localize('timeline.pan_behavior.pan') + : this._panBehavior === 'seek' + ? localize('timeline.pan_behavior.seek') + : localize('timeline.pan_behavior.seek-in-media'); + const panIcon = + this._panBehavior === 'pan' + ? 'mdi:pan-horizontal' + : this._panBehavior === 'seek' + ? 'mdi:movie-search' + : 'mdi:file-find'; return html` ${capabilities?.supportsTimeline ? html`
${this._shouldSupportSeeking() ? html` { - this._locked = !this._locked; + this._panBehavior = + this._panBehavior === 'pan' + ? 'seek' + : this._panBehavior === 'seek' + ? 'seek-in-media' + : 'pan'; }} - aria-label="${lockTitle}" - title="${lockTitle}" + aria-label="${panTitle}" + title="${panTitle}" > ` : ''} @@ -340,9 +356,10 @@ export class FrigateCardTimelineCore extends LitElement { } if ( + this._shouldSupportSeeking() && this._timeline && properties.byUser && - // Do not adjust select children or seek during zoom events. + // Do not adjust select/seek media during zoom events. properties.event.type !== 'wheel' && properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchout' @@ -365,13 +382,7 @@ export class FrigateCardTimelineCore extends LitElement { } protected _shouldSupportSeeking(): boolean { - const cameraIDs = this._getTimelineCameraIDs(); - if (!this._timeline || !cameraIDs) { - return false; - } - - const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); - return (this.view?.isViewerView() && capabilities?.canSeek) ?? false; + return this.mini; } /** @@ -385,8 +396,8 @@ export class FrigateCardTimelineCore extends LitElement { const targetBarOn = this._shouldSupportSeeking() && - (!this._locked || - (this.mini && + (this._panBehavior === 'seek' || + (this._panBehavior === 'seek-in-media' && this._timeline.getSelection().some((id) => { const item = this._timelineSource?.dataset?.get(id); return ( @@ -439,6 +450,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 @@ -446,37 +458,38 @@ export class FrigateCardTimelineCore extends LitElement { return; } - const canSeek = !!this.view?.isViewerView(); - const newResults = this._locked - ? null - : results - .clone() - .resetSelectedResult() - .selectBestResult((media) => - findClosestMediaIndex( - media, - targetTime, - properties.event.additionalEvent === 'panright' ? 'end' : 'start', - ), - ); + const canSeek = this._shouldSupportSeeking(); + const newResults = + this._panBehavior === 'seek-in-media' + ? null + : results + .clone() + .resetSelectedResult() + .selectBestResult((media) => + findClosestMediaIndex( + media, + targetTime, + properties.event.additionalEvent === 'panright' ? 'end' : 'start', + ), + ); - if ( - canSeek || - (newResults && - newResults.hasSelectedResult() && - newResults.getResult() !== results.getResult()) - ) { - this.view - .evolve({ - ...(newResults && - newResults.hasSelectedResult() && { queryResults: newResults }), - }) // Whether or not to set the timeline window. - .mergeInContext({ - ...(canSeek && { mediaViewer: { seek: targetTime } }), - ...this._setWindowInContext(properties), - }) - .dispatchChangeEvent(this); - } + const desiredView: FrigateCardView = this.mini + ? targetTime >= new Date() + ? 'live' + : 'media' + : this.view.view; + + this.view + .evolve({ + view: desiredView, + ...(newResults && + newResults.hasSelectedResult() && { queryResults: newResults }), + }) // Whether or not to set the timeline window. + .mergeInContext({ + ...(canSeek && { mediaViewer: { seek: targetTime } }), + ...this._getTimelineContext(properties), + }) + .dispatchChangeEvent(this); } /** @@ -667,7 +680,7 @@ export class FrigateCardTimelineCore extends LitElement { // 'flicker' for the user as the viewer reloads all the media). const newResults = newView?.queryResults; if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { - newView?.mergeInContext(this._setWindowInContext())?.dispatchChangeEvent(this); + newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); } } } @@ -948,6 +961,10 @@ 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 (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { @@ -1021,7 +1038,7 @@ export class FrigateCardTimelineCore extends LitElement { !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) ) { (await this._createViewWithEventMediaQuery(freshMediaQuery)) - ?.mergeInContext(this._setWindowInContext(desiredWindow)) + ?.mergeInContext(this._getTimelineContext(desiredWindow)) .dispatchChangeEvent(this); } } @@ -1046,11 +1063,12 @@ export class FrigateCardTimelineCore extends LitElement { * Generate the context for timeline views. * @returns The TimelineViewContext object. */ - protected _setWindowInContext(window?: TimelineWindow): ViewContext { + protected _getTimelineContext(window?: TimelineWindow): ViewContext { const newWindow = window ?? this._timeline?.getWindow(); return { timeline: { ...this.view?.context?.timeline, + panBehavior: this._panBehavior, ...(newWindow && { window: newWindow }), }, }; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index daaed937..cb99e3cf 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -460,9 +460,12 @@ "timeline": "See media in timeline" }, "timeline": { - "lock": "Lock timeline to a single event", - "select_date": "Choose date", - "unlock": "Unlock timeline" + "pan_behavior": { + "pan": "Pan", + "seek": "Pan seeks across media", + "seek-in-media": "Pan seeks within media" + }, + "select_date": "Choose date" }, "elements": { "ptz": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index f2acbc77..6dfc507a 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -450,9 +450,12 @@ "timeline": "Vedi evento nella timeline" }, "timeline": { - "lock": "Blocca la sequenza temporale su un singolo evento", - "select_date": "Scegli la data", - "unlock": "Sblocca la cronologia" + "pan_behavior": { + "pan": "", + "seek": "", + "seek-in-media": "" + }, + "select_date": "Scegli la data" }, "elements": { "ptz": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 53758d44..043603a6 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -471,8 +471,11 @@ "timeline": "Ver evento na linha do tempo" }, "timeline": { - "lock": "Bloquear linha do tempo em um Ășnico evento", - "select_date": "Escolha a data", - "unlock": "Desbloquear linha do tempo" + "pan_behavior": { + "pan": "", + "seek": "", + "seek-in-media": "" + }, + "select_date": "Escolha a data" } } diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index 274875e2..3c4223c2 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -194,7 +194,7 @@ export const executeMediaQueryForView = async ( /** * Find the closest matching media object. - * @param mediaArray The media. Must be sorted most recent first. + * @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 @@ -214,19 +214,25 @@ export const findClosestMediaIndex = ( | undefined; for (const [i, media] of mediaArray.entries()) { - if (media.includesTime(targetTime)) { - const start = media.getStartTime(); - const end = media.getEndTime(); - if (!refPoint || !start || !end) { + if (!refPoint) { + if (media.includesTime(targetTime)) { return i; } - const delta = - refPoint === 'end' - ? end.getTime() - targetTime.getTime() - : targetTime.getTime() - start.getTime(); - if (!bestMatch || delta < bestMatch.delta) { - bestMatch = { index: i, delta: delta }; - } + continue; + } + + const start = media.getStartTime(); + const end = media.getEndTime() ?? start; + if (!start || !end) { + continue; + } + + const delta = + refPoint === 'end' + ? end.getTime() - targetTime.getTime() + : targetTime.getTime() - start.getTime(); + if (delta > 0 && (!bestMatch || delta < bestMatch.delta)) { + bestMatch = { index: i, delta: delta }; } } return bestMatch ? bestMatch.index : null; diff --git a/src/view/media.ts b/src/view/media.ts index 5af18c04..758ed34b 100644 --- a/src/view/media.ts +++ b/src/view/media.ts @@ -51,7 +51,7 @@ export class ViewMedia { } public includesTime(seek: Date): boolean { const startTime = this.getStartTime(); - const endTime = this.getEndTime(); + const endTime = this.getEndTime() ?? startTime; return !!startTime && !!endTime && seek >= startTime && seek <= endTime; }