${score}
@@ -117,16 +126,19 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false })
public recording?: FrigateRecording;
+ @property({ attribute: false })
+ public mediaSeek?: MediaSeek;
+
protected render(): TemplateResult | void {
if (!this.recording) {
return;
}
return html`
${prettifyTitle(this.recording.camera) || ''}
- ${this.recording.seek_time
+ ${this.mediaSeek
? html`
${localize('recording.seek')}
- ${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')}
+ ${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}
`
: html``}
@@ -161,6 +173,9 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: false })
public childIndex?: number;
+ @property({ attribute: false })
+ public mediaSeek?: MediaSeek;
+
// ===================================================
// Raw interface (can override target-based interface)
// ===================================================
@@ -263,10 +278,12 @@ export class FrigateCardThumbnail extends LitElement {
${this.details && event
? html`
`
: this.details && recording
? html`
`
: html``}
${this.show_timeline_control
diff --git a/src/components/timeline.ts b/src/components/timeline.ts
index ca009e4e..36e88103 100644
--- a/src/components/timeline.ts
+++ b/src/components/timeline.ts
@@ -1,4 +1,12 @@
-import { HomeAssistant } from 'custom-card-helpers';
+// TODO: When a media viewer is first loaded the selected child won't work (because the underlying carousel has not yet rendered)
+// TODO: rename surround to surround basic and this file to surround?
+// TODO: get rid of circular dependency: src/components/surround-thumbnails.ts -> src/components/timeline.ts -> src/components/surround-thumbnails.ts
+// TODO: thumbnails in drawers don't work.
+// TODO: delete segments if not in summary? is this actually necessary? could it create gaps in data? better off stopping access via summary?
+// TODO: support filtering created dataviews by recordings or mediatype (so storage )
+// TODO: dataview refresh instead of rewriteitem?
+// TODO: Make minitimeline configurable in the editor
+
import {
add,
differenceInSeconds,
@@ -17,14 +25,14 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
-import { customElement, property } from 'lit/decorators.js';
-import { classMap } from 'lit/directives/class-map.js';
+import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
-import { isEqual } from 'lodash-es';
+import { isEqual, throttle } from 'lodash-es';
import { ViewContext } from 'view';
-import { DataSet } from 'vis-data/esnext';
+import { DataView, DataSet } from 'vis-data/esnext';
import {
DataGroupCollectionType,
+ IdType,
Timeline,
TimelineEventPropertiesResult,
TimelineItem,
@@ -37,49 +45,47 @@ import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss';
import timelineStyle from '../scss/timeline.scss';
import {
- BrowseMediaQueryParameters,
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
frigateCardConfigDefaults,
- FrigateCardError,
FrigateEvent,
TimelineConfig,
+ TimelineCoreConfig,
} from '../types';
import { stopEventFromActivatingCardWideActions } from '../utils/action';
-import { dispatchFrigateCardEvent, errorToConsole, isHoverableDevice, prettifyTitle } from '../utils/basic';
-import { getCameraTitle } from '../utils/camera.js';
import {
- getRecordingSegments,
- getRecordingsSummary,
- getUniqueFrigateCameraEventsID,
- getUniqueFrigateCameraID,
- RecordingSegments,
- RecordingSummary,
-} from '../utils/frigate';
+ contentsChanged,
+ dispatchFrigateCardEvent,
+ isHoverableDevice,
+ prettifyTitle,
+} from '../utils/basic';
+import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js';
+
import {
createEventParentForChildren,
createVideoChild,
generateRecordingIdentifier,
- getBrowseMediaQueryParameters,
- isTrueMedia,
- multipleBrowseMediaQuery,
} from '../utils/ha/browse-media';
+import {
+ FrigateCardTimelineItem,
+ RecordingSegmentsItem,
+ sortSegmentsOldestToYoungest,
+ sortTimelineItemsYoungestToOldest,
+ TimelineDataManager,
+} from '../utils/timeline-data-manager';
import { View } from '../view';
-import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
+import { dispatchMessageEvent } from './message.js';
import './surround-thumbnails.js';
-const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
-
interface FrigateCardGroupData {
id: string;
content: string;
}
-interface FrigateCardTimelineItem extends TimelineItem {
- start: number;
- end?: number;
- event?: FrigateEvent;
- source?: FrigateBrowseMediaSource;
+
+interface TimelineRangeChange extends TimelineWindow {
+ event: Event & { additionalEvent: string };
+ byUser: boolean;
}
interface TimelineViewContext {
@@ -88,6 +94,9 @@ interface TimelineViewContext {
// The date of the last event fetch.
dateFetch?: Date;
+
+ // Whether or not thumbnails were generated.
+ generatedThumbnails?: boolean;
}
declare module 'view' {
@@ -96,330 +105,12 @@ declare module 'view' {
}
}
-type TimelineMediaType = 'all' | 'clips' | 'snapshots';
-
-interface CameraRecordings {
- segments: RecordingSegments;
- summary: RecordingSummary;
-}
-
// An event used to fetch the HASS object. See "Special note" below.
class HASSRequestEvent extends Event {
public hass?: ExtendedHomeAssistant;
}
-/**
- * A manager to maintain/fetch timeline events.
- */
-class TimelineDataManager {
- protected _dataset = new DataSet
();
-
- // The earliest date managed.
- protected _dateStart?: Date;
-
- // The latest date managed.
- protected _dateEnd?: Date;
-
- // The last fetch date.
- protected _dateFetch?: Date;
-
- // The maximum allowable age of fetch data (will not fetch more frequently
- // than this).
- protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
-
- // Get the last event fetch date.
- get lastFetchDate(): Date | null {
- return this._dateFetch ?? null;
- }
-
- /**
- * Retrieve the underlying dataset.
- */
- get dataset(): DataSet {
- return this._dataset;
- }
-
- /**
- * Determine if the dataset is empty.
- * @returns
- */
- public isEmpty(): boolean {
- return this._dataset.length === 0;
- }
-
- /**
- * Clear the dataset.
- */
- public clear(): void {
- this._dataset.clear();
- }
-
- /**
- * Add a FrigateBrowseMediaSource object to the managed timeline.
- * @param camera The id the camera this object is from.
- * @param target The FrigateBrowseMediaSource to add.
- */
- protected _addMediaSource(
- camera: string,
- mediaPriority: TimelineMediaType,
- target: FrigateBrowseMediaSource,
- ): void {
- const items: FrigateCardTimelineItem[] = [];
- target.children?.forEach((child) => {
- const event = child.frigate?.event;
- if (
- event &&
- isTrueMedia(child) &&
- ['video', 'image'].includes(child.media_content_type)
- ) {
- let item = this._dataset.get(event.id);
- if (!item) {
- item = {
- id: event.id,
- group: camera,
- content: '',
- start: event.start_time * 1000,
- event: event,
- };
- }
- if (
- (child.media_content_type === 'video' &&
- ['all', 'clips'].includes(mediaPriority)) ||
- (!item.source &&
- child.media_content_type === 'image' &&
- ['all', 'snapshots'].includes(mediaPriority))
- ) {
- item.source = child;
- }
- if (event.end_time) {
- item['end'] = event.end_time * 1000;
- item['type'] = 'range';
- } else {
- item['type'] = 'point';
- }
- items.push(item);
- }
- });
- this._dataset.update(items);
- }
-
- /**
- * Determine if the timeline has coverage for a given range of dates.
- * @param start The start of the date range.
- * @param end An optional end of the date range.
- * @returns
- */
- public hasCoverage(now: Date, start: Date, end?: Date): boolean {
- // Never fetched: no coverage.
- if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
- return false;
- }
-
- // If the most recent fetch is older than maxAgeSeconds: no coverage.
- if (
- this._maxAgeSeconds &&
- now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
- ) {
- return false;
- }
-
- // If the most requested data is earlier than the earliest stored: no
- // coverage.
- if (start < this._dateStart) {
- return false;
- }
-
- // If there's no end time specified: there IS coverage.
- if (!end) {
- return true;
- }
- // If the requested end time is older than the oldest requested: there IS
- // coverage.
- if (end.getTime() < this._dateEnd.getTime()) {
- return true;
- }
- // If there's no maxAgeSeconds specified: no coverage.
- if (!this._maxAgeSeconds) {
- return false;
- }
- // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
- if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
- return false;
- }
-
- // End time is within `_maxAgeSeconds` of the latest data: there IS
- // coverage.
- return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
- }
-
- /**
- * Fetch events if no coverage in given range.
- * @param element The element to send error events from.
- * @param hass The HomeAssistant object.
- * @param cameras The cameras map.
- * @param start Fetch events that start later than this date.
- * @param end Fetch events that start earlier than this date.
- * @returns `true` if events were fetched, `false` otherwise.
- */
- public async fetchIfNecessary(
- element: HTMLElement,
- hass: ExtendedHomeAssistant,
- cameras: Map,
- eventMedia: TimelineMediaType,
- start: Date,
- end: Date,
- recordings?: boolean,
- ): Promise {
- // Cannot fetch the future, always clip the end date to now so as to avoid
- // checking for coverage that could not possibly exist yet.
- const now = new Date();
- end = end > now ? now : end;
-
- if (this.hasCoverage(now, start, end)) {
- return false;
- }
-
- if (!this._dateStart || start < this._dateStart) {
- this._dateStart = start;
- }
- if (!this._dateEnd || end > this._dateEnd) {
- this._dateEnd = end;
- }
- this._dateFetch = new Date();
-
- await Promise.all([
- // Events are always fetched for the maximum extent of the managed
- // range. This is because events may change at any point in time
- // (e.g. a long-running event that ends).
- this._fetchEvents(
- element,
- hass,
- cameras,
- eventMedia,
- this._dateStart,
- this._dateEnd,
- ),
- ...(recordings ? [this._fetchRecordings(hass, cameras)] : []),
- ]);
-
- return true;
- }
-
- /**
- * Fetch recording hours for the timeline.
- * @param element The element to send error events from.
- * @param hass The HomeAssistant object.
- * @param cameras The cameras map.
- * @param start Fetch events that start later than this date.
- * @param end Fetch events that start earlier than this date.
- */
- protected async _fetchRecordings(
- hass: ExtendedHomeAssistant,
- cameras: Map,
- ): Promise {
- const items: FrigateCardTimelineItem[] = [];
- const now = new Date();
-
- const storeRecordings = async (
- camera: string,
- config: CameraConfig,
- ): Promise => {
- if (!config.frigate.camera_name) {
- return;
- }
- let summary: RecordingSummary = [];
- try {
- summary = await getRecordingsSummary(
- hass,
- config.frigate.client_id,
- config.frigate.camera_name,
- );
- } catch (e) {
- // Recording failure should not disrupt the rest of the timeline
- // experience.
- errorToConsole(e as Error);
- }
-
- for (const dayData of summary) {
- for (const hourData of dayData.hours) {
- const hour = add(dayData.day, { hours: hourData.hour });
- const endHour = endOfHour(hour);
- items.push({
- id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`,
- group: camera,
- start: getUnixTime(startOfHour(hour)) * 1000,
-
- // Don't let the recordings show off into the future (even though it
- // is intended to be indicative of any recordings within that hour
- // -- it still looks strange!)
- end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000,
- type: 'background',
- content: '',
- });
- }
- }
- };
-
- await Promise.all(
- Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) =>
- storeRecordings(camera, config),
- ),
- );
-
- this._dataset.update(items);
- }
-
- /**
- * Fetch events for the timeline.
- * @param element The element to send error events from.
- * @param hass The HomeAssistant object.
- * @param cameras The cameras map.
- * @param start Fetch events that start later than this date.
- * @param end Fetch events that start earlier than this date.
- */
- protected async _fetchEvents(
- element: HTMLElement,
- hass: HomeAssistant,
- cameras: Map,
- media: TimelineMediaType,
- start: Date,
- end: Date,
- ): Promise {
- const params: BrowseMediaQueryParameters[] = [];
- cameras.forEach((cameraConfig, cameraID) => {
- (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
- if (cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE) {
- const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
- before: end.getTime() / 1000,
- after: start.getTime() / 1000,
- unlimited: true,
- mediaType: mediaType as 'clips' | 'snapshots',
- });
- if (param) {
- params.push(param);
- }
- }
- });
- });
-
- if (!params.length) {
- return;
- }
-
- let results: Map;
- try {
- results = await multipleBrowseMediaQuery(hass, params);
- } catch (e) {
- return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
- }
-
- for (const [query, result] of results.entries()) {
- if (query.cameraID) {
- this._addMediaSource(query.cameraID, media, result);
- }
- }
- }
-}
+const TIMELINE_TARGET_BAR_ID = 'target_bar';
/**
* A simgple thumbnail wrapper class for use in the timeline where LIT data
@@ -491,6 +182,9 @@ export class FrigateCardTimeline extends LitElement {
@property({ attribute: false })
public timelineConfig?: TimelineConfig;
+ @property({ attribute: false })
+ public timelineDataManager?: TimelineDataManager;
+
/**
* Master render method.
* @returns A rendered template.
@@ -503,7 +197,7 @@ export class FrigateCardTimeline extends LitElement {
return html`
`;
@@ -535,21 +232,46 @@ export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false })
public cameras?: Map;
+ @property({ attribute: false, hasChanged: contentsChanged })
+ public timelineConfig?: TimelineCoreConfig;
+
+ @property({ attribute: true, type: Boolean })
+ public thumbnailDetails? = false;
+
@property({ attribute: false })
- public timelineConfig?: TimelineConfig;
+ public thumbnailSize?: number;
- protected _data = new TimelineDataManager();
+ // Whether or not this is a mini-timeline for a different view (e.g. media
+ // viewer).
+ @property({ attribute: true, type: Boolean, reflect: true })
+ public mini = false;
+ @property({ attribute: false })
+ public timelineDataManager?: TimelineDataManager;
+
+ @state()
+ protected _locked = false;
+
+ protected _targetBarVisible = false;
protected _refTimeline: Ref = createRef();
protected _timeline?: Timeline;
+ protected _dataview?: DataView;
// Need a way to separate when a user clicks (to pan the timeline) vs when a
// user clicks (to choose a recording (non-event) to play).
- protected _pointerHeld = false;
+ protected _pointerHeld:
+ | (TimelineEventPropertiesResult & { window?: TimelineWindow })
+ | null = null;
protected _ignoreClick = false;
protected readonly _isHoverableDevice = isHoverableDevice();
+ // Range changes are volumonous: throttle the calls on seeking.
+ protected _throttledSetViewDuringRangeChange = throttle(
+ this._setViewDuringRangeChange.bind(this),
+ 1000 / 10,
+ );
+
/**
* Get a tooltip for a given timeline event.
* @param source The FrigateBrowseMediaSource in question.
@@ -566,9 +288,7 @@ export class FrigateCardTimelineCore extends LitElement {
const eventAttr = source.frigate?.event
? `event='${JSON.stringify(source.frigate.event)}'`
: '';
- const detailsAttr = this.timelineConfig?.controls.thumbnails.show_details
- ? 'details'
- : '';
+ const detailsAttr = this.thumbnailDetails ? 'details' : '';
// Cannot use Lit data-bindings as visjs requires a string for tooltips.
// Note that changes to attributes here must be mirrored in the xss
@@ -591,97 +311,80 @@ export class FrigateCardTimelineCore extends LitElement {
if (!this.hass || !this.view || !this.timelineConfig) {
return;
}
-
- const thumbnailsConfig = this.timelineConfig.controls.thumbnails;
- const timelineClasses = {
- timeline: true,
- 'left-margin': thumbnailsConfig.mode === 'left',
- 'right-margin': thumbnailsConfig.mode === 'right',
- };
-
return html` {
request.hass = this.hass;
}}
- class="${classMap(timelineClasses)}"
+ class="timeline"
${ref(this._refTimeline)}
- >
`;
+ >
+ {
+ this._locked = !this._locked;
+ }}
+ aria-label="${this._locked
+ ? localize('timeline.unlock')
+ : localize('timeline.lock')}"
+ title="${this._locked ? localize('timeline.unlock') : localize('timeline.lock')}"
+ >
+
+ `;
}
/**
- * Get the number of seconds to seek into a video stream consisting of the
- * provided segments to reach the target time provided.
- * @param time Target time.
- * @param segments A RecordingSegments object.
- * @returns
+ * Get all the keys of the cameras in scope for this timeline.
+ * @returns A set of camera ids (may be empty).
*/
- protected _getSeekTime(time: Date, segments: RecordingSegments): number | null {
- if (!segments.length) {
- return null;
+ protected _getTimelineCameraIDs(): Set