Merge pull request #858 from dermotduffy/recordings-button
Add view / menu button for recordings
This commit is contained in:
@@ -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
@@ -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>`
|
||||||
: ``}
|
: ``}
|
||||||
|
|||||||
+48
-28
@@ -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,31 +57,45 @@ 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) {
|
||||||
const browseMediaQueryParameters =
|
if (mediaType === 'recordings') {
|
||||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
changeViewToRecentRecordingForCameraAndDependents(
|
||||||
this,
|
this,
|
||||||
this.hass,
|
this.hass,
|
||||||
|
this.dataManager,
|
||||||
this.cameras,
|
this.cameras,
|
||||||
this.view.camera,
|
this.view,
|
||||||
mediaType,
|
{
|
||||||
|
targetView: 'recordings',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const browseMediaQueryParameters =
|
||||||
|
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
||||||
|
this,
|
||||||
|
this.hass,
|
||||||
|
this.cameras,
|
||||||
|
this.view.camera,
|
||||||
|
mediaType,
|
||||||
|
);
|
||||||
|
|
||||||
if (!browseMediaQueryParameters) {
|
if (!browseMediaQueryParameters) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLatestMediaAndDispatchViewChange(
|
||||||
|
this,
|
||||||
|
this.hass,
|
||||||
|
this.view,
|
||||||
|
browseMediaQueryParameters,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchLatestMediaAndDispatchViewChange(
|
|
||||||
this,
|
|
||||||
this.hass,
|
|
||||||
this.view,
|
|
||||||
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) {
|
||||||
this.view
|
const targetView = this.view.getViewerViewForGalleryView();
|
||||||
.evolve({
|
if (targetView) {
|
||||||
view: this.view.is('clips') ? 'clip' : 'snapshot',
|
this.view
|
||||||
childIndex: index,
|
.evolve({
|
||||||
})
|
view: targetView,
|
||||||
.dispatchChangeEvent(this);
|
childIndex: index,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-thumbnail>`
|
</frigate-card-thumbnail>`}
|
||||||
: ``}
|
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+72
-264
@@ -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)
|
||||||
) {
|
) {
|
||||||
|
stopEventFromActivatingCardWideActions(properties.event);
|
||||||
|
|
||||||
if (['background', 'group-label'].includes(properties.what)) {
|
if (['background', 'group-label'].includes(properties.what)) {
|
||||||
stopEventFromActivatingCardWideActions(properties.event);
|
|
||||||
const window = this._timeline?.getWindow();
|
const window = this._timeline?.getWindow();
|
||||||
if (window) {
|
if (window) {
|
||||||
if (properties.group) {
|
if (properties.group) {
|
||||||
this._changeViewToRecording(
|
changeViewToRecording(
|
||||||
properties.what === 'background' ? properties.time : window.end,
|
this,
|
||||||
String(properties.group),
|
this.hass,
|
||||||
|
this.timelineDataManager,
|
||||||
|
this.cameras,
|
||||||
|
this.view, {
|
||||||
|
cameraIDs: new Set([String(properties.group)]),
|
||||||
|
targetTime:
|
||||||
|
properties.what === 'background' ? properties.time : window.end,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} 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,7 +1099,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changedProperties.has('view')) {
|
if (changedProperties.has('view')) {
|
||||||
this._updateTimelineFromView();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +130,27 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchLatestMediaAndDispatchViewChange(
|
if (mediaType === 'recordings') {
|
||||||
this,
|
changeViewToRecentRecordingForCameraAndDependents(
|
||||||
this.hass,
|
this,
|
||||||
this.view,
|
this.hass,
|
||||||
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
|
this.timelineDataManager,
|
||||||
mediaType: mediaType,
|
this.cameras,
|
||||||
}),
|
this.view,
|
||||||
);
|
{
|
||||||
|
targetView: 'recording',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fetchLatestMediaAndDispatchViewChange(
|
||||||
|
this,
|
||||||
|
this.hass,
|
||||||
|
this.view,
|
||||||
|
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
|
||||||
|
mediaType: mediaType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
return renderProgressIndicator();
|
return renderProgressIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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')}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user