Merge pull request #619 from dermotduffy/recordings

Add recording support
This commit is contained in:
Dermot Duffy
2022-06-05 15:30:24 -07:00
committed by GitHub
39 changed files with 1322 additions and 398 deletions
+56 -24
View File
@@ -359,17 +359,17 @@ live:
| `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | | `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
| `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.| | `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.|
### Event Viewer Options ### Media Viewer Options
The `event_viewer` is used for viewing all `clip` and `snapshot` media, in a media carousel. The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel.
All configuration is under: All configuration is under:
```yaml ```yaml
event_viewer: media_viewer:
``` ```
See the [fully expanded event viewer configuration example](#config-expanded-event-viewer) for how these parameters are structured. See the [fully expanded Media viewer configuration example](#config-expanded-media-viewer) for how these parameters are structured.
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
@@ -377,18 +377,18 @@ See the [fully expanded event viewer configuration example](#config-expanded-eve
| `auto_pause` | `all` | :heavy_multiplication_x: | Whether to automatically pause events. `never` will never automatically pause, `unselected` will automatically pause when an event is unselected in the carousel, `hidden` will automatically pause when the browser/tab becomes hidden or `all` on any opportunity to automatically pause (i.e. either case).| | `auto_pause` | `all` | :heavy_multiplication_x: | Whether to automatically pause events. `never` will never automatically pause, `unselected` will automatically pause when an event is unselected in the carousel, `hidden` will automatically pause when the browser/tab becomes hidden or `all` on any opportunity to automatically pause (i.e. either case).|
| `auto_mute` | `all` | :heavy_multiplication_x: | Whether to automatically mute events. `never` will never automatically mute, `unselected` will automatically mute when an event is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `all` on any opportunity to automatically mute (i.e. either case).| | `auto_mute` | `all` | :heavy_multiplication_x: | Whether to automatically mute events. `never` will never automatically mute, `unselected` will automatically mute when an event is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `all` on any opportunity to automatically mute (i.e. either case).|
| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute events. `never` will never automatically unmute, `selected` will automatically unmute when an event is selected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case). Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.| | `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute events. `never` will never automatically unmute, `selected` will automatically unmute when an event is selected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case). Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.|
| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. | | `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the Media viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. |
| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the event viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. | | `draggable` | `true` | :heavy_multiplication_x: | Whether or not the Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. |
| `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. | | `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. |
| `controls` | | :heavy_multiplication_x: | Configuration for the event viewer controls. See below. | | `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. |
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| | `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.|
#### Event Viewer Controls: Next / Previous #### Media Viewer Controls: Next / Previous
All configuration is under: All configuration is under:
```yaml ```yaml
event_viewer: media_viewer:
controls: controls:
next_previous: next_previous:
``` ```
@@ -398,12 +398,12 @@ event_viewer:
| `style` | `thumbnails` | :heavy_multiplication_x: | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | | `style` | `thumbnails` | :heavy_multiplication_x: | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . |
| `size` | 48 | :heavy_multiplication_x: | The size of the next/previous controls in pixels. Must be >= `20`.| | `size` | 48 | :heavy_multiplication_x: | The size of the next/previous controls in pixels. Must be >= `20`.|
#### Event Viewer Controls: Thumbnails #### Media Viewer Controls: Thumbnails
All configuration is under: All configuration is under:
```yaml ```yaml
event_viewer: media_viewer:
controls: controls:
thumbnails: thumbnails:
``` ```
@@ -415,19 +415,19 @@ event_viewer:
| `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_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.| | `show_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.|
#### Event Viewer Controls: Title #### Media Viewer Controls: Title
All configuration is under: All configuration is under:
```yaml ```yaml
event_viewer: media_viewer:
controls: controls:
title: title:
``` ```
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
| `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the event viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | | `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the Media viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
| `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.| | `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.|
### Event Gallery Options ### Event Gallery Options
@@ -468,7 +468,11 @@ See the [fully expanded image configuration example](#config-expanded-image) for
### Timeline Options ### Timeline Options
The `timeline` is used to show the timing sequence of events across cameras. The `timeline` is used to show the timing sequence of events and recordings across cameras. You can interact with the timeline in a number of ways:
* Clicking on an event will take you to the media viewer for that event.
* Clicking on the "background", or a camera title, will take you to the recordings for that camera (seeking to the clicked time).
* Clicking on the time axis will take you to recordings for all cameras (seeking to the clicked time).
All configuration is under: All configuration is under:
@@ -483,6 +487,7 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin
| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | | `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| | `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.|
| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.| | `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).|
| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.| | `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
| `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.| | `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.|
@@ -790,9 +795,9 @@ This card supports several different views:
| ------------- | --------------------------------------------- | | ------------- | --------------------------------------------- |
|`live` (default)| Shows the live camera view with the configured live provider.| |`live` (default)| Shows the live camera view with the configured live provider.|
|`snapshots`|Shows an event gallery of snapshots for this camera/zone/label.| |`snapshots`|Shows an event gallery of snapshots for this camera/zone/label.|
|`snapshot`|Shows an event 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 an event 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.|
|`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
@@ -853,7 +858,7 @@ 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) |
| `event_viewer.actions` | `clip`, `snapshot` | | `media_viewer.actions` | `clip`, `snapshot` |
| `event_gallery.actions` | `clips`, `snapshots` | | `event_gallery.actions` | `clips`, `snapshots` |
| `live.actions` | `live` | | `live.actions` | `live` |
| `image.actions` | `image` | | `image.actions` | `image` |
@@ -951,6 +956,24 @@ View events in the timeline.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/timeline.gif" alt="Event Timeline" width="400px"> <img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/timeline.gif" alt="Event Timeline" width="400px">
### Single Camera Recordings
View recordings for a camera across time:
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/recording-seek.gif" alt="Recording for single camera" width="400px">
### Multiple Camera Recordings
View recordings for multiple cameras at a given time:
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/recording-seek-all-cameras.gif" alt="Recording for multiple cameras" width="400px">
### Dark Mode
Dim the card when not used.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/dark-mode.gif" alt="Card dark mode" width="400px">
<a name="screenshots-card-casting"></a> <a name="screenshots-card-casting"></a>
### Card Casting ### Card Casting
@@ -1185,15 +1208,15 @@ live:
``` ```
</details> </details>
<a name="config-expanded-event-viewer"></a> <a name="config-expanded-media-viewer"></a>
<details> <details>
<summary>Expand: Event Viewer section</summary> <summary>Expand: Media Viewer section</summary>
Reference: [Event Viewer Options](#event-viewer-options). Reference: [Media Viewer Options](#event-viewer-options).
```yaml ```yaml
event_viewer: media_viewer:
auto_play: all auto_play: all
auto_pause: all auto_pause: all
auto_mute: all auto_mute: all
@@ -1617,6 +1640,7 @@ Reference: [Timeline Options](#timeline-options).
timeline: timeline:
clustering_threshold: 3 clustering_threshold: 3
media: all media: all
show_recordings: true
window_seconds: 3600 window_seconds: 3600
controls: controls:
thumbnails: thumbnails:
@@ -2559,6 +2583,14 @@ You must be using a version of the [Frigate integration](https://github.com/blak
to use JSMPEG proxying. The `frigate-jsmpeg` live provider will not work with earlier to use JSMPEG proxying. The `frigate-jsmpeg` live provider will not work with earlier
integration versions. integration versions.
### Timeline shows error message
If the timeline shows a message such as `Failed to receive response from Home
Assistant for request.` make sure you are running a version of the [Frigate
integration](https://github.com/blakeblackshear/frigate-hass-integration) >=
3.0.0.
### Fullscreen Button Does Not Appear On iPhone ### Fullscreen Button Does Not Appear On iPhone
Unfortunately, [iOS does not support the Javascript fullscreen Unfortunately, [iOS does not support the Javascript fullscreen
@@ -2602,7 +2634,7 @@ Dragging the Safari video controls "progress bar" conflicts with carousel "dragg
```yaml ```yaml
live: live:
draggable: false draggable: false
event_viewer: media_viewer:
draggable: false draggable: false
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+11 -11
View File
@@ -5,7 +5,7 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
@@ -20,7 +20,7 @@ import {
ConditionState, ConditionState,
conditionStateRequestHandler, conditionStateRequestHandler,
getOverriddenConfig, getOverriddenConfig,
getOverridesByKey, getOverridesByKey
} from './card-condition.js'; } from './card-condition.js';
import './components/elements.js'; import './components/elements.js';
import { FrigateCardElements } from './components/elements.js'; import { FrigateCardElements } from './components/elements.js';
@@ -40,7 +40,7 @@ import {
CAMERA_BIRDSEYE, CAMERA_BIRDSEYE,
CARD_VERSION, CARD_VERSION,
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
REPO_URL, REPO_URL
} from './const.js'; } from './const.js';
import './editor.js'; import './editor.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
@@ -63,14 +63,14 @@ import {
MediaShowInfo, MediaShowInfo,
MenuButton, MenuButton,
Message, Message,
RawFrigateCardConfig, RawFrigateCardConfig
} from './types.js'; } from './types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction, createFrigateCardCustomAction,
frigateCardHandleAction, frigateCardHandleAction,
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction, getActionConfigGivenAction
} from './utils/action.js'; } from './utils/action.js';
import { contentsChanged } from './utils/basic.js'; import { contentsChanged } from './utils/basic.js';
import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js'; import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js';
@@ -81,14 +81,14 @@ import {
homeAssistantSignPath, homeAssistantSignPath,
isHassDifferent, isHassDifferent,
isTriggeredState, isTriggeredState,
sideLoadHomeAssistantElements, sideLoadHomeAssistantElements
} from './utils/ha'; } from './utils/ha';
import { getEventID } from './utils/ha/browse-media.js'; import { getEventID } from './utils/ha/browse-media.js';
import { import {
ExtendedEntityCache, ExtendedEntityCache,
getAllEntities, getAllEntities,
getExtendedEntities, getExtendedEntities,
getExtendedEntity, getExtendedEntity
} from './utils/ha/entity-registry.js'; } from './utils/ha/entity-registry.js';
import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
import { supportsFeature } from './utils/ha/update.js'; import { supportsFeature } from './utils/ha/update.js';
@@ -975,7 +975,7 @@ export class FrigateCard extends LitElement {
* @returns A boolean indicating whether the camera was changed. * @returns A boolean indicating whether the camera was changed.
*/ */
protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean { protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean {
if (!this._view) { if (!this._view || !this._isAutomatedViewUpdateAllowed(true)) {
return false; return false;
} }
@@ -1001,7 +1001,7 @@ export class FrigateCard extends LitElement {
} }
} }
if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { if (triggerChanges) {
if (!this._triggers.size) { if (!this._triggers.size) {
this._changeView(); this._changeView();
changedCamera = true; changedCamera = true;
@@ -1545,7 +1545,7 @@ export class FrigateCard extends LitElement {
} else if (this._view?.isGalleryView()) { } else if (this._view?.isGalleryView()) {
specificActions = this._getConfig().event_gallery?.actions; specificActions = this._getConfig().event_gallery?.actions;
} else if (this._view?.isViewerView()) { } else if (this._view?.isViewerView()) {
specificActions = this._getConfig().event_viewer.actions; specificActions = this._getConfig().media_viewer.actions;
} else if (this._view?.is('image')) { } else if (this._view?.is('image')) {
specificActions = this._getConfig().image?.actions; specificActions = this._getConfig().image?.actions;
} else if (this._view?.is('timeline')) { } else if (this._view?.is('timeline')) {
@@ -1678,7 +1678,7 @@ export class FrigateCard extends LitElement {
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameras=${this._cameras} .cameras=${this._cameras}
.viewerConfig=${this._getConfig().event_viewer} .viewerConfig=${this._getConfig().media_viewer}
.resolvedMediaCache=${this._resolvedMediaCache} .resolvedMediaCache=${this._resolvedMediaCache}
> >
</frigate-card-viewer>` </frigate-card-viewer>`
+4 -3
View File
@@ -12,6 +12,7 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss'; import elementsStyle from '../scss/elements.scss';
import { import {
FrigateCardError,
FrigateConditional, FrigateConditional,
MenuButton, MenuButton,
MenuIcon, MenuIcon,
@@ -21,7 +22,7 @@ import {
PictureElements PictureElements
} from '../types.js'; } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { dispatchErrorMessageEvent } from './message.js'; import { dispatchFrigateCardErrorEvent } from './message.js';
/* A note on picture element rendering: /* A note on picture element rendering:
* *
@@ -108,7 +109,7 @@ export class FrigateCardElementsCore extends LitElement {
element.setConfig(config); element.setConfig(config);
} catch (e) { } catch (e) {
console.error(e, (e as Error).stack); console.error(e, (e as Error).stack);
throw new Error(localize('error.invalid_elements_config')); throw new FrigateCardError(localize('error.invalid_elements_config'));
} }
return element; return element;
} }
@@ -125,7 +126,7 @@ export class FrigateCardElementsCore extends LitElement {
this._root = this._createRoot(); this._root = this._createRoot();
} }
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message); return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
} }
} }
+25 -12
View File
@@ -11,7 +11,12 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import galleryStyle from '../scss/gallery.scss';
import { CameraConfig, frigateCardConfigDefaults, GalleryConfig } from '../types.js'; import {
CameraConfig,
frigateCardConfigDefaults,
GalleryConfig,
THUMBNAIL_WIDTH_MAX
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { import {
fetchChildMediaAndDispatchViewChange, fetchChildMediaAndDispatchViewChange,
@@ -135,20 +140,28 @@ export class FrigateCardGalleryCore extends LitElement {
} }
/** /**
* Handle gallery resize. * Set gallery columns.
*/ */
protected _resizeHandler(): void { protected _setColumnCount(): void {
const thumbnailSize = const thumbnailSize =
this.galleryConfig?.controls.thumbnails.size ?? this.galleryConfig?.controls.thumbnails.size ??
frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; frigateCardConfigDefaults.event_gallery.controls.thumbnails.size;
this.style.setProperty( const columns = this.galleryConfig?.controls.thumbnails.show_details
'--frigate-card-gallery-columns', ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN))
String( : Math.max(
!this.galleryConfig?.controls.thumbnails.show_details 1,
? Math.round(this.clientWidth / thumbnailSize) Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX),
: Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)), Math.ceil(this.clientWidth / thumbnailSize),
), );
);
this.style.setProperty('--frigate-card-gallery-columns', String(columns));
}
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
this._setColumnCount();
} }
/** /**
@@ -174,6 +187,7 @@ export class FrigateCardGalleryCore extends LitElement {
} else { } else {
this.removeAttribute('details'); this.removeAttribute('details');
} }
this._setColumnCount();
if (this.galleryConfig?.controls.thumbnails.size) { if (this.galleryConfig?.controls.thumbnails.size) {
this.style.setProperty( this.style.setProperty(
'--frigate-card-thumbnail-size', '--frigate-card-thumbnail-size',
@@ -230,7 +244,6 @@ export class FrigateCardGalleryCore extends LitElement {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
outlined="" outlined=""
class="foo"
> >
<div>${child.title}</div> <div>${child.title}</div>
</ha-card> </ha-card>
+34 -1
View File
@@ -612,6 +612,13 @@ export class FrigateCardLiveProvider extends LitElement {
this._providerRef.value?.unmute(); this._providerRef.value?.unmute();
} }
/**
* Seek the video.
*/
public seek(seconds: number): void {
this._providerRef.value?.seek(seconds);
}
protected _getResolvedProvider(): LiveProvider { protected _getResolvedProvider(): LiveProvider {
if (this.cameraConfig?.live_provider === 'auto') { if (this.cameraConfig?.live_provider === 'auto') {
if ( if (
@@ -711,6 +718,13 @@ export class FrigateCardLiveFrigate extends LitElement {
this._playerRef.value?.unmute(); this._playerRef.value?.unmute();
} }
/**
* Seek the video.
*/
public seek(seconds: number): void {
this._playerRef.value?.seek(seconds);
}
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
@@ -810,6 +824,16 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
} }
} }
/**
* Seek the video.
*/
public seek(seconds: number): void {
const player = this._getPlayer();
if (player) {
player.currentTime = seconds;
}
}
/** /**
* Get the underlying video player. * Get the underlying video player.
* @returns The player or `null` if not found. * @returns The player or `null` if not found.
@@ -867,8 +891,9 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
return dispatchErrorMessageEvent( return dispatchErrorMessageEvent(
this, this,
e instanceof FrigateCardError e instanceof FrigateCardError
? (e as FrigateCardError).message ? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
(e as FrigateCardError).context
); );
} }
return html`${webrtcElement}`; return html`${webrtcElement}`;
@@ -964,6 +989,14 @@ export class FrigateCardLiveJSMPEG extends LitElement {
} }
} }
/**
* Seek the video (unsupported).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public seek(_seconds: number): void {
// JSMPEG does not support seeking.
}
/** /**
* Get a signed player URL. * Get a signed player URL.
* @returns A URL or null. * @returns A URL or null.
+17 -1
View File
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
import { TROUBLESHOOTING_URL } from '../const.js'; import { TROUBLESHOOTING_URL } from '../const.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import messageStyle from '../scss/message.scss'; import messageStyle from '../scss/message.scss';
import { Message } from '../types.js'; import { FrigateCardError, Message } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
@customElement('frigate-card-message') @customElement('frigate-card-message')
@@ -140,3 +140,19 @@ export function dispatchErrorMessageEvent(
context: context, context: context,
}); });
} }
/**
* Dispatch an event with an error message to show to the user.
* @param element The element to send the event.
* @param message The message to show.
*/
export function dispatchFrigateCardErrorEvent(
element: HTMLElement,
error: FrigateCardError
): void {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: error.message,
type: 'error',
context: error.context || ''
});
}
+8 -4
View File
@@ -12,6 +12,7 @@ import surroundThumbnailsStyle from '../scss/surround.scss';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
FrigateCardError,
FrigateCardView, FrigateCardView,
ThumbnailsControlConfig ThumbnailsControlConfig
} from '../types.js'; } from '../types.js';
@@ -21,7 +22,7 @@ import {
multipleBrowseMediaQueryMerged multipleBrowseMediaQueryMerged
} from '../utils/ha/browse-media'; } from '../utils/ha/browse-media';
import { View } from '../view.js'; import { View } from '../view.js';
import { dispatchErrorMessageEvent } from './message.js'; import { dispatchFrigateCardErrorEvent } from './message.js';
import './surround.js'; import './surround.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
@@ -69,7 +70,7 @@ export class FrigateCardSurround extends LitElement {
try { try {
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams); parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message); return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
} }
if (getFirstTrueMediaChildIndex(parent) !== null) { if (getFirstTrueMediaChildIndex(parent) !== null) {
this.view this.view
@@ -144,13 +145,16 @@ export class FrigateCardSurround extends LitElement {
.selected=${this.view.childIndex} .selected=${this.view.childIndex}
@frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
// Send the view change from the source of the tap event, so the
// view change will be caught by the handler above (to close the drawer).
this.view this.view
?.evolve({ ?.evolve({
view: this.targetView || 'event', view: this.targetView || 'media',
target: ev.detail.target, target: ev.detail.target,
childIndex: ev.detail.childIndex, childIndex: ev.detail.childIndex,
context: null,
}) })
.dispatchChangeEvent(this); .dispatchChangeEvent(ev.composedPath()[0]);
}} }}
> >
</frigate-card-thumbnail-carousel>` </frigate-card-thumbnail-carousel>`
+15 -2
View File
@@ -2,7 +2,6 @@ import { EmblaOptionsType } from 'embla-carousel';
import { CSSResultGroup, html, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, PropertyValues, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
@@ -120,6 +119,21 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
return slides; return slides;
} }
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('_config')) {
if (this._config?.size) {
this.style.setProperty(
'--frigate-card-thumbnail-size',
`${this._config.size}px`,
);
}
}
}
/** /**
* The updated lifecycle callback for this element. * The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render. * @param changedProperties The properties that were changed in this render.
@@ -170,7 +184,6 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
.childIndex=${childIndex} .childIndex=${childIndex}
?details=${this._config?.show_details} ?details=${this._config?.show_details}
?controls=${this._config?.show_controls} ?controls=${this._config?.show_controls}
thumbnail_size=${ifDefined(this._config?.size)}
class="${classMap(classes)}" class="${classMap(classes)}"
@click=${(ev) => { @click=${(ev) => {
if (this._carousel && this._carousel.clickAllowed()) { if (this._carousel && this._carousel.clickAllowed()) {
+133 -36
View File
@@ -3,8 +3,14 @@ import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss'; import thumbnailStyle from '../scss/thumbnail.scss';
import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js'; import type {
FrigateBrowseMediaSource,
FrigateEvent,
FrigateRecording
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { prettifyTitle } from '../utils/basic.js'; import { prettifyTitle } from '../utils/basic.js';
import { getEventDurationString } from '../utils/ha/browse-media.js'; import { getEventDurationString } from '../utils/ha/browse-media.js';
@@ -13,8 +19,49 @@ import { View } from '../view.js';
// The minimum width of a thumbnail with details enabled. // The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@customElement('frigate-card-thumbnail-details') @customElement('frigate-card-thumbnail-feature-event')
export class FrigateCardThumbnailDetails extends LitElement { export class FrigateCardThumbnailFeatureEvent extends LitElement {
@property({ attribute: false })
public thumbnail?: string;
protected render(): TemplateResult | void {
return html`
${this.thumbnail
? html`<img src="${this.thumbnail}" />`
: html`<ha-icon
icon="mdi:image-off"
title=${localize('thumbnail.no_thumbnail')}
></ha-icon> `}
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureEventStyle);
}
}
@customElement('frigate-card-thumbnail-feature-recording')
export class FrigateCardThumbnailFeatureRecording extends LitElement {
@property({ attribute: false })
public date?: Date;
protected render(): TemplateResult | void {
if (!this.date) {
return;
}
return html`
<div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div>
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureRecordingStyle);
}
}
@customElement('frigate-card-thumbnail-details-event')
export class FrigateCardThumbnailDetailsEvent extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public event?: FrigateEvent; public event?: FrigateEvent;
@@ -39,9 +86,35 @@ export class FrigateCardThumbnailDetails extends LitElement {
</div>`; </div>`;
} }
/** static get styles(): CSSResult {
* Get element styles. return unsafeCSS(thumbnailDetailsStyle);
*/ }
}
@customElement('frigate-card-thumbnail-details-recording')
export class FrigateCardThumbnailDetailsRecording extends LitElement {
@property({ attribute: false })
public recording?: FrigateRecording;
protected render(): TemplateResult | void {
if (!this.recording) {
return;
}
return html`<div class="left">
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
${this.recording.seek_time
? html` <div>
<span class="heading">${localize('recording.seek')}</span>
<span>${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')}</span>
</div>`
: html``}
</div>
<div class="right">
<span class="larger">${this.recording.events}</span>
<span>${localize('recording.events')}</span>
</div>`;
}
static get styles(): CSSResult { static get styles(): CSSResult {
return unsafeCSS(thumbnailDetailsStyle); return unsafeCSS(thumbnailDetailsStyle);
} }
@@ -55,11 +128,6 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public controls = false; public controls = false;
@property({ attribute: true, type: Number })
set thumbnail_size(size: number) {
this.style.setProperty('--frigate-card-thumbnail-size', `${size}px`);
}
// ============================ // ============================
// Data-binding based interface // Data-binding based interface
// ============================ // ============================
@@ -90,6 +158,7 @@ export class FrigateCardThumbnail extends LitElement {
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
let event: FrigateEvent | null = null; let event: FrigateEvent | null = null;
let recording: FrigateRecording | null = null;
let thumbnail: string | null = null; let thumbnail: string | null = null;
let label: string | null = null; let label: string | null = null;
@@ -97,6 +166,7 @@ export class FrigateCardThumbnail extends LitElement {
if (this.target && this.target.children && this.childIndex !== undefined) { if (this.target && this.target.children && this.childIndex !== undefined) {
const media = this.target.children[this.childIndex]; const media = this.target.children[this.childIndex];
event = media.frigate?.event ?? null; event = media.frigate?.event ?? null;
recording = media.frigate?.recording ?? null;
thumbnail = media.thumbnail; thumbnail = media.thumbnail;
label = media.title; label = media.title;
} }
@@ -108,34 +178,46 @@ export class FrigateCardThumbnail extends LitElement {
thumbnail = this.thumbnail ? this.thumbnail : thumbnail; thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
label = this.label ? this.label : label; label = this.label ? this.label : label;
if (!thumbnail) { if (!event && !recording) {
return; return;
} }
return html` <img return html` ${event
aria-label="${label ?? ''}" ? html`<frigate-card-thumbnail-feature-event
src="${thumbnail}" aria-label="${label ?? ''}"
title="${label ?? ''}" title="${label ?? ''}"
/> .thumbnail=${thumbnail ?? undefined}
${this.controls && event?.retain_indefinitely .label=${label ?? undefined}
? html` <ha-icon ></frigate-card-thumbnail-feature-event>`
: html`<frigate-card-thumbnail-feature-recording
aria-label="${label ?? ''}"
title="${label ?? ''}"
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
></frigate-card-thumbnail-feature-recording>`}
${this.controls && event?.retain_indefinitely
? html` <ha-icon
class="favorite" class="favorite"
icon="mdi:star" icon="mdi:star"
title=${localize('thumbnail.retain_indefinitely')} title=${localize('thumbnail.retain_indefinitely')}
/>` /></ha-icon>`
: ``} : ``}
${this.details && event ${this.details && event
? html`<frigate-card-thumbnail-details ? html`<frigate-card-thumbnail-details-event
.event=${event} .event=${event ?? undefined}
></frigate-card-thumbnail-details>` ></frigate-card-thumbnail-details-event>`
: html``} : this.details && recording
${this.controls ? html`<frigate-card-thumbnail-details-recording
? html`<ha-icon .recording=${recording ?? undefined}
class="timeline" ></frigate-card-thumbnail-details-recording>`
icon="mdi:target" : html``}
title=${localize('thumbnail.timeline')} ${this.controls
@click=${(ev: Event) => { ? html`<ha-icon
stopEventFromActivatingCardWideActions(ev); class="timeline"
icon="mdi:target"
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (event) {
this.view this.view
?.evolve({ ?.evolve({
view: 'timeline', view: 'timeline',
@@ -144,9 +226,24 @@ export class FrigateCardThumbnail extends LitElement {
context: {}, context: {},
}) })
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
}} } else if (recording) {
></ha-icon>` this.view
: ''}`; ?.evolve({
view: 'timeline',
target: null,
childIndex: null,
context: {
window: {
start: fromUnixTime(recording.start_time),
end: fromUnixTime(recording.end_time),
},
},
})
.dispatchChangeEvent(this);
}
}}
></ha-icon>`
: ''}`;
} }
/** /**
+385 -82
View File
@@ -1,5 +1,13 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { add, fromUnixTime, sub } from 'date-fns'; import {
add,
endOfHour,
format,
fromUnixTime,
getUnixTime,
startOfHour,
sub
} from 'date-fns';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
@@ -29,32 +37,44 @@ import timelineStyle from '../scss/timeline.scss';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
CameraConfig, CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
frigateCardConfigDefaults, frigateCardConfigDefaults,
FrigateCardError,
FrigateEvent, FrigateEvent,
TimelineConfig TimelineConfig
} from '../types'; } from '../types';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent, prettifyTitle } from '../utils/basic';
import { getCameraTitle } from '../utils/camera.js'; import { getCameraTitle } from '../utils/camera.js';
import {
getRecordingSegments,
getRecordingsSummary,
RecordingSegments,
RecordingSummary
} from '../utils/frigate';
import { import {
createEventParentForChildren, createEventParentForChildren,
createVideoChild,
generateRecordingIdentifier,
getBrowseMediaQueryParameters, getBrowseMediaQueryParameters,
isTrueMedia, isTrueMedia,
multipleBrowseMediaQuery multipleBrowseMediaQuery
} from '../utils/ha/browse-media'; } from '../utils/ha/browse-media';
import { View, ViewContext } from '../view'; import { View, ViewContext } from '../view';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js'; import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js';
import './surround-thumbnails.js'; import './surround-thumbnails.js';
const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10; const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
interface FrigateCardGroupData { interface FrigateCardGroupData {
id: string; id: string;
content: string; content: string;
} }
interface FrigateCardTimelineItem extends TimelineItem { interface FrigateCardTimelineItem extends TimelineItem {
event: FrigateEvent; start: number;
end?: number;
event?: FrigateEvent;
source?: FrigateBrowseMediaSource; source?: FrigateBrowseMediaSource;
} }
@@ -68,12 +88,17 @@ interface TimelineViewContext extends ViewContext {
type TimelineMediaType = 'all' | 'clips' | 'snapshots'; type TimelineMediaType = 'all' | 'clips' | 'snapshots';
interface CameraRecordings {
segments: RecordingSegments;
summary: RecordingSummary;
}
const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)'); const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)');
/** /**
* A manager to maintain/fetch timeline events. * A manager to maintain/fetch timeline events.
*/ */
class TimelineEventManager { class TimelineDataManager {
protected _dataset = new DataSet<FrigateCardTimelineItem>(); protected _dataset = new DataSet<FrigateCardTimelineItem>();
// The earliest date managed. // The earliest date managed.
@@ -87,7 +112,7 @@ class TimelineEventManager {
// The maximum allowable age of fetch data (will not fetch more frequently // The maximum allowable age of fetch data (will not fetch more frequently
// than this). // than this).
protected _maxAgeSeconds: number = TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS; protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
protected _contentCallback?: (source: FrigateBrowseMediaSource) => string; protected _contentCallback?: (source: FrigateBrowseMediaSource) => string;
protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string; protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string;
@@ -183,9 +208,7 @@ class TimelineEventManager {
* @param end An optional end of the date range. * @param end An optional end of the date range.
* @returns * @returns
*/ */
public hasCoverage(start: Date, end?: Date): boolean { public hasCoverage(now: Date, start: Date, end?: Date): boolean {
const now = new Date().getTime();
// Never fetched: no coverage. // Never fetched: no coverage.
if (!this._dateFetch || !this._dateStart || !this._dateEnd) { if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
return false; return false;
@@ -194,7 +217,7 @@ class TimelineEventManager {
// If the most recent fetch is older than maxAgeSeconds: no coverage. // If the most recent fetch is older than maxAgeSeconds: no coverage.
if ( if (
this._maxAgeSeconds && this._maxAgeSeconds &&
now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
) { ) {
return false; return false;
} }
@@ -219,7 +242,7 @@ class TimelineEventManager {
return false; return false;
} }
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
if (now - end.getTime() > this._maxAgeSeconds * 1000) { if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
return false; return false;
} }
@@ -237,21 +260,109 @@ class TimelineEventManager {
* @param end Fetch events that start earlier than this date. * @param end Fetch events that start earlier than this date.
* @returns `true` if events were fetched, `false` otherwise. * @returns `true` if events were fetched, `false` otherwise.
*/ */
public async fetchEventsIfNecessary( public async fetchIfNecessary(
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
media: TimelineMediaType, eventMedia: TimelineMediaType,
start: Date, start: Date,
end: Date, end: Date,
recordings?: boolean,
): Promise<boolean> { ): Promise<boolean> {
if (this.hasCoverage(start, end)) { // Cannot fetch the future, always clip the end date to now so as to avoid
// checking for coverage that could not possibly exist yet.
const now = new Date();
end = end > now ? now : end;
if (this.hasCoverage(now, start, end)) {
return false; return false;
} }
await this._fetchEvents(element, hass, cameras, media, start, end);
if (!this._dateStart || start < this._dateStart) {
this._dateStart = start;
}
if (!this._dateEnd || end > this._dateEnd) {
this._dateEnd = end;
}
this._dateFetch = new Date();
await Promise.all([
// Events are always fetched for the maximum extent of the managed
// range. This is because events may change at any point in time
// (e.g. a long-running event that ends).
this._fetchEvents(
element,
hass,
cameras,
eventMedia,
this._dateStart,
this._dateEnd,
),
...(recordings ? [this._fetchRecordings(element, hass, cameras)] : []),
]);
return true; return true;
} }
/**
* Fetch recording hours for the timeline.
* @param element The element to send error events from.
* @param hass The HomeAssistant object.
* @param cameras The cameras map.
* @param start Fetch events that start later than this date.
* @param end Fetch events that start earlier than this date.
*/
protected async _fetchRecordings(
element: HTMLElement,
hass: ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>,
): Promise<void> {
const items: FrigateCardTimelineItem[] = [];
const now = new Date();
const storeRecordings = async (
camera: string,
config: CameraConfig,
): Promise<void> => {
if (!config.camera_name) {
return;
}
let summary: RecordingSummary;
try {
summary = await getRecordingsSummary(hass, config.client_id, config.camera_name);
} catch (e) {
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
for (const dayData of summary) {
for (const hourData of dayData.hours) {
const hour = add(dayData.day, { hours: hourData.hour });
const endHour = endOfHour(hour);
items.push({
id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`,
group: camera,
start: getUnixTime(startOfHour(hour)) * 1000,
// Don't let the recordings show off into the future (even though it
// is intended to be indicative of any recordings within that hour
// -- it still looks strange!)
end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000,
type: 'background',
content: '',
});
}
}
};
await Promise.all(
Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) =>
storeRecordings(camera, config),
),
);
this._dataset.update(items);
}
/** /**
* Fetch events for the timeline. * Fetch events for the timeline.
* @param element The element to send error events from. * @param element The element to send error events from.
@@ -265,34 +376,16 @@ class TimelineEventManager {
hass: HomeAssistant, hass: HomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
media: TimelineMediaType, media: TimelineMediaType,
start?: Date, start: Date,
end?: Date, end: Date,
): Promise<void> { ): Promise<void> {
if (!this._dateStart || (start && start < this._dateStart)) {
this._dateStart = start;
}
if (!this._dateEnd || (end && end > this._dateEnd)) {
this._dateEnd = end;
}
if (!this._dateStart || !this._dateEnd) {
return;
}
this._dateFetch = new Date();
const params: BrowseMediaQueryParameters[] = []; const params: BrowseMediaQueryParameters[] = [];
cameras.forEach((cameraConfig, cameraID) => { cameras.forEach((cameraConfig, cameraID) => {
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
if ( if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) {
this._dateEnd &&
this._dateStart &&
cameraConfig.camera_name !== CAMERA_BIRDSEYE
) {
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, { const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
// Events are always fetched for the maximum extent of the managed before: end.getTime() / 1000,
// range. This is because events may change at any point in time after: start.getTime() / 1000,
// (e.g. a long-running event that ends).
before: this._dateEnd.getTime() / 1000,
after: this._dateStart.getTime() / 1000,
unlimited: true, unlimited: true,
mediaType: mediaType as 'clips' | 'snapshots', mediaType: mediaType as 'clips' | 'snapshots',
}); });
@@ -311,7 +404,7 @@ class TimelineEventManager {
try { try {
results = await multipleBrowseMediaQuery(hass, params); results = await multipleBrowseMediaQuery(hass, params);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message); return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
} }
for (const [query, result] of results.entries()) { for (const [query, result] of results.entries()) {
@@ -325,7 +418,7 @@ class TimelineEventManager {
@customElement('frigate-card-timeline') @customElement('frigate-card-timeline')
export class FrigateCardTimeline extends LitElement { export class FrigateCardTimeline extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected hass?: HomeAssistant; protected hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected view?: Readonly<View>; protected view?: Readonly<View>;
@@ -371,7 +464,7 @@ export class FrigateCardTimeline extends LitElement {
@customElement('frigate-card-timeline-core') @customElement('frigate-card-timeline-core')
export class FrigateCardTimelineCore extends LitElement { export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected hass?: HomeAssistant; protected hass?: ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
protected view?: Readonly<View>; protected view?: Readonly<View>;
@@ -382,12 +475,17 @@ export class FrigateCardTimelineCore extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
protected timelineConfig?: TimelineConfig; protected timelineConfig?: TimelineConfig;
protected _events = new TimelineEventManager({ protected _data = new TimelineDataManager({
tooltipCallback: this._getTooltip.bind(this), tooltipCallback: this._getTooltip.bind(this),
}); });
protected _refTimeline: Ref<HTMLElement> = createRef(); protected _refTimeline: Ref<HTMLElement> = createRef();
protected _timeline?: Timeline; protected _timeline?: Timeline;
// Need a way to separate when a user clicks (to pan the timeline) vs when a
// user clicks (to choose a recording (non-event) to play). On pan,
// _wasDragged will be set to true, and the click subsequently ignored.
protected _wasDragged = false;
/** /**
* Get a tooltip for a given timeline event. * Get a tooltip for a given timeline event.
* @param source The FrigateBrowseMediaSource in question. * @param source The FrigateBrowseMediaSource in question.
@@ -400,9 +498,6 @@ export class FrigateCardTimelineCore extends LitElement {
return ''; return '';
} }
const thumbnailSizeAttr = this.timelineConfig
? `thumbnail_size="${this.timelineConfig.controls.thumbnails.size}"`
: '';
const eventAttr = source.frigate?.event const eventAttr = source.frigate?.event
? `event='${JSON.stringify(source.frigate.event)}'` ? `event='${JSON.stringify(source.frigate.event)}'`
: ''; : '';
@@ -419,7 +514,6 @@ export class FrigateCardTimelineCore extends LitElement {
thumbnail="${source.thumbnail}" thumbnail="${source.thumbnail}"
label="${source.title}" label="${source.title}"
${eventAttr} ${eventAttr}
${thumbnailSizeAttr}
> >
</frigate-card-thumbnail>`; </frigate-card-thumbnail>`;
} }
@@ -446,12 +540,211 @@ export class FrigateCardTimelineCore extends LitElement {
></div>`; ></div>`;
} }
/**
* Get the number of seconds to seek into a video stream consisting of the
* provided segments to reach the target time provided.
* @param time Target time.
* @param segments A RecordingSegments object.
* @returns
*/
protected _getSeekTime(time: Date, segments: RecordingSegments): number | null {
if (!segments.length) {
return null;
}
const target = getUnixTime(time);
const hourStart = getUnixTime(startOfHour(time));
let seekSeconds = 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_time > target) {
break;
}
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
const end = segment.end_time > target ? target : segment.end_time;
seekSeconds += end - start;
}
return seekSeconds;
}
/**
* Create recording objects.
* @param results A map of camera ID to a CameraRecordings object.
* @param time The target time for the recordings.
* @param onlyMatchingHour If `true` only shows the hour matching the target
* for the provided cameras, otherwise shows all hours.
* @returns
*/
protected _createRecordingChildren(
results: Map<string, CameraRecordings>,
time: Date,
onlyMatchingHour: boolean,
): FrigateBrowseMediaSource[] {
const children: FrigateBrowseMediaSource[] = [];
const processedCameras: Set<string> = new Set();
// Get results in the order the cameras are specified in the configuration.
for (const camera of (this.cameras?.keys() || [])) {
const recording = results.get(camera);
const config = this.cameras?.get(camera);
if (!recording || !config?.camera_name) {
continue;
}
// There is a single set of recordings for a given Frigate camera name.
// Zones on that same camera do not get separate recordings. The card may
// have multiple instances of the same camera for different zoness, so
// need to enforce uniqueness here.
const uniqueID = `${config.client_id}/${config.camera_name}`;
if (processedCameras.has(uniqueID)) {
continue;
}
processedCameras.add(uniqueID);
const seekSeconds = this._getSeekTime(time, recording.segments);
if (seekSeconds === null) {
continue;
}
for (const dayData of recording.summary) {
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 (!onlyMatchingHour || isMatchingHour) {
children.push(
createVideoChild(
`${prettifyTitle(config.camera_name)} ${format(
hour,
'yyyy-MM-dd HH:mm',
)}`,
generateRecordingIdentifier({
clientId: config.client_id,
year: dayData.day.getFullYear(),
month: dayData.day.getMonth() + 1,
day: dayData.day.getDate(),
hour: hourData.hour,
cameraName: config.camera_name,
}),
{
recording: {
camera: config.camera_name,
start_time: getUnixTime(startHour),
end_time: getUnixTime(endHour),
events: hourData.events,
...(isMatchingHour && {
seek_seconds: seekSeconds,
seek_time: time.getTime() / 1000,
}),
},
},
),
);
}
}
}
}
return children;
}
/**
* Change the view to a recording.
* @param time The time of the recording to show.
* @param camera An optional camera to show a recording of, otherwise all
* cameras are shown at the given time.
*/
protected async _changeViewToRecording(time: Date, camera?: string): Promise<void> {
if (!this.hass) {
return;
}
const before = endOfHour(time);
const after = startOfHour(time);
const results: Map<string, CameraRecordings> = new Map();
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
if (!config || !config.camera_name || !this.hass) {
return;
}
try {
const cameraResults = await Promise.all([
getRecordingSegments(
this.hass,
config.client_id,
config.camera_name,
before,
after,
),
getRecordingsSummary(this.hass, config.client_id, config.camera_name),
]);
results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] });
} catch (e) {}
};
const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])];
await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera))));
const children = this._createRecordingChildren(results, time, !camera);
if (!children.length) {
return;
}
let childIndex = 0;
if (camera) {
childIndex = children.findIndex(
(child) =>
child.frigate?.recording &&
child.frigate.recording.start_time * 1000 === after.getTime(),
);
if (childIndex < 0) {
return;
}
}
this.view
?.evolve({
view: 'media',
target: createEventParentForChildren(localize('common.recordings'), children),
childIndex: childIndex,
})
.dispatchChangeEvent(this);
}
/**
* Called whenever the range is in the process of being changed.
* @param properties
*/
protected _timelineRangeChangeHandler(
properties: TimelineEventPropertiesResult,
): void {
if (properties.event) {
// When a human changes the range, an event will be set.
this._wasDragged = true;
}
}
/**
* Called whenever the timeline is clicked.
* @param properties The properties of the timeline click event.
*/
protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void {
if (properties.what === 'item') { if (properties.what && ['item', 'background'].includes(properties.what)) {
// Prevent interaction with items on the timeline from activating card // Prevent interaction with items on the timeline from activating card
// wide actions. // wide actions.
stopEventFromActivatingCardWideActions(properties.event); stopEventFromActivatingCardWideActions(properties.event);
} }
if (!this._wasDragged && properties.what) {
if (['background', 'group-label'].includes(properties.what)) {
this._changeViewToRecording(properties.time, String(properties.group));
} else if (properties.what === 'axis') {
this._changeViewToRecording(properties.time);
}
}
this._wasDragged = false;
} }
/** /**
@@ -468,14 +761,15 @@ export class FrigateCardTimelineCore extends LitElement {
return; return;
} }
if (this.hass && this.cameras && this._timeline && this.timelineConfig) { if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
this._events this._data
.fetchEventsIfNecessary( .fetchIfNecessary(
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
this.timelineConfig.media, this.timelineConfig.media,
properties.start, properties.start,
properties.end, properties.end,
this.timelineConfig.show_recordings,
) )
.then(() => { .then(() => {
if (this._timeline) { if (this._timeline) {
@@ -507,7 +801,7 @@ export class FrigateCardTimelineCore extends LitElement {
const childIndex = data.items.length const childIndex = data.items.length
? this.view.target.children.findIndex( ? this.view.target.children.findIndex(
(child) => child.frigate?.event.id === data.items[0], (child) => child.frigate?.event?.id === data.items[0],
) )
: null; : null;
@@ -560,8 +854,8 @@ export class FrigateCardTimelineCore extends LitElement {
const selected = this._timeline.getSelection(); const selected = this._timeline.getSelection();
let childIndex = -1; let childIndex = -1;
const children: FrigateBrowseMediaSource[] = []; const children: FrigateBrowseMediaSource[] = [];
this._events.dataset.get({ order: sortEvent }).forEach((item) => { this._data.dataset.get({ order: sortEvent }).forEach((item) => {
if (item.source) { if (item.event && item.source) {
children.push(item.source); children.push(item.source);
if (selected.includes(item.event.id)) { if (selected.includes(item.event.id)) {
childIndex = children.length - 1; childIndex = children.length - 1;
@@ -572,9 +866,8 @@ export class FrigateCardTimelineCore extends LitElement {
return null; return null;
} }
const target = createEventParentForChildren('Timeline events', children);
return { return {
target: target, target: createEventParentForChildren('Timeline events', children),
childIndex: childIndex < 0 ? null : childIndex, childIndex: childIndex < 0 ? null : childIndex,
}; };
} }
@@ -695,12 +988,14 @@ export class FrigateCardTimelineCore extends LitElement {
// Never include the target media in a cluster, and never group // Never include the target media in a cluster, and never group
// different object types together (e.g. person and car). // different object types together (e.g. person and car).
return ( return (
[first.type, second.type].every((type) => type !== 'background') &&
first.type === second.type &&
!!first.id && !!first.id &&
first.id !== this.view?.media?.frigate?.event?.id && first.id !== this.view?.media?.frigate?.event?.id &&
!!second.id && !!second.id &&
second.id != this.view?.media?.frigate?.event?.id && second.id != this.view?.media?.frigate?.event?.id &&
(<FrigateCardTimelineItem>first).event.label === (<FrigateCardTimelineItem>first).event?.label ===
(<FrigateCardTimelineItem>second).event.label (<FrigateCardTimelineItem>second).event?.label
); );
}, },
} }
@@ -721,13 +1016,7 @@ export class FrigateCardTimelineCore extends LitElement {
disabled: false, disabled: false,
filterOptions: { filterOptions: {
whiteList: { whiteList: {
'frigate-card-thumbnail': [ 'frigate-card-thumbnail': ['details', 'thumbnail', 'label', 'event'],
'details',
'thumbnail',
'label',
'event',
'thumbnail_size',
],
div: ['title'], div: ['title'],
span: ['style'], span: ['style'],
}, },
@@ -750,13 +1039,7 @@ export class FrigateCardTimelineCore extends LitElement {
* Update the timeline from the view object. * Update the timeline from the view object.
*/ */
protected async _updateTimelineFromView(): Promise<void> { protected async _updateTimelineFromView(): Promise<void> {
if ( if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) {
!this.hass ||
!this.cameras ||
!this.view ||
!this._timeline ||
!this.timelineConfig
) {
return; return;
} }
@@ -765,15 +1048,20 @@ export class FrigateCardTimelineCore extends LitElement {
? this._getStartEndFromEvent(event) ? this._getStartEndFromEvent(event)
: this._getStartEnd(); : this._getStartEnd();
await this._events.fetchEventsIfNecessary( await this._data.fetchIfNecessary(
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
this.timelineConfig.media, this.timelineConfig.media,
windowStart, windowStart,
windowEnd, windowEnd,
this.timelineConfig.show_recordings,
); );
if (!this._timeline) {
return;
}
this._timeline.setSelection(event ? [event.id] : [], { this._timeline.setSelection(event ? [event.id] : [], {
focus: false, focus: false,
animation: { animation: {
@@ -807,9 +1095,9 @@ export class FrigateCardTimelineCore extends LitElement {
// Hack: Clustering may not update unless the dataset changes, artifically // Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included // update the dataset to ensure the newly selected item cannot be included
// in a cluster. // in a cluster.
const item = this._events.dataset.get(event.id); const item = this._data.dataset.get(event.id);
if (item) { if (item) {
this._events.dataset.updateOnly(item); this._data.dataset.updateOnly(item);
} }
} }
} else { } else {
@@ -825,7 +1113,7 @@ export class FrigateCardTimelineCore extends LitElement {
// -> New view dispatched (to load thumbnails into outer carousel). // -> New view dispatched (to load thumbnails into outer carousel).
// -> New view received ... [loop] // -> New view received ... [loop]
const currentContext = this.view.context as TimelineViewContext | null; const currentContext = this.view.context as TimelineViewContext | null;
if (currentContext?.dateFetch !== this._events.lastFetchDate) { if (!isEqual(currentContext?.dateFetch, this._data.lastFetchDate)) {
const thumbnails = this._generateThumbnails(); const thumbnails = this._generateThumbnails();
this.view this.view
?.evolve({ ?.evolve({
@@ -851,12 +1139,27 @@ export class FrigateCardTimelineCore extends LitElement {
} else if (currentContext?.window) { } else if (currentContext?.window) {
newContext.window = currentContext.window; newContext.window = currentContext.window;
} }
if (this._events.lastFetchDate) { if (this._data.lastFetchDate) {
newContext.dateFetch = this._events.lastFetchDate; newContext.dateFetch = this._data.lastFetchDate;
} }
return newContext || null; return newContext || null;
} }
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('timelineConfig')) {
if (this.timelineConfig?.controls.thumbnails.size) {
this.style.setProperty(
'--frigate-card-thumbnail-size',
`${this.timelineConfig.controls.thumbnails.size}px`,
);
}
}
}
/** /**
* Called when the component is updated. * Called when the component is updated.
* @param changedProperties The changed properties if any. * @param changedProperties The changed properties if any.
@@ -865,7 +1168,7 @@ export class FrigateCardTimelineCore extends LitElement {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('cameras')) { if (changedProperties.has('cameras')) {
this._events.clear(); this._data.clear();
this._timeline?.destroy(); this._timeline?.destroy();
this._timeline = undefined; this._timeline = undefined;
} }
@@ -888,14 +1191,14 @@ export class FrigateCardTimelineCore extends LitElement {
this._timeline = new Timeline( this._timeline = new Timeline(
this._refTimeline.value, this._refTimeline.value,
this._events.dataset, this._data.dataset,
groups, groups,
options, options,
); );
this._timeline.on('select', this._timelineSelectHandler.bind(this)); this._timeline.on('select', this._timelineSelectHandler.bind(this));
this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this));
this._timeline.on('click', this._timelineClickHandler.bind(this)); this._timeline.on('click', this._timelineClickHandler.bind(this));
this._timeline.on('doubleclick', this._timelineClickHandler.bind(this)); this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this));
} }
} }
+47 -10
View File
@@ -22,6 +22,7 @@ import type {
CameraConfig, CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
FrigateCardMediaPlayer,
MediaShowInfo, MediaShowInfo,
TransitionEffect, TransitionEffect,
ViewerConfig ViewerConfig
@@ -36,8 +37,8 @@ import {
multipleBrowseMediaQueryMerged, multipleBrowseMediaQueryMerged,
overrideMultiBrowseMediaQueryParameters overrideMultiBrowseMediaQueryParameters
} from '../utils/ha/browse-media.js'; } from '../utils/ha/browse-media.js';
import { createMediaShowInfo } from '../utils/media-info.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { createMediaShowInfo } from '../utils/media-info.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
@@ -125,6 +126,8 @@ export class FrigateCardViewer extends LitElement {
} }
} }
const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player';
@customElement('frigate-card-viewer-carousel') @customElement('frigate-card-viewer-carousel')
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
@property({ attribute: false }) @property({ attribute: false })
@@ -168,11 +171,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
++i ++i
) { ) {
if (isTrueMedia(target.children[i])) { if (isTrueMedia(target.children[i])) {
await resolveMedia( await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
this.hass,
target.children[i],
this.resolvedMediaCache,
);
} }
} }
}, },
@@ -278,6 +277,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}; };
} }
/**
* The the HLS player on a slide (or current slide if not provided.)
* @param slide An optional slide.
* @returns The FrigateCardMediaPlayer or null if not found.
*/
protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null {
if (this._carousel) {
if (!slide) {
slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()];
}
return slide?.querySelector(
FRIGATE_CARD_HLS_SELECTOR,
) as FrigateCardMediaPlayer | null;
}
return null;
}
/** /**
* Get the Embla plugins to use. * Get the Embla plugins to use.
* @returns An EmblaOptionsType object or undefined for no options. * @returns An EmblaOptionsType object or undefined for no options.
@@ -291,7 +307,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
}), }),
}), }),
AutoMediaPlugin({ AutoMediaPlugin({
playerSelector: 'frigate-card-ha-hls-player', playerSelector: FRIGATE_CARD_HLS_SELECTOR,
...(this.viewerConfig?.auto_play && { ...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play, autoPlayCondition: this.viewerConfig.auto_play,
}), }),
@@ -511,9 +527,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
const img = slide.querySelector('img') as HTMLImageElement; const img = slide.querySelector('img') as HTMLImageElement;
// Frigate >= 0.9.0+ clips. // Frigate >= 0.9.0+ clips.
const hls_player = slide.querySelector( const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
'frigate-card-ha-hls-player', url: string;
) as HTMLElement & { url: string }; };
if (img) { if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) || ''; img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
@@ -661,6 +677,24 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
: ``} `; : ``} `;
} }
/**
* Fire a media show event when a slide is selected.
*/
protected _selectSlideMediaShowHandler(): void {
super._selectSlideMediaShowHandler();
// If this is a recording and play is desired to be started from a
// particular point, seek to that point. Use the media off the slide itself
// -- when the slide is changed, the media show event may be dispatched
// before this.view has been updated to reflect the new selection.
const player = this._getPlayer() as FrigateCardMediaPlayer & {
media?: FrigateBrowseMediaSource;
};
if (player && player.media && player.media.frigate?.recording?.seek_seconds) {
player.seek(player.media.frigate.recording.seek_seconds);
}
}
protected _renderMediaItem( protected _renderMediaItem(
mediaToRender: FrigateBrowseMediaSource, mediaToRender: FrigateBrowseMediaSource,
slideIndex: number, slideIndex: number,
@@ -682,6 +716,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return; return;
} }
// The media is attached to the player as '.media' which is used in
// `_selectSlideMediaShowHandler` (and not used by the player itself).
return html` return html`
<div class="embla__slide"> <div class="embla__slide">
${mediaToRender.media_content_type === 'video' ${mediaToRender.media_content_type === 'video'
@@ -696,6 +732,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
url=${ifDefined( url=${ifDefined(
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url), lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
)} )}
.media=${mediaToRender}
.hass=${this.hass} .hass=${this.hass}
@frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) => @frigate-card:media-show=${(e: CustomEvent<MediaShowInfo>) =>
this._mediaShowEventHandler(slideIndex, e)} this._mediaShowEventHandler(slideIndex, e)}
+16 -19
View File
@@ -8,42 +8,38 @@ import {
CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
CONF_CAMERAS_ARRAY_URL, CONF_CAMERAS_ARRAY_URL,
CONF_CAMERAS_ARRAY_ZONE, CONF_CAMERAS_ARRAY_ZONE,
CONF_EVENT_VIEWER_AUTO_PLAY,
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
CONF_IMAGE_URL, CONF_IMAGE_URL,
CONF_LIVE_AUTO_UNMUTE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_PRELOAD, CONF_LIVE_PRELOAD,
CONF_LIVE_WEBRTC_CARD, CONF_LIVE_WEBRTC_CARD,
CONF_MEDIA_VIEWER,
CONF_MENU, CONF_MENU,
CONF_MENU_BUTTONS_FRIGATE,
CONF_MENU_BUTTONS_CAMERAS, CONF_MENU_BUTTONS_CAMERAS,
CONF_MENU_BUTTONS_LIVE,
CONF_MENU_BUTTONS_CLIPS, CONF_MENU_BUTTONS_CLIPS,
CONF_MENU_BUTTONS_SNAPSHOTS,
CONF_MENU_BUTTONS_IMAGE,
CONF_MENU_BUTTONS_DOWNLOAD, CONF_MENU_BUTTONS_DOWNLOAD,
CONF_MENU_BUTTONS_FRIGATE,
CONF_MENU_BUTTONS_FRIGATE_UI, CONF_MENU_BUTTONS_FRIGATE_UI,
CONF_MENU_BUTTONS_FULLSCREEN, CONF_MENU_BUTTONS_FULLSCREEN,
CONF_MENU_BUTTONS_IMAGE,
CONF_MENU_BUTTONS_LIVE,
CONF_MENU_BUTTONS_SNAPSHOTS,
CONF_MENU_BUTTON_SIZE, CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION, CONF_MENU_POSITION,
CONF_MENU_STYLE, CONF_MENU_STYLE,
CONF_OVERRIDES, CONF_OVERRIDES,
CONF_VIEW_DEFAULT, CONF_VIEW_DEFAULT,
CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_ENTITIES, CONF_VIEW_UPDATE_ENTITIES
CONF_LIVE_AUTO_UNMUTE,
CONF_EVENT_VIEWER_AUTO_UNMUTE,
} from './const'; } from './const';
import { import {
BUTTON_SIZE_MIN, BUTTON_SIZE_MIN,
RawFrigateCardConfig, RawFrigateCardConfig,
RawFrigateCardConfigArray, RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MIN
} from './types'; } from './types';
/** /**
@@ -532,8 +528,8 @@ const UPGRADES = [
upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD), upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD),
upgradeMoveTo('webrtc', 'live.webrtc'), upgradeMoveTo('webrtc', 'live.webrtc'),
upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'), upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'),
upgradeMoveTo('controls.nextprev', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE), upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'),
upgradeMoveTo('controls.nextprev_size', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE), upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'),
upgradeMoveTo('menu_mode', 'menu.mode'), upgradeMoveTo('menu_mode', 'menu.mode'),
upgradeMoveTo('menu_buttons', 'menu.buttons'), upgradeMoveTo('menu_buttons', 'menu.buttons'),
upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE), upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE),
@@ -546,7 +542,7 @@ const UPGRADES = [
upgradeToMultipleCameras(), upgradeToMultipleCameras(),
upgradeMenuConditionToMenuOverride(), upgradeMenuConditionToMenuOverride(),
upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore), upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore),
upgradeMoveTo('event_viewer.autoplay_clip', CONF_EVENT_VIEWER_AUTO_PLAY), upgradeMoveTo('event_viewer.autoplay_clip', 'event_viewer.auto_play'),
// v3.0.0-rc.1 -> v3.0.0-rc.2 // v3.0.0-rc.1 -> v3.0.0-rc.2
upgradeArrayValue( upgradeArrayValue(
@@ -565,7 +561,7 @@ const UPGRADES = [
createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX), createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
), ),
upgradeWithOverrides( upgradeWithOverrides(
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, 'event_viewer.controls.thumbnails.size',
createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX), createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX),
), ),
upgradeWithOverrides( upgradeWithOverrides(
@@ -573,7 +569,7 @@ const UPGRADES = [
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN), createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
), ),
upgradeWithOverrides( upgradeWithOverrides(
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, 'event_viewer.controls.next_previous.size',
createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN), createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN),
), ),
upgradeWithOverrides( upgradeWithOverrides(
@@ -597,10 +593,11 @@ const UPGRADES = [
upgrade(CONF_LIVE_AUTO_UNMUTE, (val) => upgrade(CONF_LIVE_AUTO_UNMUTE, (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
), ),
upgrade(CONF_EVENT_VIEWER_AUTO_PLAY, (val) => upgrade('event_viewer.auto_play', (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
), ),
upgrade(CONF_EVENT_VIEWER_AUTO_UNMUTE, (val) => upgrade('event_viewer.auto_unmute', (val) =>
typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined,
), ),
upgradeMoveToWithOverrides('event_viewer', CONF_MEDIA_VIEWER),
]; ];
+26 -25
View File
@@ -52,31 +52,31 @@ export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE = export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE =
`${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const; `${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const;
export const CONF_EVENT_VIEWER = 'event_viewer' as const; export const CONF_MEDIA_VIEWER = 'media_viewer' as const;
export const CONF_EVENT_VIEWER_AUTO_PLAY = `${CONF_EVENT_VIEWER}.auto_play` as const; export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
export const CONF_EVENT_VIEWER_AUTO_PAUSE = `${CONF_EVENT_VIEWER}.auto_pause` as const; export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
export const CONF_EVENT_VIEWER_AUTO_MUTE = `${CONF_EVENT_VIEWER}.auto_mute` as const; export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
export const CONF_EVENT_VIEWER_AUTO_UNMUTE = `${CONF_EVENT_VIEWER}.auto_unmute` as const; export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
export const CONF_EVENT_VIEWER_DRAGGABLE = `${CONF_EVENT_VIEWER}.draggable` as const; export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
export const CONF_EVENT_VIEWER_LAZY_LOAD = `${CONF_EVENT_VIEWER}.lazy_load` as const; export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
export const CONF_EVENT_VIEWER_TRANSITION_EFFECT = export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
`${CONF_EVENT_VIEWER}.transition_effect` as const; `${CONF_MEDIA_VIEWER}.transition_effect` as const;
export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_EVENT_VIEWER}.controls.next_previous.style` as const; `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
`${CONF_EVENT_VIEWER}.controls.next_previous.size` as const; `${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const;
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
`${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_EVENT_VIEWER}.controls.thumbnails.show_details` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const;
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
`${CONF_EVENT_VIEWER}.controls.thumbnails.show_controls` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_controls` as const;
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE = export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
`${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const; `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE = export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
`${CONF_EVENT_VIEWER}.controls.title.mode` as const; `${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS = export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_EVENT_VIEWER}.controls.title.duration_seconds` as const; `${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const;
export const CONF_LIVE = 'live' as const; export const CONF_LIVE = 'live' as const;
export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
@@ -118,6 +118,7 @@ export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as
export const CONF_TIMELINE_CLUSTERING_THRESHOLD = export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const; `${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const; export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const; `${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
+62 -60
View File
@@ -32,23 +32,7 @@ import {
CONF_DIMENSIONS_ASPECT_RATIO_MODE, CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_IMAGE_MODE,
CONF_EVENT_VIEWER_AUTO_MUTE,
CONF_EVENT_VIEWER_AUTO_PAUSE,
CONF_EVENT_VIEWER_AUTO_PLAY,
CONF_EVENT_VIEWER_AUTO_UNMUTE,
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
CONF_EVENT_VIEWER_DRAGGABLE,
CONF_EVENT_VIEWER_LAZY_LOAD,
CONF_EVENT_VIEWER_TRANSITION_EFFECT,
CONF_IMAGE_MODE,
CONF_IMAGE_REFRESH_SECONDS, CONF_IMAGE_REFRESH_SECONDS,
CONF_IMAGE_URL, CONF_IMAGE_URL,
CONF_LIVE_AUTO_MUTE, CONF_LIVE_AUTO_MUTE,
@@ -68,8 +52,21 @@ import {
CONF_LIVE_LAZY_LOAD, CONF_LIVE_LAZY_LOAD,
CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_LAZY_UNLOAD,
CONF_LIVE_PRELOAD, CONF_LIVE_PRELOAD,
CONF_LIVE_TRANSITION_EFFECT, CONF_LIVE_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_AUTO_MUTE,
CONF_MENU_ALIGNMENT, CONF_MEDIA_VIEWER_AUTO_PAUSE,
CONF_MEDIA_VIEWER_AUTO_PLAY,
CONF_MEDIA_VIEWER_AUTO_UNMUTE,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
CONF_MEDIA_VIEWER_DRAGGABLE,
CONF_MEDIA_VIEWER_LAZY_LOAD,
CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTONS, CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE, CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION, CONF_MENU_POSITION,
@@ -80,6 +77,7 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
CONF_TIMELINE_MEDIA, CONF_TIMELINE_MEDIA,
CONF_TIMELINE_SHOW_RECORDINGS,
CONF_TIMELINE_WINDOW_SECONDS, CONF_TIMELINE_WINDOW_SECONDS,
CONF_VIEW_CAMERA_SELECT, CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE, CONF_VIEW_DARK_MODE,
@@ -152,10 +150,10 @@ const options: EditorOptions = {
name: localize('editor.live'), name: localize('editor.live'),
secondary: localize('editor.live_secondary'), secondary: localize('editor.live_secondary'),
}, },
event_viewer: { media_viewer: {
icon: 'filmstrip', icon: 'filmstrip',
name: localize('editor.event_viewer'), name: localize('editor.media_viewer'),
secondary: localize('editor.event_viewer_secondary'), secondary: localize('editor.media_viewer_secondary'),
}, },
event_gallery: { event_gallery: {
icon: 'grid', icon: 'grid',
@@ -239,15 +237,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: '', label: '' }, { value: '', label: '' },
{ {
value: 'thumbnails', value: 'thumbnails',
label: localize('config.event_viewer.controls.next_previous.styles.thumbnails'), label: localize('config.media_viewer.controls.next_previous.styles.thumbnails'),
}, },
{ {
value: 'chevrons', value: 'chevrons',
label: localize('config.event_viewer.controls.next_previous.styles.chevrons'), label: localize('config.media_viewer.controls.next_previous.styles.chevrons'),
}, },
{ {
value: 'none', value: 'none',
label: localize('config.event_viewer.controls.next_previous.styles.none'), label: localize('config.media_viewer.controls.next_previous.styles.none'),
}, },
]; ];
@@ -281,23 +279,23 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: '', label: '' }, { value: '', label: '' },
{ {
value: 'none', value: 'none',
label: localize('config.event_viewer.controls.thumbnails.modes.none'), label: localize('config.media_viewer.controls.thumbnails.modes.none'),
}, },
{ {
value: 'above', value: 'above',
label: localize('config.event_viewer.controls.thumbnails.modes.above'), label: localize('config.media_viewer.controls.thumbnails.modes.above'),
}, },
{ {
value: 'below', value: 'below',
label: localize('config.event_viewer.controls.thumbnails.modes.below'), label: localize('config.media_viewer.controls.thumbnails.modes.below'),
}, },
{ {
value: 'left', value: 'left',
label: localize('config.event_viewer.controls.thumbnails.modes.left'), label: localize('config.media_viewer.controls.thumbnails.modes.left'),
}, },
{ {
value: 'right', value: 'right',
label: localize('config.event_viewer.controls.thumbnails.modes.right'), label: localize('config.media_viewer.controls.thumbnails.modes.right'),
}, },
]; ];
@@ -312,29 +310,29 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
protected _titleModes: EditorSelectOption[] = [ protected _titleModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'none', label: localize('config.event_viewer.controls.title.modes.none') }, { value: 'none', label: localize('config.media_viewer.controls.title.modes.none') },
{ {
value: 'popup-top-left', value: 'popup-top-left',
label: localize('config.event_viewer.controls.title.modes.popup-top-left'), label: localize('config.media_viewer.controls.title.modes.popup-top-left'),
}, },
{ {
value: 'popup-top-right', value: 'popup-top-right',
label: localize('config.event_viewer.controls.title.modes.popup-top-right'), label: localize('config.media_viewer.controls.title.modes.popup-top-right'),
}, },
{ {
value: 'popup-bottom-left', value: 'popup-bottom-left',
label: localize('config.event_viewer.controls.title.modes.popup-bottom-left'), label: localize('config.media_viewer.controls.title.modes.popup-bottom-left'),
}, },
{ {
value: 'popup-bottom-right', value: 'popup-bottom-right',
label: localize('config.event_viewer.controls.title.modes.popup-bottom-right'), label: localize('config.media_viewer.controls.title.modes.popup-bottom-right'),
}, },
]; ];
protected _transitionEffects: EditorSelectOption[] = [ protected _transitionEffects: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'none', label: localize('config.event_viewer.transition_effects.none') }, { value: 'none', label: localize('config.media_viewer.transition_effects.none') },
{ value: 'slide', label: localize('config.event_viewer.transition_effects.slide') }, { value: 'slide', label: localize('config.media_viewer.transition_effects.slide') },
]; ];
protected _imageModes: EditorSelectOption[] = [ protected _imageModes: EditorSelectOption[] = [
@@ -1134,74 +1132,74 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
})} })}
${this._renderSwitch( ${this._renderSwitch(
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
defaults.event_viewer.controls.thumbnails.show_details, defaults.media_viewer.controls.thumbnails.show_details,
)} )}
${this._renderSwitch( ${this._renderSwitch(
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS, CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
defaults.event_viewer.controls.thumbnails.show_controls, defaults.media_viewer.controls.thumbnails.show_controls,
)} )}
</div>` </div>`
: ''} : ''}
${this._renderOptionSetHeader('event_viewer')} ${this._renderOptionSetHeader('media_viewer')}
${this._expandedMenus[MENU_OPTIONS] === 'event_viewer' ${this._expandedMenus[MENU_OPTIONS] === 'media_viewer'
? html` <div class="values"> ? html` <div class="values">
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_AUTO_PLAY, CONF_MEDIA_VIEWER_AUTO_PLAY,
this._mediaActionPositiveConditions, this._mediaActionPositiveConditions,
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_AUTO_PAUSE, CONF_MEDIA_VIEWER_AUTO_PAUSE,
this._mediaActionNegativeConditions, this._mediaActionNegativeConditions,
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_AUTO_MUTE, CONF_MEDIA_VIEWER_AUTO_MUTE,
this._mediaActionNegativeConditions, this._mediaActionNegativeConditions,
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_AUTO_UNMUTE, CONF_MEDIA_VIEWER_AUTO_UNMUTE,
this._mediaActionPositiveConditions, this._mediaActionPositiveConditions,
)} )}
${this._renderSwitch( ${this._renderSwitch(
CONF_EVENT_VIEWER_DRAGGABLE, CONF_MEDIA_VIEWER_DRAGGABLE,
defaults.event_viewer.draggable, defaults.media_viewer.draggable,
)} )}
${this._renderSwitch( ${this._renderSwitch(
CONF_EVENT_VIEWER_LAZY_LOAD, CONF_MEDIA_VIEWER_LAZY_LOAD,
defaults.event_viewer.lazy_load, defaults.media_viewer.lazy_load,
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
this._eventViewerNextPreviousControlStyles, this._eventViewerNextPreviousControlStyles,
)} )}
${this._renderNumberInput(CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, { ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, {
min: BUTTON_SIZE_MIN, min: BUTTON_SIZE_MIN,
})} })}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
this._thumbnailModes, this._thumbnailModes,
)} )}
${this._renderNumberInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, { ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, {
min: THUMBNAIL_WIDTH_MIN, min: THUMBNAIL_WIDTH_MIN,
max: THUMBNAIL_WIDTH_MAX, max: THUMBNAIL_WIDTH_MAX,
})} })}
${this._renderSwitch( ${this._renderSwitch(
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
defaults.event_viewer.controls.thumbnails.show_details, defaults.media_viewer.controls.thumbnails.show_details,
)} )}
${this._renderSwitch( ${this._renderSwitch(
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
defaults.event_viewer.controls.thumbnails.show_controls, defaults.media_viewer.controls.thumbnails.show_controls,
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
this._titleModes, this._titleModes,
)} )}
${this._renderNumberInput( ${this._renderNumberInput(
CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
{ min: 0, max: 60 }, { min: 0, max: 60 },
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_EVENT_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
this._transitionEffects, this._transitionEffects,
)} )}
</div>` </div>`
@@ -1223,6 +1221,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_TIMELINE_MEDIA, CONF_TIMELINE_MEDIA,
this._timelineMediaTypes, this._timelineMediaTypes,
)} )}
${this._renderSwitch(
CONF_TIMELINE_SHOW_RECORDINGS,
defaults.timeline.show_recordings,
)}
${this._renderOptionSelector( ${this._renderOptionSelector(
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
this._thumbnailModes, this._thumbnailModes,
+25 -15
View File
@@ -7,7 +7,8 @@
"no_clips": "No clips", "no_clips": "No clips",
"no_snapshot": "No recent snapshot", "no_snapshot": "No recent snapshot",
"no_clip": "No recent clip", "no_clip": "No recent clip",
"live": "Live" "live": "Live",
"recordings": "Recordings"
}, },
"config": { "config": {
"cameras": { "cameras": {
@@ -84,22 +85,22 @@
} }
} }
}, },
"event_viewer": { "media_viewer": {
"auto_play": "Automatically play media", "auto_play": "Automatically play media",
"auto_pause": "Automatically pause media", "auto_pause": "Automatically pause media",
"auto_mute": "Automatically mute media", "auto_mute": "Automatically mute media",
"auto_unmute": "Automatically unmute media", "auto_unmute": "Automatically unmute media",
"draggable": "Event Viewer can be dragged/swiped", "draggable": "Media Viewer can be dragged/swiped",
"lazy_load": "Event Viewer media is lazily loaded in carousel", "lazy_load": "Media Viewer media is lazily loaded in carousel",
"transition_effect": "Event Viewer transition effect", "transition_effect": "Media Viewer transition effect",
"transition_effects": { "transition_effects": {
"none": "No transition", "none": "No transition",
"slide": "Slide transition" "slide": "Slide transition"
}, },
"controls": { "controls": {
"next_previous": { "next_previous": {
"style": "Event Viewer next & previous control style", "style": "Media Viewer next & previous control style",
"size": "Event Viewer next & previous control size in pixels", "size": "Media Viewer next & previous control size in pixels",
"styles": { "styles": {
"thumbnails": "Thumbnails", "thumbnails": "Thumbnails",
"chevrons": "Chevrons", "chevrons": "Chevrons",
@@ -107,10 +108,10 @@
} }
}, },
"thumbnails": { "thumbnails": {
"mode": "Event Viewer thumbnails mode", "mode": "Media Viewer thumbnails mode",
"size": "Event Viewer thumbnails size in pixels", "size": "Media Viewer thumbnails size in pixels",
"show_details": "Show event details with thumbnails", "show_details": "Show details with thumbnails",
"show_controls": "Show event controls with thumbnails", "show_controls": "Show controls with thumbnails",
"modes": { "modes": {
"below": "Thumbnails below the media", "below": "Thumbnails below the media",
"above": "Thumbnails above the media", "above": "Thumbnails above the media",
@@ -120,7 +121,7 @@
} }
}, },
"title": { "title": {
"mode": "Event Viewer media title display mode", "mode": "Media Viewer media title display mode",
"modes": { "modes": {
"none": "No title display", "none": "No title display",
"popup-top-left": "Popup on the top left", "popup-top-left": "Popup on the top left",
@@ -229,6 +230,7 @@
"window_seconds": "The default length of the timeline view in seconds", "window_seconds": "The default length of the timeline view in seconds",
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)", "clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
"media": "The media the timeline displays", "media": "The media the timeline displays",
"show_recordings": "Show recordings",
"medias": { "medias": {
"all": "All media types", "all": "All media types",
"clips": "Clips", "clips": "Clips",
@@ -267,8 +269,8 @@
"live_secondary": "Live camera view options", "live_secondary": "Live camera view options",
"event_gallery": "Event gallery", "event_gallery": "Event gallery",
"event_gallery_secondary": "Snapshots & clips gallery options", "event_gallery_secondary": "Snapshots & clips gallery options",
"event_viewer": "Event viewer", "media_viewer": "Media viewer",
"event_viewer_secondary": "Snapshots & clips viewer options", "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)",
"image": "Image", "image": "Image",
"image_secondary": "Static image view options", "image_secondary": "Static image view options",
"dimensions": "Dimensions", "dimensions": "Dimensions",
@@ -292,13 +294,21 @@
"in_progress": "In Progress", "in_progress": "In Progress",
"score": "Score" "score": "Score"
}, },
"recording": {
"events": "Events",
"seek": "Seek"
},
"thumbnail": { "thumbnail": {
"retain_indefinitely": "Event will be indefinitely retained", "retain_indefinitely": "Event will be indefinitely retained",
"timeline": "See event in timeline" "timeline": "See event in timeline",
"no_thumbnail": "No thumbnail available"
}, },
"error": { "error": {
"undecodable_response": "Could not decode response from Home Assistant for request",
"empty_response": "Received empty response from Home Assistant for request", "empty_response": "Received empty response from Home Assistant for request",
"invalid_response": "Received invalid response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request",
"failed_response": "Failed to receive response from Home Assistant for request",
"failed_sign": "Could not sign Home Assistant URL",
"invalid_keys": "Invalid keys", "invalid_keys": "Invalid keys",
"unknown": "Unknown error", "unknown": "Unknown error",
"troubleshooting": "Check troubleshooting", "troubleshooting": "Check troubleshooting",
+3 -3
View File
@@ -52,7 +52,7 @@
"event_gallery": { "event_gallery": {
"min_columns": "Número mínimo de colunas" "min_columns": "Número mínimo de colunas"
}, },
"event_viewer": { "media_viewer": {
"auto_play": "Reproduzir mídia automaticamente", "auto_play": "Reproduzir mídia automaticamente",
"auto_unmute": "Ativar mídia automaticamente", "auto_unmute": "Ativar mídia automaticamente",
"draggable": "Visualizador de eventos pode ser arrastado/deslizado", "draggable": "Visualizador de eventos pode ser arrastado/deslizado",
@@ -188,8 +188,8 @@
"live_secondary": "Opções de visualização da câmera ao vivo", "live_secondary": "Opções de visualização da câmera ao vivo",
"event_gallery": "Galeria de eventos", "event_gallery": "Galeria de eventos",
"event_gallery_secondary": "Opções da galeria de Snapshots e clipes", "event_gallery_secondary": "Opções da galeria de Snapshots e clipes",
"event_viewer": "Visualizador de eventos", "media_viewer": "Visualizador de eventos",
"event_viewer_secondary": "Opções do visualizador de Snapshots e clipes", "media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"image": "Imagem", "image": "Imagem",
"image_secondary": "Opções de visualização de imagem estática", "image_secondary": "Opções de visualização de imagem estática",
"dimensions": "Dimensões", "dimensions": "Dimensões",
+7
View File
@@ -77,6 +77,13 @@ customElements.whenDefined('ha-camera-stream').then(() => {
this._player?.unmute(); this._player?.unmute();
} }
/**
* Seek the video (unsupported).
*/
public seek(seconds: number): void {
this._player?.seek(seconds);
}
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
+9
View File
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-hls-player').then(() => {
} }
} }
/**
* Seek the video.
*/
public seek(seconds: number): void {
if (this._video) {
this._video.currentTime = seconds;
}
}
// ===================================================================================== // =====================================================================================
// Minor modifications from: // Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
+9
View File
@@ -59,6 +59,15 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
} }
} }
/**
* Seek the video.
*/
public seek(seconds: number): void {
if (this._video) {
this._video.currentTime = seconds;
}
}
// ===================================================================================== // =====================================================================================
// Minor modifications from: // Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
-1
View File
@@ -55,7 +55,6 @@ img,video {
.embla__slide { .embla__slide {
position: relative; position: relative;
height: 100%;
overflow: visible; overflow: visible;
} }
:host([direction=vertical]) .embla__slide { :host([direction=vertical]) .embla__slide {
-1
View File
@@ -1,5 +1,4 @@
:host { :host {
--frigate-card-thumbnail-size: 100px;
--frigate-card-thumbnail-size-max: 175px; --frigate-card-thumbnail-size-max: 175px;
--frigate-card-thumbnail-details-width: calc(var(--frigate-card-thumbnail-size) + 200px); --frigate-card-thumbnail-details-width: calc(var(--frigate-card-thumbnail-size) + 200px);
} }
+13 -10
View File
@@ -1,8 +1,6 @@
@use "const.scss";
:host { :host {
width: 100%; width: 100%;
height: 100%; height: auto;
overflow: auto; overflow: auto;
// Hide scrollbar: IE and Edge // Hide scrollbar: IE and Edge
@@ -16,8 +14,8 @@
display: grid; display: grid;
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
grid-auto-rows: var(--frigate-card-thumbnail-size); grid-auto-rows: 1fr;
grid-column-gap: var(--frigate-card-gallery-gap); gap: var(--frigate-card-gallery-gap);
} }
// Hide scrollbar for Chrome, Safari and Opera // Hide scrollbar for Chrome, Safari and Opera
@@ -32,16 +30,21 @@ ha-card {
box-sizing: border-box; box-sizing: border-box;
text-align: center; text-align: center;
opacity: 0.7; opacity: 0.7;
color: var(--secondary-text-color, white); color: var(--primary-text-color, white);
border: 2px ridge var(--secondary-text-color, black); border: 2px ridge var(--primary-text-color, black);
border-radius: 5px; border-radius: 5px;
background-color: var(--primary-background-color, black); background-color: var(--primary-background-color, black);
padding: 10px; padding: 10px;
line-height: 1; line-height: 1;
overflow: hidden; overflow: hidden;
transition: transform 0.2s linear;
}
ha-card:hover {
transform: scale(1.04)
} }
:host(:not([details])) ha-card, ha-card, frigate-card-thumbnail {
:host(:not([details])) frigate-card-thumbnail { height: 100%;
aspect-ratio: 1 / 1; max-height: var(--frigate-card-thumbnail-size);
} }
+1
View File
@@ -4,4 +4,5 @@
.embla__slide { .embla__slide {
flex: 0 0 100%; flex: 0 0 100%;
height: 100%;
} }
+8
View File
@@ -2,6 +2,11 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
display: block; display: block;
// Ensure error messages are selectable.
user-select: text;
// Safari only has prefixed support.
-webkit-user-select: text;
} }
div.wrapper { div.wrapper {
@@ -20,6 +25,7 @@ div.message {
div.message div.contents { div.message div.contents {
padding: 10px; padding: 10px;
height: 100%; height: 100%;
max-width: 100%;
} }
div.message div.icon { div.message div.icon {
@@ -37,4 +43,6 @@ div.message div.icon {
.message pre { .message pre {
margin-top: 20px; margin-top: 20px;
white-space: pre-wrap;
word-break: break-all;
} }
+1
View File
@@ -32,6 +32,7 @@
box-shadow: 0px 0px 20px 5px black; box-shadow: 0px 0px 20px 5px black;
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
opacity: 0.8; opacity: 0.8;
aspect-ratio: 1 / 1;
} }
.controls.thumbnails:hover { .controls.thumbnails:hover {
opacity: 1 !important; opacity: 1 !important;
+9 -10
View File
@@ -1,13 +1,13 @@
@use "const.scss"; @use 'const.scss';
:host { :host {
--frigate-card-carousel-thumbnail-opacity: 1.0; --frigate-card-carousel-thumbnail-opacity: 1;
} }
:host([direction=vertical]) { :host([direction='vertical']) {
height: 100%; height: 100%;
} }
:host([direction=horizontal]) { :host([direction='horizontal']) {
// In fullscreen mode, without explicitly setting the height to auto Chrome // In fullscreen mode, without explicitly setting the height to auto Chrome
// will construct a stylesheet with 100% height. // will construct a stylesheet with 100% height.
height: auto; height: auto;
@@ -19,14 +19,13 @@
transition: opacity 0.6s ease; transition: opacity 0.6s ease;
} }
.embla__slide.slide-selected { .embla__slide.slide-selected {
opacity: 1.0; opacity: 1;
} }
frigate-card-thumbnail[details] { frigate-card-thumbnail {
width: var(--frigate-card-thumbnail-details-width);
height: var(--frigate-card-thumbnail-size);
}
frigate-card-thumbnail:not([details]) {
width: var(--frigate-card-thumbnail-size); width: var(--frigate-card-thumbnail-size);
height: var(--frigate-card-thumbnail-size); height: var(--frigate-card-thumbnail-size);
} }
frigate-card-thumbnail[details] {
width: var(--frigate-card-thumbnail-details-width);
}
+7 -2
View File
@@ -8,11 +8,15 @@
column-gap: 5%; column-gap: 5%;
} }
div.right, div.left { div.right,
div.left {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
div.right {
align-items: center;
}
div.left { div.left {
flex: 1; flex: 1;
@@ -36,6 +40,7 @@ span.heading {
font-weight: bold; font-weight: bold;
} }
.larger { div.larger,
span.larger {
font-size: 1.5rem; font-size: 1.5rem;
} }
+37
View File
@@ -0,0 +1,37 @@
:host {
display: block;
max-width: var(--frigate-card-thumbnail-size);
max-height: var(--frigate-card-thumbnail-size);
overflow: hidden;
// Restrict images to a maximum of thumbnail size.
aspect-ratio: 1 / 1;
}
img, ha-icon {
border-radius: var(--ha-card-border-radius, 4px);
width: 100%;
height: 100%;
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
// should be clipped to fill the thumbnail div whilst maintaining
// aspect-ratio.
object-fit: cover;
transition: transform 0.2s linear;
}
ha-icon {
--mdc-icon-size: 50%;
color: var(--primary-text-color);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid rgba(255,255,255,0.3);
box-sizing: border-box;
}
img:hover {
transform: scale(1.04);
}
+29
View File
@@ -0,0 +1,29 @@
:host {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
aspect-ratio: 1 / 1;
overflow: hidden;
max-width: var(--frigate-card-thumbnail-size);
max-height: var(--frigate-card-thumbnail-size);
padding: 10px;
border: 1px solid var(--secondary-color);
background-color: var(--secondary-background-color);
border-radius: var(--ha-card-border-radius, 4px);
box-sizing: border-box;
color: var(--primary-text-color);
transition: transform 0.2s linear;
}
:host(:hover) {
transform: scale(1.04);
}
div.title {
font-size: 1.5rem;
}
+12 -27
View File
@@ -1,5 +1,3 @@
@use "const.scss";
:host { :host {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -7,6 +5,11 @@
// Ensure control icons are relative to the thumbnail. // Ensure control icons are relative to the thumbnail.
position: relative; position: relative;
overflow: hidden;
}
:host(:not([details])) {
aspect-ratio: 1 / 1;
} }
:host([details]) { :host([details]) {
@@ -19,39 +22,21 @@
background-color: var(--primary-background-color, black); background-color: var(--primary-background-color, black);
} }
img { ha-icon {
border-radius: var(--ha-card-border-radius, 4px); position: absolute;
border-radius: 50%;
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
// should be clipped to fill the thumbnail div whilst maintaining
// aspect-ratio.
object-fit: cover;
// Restrict images to a maximum of thumbnail size.
aspect-ratio: 1 / 1;
height: 100%;
max-width: var(--frigate-card-thumbnail-size-max);
max-height: var(--frigate-card-thumbnail-size-max);
transition: transform 0.2s linear;
}
img:hover {
transform: scale(1.04);
} }
ha-icon.favorite { ha-icon.favorite {
position: absolute;
color: gold; color: gold;
background: rgba(0, 0, 0, 0.2);
border-radius: 50%;
opacity: 0.8; opacity: 0.8;
} }
ha-icon.timeline { ha-icon.timeline {
position: absolute;
color: var(--primary-color); color: var(--primary-color);
right: 0px; right: 0px;
background: rgba(0, 0, 0, 0.2); }
border-radius: 50%;
frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording {
flex: 1;
} }
+29 -3
View File
@@ -1,5 +1,6 @@
@use 'vis-timeline/dist/vis-timeline-graph2d.css'; @use 'vis-timeline/dist/vis-timeline-graph2d.css';
@use 'drawer'; @use 'drawer';
@use 'const.scss';
:host { :host {
width: 100%; width: 100%;
@@ -15,6 +16,14 @@
position: relative; position: relative;
} }
frigate-card-thumbnail {
height: var(--frigate-card-thumbnail-size);
width: var(--frigate-card-thumbnail-size);
}
frigate-card-thumbnail[details] {
width: var(--frigate-card-thumbnail-details-width);
}
div.timeline { div.timeline {
flex: 1; flex: 1;
} }
@@ -46,10 +55,19 @@ div.timeline.right-margin {
color: var(--primary-text-color); color: var(--primary-text-color);
background-color: var(--primary-color); background-color: var(--primary-color);
} }
.vis-item.vis-background {
background-color: var(--primary-color);
opacity: 0.1;
}
.vis-item:hover { .vis-item:not(.vis-background) {
// Float icons upwards when the user hovers over them. cursor: pointer;
z-index: 2; }
.vis-item.vis-background, .vis-labelset, .vis-time-axis {
cursor: crosshair;
}
.vis-item:active {
cursor: unset;
} }
.vis-item.vis-box { .vis-item.vis-box {
@@ -71,6 +89,14 @@ div.timeline.right-margin {
border-color: var(--secondary-color); border-color: var(--secondary-color);
} }
// Give an indication that the user can interact with the axes.
.vis-text.vis-minor, .vis-label {
transition: background-color 0.5s ease-out;
}
.vis-text.vis-minor:hover, .vis-label:hover {
background-color: var(--primary-color);
}
.vis-time-axis .vis-grid.vis-major { .vis-time-axis .vis-grid.vis-major {
border-color: var(--secondary-color); border-color: var(--secondary-color);
} }
+55 -13
View File
@@ -48,8 +48,8 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
const FRIGATE_CARD_VIEWS = [ const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
// Event: A clip or snapshot (timeline may produce mixed media lists). // Media: A generic piece of media (could be clip, snapshot, recording).
'event', 'media',
] as const; ] as const;
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number]; export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
@@ -83,7 +83,14 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number];
export class FrigateCardError extends Error {} export class FrigateCardError extends Error {
context?: unknown;
constructor(message: string, context?: unknown) {
super(message);
this.context = context;
}
}
/** /**
* Action Types (for "Picture Elements" / Menu) * Action Types (for "Picture Elements" / Menu)
@@ -400,7 +407,10 @@ const cameraConfigSchema = z
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion), trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy), trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy),
trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities), trigger_by_entities: z
.string()
.array()
.default(cameraConfigDefault.trigger_by_entities),
}) })
.default(cameraConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>; export type CameraConfig = z.infer<typeof cameraConfigSchema>;
@@ -508,7 +518,7 @@ const viewConfigDefault = {
scan: { scan: {
enabled: false, enabled: false,
show_trigger_status: true, show_trigger_status: true,
} },
}; };
const viewConfigSchema = z const viewConfigSchema = z
.object({ .object({
@@ -525,10 +535,14 @@ const viewConfigSchema = z
update_entities: z.string().array().optional(), update_entities: z.string().array().optional(),
render_entities: z.string().array().optional(), render_entities: z.string().array().optional(),
dark_mode: z.enum(['on', 'off', 'auto']).optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(),
scan: z.object({ scan: z
enabled: z.boolean().default(viewConfigDefault.scan.enabled), .object({
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), enabled: z.boolean().default(viewConfigDefault.scan.enabled),
}).default(viewConfigDefault.scan) show_trigger_status: z
.boolean()
.default(viewConfigDefault.scan.show_trigger_status),
})
.default(viewConfigDefault.scan),
}) })
.merge(actionsSchema) .merge(actionsSchema)
.default(viewConfigDefault); .default(viewConfigDefault);
@@ -970,6 +984,7 @@ const timelineConfigDefault = {
clustering_threshold: 3, clustering_threshold: 3,
media: 'all' as const, media: 'all' as const,
window_seconds: 60 * 60, window_seconds: 60 * 60,
show_recordings: true,
controls: { controls: {
thumbnails: { thumbnails: {
mode: 'left' as const, mode: 'left' as const,
@@ -995,6 +1010,7 @@ const timelineConfigSchema = z
.max(24 * 60 * 60) .max(24 * 60 * 60)
.optional() .optional()
.default(timelineConfigDefault.window_seconds), .default(timelineConfigDefault.window_seconds),
show_recordings: z.boolean().optional().default(timelineConfigDefault.show_recordings),
controls: z controls: z
.object({ .object({
thumbnails: thumbnailsControlSchema thumbnails: thumbnailsControlSchema
@@ -1059,7 +1075,7 @@ export const frigateCardConfigSchema = z.object({
view: viewConfigSchema, view: viewConfigSchema,
menu: menuConfigSchema, menu: menuConfigSchema,
live: liveConfigSchema, live: liveConfigSchema,
event_viewer: viewerConfigSchema, media_viewer: viewerConfigSchema,
event_gallery: galleryConfigSchema, event_gallery: galleryConfigSchema,
image: imageConfigSchema, image: imageConfigSchema,
elements: pictureElementsSchema, elements: pictureElementsSchema,
@@ -1085,7 +1101,7 @@ export const frigateCardConfigDefaults = {
view: viewConfigDefault, view: viewConfigDefault,
menu: menuConfigDefault, menu: menuConfigDefault,
live: liveConfigDefault, live: liveConfigDefault,
event_viewer: viewerConfigDefault, media_viewer: viewerConfigDefault,
event_gallery: galleryConfigDefault, event_gallery: galleryConfigDefault,
image: imageConfigDefault, image: imageConfigDefault,
timeline: timelineConfigDefault, timeline: timelineConfigDefault,
@@ -1130,6 +1146,15 @@ export interface BrowseMediaQueryParameters {
cameraID?: string; cameraID?: string;
} }
export interface BrowseRecordingQueryParameters {
clientId: string;
cameraName: string;
year: number;
month: number;
day: number;
hour: number;
}
export interface BrowseMediaNeighbors { export interface BrowseMediaNeighbors {
previous: FrigateBrowseMediaSource | null; previous: FrigateBrowseMediaSource | null;
previousIndex: number | null; previousIndex: number | null;
@@ -1165,6 +1190,7 @@ export interface FrigateCardMediaPlayer {
pause(): void; pause(): void;
mute(): void; mute(): void;
unmute(): void; unmute(): void;
seek(seconds: number): void;
} }
export interface CardHelpers { export interface CardHelpers {
@@ -1180,7 +1206,9 @@ export interface CardHelpers {
*/ */
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const; export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
export const MEDIA_CLASS_VIDEO = 'video' as const;
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const; export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
export const MEDIA_TYPE_VIDEO = 'video' as const;
// Recursive type, cannot use type interference: // Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types // See: https://github.com/colinhacks/zod#recursive-types
@@ -1212,10 +1240,24 @@ export interface FrigateEvent {
retain_indefinitely?: boolean; retain_indefinitely?: boolean;
} }
export interface FrigateRecording {
camera: string;
start_time: number;
end_time: number;
events: number;
// Specifies the point at which this recording should be played, the
// seek_time is the date of the desired play point, and seek_seconds is the
// number of seconds to seek to reach that point.
seek_time?: number;
seek_seconds?: number;
}
export interface FrigateBrowseMediaSource extends BrowseMediaSource { export interface FrigateBrowseMediaSource extends BrowseMediaSource {
children?: FrigateBrowseMediaSource[] | null; children?: FrigateBrowseMediaSource[] | null;
frigate?: { frigate?: {
event: FrigateEvent; event?: FrigateEvent;
recording?: FrigateRecording;
}; };
} }
@@ -1274,7 +1316,7 @@ export type Entity = z.infer<typeof entitySchema>;
export const extendedEntitySchema = entitySchema.extend({ export const extendedEntitySchema = entitySchema.extend({
// Extended entity results. // Extended entity results.
unique_id: z.string().optional(), unique_id: z.string().optional(),
}) });
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>; export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
export const entityListSchema = entitySchema.array(); export const entityListSchema = entitySchema.array();
+76
View File
@@ -0,0 +1,76 @@
import { z } from 'zod';
import { ExtendedHomeAssistant } from '../types';
import { homeAssistantHTTPRequest } from './ha';
const recordingSummaryHourSchema = z.object({
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
duration: z.number().min(0),
events: z.number().min(0),
});
const recordingSummarySchema = z
.object({
day: z.preprocess((arg) => {
// Must provide the hour:minute:second on parsing or Javascript will
// assume UTC midnight.
return typeof arg === 'string' ? new Date(`${arg} 00:00:00`) : arg;
}, z.date()),
events: z.number(),
hours: recordingSummaryHourSchema.array(),
})
.array();
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
const recordingSegmentSchema = z.object({
start_time: z.number(),
end_time: z.number(),
id: z.string(),
});
const recordingSegmentsSchema = recordingSegmentSchema.array();
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
/**
* Get the recordings summary.
* @param hass The Home Assistant object.
* @param client_id The Frigate client_id.
* @param camera_name The Frigate camera name.
* @returns A RecordingSummary object.
*/
export const getRecordingsSummary = async (
hass: ExtendedHomeAssistant,
client_id: string,
camera_name: string,
): Promise<RecordingSummary> => {
return await homeAssistantHTTPRequest(
hass,
recordingSummarySchema,
`/api/frigate/${client_id}/${camera_name}/recordings/summary`,
);
};
/**
* Get the recording segments..
* @param hass The Home Assistant object.
* @param client_id The Frigate client_id.
* @param camera_name The Frigate camera name.
* @param before The segment low watermark.
* @param after The segment high watermark.
* @returns A RecordingSegments object.
*/
export const getRecordingSegments = async (
hass: ExtendedHomeAssistant,
client_id: string,
camera_name: string,
before: Date,
after: Date,
): Promise<RecordingSegments> => {
return await homeAssistantHTTPRequest(
hass,
recordingSegmentsSchema,
`/api/frigate/${client_id}/${camera_name}/recordings`,
new URLSearchParams({
before: String(before.getTime() / 1000),
after: String(after.getTime() / 1000),
}),
);
};
+66 -6
View File
@@ -8,15 +8,23 @@ import {
import { homeAssistantWSRequest } from '.'; import { homeAssistantWSRequest } from '.';
import { import {
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchFrigateCardErrorEvent,
dispatchMessageEvent dispatchMessageEvent
} from '../../components/message.js'; } from '../../components/message.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import { import {
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
BrowseRecordingQueryParameters,
CameraConfig, CameraConfig,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST, frigateBrowseMediaSourceSchema,
MEDIA_TYPE_PLAYLIST FrigateCardError,
FrigateEvent,
FrigateRecording,
MEDIA_CLASS_PLAYLIST,
MEDIA_CLASS_VIDEO,
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_VIDEO
} from '../../types.js'; } from '../../types.js';
import { View } from '../../view.js'; import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js'; import { getCameraTitle } from '../camera.js';
@@ -27,7 +35,7 @@ import { getCameraTitle } from '../camera.js';
* @returns The `event_id` or `null` if not successfully parsed. * @returns The `event_id` or `null` if not successfully parsed.
*/ */
export const getEventID = (media: FrigateBrowseMediaSource): string | null => { export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
return media.frigate?.event.id ?? null; return media.frigate?.event?.id ?? null;
}; };
/** /**
@@ -36,7 +44,7 @@ export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
* @returns The start time in unix/epoch time, or null if it cannot be determined. * @returns The start time in unix/epoch time, or null if it cannot be determined.
*/ */
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => { export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
return media.frigate?.event.start_time ?? null; return media.frigate?.event?.start_time ?? null;
}; };
/** /**
@@ -336,7 +344,7 @@ export const fetchLatestMediaAndDispatchViewChange = async (
try { try {
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters); parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message); return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
} }
const childIndex = getFirstTrueMediaChildIndex(parent); const childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) { if (!parent || !parent.children || childIndex == null) {
@@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
try { try {
parent = await browseMedia(hass, child.media_content_id); parent = await browseMedia(hass, child.media_content_id);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message); return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
} }
view view
@@ -409,6 +417,38 @@ export const createEventParentForChildren = (
}; };
}; };
/**
* Given a media video child with a given media_content_id.
* @param title The title to use for the child.
* @param media_con
* @param children The children media items.
* @returns A single parent containing the children.
*/
export const createVideoChild = (
title: string,
mediaContentID: string,
options?: {
thumbnail?: string;
recording?: FrigateRecording;
},
): FrigateBrowseMediaSource => {
return {
title: title,
media_class: MEDIA_CLASS_VIDEO,
media_content_type: MEDIA_TYPE_VIDEO,
media_content_id: mediaContentID,
can_play: true,
can_expand: false,
thumbnail: options?.thumbnail ?? null,
children: null,
...(options?.recording && {
frigate: {
recording: options.recording,
},
}),
};
};
/** /**
* Convenience function to convert a timestamp to hours, minutes and seconds * Convenience function to convert a timestamp to hours, minutes and seconds
* string. Heavily inspired by, and returning the same format as, the Frigate * string. Heavily inspired by, and returning the same format as, the Frigate
@@ -436,3 +476,23 @@ export function getEventDurationString(event: FrigateEvent): string {
duration += `${seconds}s`; duration += `${seconds}s`;
return duration; return duration;
} }
/**
* Generate a recording identifier.
* @param hass The HomeAssistant object.
* @param params The recording parameters to use in the identifer.
* @returns A recording identifier.
*/
export const generateRecordingIdentifier = (
params: BrowseRecordingQueryParameters,
): string => {
return [
'media-source://frigate',
params.clientId,
'recordings',
`${params.year}-${String(params.month).padStart(2, '0')}`,
String(params.day).padStart(2, '0'),
String(params.hour).padStart(2, '0'),
params.cameraName,
].join('/');
};
+79 -9
View File
@@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod'; import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import { import {
CardHelpers, ExtendedHomeAssistant, CardHelpers,
ExtendedHomeAssistant,
FrigateCardError,
SignedPath, SignedPath,
signedPathSchema, signedPathSchema,
StateParameters StateParameters
@@ -36,12 +38,16 @@ export async function homeAssistantWSRequest<T>(
const parseResult = schema.safeParse(response); const parseResult = schema.safeParse(response);
if (!parseResult.success) { if (!parseResult.success) {
const keys = getParseErrorKeys<T>(parseResult.error); const keys = getParseErrorKeys<T>(parseResult.error);
const error_message = const error_message = localize('error.invalid_response');
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` + console.warn(
localize('error.invalid_keys') + `${error_message}: ${JSON.stringify(request)}. ${localize(
`: '${keys}'`; 'error.invalid_keys',
console.warn(error_message); )}: ${keys}`,
throw new Error(error_message); );
throw new FrigateCardError(error_message, {
request: request,
invalid_keys: keys,
});
} }
return parseResult.data; return parseResult.data;
} }
@@ -74,6 +80,70 @@ export async function homeAssistantSignPath(
return hass.hassUrl(response.path); return hass.hassUrl(response.path);
} }
/**
* Make a HomeAssistant HTTP request. May throw.
* @param hass The HomeAssistant object to send the request with.
* @param schema The expected Zod schema of the response.
* @param request The request to make.
* @returns The parsed valid response or null on malformed.
*/
export async function homeAssistantHTTPRequest<T>(
hass: ExtendedHomeAssistant,
schema: ZodSchema<T>,
url: string,
params?: URLSearchParams,
): Promise<T> {
let signResponse: string | null | undefined;
try {
signResponse = await homeAssistantSignPath(hass, url);
} catch (e) {
console.warn(e);
}
if (!signResponse) {
throw new FrigateCardError(localize('error.failed_sign'), {
url: url.toString(),
});
}
const signedURL = new URL(signResponse);
if (params) {
for (const [key, value] of params.entries()) {
signedURL.searchParams.append(key, value);
}
}
const response = await fetch(signedURL.toString());
if (!response.ok) {
throw new FrigateCardError(localize('error.failed_response'), {
url: signedURL.toString(),
status: response.status,
statusText: response.statusText,
});
}
let raw_json;
try {
raw_json = await response.json();
} catch (e) {
console.warn(e);
throw new FrigateCardError(localize('error.undecodable_response'), {
url: signedURL.toString(),
});
}
try {
return schema.parse(raw_json);
} catch (e) {
console.warn(e);
throw new FrigateCardError(localize('error.invalid_response'), {
url: signedURL.toString(),
response: raw_json,
});
}
}
interface HassStateDifference { interface HassStateDifference {
entity: string; entity: string;
oldState?: HassEntity; oldState?: HassEntity;
@@ -310,7 +380,7 @@ export const isTriggeredState = (state?: HassEntity): boolean => {
* Get entities from the HASS object. * Get entities from the HASS object.
* @param hass * @param hass
* @param domain * @param domain
* @returns * @returns A list of entities ids.
*/ */
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => { export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) { if (!hass) {
@@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin
); );
entities.sort(); entities.sort();
return entities; return entities;
} };
+4 -4
View File
@@ -102,7 +102,7 @@ export class View {
* Determine if a view is for the media viewer. * Determine if a view is for the media viewer.
*/ */
public isViewerView(): boolean { public isViewerView(): boolean {
return ['clip', 'snapshot', 'event'].includes(this.view); return ['clip', 'snapshot', 'media'].includes(this.view);
} }
/** /**
@@ -145,9 +145,9 @@ export class View {
/** /**
* Dispatch an event to request a view change. * Dispatch an event to request a view change.
* @param node The element dispatching the event. * @param target The target dispatching the event.
*/ */
public dispatchChangeEvent(node: HTMLElement): void { public dispatchChangeEvent(target: EventTarget): void {
dispatchFrigateCardEvent(node, 'change-view', this); dispatchFrigateCardEvent(target, 'change-view', this);
} }
} }