Modify timeline to allow pan into past/future.

This commit is contained in:
Dermot Duffy
2023-04-08 12:51:10 -07:00
parent ecf954249d
commit b3bb56298a
6 changed files with 106 additions and 73 deletions
+69 -51
View File
@@ -76,9 +76,11 @@ interface TimelineRangeChange extends TimelineWindow {
interface TimelineViewContext { interface TimelineViewContext {
window?: TimelineWindow; window?: TimelineWindow;
panBehavior?: TimelinePanBehavior;
} }
type TimelineItemClickAction = 'play' | 'select'; type TimelineItemClickAction = 'play' | 'select';
type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media';
declare module 'view' { declare module 'view' {
interface ViewContext { interface ViewContext {
@@ -197,7 +199,7 @@ export class FrigateCardTimelineCore extends LitElement {
public itemClickAction?: TimelineItemClickAction; public itemClickAction?: TimelineItemClickAction;
@state() @state()
protected _locked = false; protected _panBehavior: TimelinePanBehavior = 'seek';
protected _targetBarVisible = false; protected _targetBarVisible = false;
@@ -272,9 +274,18 @@ export class FrigateCardTimelineCore extends LitElement {
} }
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
const lockTitle = this._locked const panTitle =
? localize('timeline.unlock') this._panBehavior === 'pan'
: localize('timeline.lock'); ? 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 return html` ${capabilities?.supportsTimeline
? html` <div ? html` <div
@@ -287,12 +298,17 @@ export class FrigateCardTimelineCore extends LitElement {
<div class="timeline-tools"> <div class="timeline-tools">
${this._shouldSupportSeeking() ${this._shouldSupportSeeking()
? html` <ha-icon ? html` <ha-icon
.icon=${`mdi:${this._locked ? 'lock' : 'lock-open-variant'}`} .icon=${panIcon}
@click=${() => { @click=${() => {
this._locked = !this._locked; this._panBehavior =
this._panBehavior === 'pan'
? 'seek'
: this._panBehavior === 'seek'
? 'seek-in-media'
: 'pan';
}} }}
aria-label="${lockTitle}" aria-label="${panTitle}"
title="${lockTitle}" title="${panTitle}"
> >
</ha-icon>` </ha-icon>`
: ''} : ''}
@@ -340,9 +356,10 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if ( if (
this._shouldSupportSeeking() &&
this._timeline && this._timeline &&
properties.byUser && 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.type !== 'wheel' &&
properties.event.additionalEvent !== 'pinchin' && properties.event.additionalEvent !== 'pinchin' &&
properties.event.additionalEvent !== 'pinchout' properties.event.additionalEvent !== 'pinchout'
@@ -365,13 +382,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
protected _shouldSupportSeeking(): boolean { protected _shouldSupportSeeking(): boolean {
const cameraIDs = this._getTimelineCameraIDs(); return this.mini;
if (!this._timeline || !cameraIDs) {
return false;
}
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
return (this.view?.isViewerView() && capabilities?.canSeek) ?? false;
} }
/** /**
@@ -385,8 +396,8 @@ export class FrigateCardTimelineCore extends LitElement {
const targetBarOn = const targetBarOn =
this._shouldSupportSeeking() && this._shouldSupportSeeking() &&
(!this._locked || (this._panBehavior === 'seek' ||
(this.mini && (this._panBehavior === 'seek-in-media' &&
this._timeline.getSelection().some((id) => { this._timeline.getSelection().some((id) => {
const item = this._timelineSource?.dataset?.get(id); const item = this._timelineSource?.dataset?.get(id);
return ( return (
@@ -439,6 +450,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.view || !this.view ||
!this.hass || !this.hass ||
!this.cameraManager || !this.cameraManager ||
this._panBehavior === 'pan' ||
// Skip range changes that do not have hammerjs pan directions associated // Skip range changes that do not have hammerjs pan directions associated
// with them, as these outliers cause media matching issues below. // with them, as these outliers cause media matching issues below.
!properties.event.additionalEvent !properties.event.additionalEvent
@@ -446,37 +458,38 @@ export class FrigateCardTimelineCore extends LitElement {
return; return;
} }
const canSeek = !!this.view?.isViewerView(); const canSeek = this._shouldSupportSeeking();
const newResults = this._locked const newResults =
? null this._panBehavior === 'seek-in-media'
: results ? null
.clone() : results
.resetSelectedResult() .clone()
.selectBestResult((media) => .resetSelectedResult()
findClosestMediaIndex( .selectBestResult((media) =>
media, findClosestMediaIndex(
targetTime, media,
properties.event.additionalEvent === 'panright' ? 'end' : 'start', targetTime,
), properties.event.additionalEvent === 'panright' ? 'end' : 'start',
); ),
);
if ( const desiredView: FrigateCardView = this.mini
canSeek || ? targetTime >= new Date()
(newResults && ? 'live'
newResults.hasSelectedResult() && : 'media'
newResults.getResult() !== results.getResult()) : this.view.view;
) {
this.view this.view
.evolve({ .evolve({
...(newResults && view: desiredView,
newResults.hasSelectedResult() && { queryResults: newResults }), ...(newResults &&
}) // Whether or not to set the timeline window. newResults.hasSelectedResult() && { queryResults: newResults }),
.mergeInContext({ }) // Whether or not to set the timeline window.
...(canSeek && { mediaViewer: { seek: targetTime } }), .mergeInContext({
...this._setWindowInContext(properties), ...(canSeek && { mediaViewer: { seek: targetTime } }),
}) ...this._getTimelineContext(properties),
.dispatchChangeEvent(this); })
} .dispatchChangeEvent(this);
} }
/** /**
@@ -667,7 +680,7 @@ export class FrigateCardTimelineCore extends LitElement {
// 'flicker' for the user as the viewer reloads all the media). // 'flicker' for the user as the viewer reloads all the media).
const newResults = newView?.queryResults; const newResults = newView?.queryResults;
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { 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; : null;
const context = this.view.context?.timeline; const context = this.view.context?.timeline;
if (context && context.panBehavior) {
this._panBehavior = context.panBehavior;
}
if (context && context.window) { if (context && context.window) {
desiredWindow = context.window; desiredWindow = context.window;
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { } else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
@@ -1021,7 +1038,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery) !this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) { ) {
(await this._createViewWithEventMediaQuery(freshMediaQuery)) (await this._createViewWithEventMediaQuery(freshMediaQuery))
?.mergeInContext(this._setWindowInContext(desiredWindow)) ?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
} }
@@ -1046,11 +1063,12 @@ export class FrigateCardTimelineCore extends LitElement {
* Generate the context for timeline views. * Generate the context for timeline views.
* @returns The TimelineViewContext object. * @returns The TimelineViewContext object.
*/ */
protected _setWindowInContext(window?: TimelineWindow): ViewContext { protected _getTimelineContext(window?: TimelineWindow): ViewContext {
const newWindow = window ?? this._timeline?.getWindow(); const newWindow = window ?? this._timeline?.getWindow();
return { return {
timeline: { timeline: {
...this.view?.context?.timeline, ...this.view?.context?.timeline,
panBehavior: this._panBehavior,
...(newWindow && { window: newWindow }), ...(newWindow && { window: newWindow }),
}, },
}; };
+6 -3
View File
@@ -460,9 +460,12 @@
"timeline": "See media in timeline" "timeline": "See media in timeline"
}, },
"timeline": { "timeline": {
"lock": "Lock timeline to a single event", "pan_behavior": {
"select_date": "Choose date", "pan": "Pan",
"unlock": "Unlock timeline" "seek": "Pan seeks across media",
"seek-in-media": "Pan seeks within media"
},
"select_date": "Choose date"
}, },
"elements": { "elements": {
"ptz": { "ptz": {
+6 -3
View File
@@ -450,9 +450,12 @@
"timeline": "Vedi evento nella timeline" "timeline": "Vedi evento nella timeline"
}, },
"timeline": { "timeline": {
"lock": "Blocca la sequenza temporale su un singolo evento", "pan_behavior": {
"select_date": "Scegli la data", "pan": "",
"unlock": "Sblocca la cronologia" "seek": "",
"seek-in-media": ""
},
"select_date": "Scegli la data"
}, },
"elements": { "elements": {
"ptz": { "ptz": {
+6 -3
View File
@@ -471,8 +471,11 @@
"timeline": "Ver evento na linha do tempo" "timeline": "Ver evento na linha do tempo"
}, },
"timeline": { "timeline": {
"lock": "Bloquear linha do tempo em um único evento", "pan_behavior": {
"select_date": "Escolha a data", "pan": "",
"unlock": "Desbloquear linha do tempo" "seek": "",
"seek-in-media": ""
},
"select_date": "Escolha a data"
} }
} }
+18 -12
View File
@@ -194,7 +194,7 @@ export const executeMediaQueryForView = async (
/** /**
* Find the closest matching media object. * 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 targetTime The target time used to find the relevant child.
* @param refPoint Whether to find based on the start or end of the * @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 * event/recording. If not specified, the first match is returned rather than
@@ -214,19 +214,25 @@ export const findClosestMediaIndex = (
| undefined; | undefined;
for (const [i, media] of mediaArray.entries()) { for (const [i, media] of mediaArray.entries()) {
if (media.includesTime(targetTime)) { if (!refPoint) {
const start = media.getStartTime(); if (media.includesTime(targetTime)) {
const end = media.getEndTime();
if (!refPoint || !start || !end) {
return i; return i;
} }
const delta = continue;
refPoint === 'end' }
? end.getTime() - targetTime.getTime()
: targetTime.getTime() - start.getTime(); const start = media.getStartTime();
if (!bestMatch || delta < bestMatch.delta) { const end = media.getEndTime() ?? start;
bestMatch = { index: i, delta: delta }; 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; return bestMatch ? bestMatch.index : null;
+1 -1
View File
@@ -51,7 +51,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(); const endTime = this.getEndTime() ?? startTime;
return !!startTime && !!endTime && seek >= startTime && seek <= endTime; return !!startTime && !!endTime && seek >= startTime && seek <= endTime;
} }