Initial version of recordings button.
This commit is contained in:
+26
@@ -467,6 +467,29 @@ export class FrigateCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Don't show `recordings` button if there's no `camera_name` (e.g. non-Frigate
|
||||
// cameras), or is birdseye (unless there are dependent cameras).
|
||||
if (
|
||||
cameraConfig?.frigate.camera_name &&
|
||||
(cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE ||
|
||||
cameraConfig?.dependencies.cameras.length ||
|
||||
cameraConfig?.dependencies.all_cameras)
|
||||
) {
|
||||
buttons.push({
|
||||
icon: 'mdi:album',
|
||||
...this._getConfig().menu.buttons.recordings,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.recordings'),
|
||||
style: this._view?.is('recordings') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardCustomAction(
|
||||
'recordings',
|
||||
) as FrigateCardCustomAction,
|
||||
hold_action: createFrigateCardCustomAction(
|
||||
'recording',
|
||||
) as FrigateCardCustomAction,
|
||||
});
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
icon: 'mdi:image',
|
||||
...this._getConfig().menu.buttons.image,
|
||||
@@ -1369,6 +1392,8 @@ export class FrigateCard extends LitElement {
|
||||
case 'clips':
|
||||
case 'image':
|
||||
case 'live':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
@@ -1931,6 +1956,7 @@ export class FrigateCard extends LitElement {
|
||||
.view=${this._view}
|
||||
.cameras=${this._cameras}
|
||||
.galleryConfig=${this._getConfig().event_gallery}
|
||||
.dataManager=${this._timelineDataManager}
|
||||
>
|
||||
</frigate-card-gallery>`
|
||||
: ``}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
css,
|
||||
CSSResultGroup,
|
||||
@@ -23,6 +22,10 @@ import {
|
||||
fetchLatestMediaAndDispatchViewChange,
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||
} from '../utils/ha/browse-media';
|
||||
import {
|
||||
changeViewToRecentRecording,
|
||||
} from '../utils/media-to-view.js';
|
||||
import { TimelineDataManager } from '../utils/timeline-data-manager';
|
||||
import { View } from '../view.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import './thumbnail.js';
|
||||
@@ -42,6 +45,9 @@ export class FrigateCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public dataManager?: TimelineDataManager;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
@@ -53,12 +59,25 @@ export class FrigateCardGallery extends LitElement {
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.view.isGalleryView() ||
|
||||
!mediaType
|
||||
!mediaType ||
|
||||
!this.dataManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.view.target) {
|
||||
if (mediaType === 'recordings') {
|
||||
changeViewToRecentRecording(
|
||||
this,
|
||||
this.hass,
|
||||
this.dataManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: 'recordings',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const browseMediaQueryParameters =
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
||||
this,
|
||||
@@ -78,6 +97,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
this.view,
|
||||
browseMediaQueryParameters,
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator();
|
||||
}
|
||||
|
||||
@@ -211,7 +231,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!(this.view.is('clips') || this.view.is('snapshots')) ||
|
||||
!this.view.isGalleryView() ||
|
||||
!this.cameras
|
||||
) {
|
||||
return html``;
|
||||
@@ -254,8 +274,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
<div>${child.title}</div>
|
||||
</ha-card>
|
||||
`
|
||||
: child.thumbnail
|
||||
? html`<frigate-card-thumbnail
|
||||
: html`<frigate-card-thumbnail
|
||||
.view=${this.view}
|
||||
.target=${this.view?.target ?? null}
|
||||
.childIndex=${index}
|
||||
@@ -278,8 +297,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail>`
|
||||
: ``}
|
||||
</frigate-card-thumbnail>`}
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
|
||||
+58
-260
@@ -3,7 +3,6 @@ import {
|
||||
differenceInSeconds,
|
||||
endOfHour,
|
||||
fromUnixTime,
|
||||
getUnixTime,
|
||||
startOfHour,
|
||||
sub,
|
||||
} from 'date-fns';
|
||||
@@ -46,23 +45,23 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import {
|
||||
contentsChanged,
|
||||
dispatchFrigateCardEvent,
|
||||
formatDateAndTime,
|
||||
isHoverableDevice,
|
||||
prettifyTitle,
|
||||
} from '../utils/basic';
|
||||
import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js';
|
||||
import {
|
||||
getEventMediaContentID,
|
||||
getEventThumbnailURL,
|
||||
getEventTitle,
|
||||
getRecordingMediaContentID,
|
||||
} from '../utils/frigate';
|
||||
|
||||
import { createEventParentForChildren, createChild } from '../utils/ha/browse-media';
|
||||
import {
|
||||
changeViewToRecording,
|
||||
findChildIndex,
|
||||
generateMediaViewerContextForChildren,
|
||||
} from '../utils/media-to-view';
|
||||
import {
|
||||
FrigateCardTimelineItem,
|
||||
RecordingSegmentsItem,
|
||||
sortSegmentsOldestToYoungest,
|
||||
sortTimelineItemsYoungestToOldest,
|
||||
TimelineDataManager,
|
||||
} from '../utils/timeline-data-manager';
|
||||
@@ -293,164 +292,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return new Set(this.cameras?.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create recording objects.
|
||||
* @param time The target time for the recordings.
|
||||
* @param cameraIDs The camera IDs to create recordings for.
|
||||
* @param onlyShowMatchingHour If `true` only shows the hour matching the target
|
||||
* for the provided cameras, otherwise shows all hours.
|
||||
* @returns
|
||||
*/
|
||||
protected _createRecordingChildren(
|
||||
time: Date,
|
||||
cameraIDs: Set<string>,
|
||||
onlyShowMatchingHour: boolean,
|
||||
): FrigateBrowseMediaSource[] {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
|
||||
for (const cameraID of cameraIDs) {
|
||||
const config = this.cameras?.get(cameraID);
|
||||
const recordingSummary =
|
||||
this.timelineDataManager?.getRecordingSummaryForCamera(cameraID);
|
||||
if (!config?.frigate.camera_name || !recordingSummary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dayData of recordingSummary) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
const isMatchingHour = time >= startHour && time <= endHour;
|
||||
|
||||
// If asked to only provide recordings for a given camera show all
|
||||
// hours, otherwise only show the matching hour from all cameras.
|
||||
if (!onlyShowMatchingHour || isMatchingHour) {
|
||||
children.push(
|
||||
createChild(
|
||||
`${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(
|
||||
hour,
|
||||
)}`,
|
||||
getRecordingMediaContentID({
|
||||
clientId: config.frigate.client_id,
|
||||
year: dayData.day.getFullYear(),
|
||||
month: dayData.day.getMonth() + 1,
|
||||
day: dayData.day.getDate(),
|
||||
hour: hourData.hour,
|
||||
cameraName: config.frigate.camera_name,
|
||||
}),
|
||||
{
|
||||
recording: {
|
||||
camera: config.frigate.camera_name,
|
||||
start_time: getUnixTime(startHour),
|
||||
end_time: getUnixTime(endHour),
|
||||
events: hourData.events,
|
||||
},
|
||||
cameraID: cameraID,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the view to a recording.
|
||||
* @param targetTime The time of the recording to show.
|
||||
* @param cameraID An optional camera to show a recording of, otherwise all
|
||||
* cameras are shown at the given time.
|
||||
*/
|
||||
protected async _changeViewToRecording(
|
||||
targetTime: Date,
|
||||
cameraID?: string,
|
||||
): Promise<void> {
|
||||
if (!this.hass || !this.timelineConfig || !this.cameras) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraIDs = cameraID ? new Set([cameraID]) : this._getAllCameraIDs();
|
||||
const children = this._createRecordingChildren(targetTime, cameraIDs, !cameraID);
|
||||
if (!children.length) {
|
||||
return;
|
||||
}
|
||||
const viewerContext = this._generateMediaViewerContextForChildren(
|
||||
children,
|
||||
targetTime,
|
||||
);
|
||||
const childIndex = this._findChildIndex(
|
||||
children,
|
||||
startOfHour(targetTime),
|
||||
cameraIDs,
|
||||
);
|
||||
const child = childIndex !== null ? children[childIndex] : null;
|
||||
|
||||
if (childIndex !== null && child !== null) {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'recording',
|
||||
target: createEventParentForChildren(localize('common.recordings'), children),
|
||||
childIndex: childIndex,
|
||||
...(child.frigate?.cameraID && { camera: child.frigate?.cameraID }),
|
||||
})
|
||||
.mergeInContext(viewerContext)
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the relevant recording child given a date target.
|
||||
* @param children The FrigateBrowseMediaSource[] children. Must be sorted
|
||||
* most recent first.
|
||||
* @param targetTime The target time used to find the relevant child.
|
||||
* @param cameraIDs The camera IDs to search for.
|
||||
* @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.
|
||||
*/
|
||||
protected _findChildIndex(
|
||||
children: FrigateBrowseMediaSource[],
|
||||
targetTime: Date,
|
||||
cameraIDs: Set<string>,
|
||||
refPoint?: 'start' | 'end',
|
||||
): number | null {
|
||||
let bestMatch:
|
||||
| {
|
||||
index: number;
|
||||
delta: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (let i = 0; i < children.length; ++i) {
|
||||
const child = children[i];
|
||||
if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) {
|
||||
const source = child.frigate.event ?? child.frigate.recording;
|
||||
if (!source?.start_time || !source?.end_time) {
|
||||
continue;
|
||||
}
|
||||
const startTime = fromUnixTime(source.start_time);
|
||||
const endTime = fromUnixTime(source.end_time);
|
||||
|
||||
if (startTime <= targetTime && endTime >= targetTime) {
|
||||
if (!refPoint) {
|
||||
return i;
|
||||
}
|
||||
const delta =
|
||||
refPoint === 'end'
|
||||
? endTime.getTime() - targetTime.getTime()
|
||||
: targetTime.getTime() - startTime.getTime();
|
||||
if (!bestMatch || delta < bestMatch.delta) {
|
||||
bestMatch = { index: i, delta: delta };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestMatch ? bestMatch.index : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the range is in the process of being changed.
|
||||
* @param properties
|
||||
@@ -540,13 +381,19 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
targetTime: Date,
|
||||
properties: TimelineRangeChange,
|
||||
): void {
|
||||
if (!this._timeline || !this.view || !this.view.target?.children?.length) {
|
||||
if (
|
||||
!this._timeline ||
|
||||
!this.view ||
|
||||
!this.view.target?.children?.length ||
|
||||
!this.timelineDataManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canSeek = !!this.view?.isViewerView();
|
||||
const context = canSeek
|
||||
? this._generateMediaViewerContextForChildren(
|
||||
? generateMediaViewerContextForChildren(
|
||||
this.timelineDataManager,
|
||||
this.view.target.children,
|
||||
targetTime,
|
||||
)
|
||||
@@ -554,7 +401,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
const childIndex = this._locked
|
||||
? null
|
||||
: this._findChildIndex(
|
||||
: findChildIndex(
|
||||
this.view.target.children,
|
||||
targetTime,
|
||||
this._getTimelineCameraIDs(),
|
||||
@@ -576,90 +423,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the media view context for a set of media children (used to set
|
||||
* seek times into each media item).
|
||||
* @param children The media children.
|
||||
* @param targetTime The target time.
|
||||
* @returns The ViewContext.
|
||||
*/
|
||||
protected _generateMediaViewerContextForChildren(
|
||||
children: FrigateBrowseMediaSource[],
|
||||
targetTime: Date,
|
||||
): ViewContext {
|
||||
if (!this.timelineDataManager) {
|
||||
return {};
|
||||
}
|
||||
const seek = new Map();
|
||||
const segmentsDataset = this.timelineDataManager.recordingSegments;
|
||||
const hourStart = startOfHour(targetTime);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const source = child.frigate?.recording ?? child.frigate?.event;
|
||||
if (source && source.end_time && child.frigate?.cameraID) {
|
||||
const start = source.start_time * 1000;
|
||||
const end = source.end_time * 1000;
|
||||
let seekSeconds: number | null = null;
|
||||
|
||||
if (targetTime.getTime() >= start && targetTime.getTime() <= end) {
|
||||
const segments = segmentsDataset.get({
|
||||
filter: (segment) =>
|
||||
segment.cameraID === child.frigate?.cameraID &&
|
||||
segment.start >= start &&
|
||||
segment.end <= end,
|
||||
order: sortSegmentsOldestToYoungest,
|
||||
});
|
||||
seekSeconds = this._getSeekTimeInSegments(
|
||||
// Recordings start from the top of the hour.
|
||||
child.frigate.recording ? hourStart : fromUnixTime(source.start_time),
|
||||
targetTime,
|
||||
segments,
|
||||
);
|
||||
}
|
||||
|
||||
if (seekSeconds !== null) {
|
||||
seek.set(index, {
|
||||
seekSeconds: seekSeconds,
|
||||
seekTime: targetTime.getTime() / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of seconds to seek into a video stream consisting of the
|
||||
* provided segments to reach the target time provided.
|
||||
* @param startTime The earliest allowable time to seek from.
|
||||
* @param targetTime Target time.
|
||||
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
||||
* @returns
|
||||
*/
|
||||
protected _getSeekTimeInSegments(
|
||||
startTime: Date,
|
||||
targetTime: Date,
|
||||
segments: RecordingSegmentsItem[],
|
||||
): number | null {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
}
|
||||
let seekMilliseconds = 0;
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
if (segment.start > targetTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const start =
|
||||
segment.start < startTime.getTime() ? startTime.getTime() : segment.start;
|
||||
const end =
|
||||
segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end;
|
||||
seekMilliseconds += end - start;
|
||||
}
|
||||
return seekMilliseconds / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the timeline is clicked.
|
||||
* @param properties The properties of the timeline click event.
|
||||
@@ -672,34 +435,68 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(properties.event);
|
||||
}
|
||||
|
||||
if (!this._ignoreClick && properties.what) {
|
||||
if (
|
||||
!this._ignoreClick &&
|
||||
properties.what &&
|
||||
this.hass &&
|
||||
this.timelineDataManager &&
|
||||
this.cameras &&
|
||||
this.view
|
||||
) {
|
||||
if (
|
||||
this.timelineConfig?.show_recordings &&
|
||||
['background', 'group-label', 'axis'].includes(properties.what)
|
||||
) {
|
||||
if (['background', 'group-label'].includes(properties.what)) {
|
||||
stopEventFromActivatingCardWideActions(properties.event);
|
||||
|
||||
if (['background', 'group-label'].includes(properties.what)) {
|
||||
const window = this._timeline?.getWindow();
|
||||
if (window) {
|
||||
if (properties.group) {
|
||||
this._changeViewToRecording(
|
||||
changeViewToRecording(
|
||||
this,
|
||||
this.hass,
|
||||
this.timelineDataManager,
|
||||
this.cameras,
|
||||
this.view, {
|
||||
cameraIDs: new Set([String(properties.group)]),
|
||||
targetTime:
|
||||
properties.what === 'background' ? properties.time : window.end,
|
||||
String(properties.group),
|
||||
},
|
||||
);
|
||||
} else if (this.mini && this.view?.camera) {
|
||||
// In mini mode group may not be displayed / used, so just use the camera directly.
|
||||
this._changeViewToRecording(window.end, this.view.camera);
|
||||
changeViewToRecording(
|
||||
this,
|
||||
this.hass,
|
||||
this.timelineDataManager,
|
||||
this.cameras,
|
||||
this.view, {
|
||||
targetTime: window.end,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopEventFromActivatingCardWideActions(properties.event);
|
||||
this._changeViewToRecording(properties.time);
|
||||
changeViewToRecording(
|
||||
this,
|
||||
this.hass,
|
||||
this.timelineDataManager,
|
||||
this.cameras,
|
||||
this.view, {
|
||||
cameraIDs: this._getAllCameraIDs(),
|
||||
start: startOfHour(properties.time),
|
||||
end: endOfHour(properties.time),
|
||||
targetTime: properties.time,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
properties.what === 'item' &&
|
||||
properties.item &&
|
||||
this.view &&
|
||||
this.view.target?.children
|
||||
this.view.target?.children &&
|
||||
this.timelineDataManager
|
||||
) {
|
||||
let childIndex: number | null = null;
|
||||
let target: FrigateBrowseMediaSource | null = null;
|
||||
@@ -712,7 +509,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
target = thumbnails.target;
|
||||
childIndex = thumbnails.childIndex;
|
||||
if (thumbnails.target?.children?.length) {
|
||||
context = this._generateMediaViewerContextForChildren(
|
||||
context = generateMediaViewerContextForChildren(
|
||||
this.timelineDataManager,
|
||||
thumbnails.target.children,
|
||||
properties.time,
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ import { EmblaCarouselPlugins } from './carousel.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
import { TimelineDataManager } from '../utils/timeline-data-manager.js';
|
||||
import { changeViewToRecentRecording } from '../utils/media-to-view.js';
|
||||
|
||||
export interface MediaSeek {
|
||||
// Specifies the point at which this recording should be played, the
|
||||
@@ -101,7 +102,13 @@ export class FrigateCardViewer extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.viewerConfig ||
|
||||
!this.timelineDataManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,6 +130,18 @@ export class FrigateCardViewer extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaType === 'recordings') {
|
||||
changeViewToRecentRecording(
|
||||
this,
|
||||
this.hass,
|
||||
this.timelineDataManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: 'recording',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
fetchLatestMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
@@ -131,6 +150,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
mediaType: mediaType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator();
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -248,8 +248,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: 'live', label: localize('config.view.views.live') },
|
||||
{ value: 'clips', label: localize('config.view.views.clips') },
|
||||
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
|
||||
{ value: 'recordings', label: localize('config.view.views.recordings') },
|
||||
{ value: 'clip', label: localize('config.view.views.clip') },
|
||||
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
|
||||
{ value: 'recording', label: localize('config.view.views.recording') },
|
||||
{ value: 'image', label: localize('config.view.views.image') },
|
||||
{ value: 'timeline', label: localize('config.view.views.timeline') },
|
||||
];
|
||||
@@ -936,7 +938,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
* @param configPathShowDetails Thumbnails show details config path.
|
||||
* @param configPathShowFavoriteControl Thumbnails show favorite control config path.
|
||||
* @param configPathShowTimelineControl Thumbnails show timeline control config path,
|
||||
* @param configPathMedia Thumbnails media config path.
|
||||
* @param options An optional config path to media selection and mini-timeline mode.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected _renderThumbnailsControls(
|
||||
@@ -1437,6 +1439,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderMenuButton('live') /* */}
|
||||
${this._renderMenuButton('clips') /* */}
|
||||
${this._renderMenuButton('snapshots')}
|
||||
${this._renderMenuButton('recordings')}
|
||||
${this._renderMenuButton('image') /* */}
|
||||
${this._renderMenuButton('download')}
|
||||
${this._renderMenuButton('frigate_ui')}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"no_clips": "No clips",
|
||||
"no_snapshot": "No recent snapshot",
|
||||
"no_snapshots": "No snapshots",
|
||||
"no_recording": "No recent recording",
|
||||
"recordings": "Recordings",
|
||||
"version": "Version"
|
||||
},
|
||||
@@ -215,7 +216,8 @@
|
||||
"media_player": "Send to media player",
|
||||
"priority": "Priority",
|
||||
"snapshots": "Snapshots",
|
||||
"timeline": "Timeline"
|
||||
"timeline": "Timeline",
|
||||
"recordings": "Recordings"
|
||||
},
|
||||
"position": "Menu position",
|
||||
"positions": {
|
||||
@@ -262,6 +264,8 @@
|
||||
"current": "Current view",
|
||||
"image": "Static image",
|
||||
"live": "Live view",
|
||||
"recording": "Most recent recording",
|
||||
"recordings": "Recordings gallery",
|
||||
"snapshot": "Most recent snapshot",
|
||||
"snapshots": "Snapshots gallery",
|
||||
"timeline": "Timeline view"
|
||||
|
||||
+6
-1
@@ -34,13 +34,14 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
||||
'clips',
|
||||
'snapshot',
|
||||
'snapshots',
|
||||
'recording',
|
||||
'recordings',
|
||||
'image',
|
||||
'timeline',
|
||||
] as const;
|
||||
|
||||
const FRIGATE_CARD_VIEWS = [
|
||||
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
'recording',
|
||||
|
||||
// Media: A generic piece of media (could be clip, snapshot, recording).
|
||||
'media',
|
||||
@@ -204,6 +205,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||
'fullscreen',
|
||||
'menu_toggle',
|
||||
'diagnostics',
|
||||
'recording',
|
||||
'recordings'
|
||||
] as const;
|
||||
const FRIGATE_CARD_ACTIONS = [
|
||||
...FRIGATE_CARD_GENERAL_ACTIONS,
|
||||
@@ -907,6 +910,7 @@ const menuConfigDefault = {
|
||||
frigate_ui: visibleButtonDefault,
|
||||
fullscreen: visibleButtonDefault,
|
||||
media_player: visibleButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
},
|
||||
button_size: 40,
|
||||
};
|
||||
@@ -940,6 +944,7 @@ const menuConfigSchema = z
|
||||
media_player: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.media_player,
|
||||
),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
})
|
||||
.default(menuConfigDefault.buttons),
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
CameraConfig,
|
||||
ClipsOrSnapshots,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateBrowseMediaSourceSchema,
|
||||
FrigateCardError,
|
||||
@@ -258,7 +259,7 @@ export const getFullDependentBrowseMediaQueryParameters = (
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
camera: string,
|
||||
mediaType?: 'clips' | 'snapshots',
|
||||
mediaType?: ClipsOrSnapshots,
|
||||
): BrowseMediaQueryParameters[] | null => {
|
||||
const cameraIDs = getAllDependentCameras(cameras, camera);
|
||||
const params: BrowseMediaQueryParameters[] = [];
|
||||
@@ -290,7 +291,7 @@ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = (
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
camera: string,
|
||||
mediaType?: 'clips' | 'snapshots',
|
||||
mediaType?: ClipsOrSnapshots,
|
||||
): BrowseMediaQueryParameters[] | null => {
|
||||
const params = getFullDependentBrowseMediaQueryParameters(
|
||||
hass,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { add, endOfHour, fromUnixTime, getUnixTime, startOfHour, sub } from 'date-fns';
|
||||
import { ViewContext } from 'view';
|
||||
import { dispatchMessageEvent } from '../components/message';
|
||||
import { localize } from '../localize/localize';
|
||||
import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types';
|
||||
import { View } from '../view';
|
||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||
import { getRecordingMediaContentID } from './frigate';
|
||||
import { createChild, createEventParentForChildren } from './ha/browse-media';
|
||||
import {
|
||||
RecordingSegmentsItem,
|
||||
sortSegmentsOldestToYoungest,
|
||||
TimelineDataManager,
|
||||
} from './timeline-data-manager';
|
||||
|
||||
/**
|
||||
* Change the view to a recent recording.
|
||||
* @param element The element to dispatch the view change from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param view The current view.
|
||||
* @param cameraIDs The camera IDs to include recordings for.
|
||||
* @param options A specific window (start and end) to fetch recordings for, and a targetTime to seek to.
|
||||
*/
|
||||
export const changeViewToRecentRecording = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
dataManager: TimelineDataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
cameraIDs?: Set<string>;
|
||||
targetView?: 'recording' | 'recordings';
|
||||
},
|
||||
): Promise<void> => {
|
||||
const now = new Date();
|
||||
|
||||
await changeViewToRecording(
|
||||
element,
|
||||
hass,
|
||||
dataManager,
|
||||
cameras,
|
||||
view,
|
||||
{
|
||||
...options,
|
||||
|
||||
// Fetch 1 days worth of recordings (including recordings that are for the current hour).
|
||||
start: sub(now, { days: 1 }),
|
||||
end: add(now, { hours: 1 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the view to a recording.
|
||||
* @param element The element to dispatch the view change from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param view The current view.
|
||||
* @param cameraIDs The camera IDs to include recordings for.
|
||||
* @param options A specific window (start and end) to fetch recordings for, and a targetTime to seek to.
|
||||
*/
|
||||
export const changeViewToRecording = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
dataManager: TimelineDataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
cameraIDs?: Set<string>;
|
||||
targetView?: 'recording' | 'recordings';
|
||||
targetTime?: Date;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (options && options.start && options.end) {
|
||||
await dataManager.fetchIfNecessary(element, hass, options.start, options.end);
|
||||
}
|
||||
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set([view.camera]);
|
||||
const children = createRecordingChildren(dataManager, cameras, cameraIDs, {
|
||||
...(options?.start && options?.end && { start: options.start, end: options.end }),
|
||||
});
|
||||
|
||||
if (!children.length) {
|
||||
return dispatchMessageEvent(element, localize('common.no_recording'), 'info', {
|
||||
icon: 'mdi:album',
|
||||
});
|
||||
}
|
||||
|
||||
const viewerContext = options?.targetTime
|
||||
? generateMediaViewerContextForChildren(dataManager, children, options.targetTime)
|
||||
: {};
|
||||
const childIndex = options?.targetTime
|
||||
? findChildIndex(children, options.targetTime, cameraIDs)
|
||||
: null;
|
||||
const child = childIndex !== null ? children[childIndex] ?? null : null;
|
||||
|
||||
view
|
||||
?.evolve({
|
||||
view: options?.targetView ? options.targetView : 'recording',
|
||||
target: createEventParentForChildren(localize('common.recordings'), children),
|
||||
childIndex: childIndex ?? 0,
|
||||
...(child?.frigate?.cameraID && { camera: child.frigate?.cameraID }),
|
||||
})
|
||||
.mergeInContext(viewerContext)
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create recording objects.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param cameraIDs The camera IDs to include recordings for.
|
||||
* @param options A specific window (start and end) to allow recordings for.
|
||||
* @returns
|
||||
*/
|
||||
const createRecordingChildren = (
|
||||
dataManager: TimelineDataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
options?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): FrigateBrowseMediaSource[] => {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
|
||||
for (const cameraID of cameraIDs) {
|
||||
const config = cameras.get(cameraID);
|
||||
const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID);
|
||||
if (!config?.frigate.camera_name || !recordingSummary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dayData of recordingSummary) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
|
||||
if (
|
||||
(!options?.start || startHour >= options.start) &&
|
||||
(!options?.end || endHour <= options.end)
|
||||
) {
|
||||
children.push(
|
||||
createChild(
|
||||
`${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`,
|
||||
getRecordingMediaContentID({
|
||||
clientId: config.frigate.client_id,
|
||||
year: dayData.day.getFullYear(),
|
||||
month: dayData.day.getMonth() + 1,
|
||||
day: dayData.day.getDate(),
|
||||
hour: hourData.hour,
|
||||
cameraName: config.frigate.camera_name,
|
||||
}),
|
||||
{
|
||||
recording: {
|
||||
camera: config.frigate.camera_name,
|
||||
start_time: getUnixTime(startHour),
|
||||
end_time: getUnixTime(endHour),
|
||||
events: hourData.events,
|
||||
},
|
||||
cameraID: cameraID,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the media view context for a set of media children (used to set
|
||||
* seek times into each media item).
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param children The media children.
|
||||
* @param targetTime The target time.
|
||||
* @returns The ViewContext.
|
||||
*/
|
||||
export const generateMediaViewerContextForChildren = (
|
||||
dataManager: TimelineDataManager,
|
||||
children: FrigateBrowseMediaSource[],
|
||||
targetTime: Date,
|
||||
): ViewContext => {
|
||||
const seek = new Map();
|
||||
const segmentsDataset = dataManager.recordingSegments;
|
||||
const hourStart = startOfHour(targetTime);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const source = child.frigate?.recording ?? child.frigate?.event;
|
||||
if (source && source.end_time && child.frigate?.cameraID) {
|
||||
const start = source.start_time * 1000;
|
||||
const end = source.end_time * 1000;
|
||||
let seekSeconds: number | null = null;
|
||||
|
||||
if (targetTime.getTime() >= start && targetTime.getTime() <= end) {
|
||||
const segments = segmentsDataset.get({
|
||||
filter: (segment) =>
|
||||
segment.cameraID === child.frigate?.cameraID &&
|
||||
segment.start >= start &&
|
||||
segment.end <= end,
|
||||
order: sortSegmentsOldestToYoungest,
|
||||
});
|
||||
seekSeconds = getSeekTimeInSegments(
|
||||
// Recordings start from the top of the hour.
|
||||
child.frigate.recording ? hourStart : fromUnixTime(source.start_time),
|
||||
targetTime,
|
||||
segments,
|
||||
);
|
||||
}
|
||||
|
||||
if (seekSeconds !== null) {
|
||||
seek.set(index, {
|
||||
seekSeconds: seekSeconds,
|
||||
seekTime: targetTime.getTime() / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the relevant recording child given a date target.
|
||||
* @param children The FrigateBrowseMediaSource[] children. Must be sorted
|
||||
* most recent first.
|
||||
* @param targetTime The target time used to find the relevant child.
|
||||
* @param cameraIDs The camera IDs to search for.
|
||||
* @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 findChildIndex = (
|
||||
children: FrigateBrowseMediaSource[],
|
||||
targetTime: Date,
|
||||
cameraIDs: Set<string>,
|
||||
refPoint?: 'start' | 'end',
|
||||
): number | null => {
|
||||
let bestMatch:
|
||||
| {
|
||||
index: number;
|
||||
delta: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (let i = 0; i < children.length; ++i) {
|
||||
const child = children[i];
|
||||
if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) {
|
||||
const source = child.frigate.event ?? child.frigate.recording;
|
||||
if (!source?.start_time || !source?.end_time) {
|
||||
continue;
|
||||
}
|
||||
const startTime = fromUnixTime(source.start_time);
|
||||
const endTime = fromUnixTime(source.end_time);
|
||||
|
||||
if (startTime <= targetTime && endTime >= targetTime) {
|
||||
if (!refPoint) {
|
||||
return i;
|
||||
}
|
||||
const delta =
|
||||
refPoint === 'end'
|
||||
? endTime.getTime() - targetTime.getTime()
|
||||
: targetTime.getTime() - startTime.getTime();
|
||||
if (!bestMatch || delta < bestMatch.delta) {
|
||||
bestMatch = { index: i, delta: delta };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestMatch ? bestMatch.index : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the number of seconds to seek into a video stream consisting of the
|
||||
* provided segments to reach the target time provided.
|
||||
* @param startTime The earliest allowable time to seek from.
|
||||
* @param targetTime Target time.
|
||||
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
||||
* @returns
|
||||
*/
|
||||
const getSeekTimeInSegments = (
|
||||
startTime: Date,
|
||||
targetTime: Date,
|
||||
segments: RecordingSegmentsItem[],
|
||||
): number | null => {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
}
|
||||
let seekMilliseconds = 0;
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
if (segment.start > targetTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const start =
|
||||
segment.start < startTime.getTime() ? startTime.getTime() : segment.start;
|
||||
const end = segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end;
|
||||
seekMilliseconds += end - start;
|
||||
}
|
||||
return seekMilliseconds / 1000;
|
||||
};
|
||||
+14
-4
@@ -147,7 +147,7 @@ export class View {
|
||||
* Determine if a view is a gallery.
|
||||
*/
|
||||
public isGalleryView(): boolean {
|
||||
return this.view == 'clips' || this.view == 'snapshots';
|
||||
return ['clips', 'snapshots', 'recordings'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,14 +180,24 @@ export class View {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the media type for this view if available.
|
||||
* @returns Whether the media is `clips` or `snapshots` or unknown (`null`)
|
||||
* Determine if a view is related to a recording or recordings.
|
||||
*/
|
||||
public getMediaType(): 'clips' | 'snapshots' | null {
|
||||
public isRecordingRelatedView(): boolean {
|
||||
return ['recording', 'recordings'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the media type for this view if available.
|
||||
* @returns Whether the media is `clips`, `snapshots`, `recordings` or unknown
|
||||
* (`null`).
|
||||
*/
|
||||
public getMediaType(): 'clips' | 'snapshots' | 'recordings' | null {
|
||||
return this.isClipRelatedView()
|
||||
? 'clips'
|
||||
: this.isSnapshotRelatedView()
|
||||
? 'snapshots'
|
||||
: this.isRecordingRelatedView()
|
||||
? 'recordings'
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user