Merge pull request #858 from dermotduffy/recordings-button

Add view / menu button for recordings
This commit is contained in:
Dermot Duffy
2022-10-02 20:02:09 -07:00
committed by GitHub
16 changed files with 640 additions and 353 deletions
+15 -7
View File
@@ -287,6 +287,7 @@ menu:
| `live` | :white_check_mark: | The `live` view menu button: brings the user to the `live` view. See [views](#views) below.| | `live` | :white_check_mark: | The `live` view menu button: brings the user to the `live` view. See [views](#views) below.|
| `clips` | :white_check_mark: | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| | `clips` | :white_check_mark: | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.|
| `snapshots` | :white_check_mark: | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| | `snapshots` | :white_check_mark: | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.|
| `recordings` | :white_check_mark: | The `recordings` view menu button: brings the user to the `recordings` view on tap and the most-recent `recording` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.|
| `image` | :white_check_mark: | The `image` view menu button: brings the user to the static `image` view. See [views](#views) below.| | `image` | :white_check_mark: | The `image` view menu button: brings the user to the static `image` view. See [views](#views) below.|
| `download` | :white_check_mark: | The `download` menu button: allow direct download of the media being displayed.| | `download` | :white_check_mark: | The `download` menu button: allow direct download of the media being displayed.|
| `frigate_ui` | :white_check_mark: | The `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| | `frigate_ui` | :white_check_mark: | The `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.|
@@ -521,7 +522,7 @@ media_viewer:
### Event Gallery Options ### Event Gallery Options
The `event_gallery` is used for providing an overview of all `clips` and `snapshots` in a thumbnail gallery. The `event_gallery` is used for providing an overview of all `clips`, `snapshots` and `recordings` in a thumbnail gallery.
All configuration is under: All configuration is under:
@@ -537,7 +538,7 @@ See the [fully expanded event gallery configuration example](#config-expanded-ev
| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| | `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| | `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.|
| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| | `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.|
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`). See [actions](#actions) below.| | `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.|
### Image Options ### Image Options
@@ -916,14 +917,14 @@ Parameters for the `custom:frigate-card-ptz` element:
| Parameter | Description | | Parameter | Description |
| - | - | | - | - |
| `action` | Must be `custom:frigate-card-action`. | | `action` | Must be `custom:frigate-card-action`. |
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `timeline`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`.| | `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`.|
##### Command descriptions ##### Command descriptions
| Value | Description | | Value | Description |
| - | - | | - | - |
| `default` | Trigger the default view. | | `default` | Trigger the default view. |
| `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots` | Trigger the named [view](#views).| | `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots` | Trigger the named [view](#views).|
|`download`|Download the displayed media.| |`download`|Download the displayed media.|
|`frigate_ui`|Open the Frigate UI at the configured URL.| |`frigate_ui`|Open the Frigate UI at the configured URL.|
|`fullscreen`|Toggle fullscreen.| |`fullscreen`|Toggle fullscreen.|
@@ -945,6 +946,8 @@ This card supports several different views:
|`snapshot`|Shows a Media viewer for the most recent snapshot for this camera/zone/label. Can also be accessed by holding down the `snapshots` menu icon.| |`snapshot`|Shows a Media viewer for the most recent snapshot for this camera/zone/label. Can also be accessed by holding down the `snapshots` menu icon.|
|`clips`|Shows an event gallery of clips for this camera/zone/label.| |`clips`|Shows an event gallery of clips for this camera/zone/label.|
|`clip`|Shows a Media viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.| |`clip`|Shows a Media viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.|
|`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.|
|`recording`|Shows a Media viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.|
|`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| |`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).|
### Navigating From A Snapshot To A Clip ### Navigating From A Snapshot To A Clip
@@ -1005,8 +1008,8 @@ view.
| Configuration path | Views to which it refers | | Configuration path | Views to which it refers |
| - | - | | - | - |
| `view.actions` | All (may be overriden by the below) | | `view.actions` | All (may be overriden by the below) |
| `media_viewer.actions` | `clip`, `snapshot` | | `media_viewer.actions` | `clip`, `snapshot`, `recording` |
| `event_gallery.actions` | `clips`, `snapshots` | | `event_gallery.actions` | `clips`, `snapshots`, `recordings` |
| `live.actions` | `live` | | `live.actions` | `live` |
| `image.actions` | `image` | | `image.actions` | `image` |
@@ -2002,6 +2005,11 @@ overrides:
enabled: true enabled: true
alignment: matching alignment: matching
icon: mdi:camera icon: mdi:camera
recordings:
priority: 50
enabled: false
alignment: matching
icon: mdi:album
image: image:
priority: 50 priority: 50
# Disable the image button. # Disable the image button.
@@ -2685,7 +2693,7 @@ menu:
### Using a dependent camera ### Using a dependent camera
`dependencies.cameras` allows events for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own). `dependencies.cameras` allows events/recordings for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own).
<details> <details>
<summary>Expand: Using dependent cameras with birdseye</summary> <summary>Expand: Using dependent cameras with birdseye</summary>
+26
View File
@@ -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({ buttons.push({
icon: 'mdi:image', icon: 'mdi:image',
...this._getConfig().menu.buttons.image, ...this._getConfig().menu.buttons.image,
@@ -1369,6 +1392,8 @@ export class FrigateCard extends LitElement {
case 'clips': case 'clips':
case 'image': case 'image':
case 'live': case 'live':
case 'recording':
case 'recordings':
case 'snapshot': case 'snapshot':
case 'snapshots': case 'snapshots':
case 'timeline': case 'timeline':
@@ -1931,6 +1956,7 @@ export class FrigateCard extends LitElement {
.view=${this._view} .view=${this._view}
.cameras=${this._cameras} .cameras=${this._cameras}
.galleryConfig=${this._getConfig().event_gallery} .galleryConfig=${this._getConfig().event_gallery}
.dataManager=${this._timelineDataManager}
> >
</frigate-card-gallery>` </frigate-card-gallery>`
: ``} : ``}
+30 -10
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { import {
css, css,
CSSResultGroup, CSSResultGroup,
@@ -23,6 +22,8 @@ import {
fetchLatestMediaAndDispatchViewChange, fetchLatestMediaAndDispatchViewChange,
getFullDependentBrowseMediaQueryParametersOrDispatchError, getFullDependentBrowseMediaQueryParametersOrDispatchError,
} from '../utils/ha/browse-media'; } from '../utils/ha/browse-media';
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
import { TimelineDataManager } from '../utils/timeline-data-manager';
import { View } from '../view.js'; import { View } from '../view.js';
import { renderProgressIndicator } from './message.js'; import { renderProgressIndicator } from './message.js';
import './thumbnail.js'; import './thumbnail.js';
@@ -42,6 +43,9 @@ export class FrigateCardGallery extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameras?: Map<string, CameraConfig>; public cameras?: Map<string, CameraConfig>;
@property({ attribute: false })
public dataManager?: TimelineDataManager;
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
@@ -53,12 +57,25 @@ export class FrigateCardGallery extends LitElement {
!this.view || !this.view ||
!this.cameras || !this.cameras ||
!this.view.isGalleryView() || !this.view.isGalleryView() ||
!mediaType !mediaType ||
!this.dataManager
) { ) {
return; return;
} }
if (!this.view.target) { if (!this.view.target) {
if (mediaType === 'recordings') {
changeViewToRecentRecordingForCameraAndDependents(
this,
this.hass,
this.dataManager,
this.cameras,
this.view,
{
targetView: 'recordings',
},
);
} else {
const browseMediaQueryParameters = const browseMediaQueryParameters =
getFullDependentBrowseMediaQueryParametersOrDispatchError( getFullDependentBrowseMediaQueryParametersOrDispatchError(
this, this,
@@ -78,6 +95,7 @@ export class FrigateCardGallery extends LitElement {
this.view, this.view,
browseMediaQueryParameters, browseMediaQueryParameters,
); );
}
return renderProgressIndicator(); return renderProgressIndicator();
} }
@@ -211,13 +229,12 @@ export class FrigateCardGalleryCore extends LitElement {
!this.view || !this.view ||
!this.view.target || !this.view.target ||
!this.view.target.children || !this.view.target.children ||
!(this.view.is('clips') || this.view.is('snapshots')) || !this.view.isGalleryView() ||
!this.cameras !this.cameras
) { ) {
return html``; return html``;
} }
const cameraConfig = this.cameras.get(this.view.camera);
return html` return html`
${this._showBackArrow() ${this._showBackArrow()
? html` <ha-card ? html` <ha-card
@@ -254,13 +271,14 @@ export class FrigateCardGalleryCore extends LitElement {
<div>${child.title}</div> <div>${child.title}</div>
</ha-card> </ha-card>
` `
: child.thumbnail : html`<frigate-card-thumbnail
? html`<frigate-card-thumbnail
.view=${this.view} .view=${this.view}
.target=${this.view?.target ?? null} .target=${this.view?.target ?? null}
.childIndex=${index} .childIndex=${index}
.hass=${this.hass} .hass=${this.hass}
.clientID=${cameraConfig?.frigate.client_id} .cameraConfig=${child.frigate?.cameraID
? this.cameras?.get(child.frigate.cameraID)
: undefined}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details} ?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails ?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control} .show_favorite_control}
@@ -268,18 +286,20 @@ export class FrigateCardGalleryCore extends LitElement {
.show_timeline_control} .show_timeline_control}
@click=${(ev: Event) => { @click=${(ev: Event) => {
if (this.view) { if (this.view) {
const targetView = this.view.getViewerViewForGalleryView();
if (targetView) {
this.view this.view
.evolve({ .evolve({
view: this.view.is('clips') ? 'clip' : 'snapshot', view: targetView,
childIndex: index, childIndex: index,
}) })
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
}
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
> >
</frigate-card-thumbnail>` </frigate-card-thumbnail>`}
: ``}
`, `,
)} )}
`; `;
+1 -1
View File
@@ -208,7 +208,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
.target=${parent} .target=${parent}
.childIndex=${childIndex} .childIndex=${childIndex}
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)} .mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)}
.clientID=${cameraConfig?.frigate.client_id} .cameraConfig=${cameraConfig ?? undefined}
?details=${this.config?.show_details} ?details=${this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control} ?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control} ?show_timeline_control=${this.config?.show_timeline_control}
+16 -6
View File
@@ -10,6 +10,7 @@ import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.
import thumbnailStyle from '../scss/thumbnail.scss'; import thumbnailStyle from '../scss/thumbnail.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, prettifyTitle } from '../utils/basic.js'; import { errorToConsole, prettifyTitle } from '../utils/basic.js';
import { getCameraTitle } from '../utils/camera.js';
import { retainEvent } from '../utils/frigate.js'; import { retainEvent } from '../utils/frigate.js';
import { getEventDurationString } from '../utils/frigate.js'; import { getEventDurationString } from '../utils/frigate.js';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
@@ -19,6 +20,7 @@ import { MediaSeek } from './viewer.js';
import { TaskStatus } from '@lit-labs/task'; import { TaskStatus } from '@lit-labs/task';
import type { import type {
CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
FrigateEvent, FrigateEvent,
@@ -108,6 +110,9 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public date?: Date; public date?: Date;
@property({ attribute: false })
public cameraTitle?: string;
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.date) { if (!this.date) {
return; return;
@@ -115,6 +120,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
return html` return html`
<div class="title">${format(this.date, 'HH:mm')}</div> <div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div> <div class="subtitle">${format(this.date, 'MMM do')}</div>
${this.cameraTitle ? html`<div class="camera">${this.cameraTitle}</div>` : html``}
`; `;
} }
@@ -240,7 +246,7 @@ export class FrigateCardThumbnail extends LitElement {
public hass?: ExtendedHomeAssistant; public hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public clientID?: string; public cameraConfig?: CameraConfig;
/** /**
* Render the element. * Render the element.
@@ -277,6 +283,7 @@ export class FrigateCardThumbnail extends LitElement {
starred: !!event?.retain_indefinitely, starred: !!event?.retain_indefinitely,
}; };
const clientID = this.cameraConfig?.frigate.client_id;
return html` ${event return html` ${event
? html`<frigate-card-thumbnail-feature-event ? html`<frigate-card-thumbnail-feature-event
aria-label="${label ?? ''}" aria-label="${label ?? ''}"
@@ -285,22 +292,25 @@ export class FrigateCardThumbnail extends LitElement {
.thumbnail=${thumbnail ?? undefined} .thumbnail=${thumbnail ?? undefined}
.label=${label ?? undefined} .label=${label ?? undefined}
></frigate-card-thumbnail-feature-event>` ></frigate-card-thumbnail-feature-event>`
: html`<frigate-card-thumbnail-feature-recording : recording
? html`<frigate-card-thumbnail-feature-recording
aria-label="${label ?? ''}" aria-label="${label ?? ''}"
title="${label ?? ''}" title="${label ?? ''}"
.cameraTitle=${this.details || !this.cameraConfig || !this.hass ? undefined : getCameraTitle(this.hass, this.cameraConfig)}
.date=${recording ? fromUnixTime(recording.start_time) : undefined} .date=${recording ? fromUnixTime(recording.start_time) : undefined}
></frigate-card-thumbnail-feature-recording>`} ></frigate-card-thumbnail-feature-recording>`
${this.show_favorite_control && event && this.hass && this.clientID : html``}
${this.show_favorite_control && event && this.hass && clientID
? html` <ha-icon ? html` <ha-icon
class="${classMap(starClasses)}" class="${classMap(starClasses)}"
icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'} icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'}
title=${localize('thumbnail.retain_indefinitely')} title=${localize('thumbnail.retain_indefinitely')}
@click=${(ev: Event) => { @click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
if (event && this.hass && this.clientID) { if (event && this.hass && clientID) {
retainEvent( retainEvent(
this.hass, this.hass,
this.clientID, clientID,
event.id, event.id,
!event.retain_indefinitely, !event.retain_indefinitely,
) )
+70 -262
View File
@@ -3,7 +3,6 @@ import {
differenceInSeconds, differenceInSeconds,
endOfHour, endOfHour,
fromUnixTime, fromUnixTime,
getUnixTime,
startOfHour, startOfHour,
sub, sub,
} from 'date-fns'; } from 'date-fns';
@@ -46,24 +45,24 @@ import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { import {
contentsChanged, contentsChanged,
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
formatDateAndTime,
isHoverableDevice, isHoverableDevice,
prettifyTitle,
} from '../utils/basic'; } from '../utils/basic';
import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js'; import { getAllDependentCameras, getCameraTitle } from '../utils/camera.js';
import { import {
getEventMediaContentID, getEventMediaContentID,
getEventThumbnailURL, getEventThumbnailURL,
getEventTitle, getEventTitle,
getRecordingMediaContentID,
} from '../utils/frigate'; } from '../utils/frigate';
import { createEventParentForChildren, createChild } from '../utils/ha/browse-media'; import { createEventParentForChildren, createChild } from '../utils/ha/browse-media';
import {
changeViewToRecording,
findChildIndex,
generateMediaViewerContextForChildren,
} from '../utils/media-to-view';
import { import {
FrigateCardTimelineItem, FrigateCardTimelineItem,
RecordingSegmentsItem, sortYoungestToOldest,
sortSegmentsOldestToYoungest,
sortTimelineItemsYoungestToOldest,
TimelineDataManager, TimelineDataManager,
} from '../utils/timeline-data-manager'; } from '../utils/timeline-data-manager';
import { View } from '../view'; import { View } from '../view';
@@ -293,164 +292,6 @@ export class FrigateCardTimelineCore extends LitElement {
return new Set(this.cameras?.keys()); 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. * Called whenever the range is in the process of being changed.
* @param properties * @param properties
@@ -540,13 +381,19 @@ export class FrigateCardTimelineCore extends LitElement {
targetTime: Date, targetTime: Date,
properties: TimelineRangeChange, properties: TimelineRangeChange,
): void { ): void {
if (!this._timeline || !this.view || !this.view.target?.children?.length) { if (
!this._timeline ||
!this.view ||
!this.view.target?.children?.length ||
!this.timelineDataManager
) {
return; return;
} }
const canSeek = !!this.view?.isViewerView(); const canSeek = !!this.view?.isViewerView();
const context = canSeek const context = canSeek
? this._generateMediaViewerContextForChildren( ? generateMediaViewerContextForChildren(
this.timelineDataManager,
this.view.target.children, this.view.target.children,
targetTime, targetTime,
) )
@@ -554,7 +401,7 @@ export class FrigateCardTimelineCore extends LitElement {
const childIndex = this._locked const childIndex = this._locked
? null ? null
: this._findChildIndex( : findChildIndex(
this.view.target.children, this.view.target.children,
targetTime, targetTime,
this._getTimelineCameraIDs(), 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. * Called whenever the timeline is clicked.
* @param properties The properties of the timeline click event. * @param properties The properties of the timeline click event.
@@ -672,34 +435,68 @@ export class FrigateCardTimelineCore extends LitElement {
stopEventFromActivatingCardWideActions(properties.event); stopEventFromActivatingCardWideActions(properties.event);
} }
if (!this._ignoreClick && properties.what) { if (
!this._ignoreClick &&
properties.what &&
this.hass &&
this.timelineDataManager &&
this.cameras &&
this.view
) {
if ( if (
this.timelineConfig?.show_recordings && this.timelineConfig?.show_recordings &&
['background', 'group-label', 'axis'].includes(properties.what) ['background', 'group-label', 'axis'].includes(properties.what)
) { ) {
if (['background', 'group-label'].includes(properties.what)) {
stopEventFromActivatingCardWideActions(properties.event); stopEventFromActivatingCardWideActions(properties.event);
if (['background', 'group-label'].includes(properties.what)) {
const window = this._timeline?.getWindow(); const window = this._timeline?.getWindow();
if (window) { if (window) {
if (properties.group) { 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, properties.what === 'background' ? properties.time : window.end,
String(properties.group), },
); );
} else if (this.mini && this.view?.camera) { } else if (this.mini && this.view?.camera) {
// In mini mode group may not be displayed / used, so just use the camera directly. // 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 { } else {
stopEventFromActivatingCardWideActions(properties.event); changeViewToRecording(
this._changeViewToRecording(properties.time); 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 ( } else if (
properties.what === 'item' && properties.what === 'item' &&
properties.item && properties.item &&
this.view && this.view &&
this.view.target?.children this.view.target?.children &&
this.timelineDataManager
) { ) {
let childIndex: number | null = null; let childIndex: number | null = null;
let target: FrigateBrowseMediaSource | null = null; let target: FrigateBrowseMediaSource | null = null;
@@ -712,7 +509,8 @@ export class FrigateCardTimelineCore extends LitElement {
target = thumbnails.target; target = thumbnails.target;
childIndex = thumbnails.childIndex; childIndex = thumbnails.childIndex;
if (thumbnails.target?.children?.length) { if (thumbnails.target?.children?.length) {
context = this._generateMediaViewerContextForChildren( context = generateMediaViewerContextForChildren(
this.timelineDataManager,
thumbnails.target.children, thumbnails.target.children,
properties.time, properties.time,
); );
@@ -821,7 +619,7 @@ export class FrigateCardTimelineCore extends LitElement {
this._dataview this._dataview
?.get({ ?.get({
filter: (item) => item.type !== 'background', filter: (item) => item.type !== 'background',
order: sortTimelineItemsYoungestToOldest, order: sortYoungestToOldest,
}) })
.forEach((item) => { .forEach((item) => {
const cameraID = item.group ? String(item.group) : null; const cameraID = item.group ? String(item.group) : null;
@@ -1226,6 +1024,7 @@ export class FrigateCardTimelineCore extends LitElement {
} }
const options = this._getOptions(); const options = this._getOptions();
let createdTimeline = false;
if ( if (
this.timelineDataManager && this.timelineDataManager &&
@@ -1258,6 +1057,7 @@ export class FrigateCardTimelineCore extends LitElement {
this.timelineConfig.media, this.timelineConfig.media,
); );
createdTimeline = true;
if (this.mini && groups.length === 1) { if (this.mini && groups.length === 1) {
// In a mini timeline, if there's only one group don't bother grouping // In a mini timeline, if there's only one group don't bother grouping
// at all. // at all.
@@ -1299,9 +1099,17 @@ export class FrigateCardTimelineCore extends LitElement {
} }
if (changedProperties.has('view')) { if (changedProperties.has('view')) {
if (createdTimeline) {
// If the timeline was just created, give it one frame to draw itself.
// Failure to do so may result in subsequent calls to
// `this._timeline.setwindow()` being entirely ignored. Example case:
// Clicking the timeline control on a recording thumbnail.
window.requestAnimationFrame(this._updateTimelineFromView.bind(this));
} else {
this._updateTimelineFromView(); this._updateTimelineFromView();
} }
} }
}
/** /**
* Return compiled CSS styles. * Return compiled CSS styles.
+21 -1
View File
@@ -56,6 +56,7 @@ import { EmblaCarouselPlugins } from './carousel.js';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { TimelineDataManager } from '../utils/timeline-data-manager.js'; import { TimelineDataManager } from '../utils/timeline-data-manager.js';
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
export interface MediaSeek { export interface MediaSeek {
// Specifies the point at which this recording should be played, the // Specifies the point at which this recording should be played, the
@@ -101,7 +102,13 @@ export class FrigateCardViewer extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { 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; return;
} }
@@ -123,6 +130,18 @@ export class FrigateCardViewer extends LitElement {
return; return;
} }
if (mediaType === 'recordings') {
changeViewToRecentRecordingForCameraAndDependents(
this,
this.hass,
this.timelineDataManager,
this.cameras,
this.view,
{
targetView: 'recording',
},
);
} else {
fetchLatestMediaAndDispatchViewChange( fetchLatestMediaAndDispatchViewChange(
this, this,
this.hass, this.hass,
@@ -131,6 +150,7 @@ export class FrigateCardViewer extends LitElement {
mediaType: mediaType, mediaType: mediaType,
}), }),
); );
}
return renderProgressIndicator(); return renderProgressIndicator();
} }
+4 -1
View File
@@ -248,8 +248,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'live', label: localize('config.view.views.live') }, { value: 'live', label: localize('config.view.views.live') },
{ value: 'clips', label: localize('config.view.views.clips') }, { value: 'clips', label: localize('config.view.views.clips') },
{ value: 'snapshots', label: localize('config.view.views.snapshots') }, { 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: 'clip', label: localize('config.view.views.clip') },
{ value: 'snapshot', label: localize('config.view.views.snapshot') }, { 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: 'image', label: localize('config.view.views.image') },
{ value: 'timeline', label: localize('config.view.views.timeline') }, { 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 configPathShowDetails Thumbnails show details config path.
* @param configPathShowFavoriteControl Thumbnails show favorite control config path. * @param configPathShowFavoriteControl Thumbnails show favorite control config path.
* @param configPathShowTimelineControl Thumbnails show timeline 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. * @returns A rendered template.
*/ */
protected _renderThumbnailsControls( protected _renderThumbnailsControls(
@@ -1437,6 +1439,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMenuButton('live') /* */} ${this._renderMenuButton('live') /* */}
${this._renderMenuButton('clips') /* */} ${this._renderMenuButton('clips') /* */}
${this._renderMenuButton('snapshots')} ${this._renderMenuButton('snapshots')}
${this._renderMenuButton('recordings')}
${this._renderMenuButton('image') /* */} ${this._renderMenuButton('image') /* */}
${this._renderMenuButton('download')} ${this._renderMenuButton('download')}
${this._renderMenuButton('frigate_ui')} ${this._renderMenuButton('frigate_ui')}
+5 -1
View File
@@ -7,6 +7,7 @@
"no_clips": "No clips", "no_clips": "No clips",
"no_snapshot": "No recent snapshot", "no_snapshot": "No recent snapshot",
"no_snapshots": "No snapshots", "no_snapshots": "No snapshots",
"no_recording": "No recent recording",
"recordings": "Recordings", "recordings": "Recordings",
"version": "Version" "version": "Version"
}, },
@@ -215,7 +216,8 @@
"media_player": "Send to media player", "media_player": "Send to media player",
"priority": "Priority", "priority": "Priority",
"snapshots": "Snapshots", "snapshots": "Snapshots",
"timeline": "Timeline" "timeline": "Timeline",
"recordings": "Recordings"
}, },
"position": "Menu position", "position": "Menu position",
"positions": { "positions": {
@@ -262,6 +264,8 @@
"current": "Current view", "current": "Current view",
"image": "Static image", "image": "Static image",
"live": "Live view", "live": "Live view",
"recording": "Most recent recording",
"recordings": "Recordings gallery",
"snapshot": "Most recent snapshot", "snapshot": "Most recent snapshot",
"snapshots": "Snapshots gallery", "snapshots": "Snapshots gallery",
"timeline": "Timeline view" "timeline": "Timeline view"
+3 -1
View File
@@ -21,7 +21,9 @@
div { div {
text-align: center; text-align: center;
} }
div.title { div.title {
font-size: 1.5rem; font-size: 1.5rem;
} }
div.camera {
font-size: 0.7em;
}
+6 -1
View File
@@ -34,13 +34,14 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'clips', 'clips',
'snapshot', 'snapshot',
'snapshots', 'snapshots',
'recording',
'recordings',
'image', 'image',
'timeline', 'timeline',
] as const; ] as const;
const FRIGATE_CARD_VIEWS = [ const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
'recording',
// Media: A generic piece of media (could be clip, snapshot, recording). // Media: A generic piece of media (could be clip, snapshot, recording).
'media', 'media',
@@ -204,6 +205,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'fullscreen', 'fullscreen',
'menu_toggle', 'menu_toggle',
'diagnostics', 'diagnostics',
'recording',
'recordings'
] as const; ] as const;
const FRIGATE_CARD_ACTIONS = [ const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_GENERAL_ACTIONS, ...FRIGATE_CARD_GENERAL_ACTIONS,
@@ -907,6 +910,7 @@ const menuConfigDefault = {
frigate_ui: visibleButtonDefault, frigate_ui: visibleButtonDefault,
fullscreen: visibleButtonDefault, fullscreen: visibleButtonDefault,
media_player: visibleButtonDefault, media_player: visibleButtonDefault,
recordings: hiddenButtonDefault,
}, },
button_size: 40, button_size: 40,
}; };
@@ -940,6 +944,7 @@ const menuConfigSchema = z
media_player: visibleButtonSchema.default( media_player: visibleButtonSchema.default(
menuConfigDefault.buttons.media_player, menuConfigDefault.buttons.media_player,
), ),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
}) })
.default(menuConfigDefault.buttons), .default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
+31
View File
@@ -107,3 +107,34 @@ export const getAllDependentCameras = (
} }
return cameraIDs; return cameraIDs;
}; };
/**
* Return the cameraIDs of truly unique cameras (some configured cameras may be
* the same Frigate came but with different zone/labels).
* @param cameras The full set of cameras.
* @param cameraIDs The specific IDs to dedup.
*/
export const getTrueCameras = (
cameras: Map<string, CameraConfig>,
cameraIDs: Set<string>,
): Set<string> => {
const getTrueCameraID = (cameraConfig: CameraConfig): string => {
return `${cameraConfig.frigate?.client_id ?? ''}/${
cameraConfig.frigate.camera_name ?? ''
}`;
};
const output = new Set<string>();
const visitedTrueCameras = new Set<string>();
cameraIDs.forEach((cameraID: string) => {
const cameraConfig = cameras.get(cameraID) ?? null;
if (cameraConfig && cameraConfig.frigate.camera_name) {
const trueCameraID = getTrueCameraID(cameraConfig);
if (!visitedTrueCameras.has(trueCameraID)) {
output.add(cameraID);
visitedTrueCameras.add(trueCameraID);
}
}
});
return output;
};
+33 -23
View File
@@ -9,6 +9,7 @@ import { localize } from '../../localize/localize.js';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
CameraConfig, CameraConfig,
ClipsOrSnapshots,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
frigateBrowseMediaSourceSchema, frigateBrowseMediaSourceSchema,
FrigateCardError, FrigateCardError,
@@ -182,27 +183,7 @@ export const mergeFrigateBrowseMediaSources = async (
} }
} }
const eventSort = ( return createEventParentForChildren('Merged events', children.sort(sortYoungestToOldest));
a: FrigateBrowseMediaSource,
b: FrigateBrowseMediaSource,
): number => {
if (
!a.frigate?.event ||
(b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time)
) {
return 1;
}
if (
!b.frigate?.event ||
(a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time)
) {
return -1;
}
return 0;
};
return createEventParentForChildren('Merged events', children.sort(eventSort));
}; };
/** /**
@@ -258,7 +239,7 @@ export const getFullDependentBrowseMediaQueryParameters = (
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
camera: string, camera: string,
mediaType?: 'clips' | 'snapshots', mediaType?: ClipsOrSnapshots,
): BrowseMediaQueryParameters[] | null => { ): BrowseMediaQueryParameters[] | null => {
const cameraIDs = getAllDependentCameras(cameras, camera); const cameraIDs = getAllDependentCameras(cameras, camera);
const params: BrowseMediaQueryParameters[] = []; const params: BrowseMediaQueryParameters[] = [];
@@ -290,7 +271,7 @@ export const getFullDependentBrowseMediaQueryParametersOrDispatchError = (
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
camera: string, camera: string,
mediaType?: 'clips' | 'snapshots', mediaType?: ClipsOrSnapshots,
): BrowseMediaQueryParameters[] | null => { ): BrowseMediaQueryParameters[] | null => {
const params = getFullDependentBrowseMediaQueryParameters( const params = getFullDependentBrowseMediaQueryParameters(
hass, hass,
@@ -444,3 +425,32 @@ export const createChild = (
} }
return result; return result;
}; };
/**
* Sort the timeline items most recent to least recent.
* @param a The first item.
* @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration).
*/
export const sortYoungestToOldest = (
a: FrigateBrowseMediaSource,
b: FrigateBrowseMediaSource,
): number => {
const a_source = a.frigate?.event ?? a.frigate?.recording;
const b_source = b.frigate?.event ?? b.frigate?.recording;
if (
!a_source ||
(b_source && b_source.start_time > a_source.start_time)
) {
return 1;
}
if (
!b_source ||
(a_source && b_source.start_time < a_source.start_time)
) {
return -1;
}
return 0;
};
+316
View File
@@ -0,0 +1,316 @@
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, sortYoungestToOldest } from './ha/browse-media';
import {
RecordingSegmentsItem,
sortOldestToYoungest,
TimelineDataManager,
} from './timeline-data-manager';
import { getAllDependentCameras, getTrueCameras } from './camera.js';
/**
* 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 options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to.
*/
export const changeViewToRecentRecordingForCameraAndDependents = async (
element: HTMLElement,
hass: ExtendedHomeAssistant,
dataManager: TimelineDataManager,
cameras: Map<string, CameraConfig>,
view: View,
options?: {
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).
cameraIDs: getAllDependentCameras(cameras, view.camera),
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 options A specific window (start and end) to fetch recordings for, a
* targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to
* restrict 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 getTrueCameras(
cameras, cameraIDs
)) {
const config = cameras.get(cameraID) ?? null;
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,
},
),
);
}
}
}
}
// Sort the events by time (to align recordings for different cameras at the
// same time).
return children.sort(sortYoungestToOldest);
};
/**
* 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: sortOldestToYoungest,
});
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;
};
+7 -7
View File
@@ -49,9 +49,9 @@ export interface RecordingSegmentsItem {
* @param b The second item. * @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration). * @returns -1, 0, 1 (standard array sort function configuration).
*/ */
export const sortTimelineItemsYoungestToOldest = ( export const sortYoungestToOldest = (
a: FrigateCardTimelineItem, a: RecordingSegmentsItem | FrigateCardTimelineItem,
b: FrigateCardTimelineItem, b: RecordingSegmentsItem | FrigateCardTimelineItem,
): number => { ): number => {
if (a.start < b.start) { if (a.start < b.start) {
return 1; return 1;
@@ -68,9 +68,9 @@ export const sortTimelineItemsYoungestToOldest = (
* @param b The second item. * @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration). * @returns -1, 0, 1 (standard array sort function configuration).
*/ */
export const sortSegmentsOldestToYoungest = ( export const sortOldestToYoungest = (
a: RecordingSegmentsItem, a: RecordingSegmentsItem | FrigateCardTimelineItem,
b: RecordingSegmentsItem, b: RecordingSegmentsItem | FrigateCardTimelineItem,
): number => { ): number => {
if (a.start < b.start) { if (a.start < b.start) {
return -1; return -1;
@@ -438,7 +438,7 @@ export class TimelineDataManager {
this._cameras.forEach((_, cameraID) => { this._cameras.forEach((_, cameraID) => {
const segments = this._recordingSegments.get({ const segments = this._recordingSegments.get({
filter: (item) => item.cameraID === cameraID, filter: (item) => item.cameraID === cameraID,
order: sortSegmentsOldestToYoungest, order: sortOldestToYoungest,
}); });
let current: RecordingSegmentsItem | null = null; let current: RecordingSegmentsItem | null = null;
for (let i = 0; i < segments.length; ++i) { for (let i = 0; i < segments.length; ++i) {
+28 -4
View File
@@ -147,7 +147,21 @@ export class View {
* Determine if a view is a gallery. * Determine if a view is a gallery.
*/ */
public isGalleryView(): boolean { public isGalleryView(): boolean {
return this.view == 'clips' || this.view == 'snapshots'; return ['clips', 'snapshots', 'recordings'].includes(this.view);
}
/**
* Get the viewer view given a gallery view.
*/
public getViewerViewForGalleryView(): 'clip' | 'snapshot' | 'recording' | null {
if (this.is('clips')) {
return 'clip';
} else if (this.is('snapshots')) {
return 'snapshot';
} else if (this.is('recordings')) {
return 'recording';
}
return null;
} }
/** /**
@@ -180,14 +194,24 @@ export class View {
} }
/** /**
* Get the media type for this view if available. * Determine if a view is related to a recording or recordings.
* @returns Whether the media is `clips` or `snapshots` or unknown (`null`)
*/ */
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() return this.isClipRelatedView()
? 'clips' ? 'clips'
: this.isSnapshotRelatedView() : this.isSnapshotRelatedView()
? 'snapshots' ? 'snapshots'
: this.isRecordingRelatedView()
? 'recordings'
: null; : null;
} }