Merge pull request #508 from dermotduffy/thumbnails-on-the-side
Significantly flesh out timeline support
This commit is contained in:
@@ -257,8 +257,10 @@ live:
|
|||||||
|
|
||||||
| Option | Default | Overridable | Description |
|
| Option | Default | Overridable | Description |
|
||||||
| - | - | - | - |
|
| - | - | - | - |
|
||||||
| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).|
|
| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).|
|
||||||
| `size` | `100px` | :white_check_mark: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
| `size` | `100px` | :white_check_mark: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
||||||
|
| `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||||
|
| `show_controls` | `true` | :white_check_mark: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.|
|
||||||
| `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.|
|
| `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.|
|
||||||
|
|
||||||
#### Live Controls: Next / Previous
|
#### Live Controls: Next / Previous
|
||||||
@@ -310,7 +312,7 @@ event_viewer:
|
|||||||
| `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 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. |
|
||||||
| `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 event 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. See below. |
|
| `controls` | | :heavy_multiplication_x: | Configuration for the event 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 `event_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.|
|
||||||
|
|
||||||
#### Event Viewer Controls: Next / Previous
|
#### Event Viewer Controls: Next / Previous
|
||||||
@@ -340,8 +342,10 @@ event_viewer:
|
|||||||
|
|
||||||
| Option | Default | Overridable | Description |
|
| Option | Default | Overridable | Description |
|
||||||
| - | - | - | - |
|
| - | - | - | - |
|
||||||
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).|
|
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).|
|
||||||
| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
||||||
|
| `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.|
|
||||||
|
|
||||||
#### Event Viewer Controls: Title
|
#### Event Viewer Controls: Title
|
||||||
|
|
||||||
@@ -388,6 +392,41 @@ image:
|
|||||||
| `refresh_seconds` | 0 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
| `refresh_seconds` | 0 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
||||||
| `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.|
|
| `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.|
|
||||||
|
|
||||||
|
### Timeline Options
|
||||||
|
|
||||||
|
The `timeline` is used to show the timing sequence of events across cameras.
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
timeline:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `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.|
|
||||||
|
| `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.|
|
||||||
|
| `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.|
|
||||||
|
|
||||||
|
#### Timeline Controls: Thumbnails
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
timeline:
|
||||||
|
controls:
|
||||||
|
thumbnails:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).|
|
||||||
|
| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
||||||
|
| `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.|
|
||||||
|
|
||||||
### Dimension Options
|
### Dimension Options
|
||||||
|
|
||||||
All configuration is under:
|
All configuration is under:
|
||||||
|
|||||||
+6
-3
@@ -24,16 +24,19 @@
|
|||||||
"@types/bluebird": "^3.5.36",
|
"@types/bluebird": "^3.5.36",
|
||||||
"component-emitter": "^1.3.0",
|
"component-emitter": "^1.3.0",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"custom-card-helpers": "^1.8.0",
|
"custom-card-helpers": "^1.9.0",
|
||||||
"embla-carousel": "^6.1.1",
|
"date-fns": "^2.28.0",
|
||||||
|
"embla-carousel": "^6.2.0",
|
||||||
|
"embla-carousel-wheel-gestures": "^2.1.1",
|
||||||
"home-assistant-js-websocket": "^6.1.1",
|
"home-assistant-js-websocket": "^6.1.1",
|
||||||
"lit": "^2.2.1",
|
|
||||||
"keycharm": "^0.4.0",
|
"keycharm": "^0.4.0",
|
||||||
|
"lit": "^2.2.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"propagating-hammerjs": "^2.0.1",
|
"propagating-hammerjs": "^2.0.1",
|
||||||
"quick-lru": "^6.1.0",
|
"quick-lru": "^6.1.0",
|
||||||
"screenfull": "^6.0.1",
|
"screenfull": "^6.0.1",
|
||||||
|
"side-drawer": "^3.0.0",
|
||||||
"ts-toolbelt": "^9.6.0",
|
"ts-toolbelt": "^9.6.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"vis-data": "^7.1.3",
|
"vis-data": "^7.1.3",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
|||||||
element.addEventListener('touchcancel', end);
|
element.addEventListener('touchcancel', end);
|
||||||
|
|
||||||
element.addEventListener('mousedown', start, { passive: true });
|
element.addEventListener('mousedown', start, { passive: true });
|
||||||
|
element.addEventListener('pointerdown', start, { passive: true });
|
||||||
element.addEventListener('click', end);
|
element.addEventListener('click', end);
|
||||||
|
|
||||||
element.addEventListener('keyup', handleEnter);
|
element.addEventListener('keyup', handleEnter);
|
||||||
|
|||||||
+33
-21
@@ -1,6 +1,7 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
BrowseMediaQueryParametersBase,
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParameters,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
@@ -39,8 +40,8 @@ export class BrowseMediaUtil {
|
|||||||
* @param media The media object.
|
* @param media The media object.
|
||||||
* @returns `true` if it's truly a media item, `false` otherwise.
|
* @returns `true` if it's truly a media item, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
static isTrueMedia(media: FrigateBrowseMediaSource): boolean {
|
static isTrueMedia(media?: FrigateBrowseMediaSource): boolean {
|
||||||
return !media.can_expand;
|
return !!media && !media.can_expand;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,15 +114,13 @@ export class BrowseMediaUtil {
|
|||||||
* Get the parameters to search for media.
|
* Get the parameters to search for media.
|
||||||
* @returns A BrowseMediaQueryParameters object.
|
* @returns A BrowseMediaQueryParameters object.
|
||||||
*/
|
*/
|
||||||
static getBrowseMediaQueryParameters(
|
static getBrowseMediaQueryParametersBase(
|
||||||
mediaType: 'clips' | 'snapshots',
|
|
||||||
cameraConfig?: CameraConfig,
|
cameraConfig?: CameraConfig,
|
||||||
): BrowseMediaQueryParameters | undefined {
|
): BrowseMediaQueryParametersBase | null {
|
||||||
if (!cameraConfig || !cameraConfig.camera_name) {
|
if (!cameraConfig || !cameraConfig.camera_name) {
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mediaType: mediaType,
|
|
||||||
clientId: cameraConfig.client_id,
|
clientId: cameraConfig.client_id,
|
||||||
cameraName: cameraConfig.camera_name,
|
cameraName: cameraConfig.camera_name,
|
||||||
label: cameraConfig.label,
|
label: cameraConfig.label,
|
||||||
@@ -129,33 +128,47 @@ export class BrowseMediaUtil {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the mediaType parameter from the current view.
|
||||||
|
* @param browseMediaQueryParametersBase The base media query parameters object.
|
||||||
|
* @param view The current view.
|
||||||
|
* @returns A fully populated BrowseMediaQueryParameters or null.
|
||||||
|
*/
|
||||||
|
static setMediaTypeFromView(
|
||||||
|
browseMediaQueryParametersBase: BrowseMediaQueryParametersBase | null,
|
||||||
|
view: View,
|
||||||
|
): BrowseMediaQueryParameters | null {
|
||||||
|
if (
|
||||||
|
!browseMediaQueryParametersBase ||
|
||||||
|
!(view.isClipRelatedView() || view.isSnapshotRelatedView())
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...browseMediaQueryParametersBase,
|
||||||
|
mediaType: view.isClipRelatedView() ? 'clips' : 'snapshots',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parameters to search for media related to the current view.
|
* Get the parameters to search for media related to the current view.
|
||||||
* @returns A BrowseMediaQueryParameters object.
|
* @returns A BrowseMediaQueryParameters object.
|
||||||
*/
|
*/
|
||||||
static getBrowseMediaQueryParametersOrDispatchError(
|
static getBrowseMediaQueryParametersBaseOrDispatchError(
|
||||||
node: HTMLElement,
|
node: HTMLElement,
|
||||||
view: View,
|
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
): BrowseMediaQueryParameters | undefined {
|
): BrowseMediaQueryParametersBase | null {
|
||||||
if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) {
|
// Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase()
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify there is a camera name, otherwise getBrowseMediaQueryParameters()
|
|
||||||
// will return undefined.
|
// will return undefined.
|
||||||
if (!cameraConfig.camera_name) {
|
if (!cameraConfig.camera_name) {
|
||||||
dispatchErrorMessageEvent(
|
dispatchErrorMessageEvent(
|
||||||
node,
|
node,
|
||||||
localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`,
|
localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BrowseMediaUtil.getBrowseMediaQueryParameters(
|
return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig);
|
||||||
view.isClipRelatedView() ? 'clips' : 'snapshots',
|
|
||||||
cameraConfig,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,7 +239,6 @@ export class BrowseMediaUtil {
|
|||||||
view
|
view
|
||||||
.evolve({
|
.evolve({
|
||||||
target: parent,
|
target: parent,
|
||||||
previous: view,
|
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(node);
|
.dispatchChangeEvent(node);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-7
@@ -6,12 +6,13 @@ import {
|
|||||||
html,
|
html,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
|
import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers';
|
||||||
|
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
|
||||||
import { until } from 'lit/directives/until.js';
|
|
||||||
import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers';
|
|
||||||
import screenfull from 'screenfull';
|
import screenfull from 'screenfull';
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
import { until } from 'lit/directives/until.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -180,6 +181,10 @@ export class FrigateCard extends LitElement {
|
|||||||
// A cache of resolved media URLs/mimetypes for use in the whole card.
|
// A cache of resolved media URLs/mimetypes for use in the whole card.
|
||||||
protected _resolvedMediaCache = new ResolvedMediaCache();
|
protected _resolvedMediaCache = new ResolvedMediaCache();
|
||||||
|
|
||||||
|
// The mouse handler may be called continually, throttle it to at most once
|
||||||
|
// per second for performance reasons.
|
||||||
|
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the Home Assistant object.
|
* Set the Home Assistant object.
|
||||||
*/
|
*/
|
||||||
@@ -381,7 +386,10 @@ export class FrigateCard extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._getConfig().menu.buttons.download && this._view?.isViewerView()) {
|
if (
|
||||||
|
this._getConfig().menu.buttons.download &&
|
||||||
|
(this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media))
|
||||||
|
) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
type: 'custom:frigate-card-menu-icon',
|
type: 'custom:frigate-card-menu-icon',
|
||||||
title: localize('config.menu.buttons.download'),
|
title: localize('config.menu.buttons.download'),
|
||||||
@@ -743,7 +751,7 @@ export class FrigateCard extends LitElement {
|
|||||||
* Download media being displayed in the viewer.
|
* Download media being displayed in the viewer.
|
||||||
*/
|
*/
|
||||||
protected async _downloadViewerMedia(): Promise<void> {
|
protected async _downloadViewerMedia(): Promise<void> {
|
||||||
if (!this._hass || !this._view?.isViewerView()) {
|
if (!this._hass || !(this._view?.isViewerView() || this._view?.is('timeline'))) {
|
||||||
// Should not occur.
|
// Should not occur.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -931,6 +939,13 @@ export class FrigateCard extends LitElement {
|
|||||||
this._startInteractionTimer();
|
this._startInteractionTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse movements.
|
||||||
|
*/
|
||||||
|
protected _mouseHandler(): void {
|
||||||
|
this._startInteractionTimer();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the user interaction ('screensaver') timer.
|
* Clear the user interaction ('screensaver') timer.
|
||||||
*/
|
*/
|
||||||
@@ -1067,6 +1082,7 @@ export class FrigateCard extends LitElement {
|
|||||||
if (screenfull.isEnabled) {
|
if (screenfull.isEnabled) {
|
||||||
screenfull.on('change', this._fullscreenHandler.bind(this));
|
screenfull.on('change', this._fullscreenHandler.bind(this));
|
||||||
}
|
}
|
||||||
|
this.addEventListener('mousemove', this._boundMouseHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1076,6 +1092,7 @@ export class FrigateCard extends LitElement {
|
|||||||
if (screenfull.isEnabled) {
|
if (screenfull.isEnabled) {
|
||||||
screenfull.off('change', this._fullscreenHandler.bind(this));
|
screenfull.off('change', this._fullscreenHandler.bind(this));
|
||||||
}
|
}
|
||||||
|
this.removeEventListener('mousemove', this._boundMouseHandler);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,13 +1106,15 @@ export class FrigateCard extends LitElement {
|
|||||||
// Do not artifically constrain aspect ratio if:
|
// Do not artifically constrain aspect ratio if:
|
||||||
// - It's fullscreen.
|
// - It's fullscreen.
|
||||||
// - Aspect ratio enforcement is disabled.
|
// - Aspect ratio enforcement is disabled.
|
||||||
// - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the gallery).
|
// - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the
|
||||||
|
// gallery) or timeline.
|
||||||
// - There is a message to display to the user.
|
// - There is a message to display to the user.
|
||||||
|
|
||||||
return !(
|
return !(
|
||||||
(screenfull.isEnabled && screenfull.isFullscreen) ||
|
(screenfull.isEnabled && screenfull.isFullscreen) ||
|
||||||
aspectRatioMode == 'unconstrained' ||
|
aspectRatioMode == 'unconstrained' ||
|
||||||
(aspectRatioMode == 'dynamic' && this._view?.isMediaView()) ||
|
(aspectRatioMode == 'dynamic' &&
|
||||||
|
(this._view?.isMediaView() || this._view?.is('timeline'))) ||
|
||||||
this._message != null
|
this._message != null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1138,6 +1157,8 @@ export class FrigateCard extends LitElement {
|
|||||||
specificActions = this._getConfig().event_viewer.actions;
|
specificActions = this._getConfig().event_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')) {
|
||||||
|
specificActions = this._getConfig().timeline?.actions;
|
||||||
}
|
}
|
||||||
return { ...this._getConfig().view.actions, ...specificActions };
|
return { ...this._getConfig().view.actions, ...specificActions };
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-3
@@ -8,8 +8,13 @@ import {
|
|||||||
} from 'custom-card-helpers';
|
} from 'custom-card-helpers';
|
||||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||||
import { ZodSchema, z } from 'zod';
|
import { ZodSchema, z } from 'zod';
|
||||||
|
import {
|
||||||
|
differenceInSeconds,
|
||||||
|
differenceInMinutes,
|
||||||
|
differenceInHours,
|
||||||
|
fromUnixTime,
|
||||||
|
} from 'date-fns';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
@@ -20,6 +25,7 @@ import {
|
|||||||
FrigateCardAction,
|
FrigateCardAction,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
frigateCardCustomActionSchema,
|
frigateCardCustomActionSchema,
|
||||||
|
FrigateEvent,
|
||||||
MediaShowInfo,
|
MediaShowInfo,
|
||||||
Message,
|
Message,
|
||||||
SignedPath,
|
SignedPath,
|
||||||
@@ -109,11 +115,11 @@ export async function homeAssistantSignPath(
|
|||||||
* @param detail An optional detail object to attach.
|
* @param detail An optional detail object to attach.
|
||||||
*/
|
*/
|
||||||
export function dispatchFrigateCardEvent<T>(
|
export function dispatchFrigateCardEvent<T>(
|
||||||
element: HTMLElement,
|
target: EventTarget,
|
||||||
name: string,
|
name: string,
|
||||||
detail?: T,
|
detail?: T,
|
||||||
): void {
|
): void {
|
||||||
element.dispatchEvent(
|
target.dispatchEvent(
|
||||||
new CustomEvent<T>(`frigate-card:${name}`, {
|
new CustomEvent<T>(`frigate-card:${name}`, {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
@@ -589,3 +595,31 @@ export const frigateCardHasAction = (
|
|||||||
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
|
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to convert a timestamp to hours, minutes and seconds
|
||||||
|
* string. Heavily inspired by, and returning the same format as, the Frigate
|
||||||
|
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
||||||
|
* @param event The Frigate event.
|
||||||
|
* @returns A duration string.
|
||||||
|
*/
|
||||||
|
export function getEventDurationString(event: FrigateEvent): string {
|
||||||
|
if (!event.end_time) {
|
||||||
|
return localize('event.in_progress');
|
||||||
|
}
|
||||||
|
const start = fromUnixTime(event.start_time);
|
||||||
|
const end = fromUnixTime(event.end_time);
|
||||||
|
const hours = differenceInHours(end, start);
|
||||||
|
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||||
|
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
||||||
|
let duration = '';
|
||||||
|
|
||||||
|
if (hours) {
|
||||||
|
duration += `${hours}h `;
|
||||||
|
}
|
||||||
|
if (minutes) {
|
||||||
|
duration += `${minutes}m `;
|
||||||
|
}
|
||||||
|
duration += `${seconds}s`;
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit';
|
import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import EmblaCarousel, {
|
import EmblaCarousel, {
|
||||||
EmblaCarouselType,
|
EmblaCarouselType,
|
||||||
EmblaOptionsType,
|
EmblaOptionsType,
|
||||||
EmblaPluginType,
|
EmblaPluginType,
|
||||||
} from 'embla-carousel';
|
} from 'embla-carousel';
|
||||||
|
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||||
|
|
||||||
import { TransitionEffect } from '../types';
|
import { TransitionEffect } from '../types';
|
||||||
import { dispatchFrigateCardEvent } from '../common';
|
import { dispatchFrigateCardEvent } from '../common';
|
||||||
@@ -15,6 +18,9 @@ export interface CarouselSelect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class FrigateCardCarousel extends LitElement {
|
export class FrigateCardCarousel extends LitElement {
|
||||||
|
@property({ attribute: true, reflect: true })
|
||||||
|
public direction: 'vertical' | 'horizontal' = 'horizontal';
|
||||||
|
|
||||||
protected _carousel?: EmblaCarouselType;
|
protected _carousel?: EmblaCarouselType;
|
||||||
protected _plugins: Record<string, EmblaPluginType> = {};
|
protected _plugins: Record<string, EmblaPluginType> = {};
|
||||||
|
|
||||||
@@ -71,8 +77,14 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
protected _getPlugins(): EmblaPluginType[] {
|
||||||
return undefined;
|
return [
|
||||||
|
WheelGesturesPlugin({
|
||||||
|
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||||
|
// gestures as scrolling for the carousel.
|
||||||
|
forceWheelAxis: 'y',
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _destroyCarousel(): void {
|
protected _destroyCarousel(): void {
|
||||||
@@ -98,7 +110,14 @@ export class FrigateCardCarousel extends LitElement {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
this._carousel = EmblaCarousel(carouselNode, this._getOptions(), plugins);
|
this._carousel = EmblaCarousel(
|
||||||
|
carouselNode,
|
||||||
|
{
|
||||||
|
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
||||||
|
...this._getOptions(),
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
|
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
|
||||||
this._carousel.on('select', () => {
|
this._carousel.on('select', () => {
|
||||||
const selected = this.carouselSelected();
|
const selected = this.carouselSelected();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
CSSResultGroup,
|
||||||
|
LitElement,
|
||||||
|
TemplateResult,
|
||||||
|
html,
|
||||||
|
unsafeCSS,
|
||||||
|
PropertyValues,
|
||||||
|
} from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
|
import 'side-drawer';
|
||||||
|
|
||||||
|
import drawerStyle from '../scss/drawer.scss';
|
||||||
|
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
||||||
|
|
||||||
|
@customElement('frigate-card-drawer')
|
||||||
|
export class FrigateCardDrawer extends LitElement {
|
||||||
|
@property({ attribute: true, reflect: true })
|
||||||
|
public location: 'left' | 'right' = 'left';
|
||||||
|
|
||||||
|
@property({ attribute: true, reflect: true, type: Boolean })
|
||||||
|
public control = true;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true, attribute: true })
|
||||||
|
public open = false;
|
||||||
|
|
||||||
|
protected _refDrawer: Ref<HTMLElement & { open: boolean }> = createRef();
|
||||||
|
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on the first update.
|
||||||
|
* @param changedProps The changed properties.
|
||||||
|
*/
|
||||||
|
protected firstUpdated(changedProps: PropertyValues): void {
|
||||||
|
super.firstUpdated(changedProps);
|
||||||
|
|
||||||
|
// The `side-drawer` component (and the material drawer for that matter)
|
||||||
|
// only do fixed drawers (i.e. a drawer for the whole viewport). Hackily
|
||||||
|
// override the style to customize the drawer to be absolute within the div.
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = drawerInjectStyle;
|
||||||
|
this._refDrawer.value?.shadowRoot?.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _slotChanged(): void {
|
||||||
|
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||||
|
if (elements && elements.length && this._refDrawer.value) {
|
||||||
|
// Hide the drawer unless there is content.
|
||||||
|
this._refDrawer.value.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<side-drawer
|
||||||
|
${ref(this._refDrawer)}
|
||||||
|
?hidden=${true}
|
||||||
|
location="${this.location}"
|
||||||
|
?open=${this.open}
|
||||||
|
>
|
||||||
|
${this.control
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="control-surround"
|
||||||
|
@click=${() => {
|
||||||
|
this.open = !this.open;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ha-icon
|
||||||
|
class="control"
|
||||||
|
icon="${this.open ? 'mdi:menu-open' : 'mdi:menu'}"
|
||||||
|
>
|
||||||
|
</ha-icon>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged}></slot>
|
||||||
|
</side-drawer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(drawerStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
|
import { localize } from '../localize/localize.js';
|
||||||
import { renderProgressIndicator } from './message.js';
|
import { renderProgressIndicator } from './message.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from '../common.js';
|
import { stopEventFromActivatingCardWideActions } from '../common.js';
|
||||||
|
|
||||||
@@ -37,16 +38,17 @@ export class FrigateCardGallery extends LitElement {
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.view || !this.cameraConfig) {
|
if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.view.target) {
|
if (!this.view.target) {
|
||||||
const browseMediaQueryParameters =
|
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView(
|
||||||
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
|
BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError(
|
||||||
this,
|
this,
|
||||||
this.view,
|
|
||||||
this.cameraConfig,
|
this.cameraConfig,
|
||||||
|
),
|
||||||
|
this.view,
|
||||||
);
|
);
|
||||||
if (!browseMediaQueryParameters) {
|
if (!browseMediaQueryParameters) {
|
||||||
return;
|
return;
|
||||||
@@ -127,6 +129,18 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the back arrow should be displayed.
|
||||||
|
* @returns `true` if the back arrow should be displayed, `false` otherwise.
|
||||||
|
*/
|
||||||
|
protected _showBackArrow(): boolean {
|
||||||
|
return (
|
||||||
|
!!this.view?.previous &&
|
||||||
|
!!this.view.previous.target &&
|
||||||
|
this.view.previous.view === this.view.view
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
@@ -156,7 +170,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return html` <ul class="mdc-image-list frigate-card-gallery">
|
return html` <ul class="mdc-image-list frigate-card-gallery">
|
||||||
${this.view && this.view.previous
|
${this._showBackArrow()
|
||||||
? html`<li class="mdc-image-list__item" style="${styleMap(itemStyle)}">
|
? html`<li class="mdc-image-list__item" style="${styleMap(itemStyle)}">
|
||||||
<div class="mdc-image-list__image-aspect-container">
|
<div class="mdc-image-list__image-aspect-container">
|
||||||
<div class="mdc-image-list__image">
|
<div class="mdc-image-list__image">
|
||||||
@@ -206,22 +220,24 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
class="mdc-image-list__image"
|
class="mdc-image-list__image"
|
||||||
src="${child.thumbnail}"
|
src="${child.thumbnail}"
|
||||||
title="${child.title}"
|
title="${child.title}"
|
||||||
@click=${(ev) => {
|
@click=${(ev: Event) => {
|
||||||
if (this.view) {
|
if (this.view) {
|
||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
view: this.view.is('clips') ? 'clip' : 'snapshot',
|
view: this.view.is('clips') ? 'clip' : 'snapshot',
|
||||||
childIndex: index,
|
childIndex: index,
|
||||||
previous: this.view,
|
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
/>${child.frigate?.event?.retain_indefinitely ? html`<ha-icon
|
/>${child.frigate?.event?.retain_indefinitely
|
||||||
|
? html`<ha-icon
|
||||||
class="favorite"
|
class="favorite"
|
||||||
icon="mdi:star"
|
icon="mdi:star"
|
||||||
/>` : ``}`
|
title=${localize('thumbnail.retain_indefinitely')}
|
||||||
|
/>`
|
||||||
|
: ``}`
|
||||||
: ``}
|
: ``}
|
||||||
</div>
|
</div>
|
||||||
</li>`,
|
</li>`,
|
||||||
|
|||||||
+27
-84
@@ -7,7 +7,6 @@ import {
|
|||||||
PropertyValues,
|
PropertyValues,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import {
|
import {
|
||||||
FrigateBrowseMediaSource,
|
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
JSMPEGConfig,
|
JSMPEGConfig,
|
||||||
@@ -20,25 +19,21 @@ import {
|
|||||||
LiveProvider,
|
LiveProvider,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
frigateCardConfigDefaults,
|
frigateCardConfigDefaults,
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||||
import { Task } from '@lit-labs/task';
|
import { Task } from '@lit-labs/task';
|
||||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { until } from 'lit/directives/until.js';
|
import { until } from 'lit/directives/until.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
|
||||||
|
|
||||||
import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js';
|
import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js';
|
||||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||||
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
|
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
|
||||||
import { FrigateCardMediaCarousel } from './media-carousel.js';
|
import { FrigateCardMediaCarousel } from './media-carousel.js';
|
||||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||||
import {
|
|
||||||
FrigateCardThumbnailCarousel,
|
|
||||||
ThumbnailCarouselTap,
|
|
||||||
} from './thumbnail-carousel.js';
|
|
||||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
@@ -104,9 +99,6 @@ export class FrigateCardLive extends LitElement {
|
|||||||
// pre-loading it may be propagated upwards later.
|
// pre-loading it may be propagated upwards later.
|
||||||
protected _savedMediaShowInfo?: MediaShowInfo;
|
protected _savedMediaShowInfo?: MediaShowInfo;
|
||||||
|
|
||||||
@query('frigate-card-thumbnail-carousel')
|
|
||||||
protected _thumbnailCarousel?: FrigateCardThumbnailCarousel;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for media show events that special cases preloaded live views.
|
* Handler for media show events that special cases preloaded live views.
|
||||||
* @param e The media show event.
|
* @param e The media show event.
|
||||||
@@ -120,79 +112,12 @@ export class FrigateCardLive extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render thumbnails carousel.
|
|
||||||
* @returns A rendered template or void.
|
|
||||||
*/
|
|
||||||
protected renderThumbnails(config: LiveConfig): TemplateResult | void {
|
|
||||||
if (!this.liveConfig || !this.view) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchThumbnailsThenRender = async (): Promise<TemplateResult | void> => {
|
|
||||||
if (!this.hass || !this.cameras || !this.view) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters(
|
|
||||||
config.controls.thumbnails.media,
|
|
||||||
this.cameras.get(this.view.camera),
|
|
||||||
);
|
|
||||||
if (!browseMediaParams) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let parent: FrigateBrowseMediaSource | null;
|
|
||||||
try {
|
|
||||||
parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams);
|
|
||||||
} catch (e) {
|
|
||||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
|
|
||||||
return html`<frigate-card-thumbnail-carousel
|
|
||||||
.target=${parent}
|
|
||||||
.view=${this.view}
|
|
||||||
.config=${config.controls.thumbnails}
|
|
||||||
.highlightSelected=${false}
|
|
||||||
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
|
||||||
const mediaType = browseMediaParams.mediaType;
|
|
||||||
if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) {
|
|
||||||
new View({
|
|
||||||
view: mediaType === 'clips' ? 'clip' : 'snapshot',
|
|
||||||
camera: this.view.camera,
|
|
||||||
target: ev.detail.target,
|
|
||||||
childIndex: ev.detail.childIndex,
|
|
||||||
}).dispatchChangeEvent(this);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</frigate-card-thumbnail-carousel>`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fillerStyle = {
|
|
||||||
height: config.controls.thumbnails.size,
|
|
||||||
};
|
|
||||||
|
|
||||||
// As the live carousel moves, thumbnails are re-fetched. This is an async
|
|
||||||
// request, so it can jarring to the user to have the main camera view nudge
|
|
||||||
// up/down as the thumbnails disappear and re-appear. Instead, if there was
|
|
||||||
// previously a thumbnail carousel rendered, use a filler that is the same
|
|
||||||
// size until it is replaced with a real carousel (or empty, if no carousel
|
|
||||||
// is rendered for the next camera).
|
|
||||||
return html`${until(
|
|
||||||
fetchThumbnailsThenRender(),
|
|
||||||
this._thumbnailCarousel
|
|
||||||
? html` <div style="${styleMap(fillerStyle)}"></div>`
|
|
||||||
: html``,
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.liveConfig || !this.cameras) {
|
if (!this.hass || !this.liveConfig || !this.cameras || !this.view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +127,26 @@ export class FrigateCardLive extends LitElement {
|
|||||||
this.conditionState,
|
this.conditionState,
|
||||||
) as LiveConfig;
|
) as LiveConfig;
|
||||||
|
|
||||||
|
const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase(
|
||||||
|
this.cameras.get(this.view.camera),
|
||||||
|
);
|
||||||
|
if (!browseMediaParamsBase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const browseMediaParams: BrowseMediaQueryParameters = {
|
||||||
|
...browseMediaParamsBase,
|
||||||
|
mediaType: config.controls.thumbnails.media,
|
||||||
|
}
|
||||||
|
|
||||||
// Note use of liveConfig and not config below -- the carousel will
|
// Note use of liveConfig and not config below -- the carousel will
|
||||||
// independently override the liveconfig to reflect the camera in the
|
// independently override the liveconfig to reflect the camera in the
|
||||||
// carousel (not necessarily the selected camera).
|
// carousel (not necessarily the selected camera).
|
||||||
return html`
|
return html` <frigate-card-surround-thumbnails
|
||||||
${config.controls.thumbnails.mode === 'above' ? this.renderThumbnails(config) : ''}
|
.hass=${this.hass}
|
||||||
|
.view=${this.view}
|
||||||
|
.config=${config.controls.thumbnails}
|
||||||
|
.browseMediaParams=${browseMediaParams}
|
||||||
|
>
|
||||||
<frigate-card-live-carousel
|
<frigate-card-live-carousel
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
@@ -228,8 +168,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-live-carousel>
|
</frigate-card-live-carousel>
|
||||||
${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''}
|
</frigate-card-surround-thumbnails>`;
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -344,8 +283,9 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
protected _getPlugins(): EmblaPluginType[] {
|
||||||
return [
|
return [
|
||||||
|
...super._getPlugins(),
|
||||||
Lazyload({
|
Lazyload({
|
||||||
lazyloadCallback: this.liveConfig?.lazy_load
|
lazyloadCallback: this.liveConfig?.lazy_load
|
||||||
? (...args) => this._lazyloadOrUnloadSlide('load', ...args)
|
? (...args) => this._lazyloadOrUnloadSlide('load', ...args)
|
||||||
@@ -417,7 +357,10 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
|
|||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
camera: Array.from(this.cameras.keys())[selectedSnap],
|
camera: Array.from(this.cameras.keys())[selectedSnap],
|
||||||
previous: this.view,
|
|
||||||
|
// Reset the target so thumbnails will be re-fetched.
|
||||||
|
target: null,
|
||||||
|
childIndex: null,
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { Task } from '@lit-labs/task';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||||
|
import {
|
||||||
|
BrowseMediaQueryParameters,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
FrigateBrowseMediaSource,
|
||||||
|
FrigateCardView,
|
||||||
|
ThumbnailsControlConfig,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||||
|
import { View } from '../view.js';
|
||||||
|
import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js';
|
||||||
|
|
||||||
|
import './surround.js';
|
||||||
|
|
||||||
|
import surroundThumbnailsStyle from '../scss/surround.scss';
|
||||||
|
|
||||||
|
@customElement('frigate-card-surround-thumbnails')
|
||||||
|
export class FrigateCardSurround extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected view?: Readonly<View>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected config?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected targetView?: FrigateCardView;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected browseMediaParams?: BrowseMediaQueryParameters;
|
||||||
|
|
||||||
|
// A task to await the load of the WebRTC component.
|
||||||
|
protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [
|
||||||
|
this.hass,
|
||||||
|
this.view,
|
||||||
|
this.browseMediaParams,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch thumbnail media when a target is not specified in the view (e.g. for
|
||||||
|
* the live view).
|
||||||
|
* @param param Task parameters.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
protected async _fetchMedia([hass, view, browseMediaParams]: (
|
||||||
|
| (HomeAssistant & ExtendedHomeAssistant)
|
||||||
|
| Readonly<View>
|
||||||
|
| BrowseMediaQueryParameters
|
||||||
|
| undefined
|
||||||
|
)[]): Promise<void> {
|
||||||
|
hass = hass as HomeAssistant & ExtendedHomeAssistant;
|
||||||
|
view = view as Readonly<View>;
|
||||||
|
browseMediaParams = browseMediaParams as BrowseMediaQueryParameters;
|
||||||
|
|
||||||
|
if (!hass || !view || view.target || !browseMediaParams) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parent: FrigateBrowseMediaSource | null;
|
||||||
|
try {
|
||||||
|
parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaParams);
|
||||||
|
} catch (e) {
|
||||||
|
return dispatchErrorMessageEvent(this, (e as Error).message);
|
||||||
|
}
|
||||||
|
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
...(this.targetView && { view: this.targetView }),
|
||||||
|
target: parent,
|
||||||
|
childIndex: null,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a drawer is being used.
|
||||||
|
* @returns `true` if a drawer is used, `false` otherwise.
|
||||||
|
*/
|
||||||
|
protected _hasDrawer(): boolean {
|
||||||
|
return !!this.config && ['left', 'right'].includes(this.config.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master render method.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
if (!this.hass || !this.view || !this.config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
|
||||||
|
// The event catch/re-dispatch below protect encapsulation: Catches the
|
||||||
|
// request to view thumbnails and re-dispatches a request to open the drawer
|
||||||
|
// (if the thumbnails are in a drawer). The new event needs to be dispatched
|
||||||
|
// from the origin of the inbound event, so it can be handled by
|
||||||
|
// <frigate-card-surround> .
|
||||||
|
if (this.config && this._hasDrawer()) {
|
||||||
|
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
|
||||||
|
drawer: this.config.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return html` <frigate-card-surround
|
||||||
|
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
|
||||||
|
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||||
|
>
|
||||||
|
${this.config?.mode !== 'none'
|
||||||
|
? html` <frigate-card-thumbnail-carousel
|
||||||
|
slot=${this.config.mode}
|
||||||
|
.config=${this.config}
|
||||||
|
.view=${this.view}
|
||||||
|
.target=${this.view.target}
|
||||||
|
.selected=${this.view.childIndex}
|
||||||
|
@frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||||
|
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
view: this.targetView || 'event',
|
||||||
|
target: ev.detail.target,
|
||||||
|
childIndex: ev.detail.childIndex,
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</frigate-card-thumbnail-carousel>`
|
||||||
|
: ''}
|
||||||
|
<slot></slot>
|
||||||
|
</frigate-card-surround>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return compiled CSS styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(surroundThumbnailsStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
|
import { customElement } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { FrigateCardDrawer } from './drawer.js';
|
||||||
|
|
||||||
|
import './drawer.js';
|
||||||
|
|
||||||
|
import surroundStyle from '../scss/surround.scss';
|
||||||
|
|
||||||
|
interface FrigateCardDrawerOpen {
|
||||||
|
drawer: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('frigate-card-surround')
|
||||||
|
export class FrigateCardSurround extends LitElement {
|
||||||
|
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
|
||||||
|
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
|
||||||
|
protected _boundDrawerHandler = this._drawerHandler.bind(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component connected callback.
|
||||||
|
*/
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
||||||
|
this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component disconnected callback.
|
||||||
|
*/
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
||||||
|
this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _drawerHandler(ev: Event) {
|
||||||
|
const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
|
||||||
|
const open = ev.type.endsWith(':open');
|
||||||
|
if (drawer === 'left' && this._refDrawerLeft.value) {
|
||||||
|
this._refDrawerLeft.value.open = open;
|
||||||
|
} else if (drawer === 'right' && this._refDrawerRight.value) {
|
||||||
|
this._refDrawerRight.value.open = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master render method.
|
||||||
|
* @returns A rendered template.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
return html` <slot name="above"></slot>
|
||||||
|
<slot></slot>
|
||||||
|
<frigate-card-drawer ${ref(this._refDrawerLeft)} location="left">
|
||||||
|
<slot name="left"></slot>
|
||||||
|
</frigate-card-drawer>
|
||||||
|
<frigate-card-drawer ${ref(this._refDrawerRight)} location="right">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</frigate-card-drawer>
|
||||||
|
<slot name="below"></slot>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return compiled CSS styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResultGroup {
|
||||||
|
return unsafeCSS(surroundStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||||
import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit';
|
import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit';
|
||||||
import { EmblaOptionsType } from 'embla-carousel';
|
import { EmblaOptionsType } from 'embla-carousel';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
|
import type {
|
||||||
|
FrigateBrowseMediaSource,
|
||||||
|
ThumbnailsControlConfig,
|
||||||
|
} from '../types.js';
|
||||||
import { FrigateCardCarousel } from './carousel.js';
|
import { FrigateCardCarousel } from './carousel.js';
|
||||||
import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js';
|
import { View } from '../view.js';
|
||||||
|
import {
|
||||||
|
contentsChanged,
|
||||||
|
dispatchFrigateCardEvent,
|
||||||
|
stopEventFromActivatingCardWideActions,
|
||||||
|
} from '../common.js';
|
||||||
|
|
||||||
|
import './thumbnail.js';
|
||||||
|
|
||||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||||
|
|
||||||
@@ -18,27 +31,72 @@ export interface ThumbnailCarouselTap {
|
|||||||
@customElement('frigate-card-thumbnail-carousel')
|
@customElement('frigate-card-thumbnail-carousel')
|
||||||
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected target?: FrigateBrowseMediaSource;
|
protected view?: Readonly<View>;
|
||||||
|
|
||||||
protected _tapSelected?;
|
// Use contentsChanged here to avoid the carousel rebuilding and resetting in
|
||||||
|
// front of the user, unless the contents have actually changed.
|
||||||
|
@property({ attribute: false, hasChanged: contentsChanged })
|
||||||
|
public target?: FrigateBrowseMediaSource | null;
|
||||||
|
|
||||||
|
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
|
||||||
|
// media loads). The carousel must be re-initialized in these cases, or the
|
||||||
|
// dynamic sizing fails (and users can scroll past the end of the carousel).
|
||||||
|
protected _resizeObserver: ResizeObserver;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
set config(config: ThumbnailsControlConfig | undefined) {
|
set selected(selected: number | null) {
|
||||||
if (config) {
|
this._selected = selected;
|
||||||
if (config && config.size !== undefined && config.size !== null) {
|
if (selected !== null) {
|
||||||
this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size);
|
// If there is a selection, 'dim' all the other slides.
|
||||||
|
this.style.setProperty('--frigate-card-carousel-thumbnail-opacity', '0.4');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
set config(config: ThumbnailsControlConfig) {
|
||||||
|
this.direction = ['left', 'right'].includes(config.mode) ? 'vertical' : 'horizontal';
|
||||||
this._config = config;
|
this._config = config;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@state()
|
||||||
protected _config?: ThumbnailsControlConfig;
|
protected _config?: ThumbnailsControlConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@state()
|
||||||
set highlightSelected(value: boolean) {
|
protected _selected?: number | null;
|
||||||
this.style.setProperty(
|
|
||||||
'--frigate-card-carousel-thumbnail-opacity',
|
/**
|
||||||
value ? '0.6' : '1.0',
|
* Handle gallery resize.
|
||||||
);
|
*/
|
||||||
|
protected _resizeHandler(): void {
|
||||||
|
if (this._carousel) {
|
||||||
|
this._carousel.reInit();
|
||||||
|
// Reinit will cause the scroll position to reset, so re-scroll to the
|
||||||
|
// correct location.
|
||||||
|
if (this._selected !== undefined && this._selected !== null) {
|
||||||
|
this.carouselScrollTo(this._selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component connected callback.
|
||||||
|
*/
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._resizeObserver.observe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component disconnected callback.
|
||||||
|
*/
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this._resizeObserver.disconnect();
|
||||||
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,28 +107,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
|||||||
return {
|
return {
|
||||||
containScroll: 'keepSnaps',
|
containScroll: 'keepSnaps',
|
||||||
dragFree: true,
|
dragFree: true,
|
||||||
|
startIndex: this._selected ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scroll to a particular slide.
|
|
||||||
* @param index Slide number.
|
|
||||||
*/
|
|
||||||
carouselScrollTo(index: number): void {
|
|
||||||
if (!this._carousel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._tapSelected !== undefined) {
|
|
||||||
this._carousel.slideNodes()[this._tapSelected].classList.remove('slide-selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
super.carouselScrollTo(index);
|
|
||||||
|
|
||||||
this._carousel.slideNodes()[index].classList.add('slide-selected');
|
|
||||||
this._tapSelected = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get slides to include in the render.
|
* Get slides to include in the render.
|
||||||
* @returns The slides to include in the render.
|
* @returns The slides to include in the render.
|
||||||
@@ -90,6 +130,27 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
|||||||
return slides;
|
return slides;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updated lifecycle callback for this element.
|
||||||
|
* @param changedProperties The properties that were changed in this render.
|
||||||
|
*/
|
||||||
|
updated(changedProperties: PropertyValues): void {
|
||||||
|
if (changedProperties.has('target')) {
|
||||||
|
this._destroyCarousel();
|
||||||
|
}
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
if (changedProperties.has('_selected')) {
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
if (this._carousel) {
|
||||||
|
if (this._selected !== undefined && this._selected !== null) {
|
||||||
|
this.carouselScrollTo(this._selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a given thumbnail.
|
* Render a given thumbnail.
|
||||||
* @param mediaToRender The media item to render.
|
* @param mediaToRender The media item to render.
|
||||||
@@ -100,17 +161,27 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
|||||||
childIndex: number,
|
childIndex: number,
|
||||||
slideIndex: number,
|
slideIndex: number,
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
if (!parent.children || !parent.children.length) {
|
if (
|
||||||
|
!parent.children ||
|
||||||
|
!parent.children.length ||
|
||||||
|
!BrowseMediaUtil.isTrueMedia(parent.children[childIndex])
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaToRender = parent.children[childIndex];
|
const classes = {
|
||||||
if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) {
|
embla__slide: true,
|
||||||
return;
|
'slide-selected': this._selected === childIndex,
|
||||||
}
|
};
|
||||||
|
|
||||||
return html`<div
|
return html` <frigate-card-thumbnail
|
||||||
class="embla__slide"
|
.view=${this.view}
|
||||||
|
.target=${parent}
|
||||||
|
.childIndex=${childIndex}
|
||||||
|
?details=${this._config?.show_details}
|
||||||
|
?controls=${this._config?.show_controls}
|
||||||
|
thumbnail_size=${ifDefined(this._config?.size)}
|
||||||
|
class="${classMap(classes)}"
|
||||||
@click=${(ev) => {
|
@click=${(ev) => {
|
||||||
if (this._carousel && this._carousel.clickAllowed()) {
|
if (this._carousel && this._carousel.clickAllowed()) {
|
||||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(this, 'carousel:tap', {
|
dispatchFrigateCardEvent<ThumbnailCarouselTap>(this, 'carousel:tap', {
|
||||||
@@ -122,17 +193,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
|||||||
stopEventFromActivatingCardWideActions(ev);
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
</frigate-card-thumbnail>`;
|
||||||
aria-label="${mediaToRender.title}"
|
|
||||||
src="${mediaToRender.thumbnail}"
|
|
||||||
title="${mediaToRender.title}"
|
|
||||||
/>
|
|
||||||
${mediaToRender?.frigate?.event?.retain_indefinitely ? html`
|
|
||||||
<ha-icon
|
|
||||||
class="favorite"
|
|
||||||
icon="mdi:star"
|
|
||||||
/>` : ``}
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { CSSResult, TemplateResult, html, unsafeCSS, LitElement } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { format, fromUnixTime } from 'date-fns';
|
||||||
|
|
||||||
|
import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js';
|
||||||
|
import { View } from '../view.js';
|
||||||
|
import {
|
||||||
|
getEventDurationString,
|
||||||
|
prettifyFrigateName,
|
||||||
|
stopEventFromActivatingCardWideActions,
|
||||||
|
} from '../common.js';
|
||||||
|
import { localize } from '../localize/localize.js';
|
||||||
|
|
||||||
|
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||||
|
|
||||||
|
@customElement('frigate-card-thumbnail')
|
||||||
|
export class FrigateCardThumbnail extends LitElement {
|
||||||
|
@property({ attribute: true, type: Boolean })
|
||||||
|
public details = false;
|
||||||
|
|
||||||
|
@property({ attribute: true, type: Boolean })
|
||||||
|
public controls = false;
|
||||||
|
|
||||||
|
@property({ attribute: true })
|
||||||
|
set thumbnail_size(size: string) {
|
||||||
|
this.style.setProperty('--frigate-card-thumbnail-size', String(size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Data-binding based interface
|
||||||
|
// ============================
|
||||||
|
@property({ attribute: false })
|
||||||
|
protected view?: Readonly<View>;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public target?: FrigateBrowseMediaSource;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public childIndex?: number;
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// Overrides that can be used if data bindings are not available
|
||||||
|
// =============================================================
|
||||||
|
@property({ attribute: true })
|
||||||
|
public thumbnail?: string;
|
||||||
|
|
||||||
|
@property({ attribute: true })
|
||||||
|
public label?: string;
|
||||||
|
|
||||||
|
@property({ attribute: true })
|
||||||
|
public event?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the element.
|
||||||
|
* @returns A template to display to the user.
|
||||||
|
*/
|
||||||
|
protected render(): TemplateResult | void {
|
||||||
|
let event: FrigateEvent | null = null;
|
||||||
|
let thumbnail: string | null = null;
|
||||||
|
let label: string | null = null;
|
||||||
|
|
||||||
|
// Take the event / thumbnail / label from the data-bound media (if specified).
|
||||||
|
if (this.target && this.target.children && this.childIndex !== undefined) {
|
||||||
|
const media = this.target.children[this.childIndex];
|
||||||
|
event = media.frigate?.event ?? null;
|
||||||
|
thumbnail = media.thumbnail;
|
||||||
|
label = media.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always give the overrides preference (if specified).
|
||||||
|
if (this.event) {
|
||||||
|
event = JSON.parse(this.event);
|
||||||
|
}
|
||||||
|
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
||||||
|
label = this.label ? this.label : label;
|
||||||
|
|
||||||
|
if (!thumbnail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html` <img
|
||||||
|
aria-label="${label ?? ''}"
|
||||||
|
src="${thumbnail}"
|
||||||
|
title="${label ?? ''}"
|
||||||
|
/>
|
||||||
|
${this.controls && event?.retain_indefinitely
|
||||||
|
? html` <ha-icon
|
||||||
|
class="favorite"
|
||||||
|
icon="mdi:star"
|
||||||
|
title=${localize('thumbnail.retain_indefinitely')}
|
||||||
|
/>`
|
||||||
|
: ``}
|
||||||
|
${this.details && event
|
||||||
|
? html` <div class="details">
|
||||||
|
<div class="left">
|
||||||
|
<div class="larger">${prettifyFrigateName(event.label)}</div>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span class="heading">${localize('event.start')}:</span>
|
||||||
|
${format(fromUnixTime(event.start_time), 'HH:mm:ss')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span class="heading">${localize('event.duration')}:</span>
|
||||||
|
${getEventDurationString(event)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<div class="larger">${(event.top_score * 100).toFixed(2) + '%'}</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: html``}
|
||||||
|
${this.controls
|
||||||
|
? html`<ha-icon
|
||||||
|
class="timeline"
|
||||||
|
icon="mdi:target"
|
||||||
|
title=${localize('thumbnail.timeline')}
|
||||||
|
@click=${(ev: Event) => {
|
||||||
|
stopEventFromActivatingCardWideActions(ev);
|
||||||
|
this.view
|
||||||
|
?.evolve({
|
||||||
|
view: 'timeline',
|
||||||
|
target: this.target,
|
||||||
|
childIndex: this.childIndex ?? null,
|
||||||
|
context: {},
|
||||||
|
})
|
||||||
|
.dispatchChangeEvent(this);
|
||||||
|
}}
|
||||||
|
></ha-icon>`
|
||||||
|
: ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get element styles.
|
||||||
|
*/
|
||||||
|
static get styles(): CSSResult {
|
||||||
|
return unsafeCSS(thumbnailStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
+638
-233
File diff suppressed because it is too large
Load Diff
+48
-120
@@ -10,14 +10,14 @@ import { BrowseMediaUtil } from '../browse-media-util.js';
|
|||||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { Task } from '@lit-labs/task';
|
import { Task } from '@lit-labs/task';
|
||||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
import { ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||||
import type {
|
import type {
|
||||||
BrowseMediaNeighbors,
|
BrowseMediaNeighbors,
|
||||||
BrowseMediaQueryParameters,
|
BrowseMediaQueryParametersBase,
|
||||||
FrigateBrowseMediaSource,
|
FrigateBrowseMediaSource,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
@@ -27,10 +27,6 @@ import type {
|
|||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
|
import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
|
||||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||||
import {
|
|
||||||
FrigateCardThumbnailCarousel,
|
|
||||||
ThumbnailCarouselTap,
|
|
||||||
} from './thumbnail-carousel.js';
|
|
||||||
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
||||||
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
|
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
|
||||||
import { View } from '../view.js';
|
import { View } from '../view.js';
|
||||||
@@ -46,7 +42,6 @@ import './next-prev-control.js';
|
|||||||
import './title-control.js';
|
import './title-control.js';
|
||||||
|
|
||||||
import viewerStyle from '../scss/viewer.scss';
|
import viewerStyle from '../scss/viewer.scss';
|
||||||
import viewerCoreStyle from '../scss/viewer-core.scss';
|
|
||||||
|
|
||||||
@customElement('frigate-card-viewer')
|
@customElement('frigate-card-viewer')
|
||||||
export class FrigateCardViewer extends LitElement {
|
export class FrigateCardViewer extends LitElement {
|
||||||
@@ -70,21 +65,25 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.view || !this.cameraConfig) {
|
if (!this.hass || !this.view || !this.cameraConfig || !this.viewerConfig) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const browseMediaQueryParameters =
|
const browseMediaQueryParametersBase =
|
||||||
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
|
BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError(
|
||||||
this,
|
this,
|
||||||
this.view,
|
|
||||||
this.cameraConfig,
|
this.cameraConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!this.view.target) {
|
||||||
|
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView(
|
||||||
|
browseMediaQueryParametersBase,
|
||||||
|
this.view,
|
||||||
|
);
|
||||||
if (!browseMediaQueryParameters) {
|
if (!browseMediaQueryParameters) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.view.target) {
|
|
||||||
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
|
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
|
||||||
this,
|
this,
|
||||||
this.hass,
|
this.hass,
|
||||||
@@ -94,14 +93,20 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
return renderProgressIndicator();
|
return renderProgressIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
return html` <frigate-card-viewer-core
|
return html` <frigate-card-surround-thumbnails
|
||||||
|
.hass=${this.hass}
|
||||||
|
.view=${this.view}
|
||||||
|
.config=${this.viewerConfig.controls.thumbnails}
|
||||||
|
>
|
||||||
|
<frigate-card-viewer-carousel
|
||||||
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.viewerConfig=${this.viewerConfig}
|
.viewerConfig=${this.viewerConfig}
|
||||||
|
.browseMediaQueryParametersBase=${browseMediaQueryParametersBase}
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||||
.hass=${this.hass}
|
|
||||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
|
||||||
>
|
>
|
||||||
</frigate-card-viewer-core>`;
|
</frigate-card-viewer-carousel>
|
||||||
|
</frigate-card-surround-thumbnails>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,82 +117,6 @@ export class FrigateCardViewer extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-viewer-core')
|
|
||||||
export class FrigateCardViewerCore extends LitElement {
|
|
||||||
@property({ attribute: false })
|
|
||||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
protected view?: Readonly<View>;
|
|
||||||
|
|
||||||
// See note on viewerConfig in <frigate-card-viewer-carousel>.
|
|
||||||
@property({ attribute: false, hasChanged: contentsChanged })
|
|
||||||
protected viewerConfig?: ViewerConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
|
||||||
|
|
||||||
protected _viewerCarouselRef: Ref<FrigateCardViewerCarousel> = createRef();
|
|
||||||
protected _thumbnailCarouselRef: Ref<FrigateCardThumbnailCarousel> = createRef();
|
|
||||||
|
|
||||||
protected _syncThumbnailCarousel(): void {
|
|
||||||
const mediaSelected = this._viewerCarouselRef.value?.carouselSelected();
|
|
||||||
if (mediaSelected !== undefined) {
|
|
||||||
this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _renderThumbnails(): TemplateResult {
|
|
||||||
if (!this.view || !this.viewerConfig) {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html` <frigate-card-thumbnail-carousel
|
|
||||||
${ref(this._thumbnailCarouselRef)}
|
|
||||||
.target=${this.view.target}
|
|
||||||
.config=${this.viewerConfig.controls.thumbnails}
|
|
||||||
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
|
||||||
this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex);
|
|
||||||
}}
|
|
||||||
@frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)}
|
|
||||||
>
|
|
||||||
</frigate-card-thumbnail-carousel>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
|
||||||
if (!this.view || !this.viewerConfig) {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
return html` ${this.viewerConfig &&
|
|
||||||
this.viewerConfig.controls.thumbnails.mode === 'above'
|
|
||||||
? this._renderThumbnails()
|
|
||||||
: ''}
|
|
||||||
<frigate-card-viewer-carousel
|
|
||||||
${ref(this._viewerCarouselRef)}
|
|
||||||
.hass=${this.hass}
|
|
||||||
.view=${this.view}
|
|
||||||
.viewerConfig=${this.viewerConfig}
|
|
||||||
.browseMediaQueryParameters=${this.browseMediaQueryParameters}
|
|
||||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
|
||||||
@frigate-card:carousel:select=${this._syncThumbnailCarousel.bind(this)}
|
|
||||||
>
|
|
||||||
</frigate-card-viewer-carousel>
|
|
||||||
${this.viewerConfig && this.viewerConfig.controls.thumbnails.mode === 'below'
|
|
||||||
? this._renderThumbnails()
|
|
||||||
: ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get element styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return unsafeCSS(viewerCoreStyle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 })
|
||||||
@@ -205,7 +134,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
protected viewerConfig?: ViewerConfig;
|
protected viewerConfig?: ViewerConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
protected resolvedMediaCache?: ResolvedMediaCache;
|
||||||
@@ -216,11 +145,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
|
|
||||||
// A task to resolve target media if lazy loading is disabled.
|
// A task to resolve target media if lazy loading is disabled.
|
||||||
protected _mediaResolutionTask = new Task<
|
protected _mediaResolutionTask = new Task<
|
||||||
[FrigateBrowseMediaSource | undefined],
|
[FrigateBrowseMediaSource | null | undefined],
|
||||||
void
|
void
|
||||||
>(
|
>(
|
||||||
this,
|
this,
|
||||||
async ([target]: (FrigateBrowseMediaSource | undefined)[]): Promise<void> => {
|
async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise<void> => {
|
||||||
for (
|
for (
|
||||||
let i = 0;
|
let i = 0;
|
||||||
!this.viewerConfig?.lazy_load &&
|
!this.viewerConfig?.lazy_load &&
|
||||||
@@ -254,12 +183,12 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
if (this._carousel && changedProperties.has('view')) {
|
if (this._carousel && changedProperties.has('view')) {
|
||||||
const oldView = changedProperties.get('view') as View | undefined;
|
const oldView = changedProperties.get('view') as View | undefined;
|
||||||
if (oldView) {
|
if (oldView) {
|
||||||
if (oldView.target != this.view?.target) {
|
if (oldView.target !== this.view?.target) {
|
||||||
// If the media target is different entirely, reset the carousel.
|
// If the media target is different entirely, reset the carousel.
|
||||||
this._destroyCarousel();
|
this._destroyCarousel();
|
||||||
} else if (this.view?.childIndex != oldView.childIndex) {
|
} else if (this.view.childIndex != oldView.childIndex) {
|
||||||
const slide = this._getSlideForChild(this.view?.childIndex);
|
const slide = this._getSlideForChild(this.view.childIndex);
|
||||||
if (slide !== undefined && slide !== this.carouselSelected()) {
|
if (slide !== null && slide !== this.carouselSelected()) {
|
||||||
// If the media target is the same as already loaded, but isn't of
|
// If the media target is the same as already loaded, but isn't of
|
||||||
// the selected slide, scroll to that slide.
|
// the selected slide, scroll to that slide.
|
||||||
this.carouselScrollTo(slide);
|
this.carouselScrollTo(slide);
|
||||||
@@ -297,14 +226,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
// need to be destroyed here.
|
// need to be destroyed here.
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getSlideForChild(childIndex: number | undefined): number | undefined {
|
/**
|
||||||
if (childIndex === undefined) {
|
* Get the slide number given a media child number.
|
||||||
return undefined;
|
* @param childIndex The child index (relative to `view.target`)
|
||||||
|
* @returns A number or null if the child is not found.
|
||||||
|
*/
|
||||||
|
protected _getSlideForChild(childIndex: number | null | undefined): number | null {
|
||||||
|
if (childIndex === undefined || childIndex === null) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
const slideIndex = Object.keys(this._slideToChild).find(
|
const slideIndex = Object.keys(this._slideToChild).find(
|
||||||
(key) => this._slideToChild[key] === childIndex,
|
(key) => this._slideToChild[key] === childIndex,
|
||||||
);
|
);
|
||||||
return slideIndex !== undefined ? Number(slideIndex) : undefined;
|
return slideIndex !== undefined ? Number(slideIndex) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,7 +256,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
protected _getOptions(): EmblaOptionsType {
|
protected _getOptions(): EmblaOptionsType {
|
||||||
return {
|
return {
|
||||||
// Start the carousel on the selected child number.
|
// Start the carousel on the selected child number.
|
||||||
startIndex: this._getSlideForChild(this.view?.childIndex),
|
startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
|
||||||
draggable: this.viewerConfig?.draggable,
|
draggable: this.viewerConfig?.draggable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -331,23 +265,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
protected _getPlugins(): EmblaPluginType[] {
|
||||||
return [
|
return [
|
||||||
|
...super._getPlugins(),
|
||||||
Lazyload({
|
Lazyload({
|
||||||
lazyloadCallback: this.viewerConfig?.lazy_load
|
lazyloadCallback: this.viewerConfig?.lazy_load
|
||||||
? this._lazyloadSlide.bind(this)
|
? this._lazyloadSlide.bind(this)
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
// Don't need autoplay/pause for snapshots.
|
|
||||||
...(this.view?.is('clip')
|
|
||||||
? [
|
|
||||||
AutoMediaPlugin({
|
AutoMediaPlugin({
|
||||||
playerSelector: 'frigate-card-ha-hls-player',
|
playerSelector: 'frigate-card-ha-hls-player',
|
||||||
autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
|
autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
|
||||||
autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
|
autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
|
||||||
}),
|
}),
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +291,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
!this.view ||
|
!this.view ||
|
||||||
!this.view.target ||
|
!this.view.target ||
|
||||||
!this.view.target.children ||
|
!this.view.target.children ||
|
||||||
this.view.childIndex === undefined
|
this.view.childIndex === null
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -409,7 +339,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
!this.view.target ||
|
!this.view.target ||
|
||||||
!this.view.target.children ||
|
!this.view.target.children ||
|
||||||
!this.view.target.children.length ||
|
!this.view.target.children.length ||
|
||||||
!this.browseMediaQueryParameters
|
!this.browseMediaQueryParametersBase
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -452,7 +382,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
|
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
|
||||||
...this.browseMediaQueryParameters,
|
...this.browseMediaQueryParametersBase,
|
||||||
mediaType: 'clips',
|
mediaType: 'clips',
|
||||||
before: latest,
|
before: latest,
|
||||||
after: earliest,
|
after: earliest,
|
||||||
@@ -473,12 +403,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
}
|
}
|
||||||
const clipStartTime = BrowseMediaUtil.getEventStartTime(child);
|
const clipStartTime = BrowseMediaUtil.getEventStartTime(child);
|
||||||
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
||||||
return new View({
|
return this.view.evolve({
|
||||||
view: 'clip',
|
view: 'clip',
|
||||||
camera: this.view.camera,
|
|
||||||
target: clips,
|
target: clips,
|
||||||
childIndex: i,
|
childIndex: i,
|
||||||
previous: this.view,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,7 +429,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
this.view
|
this.view
|
||||||
.evolve({
|
.evolve({
|
||||||
childIndex: childIndex,
|
childIndex: childIndex,
|
||||||
previous: this.view,
|
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
@@ -518,7 +445,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
const childIndex: number | undefined = this._slideToChild[index];
|
const childIndex: number | undefined = this._slideToChild[index];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
childIndex == undefined ||
|
childIndex === undefined ||
|
||||||
!this.hass ||
|
!this.hass ||
|
||||||
!this.view ||
|
!this.view ||
|
||||||
!this.view.target ||
|
!this.view.target ||
|
||||||
@@ -699,7 +626,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
if (
|
if (
|
||||||
!this.view ||
|
!this.view ||
|
||||||
!this.viewerConfig ||
|
!this.viewerConfig ||
|
||||||
!BrowseMediaUtil.isTrueMedia(mediaToRender)
|
!BrowseMediaUtil.isTrueMedia(mediaToRender) ||
|
||||||
|
!['video', 'image'].includes(mediaToRender.media_content_type)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -712,7 +640,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
${this.view.isClipRelatedView()
|
${mediaToRender.media_content_type === 'video'
|
||||||
? html`<frigate-card-ha-hls-player
|
? html`<frigate-card-ha-hls-player
|
||||||
allow-exoplayer
|
allow-exoplayer
|
||||||
aria-label="${mediaToRender.title}"
|
aria-label="${mediaToRender.title}"
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
|||||||
`${CONF_EVENT_VIEWER}.controls.next_previous.size` as const;
|
`${CONF_EVENT_VIEWER}.controls.next_previous.size` as const;
|
||||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
|
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
|
||||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
|
`${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
|
||||||
|
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||||
|
`${CONF_EVENT_VIEWER}.controls.thumbnails.show_details` as const;
|
||||||
|
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
|
||||||
|
`${CONF_EVENT_VIEWER}.controls.thumbnails.show_controls` as const;
|
||||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
|
`${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
|
||||||
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
|
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
|
||||||
@@ -64,6 +68,10 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
|
|||||||
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
||||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
||||||
`${CONF_LIVE}.controls.thumbnails.size` as const;
|
`${CONF_LIVE}.controls.thumbnails.size` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||||
|
`${CONF_LIVE}.controls.thumbnails.show_details` as const;
|
||||||
|
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
|
||||||
|
`${CONF_LIVE}.controls.thumbnails.show_controls` as const;
|
||||||
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
||||||
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
||||||
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
||||||
@@ -80,6 +88,16 @@ export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
|||||||
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
||||||
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
||||||
|
|
||||||
|
export const CONF_TIMELINE = 'timeline' as const;
|
||||||
|
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||||
|
export const CONF_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_TIMELINE}.clustering_threshold` as const;
|
||||||
|
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
|
||||||
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = `${CONF_TIMELINE}.controls.thumbnails.mode` as const;
|
||||||
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = `${CONF_TIMELINE}.controls.thumbnails.size` as const;
|
||||||
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_TIMELINE}.controls.thumbnails.show_details` as const;
|
||||||
|
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
|
||||||
|
`${CONF_TIMELINE}.controls.thumbnails.show_controls` as const;
|
||||||
|
|
||||||
export const CONF_MENU = 'menu' as const;
|
export const CONF_MENU = 'menu' as const;
|
||||||
export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
|
export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
|
||||||
export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const;
|
export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||||
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
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_THUMBNAILS_SIZE,
|
||||||
CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
|
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
|
||||||
@@ -46,6 +48,8 @@ import {
|
|||||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||||
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||||
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||||
@@ -64,6 +68,13 @@ import {
|
|||||||
CONF_MENU_BUTTONS_SNAPSHOTS,
|
CONF_MENU_BUTTONS_SNAPSHOTS,
|
||||||
CONF_MENU_BUTTON_SIZE,
|
CONF_MENU_BUTTON_SIZE,
|
||||||
CONF_MENU_MODE,
|
CONF_MENU_MODE,
|
||||||
|
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||||
|
CONF_TIMELINE_MEDIA,
|
||||||
|
CONF_TIMELINE_WINDOW_SECONDS,
|
||||||
CONF_VIEW_CAMERA_SELECT,
|
CONF_VIEW_CAMERA_SELECT,
|
||||||
CONF_VIEW_DEFAULT,
|
CONF_VIEW_DEFAULT,
|
||||||
CONF_VIEW_TIMEOUT_SECONDS,
|
CONF_VIEW_TIMEOUT_SECONDS,
|
||||||
@@ -145,6 +156,12 @@ const options: EditorOptions = {
|
|||||||
secondary: localize('editor.image_secondary'),
|
secondary: localize('editor.image_secondary'),
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
|
timeline: {
|
||||||
|
icon: 'chart-gantt',
|
||||||
|
name: localize('editor.timeline'),
|
||||||
|
secondary: localize('editor.timeline_secondary'),
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
dimensions: {
|
dimensions: {
|
||||||
icon: 'aspect-ratio',
|
icon: 'aspect-ratio',
|
||||||
name: localize('editor.dimensions'),
|
name: localize('editor.dimensions'),
|
||||||
@@ -178,6 +195,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
{ value: 'clip', label: localize('config.view.views.clip') },
|
{ value: 'clip', label: localize('config.view.views.clip') },
|
||||||
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
|
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
|
||||||
{ value: 'image', label: localize('config.view.views.image') },
|
{ value: 'image', label: localize('config.view.views.image') },
|
||||||
|
{ value: 'timeline', label: localize('config.view.views.timeline') },
|
||||||
];
|
];
|
||||||
|
|
||||||
protected _cameraSelectViewModes = [
|
protected _cameraSelectViewModes = [
|
||||||
@@ -260,6 +278,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
value: 'below',
|
value: 'below',
|
||||||
label: localize('config.event_viewer.controls.thumbnails.modes.below'),
|
label: localize('config.event_viewer.controls.thumbnails.modes.below'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'left',
|
||||||
|
label: localize('config.event_viewer.controls.thumbnails.modes.left'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'right',
|
||||||
|
label: localize('config.event_viewer.controls.thumbnails.modes.right'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
protected _thumbnailMedias = [
|
protected _thumbnailMedias = [
|
||||||
@@ -305,6 +331,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
{ value: 'url', label: localize('config.image.modes.url') },
|
{ value: 'url', label: localize('config.image.modes.url') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected _timelineMediaTypes = [
|
||||||
|
{ value: '', label: '' },
|
||||||
|
{ value: 'all', label: localize('config.timeline.medias.all') },
|
||||||
|
{ value: 'clips', label: localize('config.timeline.medias.clips') },
|
||||||
|
{ value: 'snapshots', label: localize('config.timeline.medias.snapshots') },
|
||||||
|
];
|
||||||
|
|
||||||
public setConfig(config: RawFrigateCardConfig): void {
|
public setConfig(config: RawFrigateCardConfig): void {
|
||||||
// Note: This does not use Zod to parse the configuration, so it may be
|
// Note: This does not use Zod to parse the configuration, so it may be
|
||||||
// partially or completely invalid. It's more useful to have a partially
|
// partially or completely invalid. It's more useful to have a partially
|
||||||
@@ -881,6 +914,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||||
this._titleModes,
|
this._titleModes,
|
||||||
)}
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
|
defaults.live.controls.thumbnails.show_details,
|
||||||
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||||
|
defaults.live.controls.thumbnails.show_controls,
|
||||||
|
)}
|
||||||
${this._renderNumberInput(
|
${this._renderNumberInput(
|
||||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||||
0,
|
0,
|
||||||
@@ -928,6 +969,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
this._thumbnailModes,
|
this._thumbnailModes,
|
||||||
)}
|
)}
|
||||||
${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE)}
|
${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
|
defaults.event_viewer.controls.thumbnails.show_details,
|
||||||
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||||
|
defaults.event_viewer.controls.thumbnails.show_controls,
|
||||||
|
)}
|
||||||
${this._renderOptionSelector(
|
${this._renderOptionSelector(
|
||||||
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
|
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
|
||||||
this._titleModes,
|
this._titleModes,
|
||||||
@@ -951,6 +1000,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
|
${this._renderOptionSetHeader('timeline')}
|
||||||
|
${options.timeline.show
|
||||||
|
? html` <div class="values">
|
||||||
|
${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)}
|
||||||
|
${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)}
|
||||||
|
${this._renderOptionSelector(CONF_TIMELINE_MEDIA, this._timelineMediaTypes)}
|
||||||
|
${this._renderOptionSelector(
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||||
|
this._thumbnailModes,
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||||
|
defaults.timeline.controls.thumbnails.show_details,
|
||||||
|
)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||||
|
defaults.timeline.controls.thumbnails.show_controls,
|
||||||
|
)}
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
${this._renderOptionSetHeader('dimensions')}
|
${this._renderOptionSetHeader('dimensions')}
|
||||||
${options.dimensions.show
|
${options.dimensions.show
|
||||||
? html` <div class="values">
|
? html` <div class="values">
|
||||||
|
|||||||
@@ -76,9 +76,13 @@
|
|||||||
"thumbnails": {
|
"thumbnails": {
|
||||||
"mode": "Event Viewer thumbnails mode",
|
"mode": "Event Viewer thumbnails mode",
|
||||||
"size": "Event Viewer thumbnails size (e.g. '100px')",
|
"size": "Event Viewer thumbnails size (e.g. '100px')",
|
||||||
|
"show_details": "Show event details with thumbnails",
|
||||||
|
"show_controls": "Show event 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",
|
||||||
|
"left": "Thumbnails in a drawer left of the media",
|
||||||
|
"right": "Thumbnails in a drawer right of the media",
|
||||||
"none": "No thumbnails"
|
"none": "No thumbnails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,6 +119,8 @@
|
|||||||
"thumbnails": {
|
"thumbnails": {
|
||||||
"mode": "Live thumbnails mode",
|
"mode": "Live thumbnails mode",
|
||||||
"size": "Live thumbnails size (e.g. '100px')",
|
"size": "Live thumbnails size (e.g. '100px')",
|
||||||
|
"show_details": "Show event details with thumbnails",
|
||||||
|
"show_controls": "Show event controls with thumbnails",
|
||||||
"media": "Whether to show thumbnails of clips or snapshots",
|
"media": "Whether to show thumbnails of clips or snapshots",
|
||||||
"medias": {
|
"medias": {
|
||||||
"clips": "Clip thumbnails",
|
"clips": "Clip thumbnails",
|
||||||
@@ -165,6 +171,24 @@
|
|||||||
},
|
},
|
||||||
"button_size": "Menu button size (e.g. '40px')"
|
"button_size": "Menu button size (e.g. '40px')"
|
||||||
},
|
},
|
||||||
|
"timeline": {
|
||||||
|
"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)",
|
||||||
|
"media": "The media the timeline displays",
|
||||||
|
"medias": {
|
||||||
|
"all": "All media types",
|
||||||
|
"clips": "Clips",
|
||||||
|
"snapshots": "Snapshots"
|
||||||
|
},
|
||||||
|
"controls": {
|
||||||
|
"thumbnails": {
|
||||||
|
"mode": "Timeline thumbnails mode",
|
||||||
|
"size": "Timeline thumbnails size (e.g. '100px')",
|
||||||
|
"show_details": "Show event details with thumbnails",
|
||||||
|
"show_controls": "Show event controls with thumbnails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dimensions": {
|
"dimensions": {
|
||||||
"aspect_ratio": "Default aspect ratio (e.g. '16:9')",
|
"aspect_ratio": "Default aspect ratio (e.g. '16:9')",
|
||||||
"aspect_ratio_mode": "Aspect ratio mode",
|
"aspect_ratio_mode": "Aspect ratio mode",
|
||||||
@@ -204,7 +228,18 @@
|
|||||||
"add_new_camera": "Add new camera",
|
"add_new_camera": "Add new camera",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"overrides": "Overrides are active",
|
"overrides": "Overrides are active",
|
||||||
"overrides_secondary": "Dynamic configuration overrides detected"
|
"overrides_secondary": "Dynamic configuration overrides detected",
|
||||||
|
"timeline": "Timeline",
|
||||||
|
"timeline_secondary": "Event timeline options"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"start": "Start",
|
||||||
|
"duration": "Duration",
|
||||||
|
"in_progress": "In Progress"
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"retain_indefinitely": "Event will be indefinitely retained",
|
||||||
|
"timeline": "See event in timeline"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"empty_response": "Received empty response from Home Assistant for request",
|
"empty_response": "Received empty response from Home Assistant for request",
|
||||||
|
|||||||
+12
-1
@@ -28,6 +28,12 @@ img,video {
|
|||||||
-khtml-user-select: none;
|
-khtml-user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
:host([direction=vertical]) .embla__container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
:host([direction=horizontal]) .embla__container {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.embla__viewport {
|
.embla__viewport {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -50,9 +56,14 @@ img,video {
|
|||||||
.embla__slide {
|
.embla__slide {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-right: 5px;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
:host([direction=vertical]) .embla__slide {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
:host([direction=horizontal]) .embla__slide {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
.embla__slide img,video {
|
.embla__slide img,video {
|
||||||
// Letterbox media. <frigate-card-ha-hls-player> has similar added directly in
|
// Letterbox media. <frigate-card-ha-hls-player> has similar added directly in
|
||||||
// its element.
|
// its element.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
:host {
|
||||||
|
// Drawer width sizes to contents.
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
#fs {
|
||||||
|
// Hide the freespace screen.
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#d,
|
||||||
|
#fs {
|
||||||
|
// Override width/height to be 100% instead of 100v[wh].
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// Position absolutely.
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
#d {
|
||||||
|
// Need to allow drawer controls to be visible.
|
||||||
|
overflow: visible;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([location=right]) #d {
|
||||||
|
// Position to the right.
|
||||||
|
left: unset;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([location=right][open]) #d {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0px 0px 25px 0px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifs {
|
||||||
|
// Override width/height to be 100% instead of 100v[wh].
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
$drawer-icon-size: 20px;
|
||||||
|
$drawer-padding-extend: 20px;
|
||||||
|
|
||||||
|
side-drawer {
|
||||||
|
background-color: var(--card-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.control-surround {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50%;
|
||||||
|
transform: translateY(50%);
|
||||||
|
z-index: 0;
|
||||||
|
padding-top: $drawer-padding-extend;
|
||||||
|
padding-bottom: $drawer-padding-extend;
|
||||||
|
}
|
||||||
|
:host([location=left]) div.control-surround {
|
||||||
|
@if $drawer-icon-size < 32 {
|
||||||
|
// Ensure the clickable area is at least 32px wide.
|
||||||
|
padding-right: calc(32px - $drawer-icon-size);
|
||||||
|
}
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
:host([location=right]) div.control-surround {
|
||||||
|
@if $drawer-icon-size < 32 {
|
||||||
|
// See note above.
|
||||||
|
padding-left: calc(32px - $drawer-icon-size);
|
||||||
|
}
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha-icon.control {
|
||||||
|
color: var(--secondary-color, white);
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
|
--mdc-icon-size: #{$drawer-icon-size};
|
||||||
|
padding-top: $drawer-padding-extend;
|
||||||
|
padding-bottom: $drawer-padding-extend;
|
||||||
|
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) ha-icon.control, ha-icon.control:hover {
|
||||||
|
// When the drawer is open or hovered make the button to close it more
|
||||||
|
// prominent.
|
||||||
|
opacity: 1;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([location=left]) ha-icon.control {
|
||||||
|
border-top-right-radius: $drawer-icon-size;
|
||||||
|
border-bottom-right-radius: $drawer-icon-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([location=right]) ha-icon.control {
|
||||||
|
border-top-left-radius: $drawer-icon-size;
|
||||||
|
border-bottom-left-radius: $drawer-icon-size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ha-icon.favorite {
|
||||||
|
position: absolute;
|
||||||
|
color: gold;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha-icon.timeline {
|
||||||
|
position: absolute;
|
||||||
|
color: var(--primary-color);
|
||||||
|
right: 0px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
+13
-13
@@ -1,16 +1,21 @@
|
|||||||
@use "@material/image-list/mdc-image-list";
|
@use '@material/image-list/mdc-image-list';
|
||||||
@use "@material/image-list";
|
@use '@material/image-list';
|
||||||
|
@use './favorite.scss';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
|
||||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
// Hide scrollbar: IE and Edge
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
// Hide scrollbar: Firefox
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
// Hide scrollbar for Chrome, Safari and Opera
|
||||||
:host::-webkit-scrollbar {
|
:host::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -44,12 +49,7 @@ ha-card.frigate-card-gallery-folder {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.favorite {
|
|
||||||
position: absolute;
|
ha-icon.favorite {
|
||||||
transform: translate(-50%, -50%);
|
opacity: 0.8;
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
top: 10%;
|
|
||||||
left: 90%;
|
|
||||||
color: yellow;
|
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,3 @@ frigate-card-live-carousel {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
frigate-card-thumbnail-carousel {
|
|
||||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// Share the screen space with thumbnails that may be above/below.
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// Set the drawer relative to this host.
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Hide any content outside the main pane (e.g. side drawers) to ensure the
|
||||||
|
// user cannot scroll across to the drawers without opening them.
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted:not([name]) {
|
||||||
|
// Expand the main body to fill available content not otherwise used by the
|
||||||
|
// surround.
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -1,37 +1,21 @@
|
|||||||
:host {
|
:host {
|
||||||
--frigate-card-carousel-thumbnail-size: 100px;
|
--frigate-card-carousel-thumbnail-opacity: 1.0;
|
||||||
--frigate-card-carousel-thumbnail-opacity: 0.6;
|
}
|
||||||
|
|
||||||
|
:host([direction=vertical]) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
:host([direction=horizontal]) {
|
||||||
|
// In fullscreen mode, without explicitly setting the height to auto Chrome
|
||||||
|
// will construct a stylesheet with 100% height.
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embla__slide {
|
.embla__slide {
|
||||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
flex: 0 0 auto;
|
||||||
opacity: var(--frigate-card-carousel-thumbnail-opacity);
|
opacity: var(--frigate-card-carousel-thumbnail-opacity);
|
||||||
transition: opacity 0.6s ease, transform 0.2s linear;
|
transition: opacity 0.6s ease;
|
||||||
}
|
}
|
||||||
.embla__slide.slide-selected {
|
.embla__slide.slide-selected {
|
||||||
opacity: 1.0;
|
opacity: 1.0;
|
||||||
}
|
}
|
||||||
.embla__slide:hover {
|
|
||||||
transform: scale(1.04);
|
|
||||||
}
|
|
||||||
.embla__slide img {
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
max-width: var(--frigate-card-carousel-thumbnail-size);
|
|
||||||
max-height: var(--frigate-card-carousel-thumbnail-size);
|
|
||||||
}
|
|
||||||
.favorite {
|
|
||||||
position: absolute;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
top: 12%;
|
|
||||||
left: 90%;
|
|
||||||
color: yellow;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@use './favorite.scss';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
--frigate-card-thumbnail-size: 100px;
|
||||||
|
|
||||||
|
// Do not let the contain expand to fill more height than the height of the
|
||||||
|
// thumbnail. Without this the thumbnail carousel will allow the thumbnail to
|
||||||
|
// expand to the full height of the viewport on a vertical carousel.
|
||||||
|
max-height: var(--frigate-card-thumbnail-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([details]) {
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
border-radius: var(--ha-card-border-radius, 4px);
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
// When details are enabled, use a background color so that the details have
|
||||||
|
// contrast with the background.
|
||||||
|
background-color: var(--primary-background-color, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: var(--ha-card-border-radius, 4px);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
max-width: var(--frigate-card-thumbnail-size);
|
||||||
|
max-height: var(--frigate-card-thumbnail-size);
|
||||||
|
min-width: var(--frigate-card-thumbnail-size);
|
||||||
|
min-height: var(--frigate-card-thumbnail-size);
|
||||||
|
|
||||||
|
transition: transform 0.2s linear;
|
||||||
|
}
|
||||||
|
img:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.details {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
width: 200px;
|
||||||
|
margin-left: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
div.details div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
div.details div.left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.larger {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.heading {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
|
||||||
|
@use 'drawer';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--card-background-color);
|
||||||
|
padding-bottom: 5px;
|
||||||
|
|
||||||
|
// Share the screen space with thumbnails that may be above/below.
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// So that absolute sidedrawer is relative to this host.
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.timeline {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
div.timeline.left-margin {
|
||||||
|
// Clearance for the drawer button.
|
||||||
|
margin-left: calc(drawer.$drawer-icon-size + 1px);
|
||||||
|
}
|
||||||
|
div.timeline.right-margin {
|
||||||
|
// Clearance for the drawer button.
|
||||||
|
margin-right: calc(drawer.$drawer-icon-size + 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-text {
|
||||||
|
color: var(--primary-text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-timeline {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-labelset .vis-label {
|
||||||
|
// Group labels.
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-item {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: none;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-item:hover {
|
||||||
|
// Float icons upwards when the user hovers over them.
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-item.vis-box {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-item .vis-item-content {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-item.vis-cluster {
|
||||||
|
border-style: dotted;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
background-color: var(--primary-background-color);
|
||||||
|
box-shadow: 0px 0px 5px 1px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-time-axis .vis-grid.vis-minor {
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-time-axis .vis-grid.vis-major {
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.vis-tooltip {
|
||||||
|
padding: 0px;
|
||||||
|
background-color: unset;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
// Use browser default font-family for tooltips.
|
||||||
|
font-family: unset;
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
--frigate-card-timeline-thumbnail-size: 75px;
|
|
||||||
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: var(--frigate-card-timeline-thumbnail-size);
|
|
||||||
height: var(--frigate-card-timeline-thumbnail-size);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -1,73 +1,5 @@
|
|||||||
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
|
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
background-color: var(--card-background-color);
|
|
||||||
padding-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.timeline {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-text {
|
|
||||||
color: var(--primary-text-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-timeline {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-labelset .vis-label {
|
|
||||||
// Group labels.
|
|
||||||
color: var(--primary-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-item {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
background: none;
|
|
||||||
color: var(--primary-text-color);
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-item:hover {
|
|
||||||
// Float icons upwards when the user hovers over them.
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-item.vis-box {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-item .vis-item-content {
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-item.vis-cluster {
|
|
||||||
border-style: dotted;
|
|
||||||
color: var(--primary-text-color);
|
|
||||||
background-color: var(--primary-background-color);
|
|
||||||
box-shadow: 0px 0px 5px 1px var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-time-axis .vis-grid.vis-minor {
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-time-axis .vis-grid.vis-major {
|
|
||||||
border-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vis-label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.vis-tooltip {
|
|
||||||
padding: 0px;
|
|
||||||
background-color: unset;
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
frigate-card-viewer-carousel {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
frigate-card-thumbnail-carousel {
|
|
||||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
:host {
|
:host {
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate-card-viewer-carousel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
+83
-35
@@ -35,7 +35,14 @@ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
|||||||
'timeline',
|
'timeline',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number];
|
const FRIGATE_CARD_VIEWS = [
|
||||||
|
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
|
|
||||||
|
// Event: A clip or snapshot (timeline may produce mixed media lists).
|
||||||
|
'event',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
|
||||||
|
|
||||||
const FRIGATE_MENU_MODES = [
|
const FRIGATE_MENU_MODES = [
|
||||||
'none',
|
'none',
|
||||||
@@ -335,8 +342,8 @@ export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
|
|||||||
const menuSubmenuItemSchema = elementsBaseSchema.extend({
|
const menuSubmenuItemSchema = elementsBaseSchema.extend({
|
||||||
entity: z.string().optional(),
|
entity: z.string().optional(),
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
state_color: z.boolean().default(true),
|
state_color: z.boolean().default(true).optional(),
|
||||||
selected: z.boolean().default(false),
|
selected: z.boolean().default(false).optional(),
|
||||||
});
|
});
|
||||||
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
||||||
|
|
||||||
@@ -434,8 +441,10 @@ export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const thumbnailsControlSchema = z.object({
|
const thumbnailsControlSchema = z.object({
|
||||||
mode: z.enum(['none', 'above', 'below']),
|
mode: z.enum(['none', 'above', 'below', 'left', 'right']),
|
||||||
size: z.string().optional(),
|
size: z.string().optional(),
|
||||||
|
show_details: z.boolean().optional(),
|
||||||
|
show_controls: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
||||||
|
|
||||||
@@ -488,6 +497,8 @@ const liveConfigDefault = {
|
|||||||
thumbnails: {
|
thumbnails: {
|
||||||
media: 'clips' as const,
|
media: 'clips' as const,
|
||||||
size: '100px',
|
size: '100px',
|
||||||
|
show_details: false,
|
||||||
|
show_controls: true,
|
||||||
mode: 'none' as const,
|
mode: 'none' as const,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
@@ -548,6 +559,12 @@ const liveOverridableConfigSchema = z
|
|||||||
size: thumbnailsControlSchema.shape.size.default(
|
size: thumbnailsControlSchema.shape.size.default(
|
||||||
liveConfigDefault.controls.thumbnails.size,
|
liveConfigDefault.controls.thumbnails.size,
|
||||||
),
|
),
|
||||||
|
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||||
|
liveConfigDefault.controls.thumbnails.show_details,
|
||||||
|
),
|
||||||
|
show_controls: thumbnailsControlSchema.shape.show_controls.default(
|
||||||
|
liveConfigDefault.controls.thumbnails.show_controls,
|
||||||
|
),
|
||||||
media: z
|
media: z
|
||||||
.enum(['clips', 'snapshots'])
|
.enum(['clips', 'snapshots'])
|
||||||
.default(liveConfigDefault.controls.thumbnails.media),
|
.default(liveConfigDefault.controls.thumbnails.media),
|
||||||
@@ -642,6 +659,8 @@ const viewerConfigDefault = {
|
|||||||
thumbnails: {
|
thumbnails: {
|
||||||
size: '100px',
|
size: '100px',
|
||||||
mode: 'none' as const,
|
mode: 'none' as const,
|
||||||
|
show_details: false,
|
||||||
|
show_controls: true,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
mode: 'popup-bottom-right' as const,
|
mode: 'popup-bottom-right' as const,
|
||||||
@@ -681,6 +700,12 @@ const viewerConfigSchema = z
|
|||||||
size: thumbnailsControlSchema.shape.size.default(
|
size: thumbnailsControlSchema.shape.size.default(
|
||||||
viewerConfigDefault.controls.thumbnails.size,
|
viewerConfigDefault.controls.thumbnails.size,
|
||||||
),
|
),
|
||||||
|
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||||
|
viewerConfigDefault.controls.thumbnails.show_details,
|
||||||
|
),
|
||||||
|
show_controls: thumbnailsControlSchema.shape.show_controls.default(
|
||||||
|
viewerConfigDefault.controls.thumbnails.show_controls,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.default(viewerConfigDefault.controls.thumbnails),
|
.default(viewerConfigDefault.controls.thumbnails),
|
||||||
title: titleControlConfigSchema
|
title: titleControlConfigSchema
|
||||||
@@ -745,38 +770,56 @@ const dimensionsConfigSchema = z
|
|||||||
* Timeline configuration section.
|
* Timeline configuration section.
|
||||||
*/
|
*/
|
||||||
const timelineConfigDefault = {
|
const timelineConfigDefault = {
|
||||||
|
clustering_threshold: 3,
|
||||||
|
media: 'all' as const,
|
||||||
|
window_seconds: 60 * 60,
|
||||||
controls: {
|
controls: {
|
||||||
thumbnails: {
|
thumbnails: {
|
||||||
size_pixels: 75,
|
mode: 'left' as const,
|
||||||
overlap_pixels: 25,
|
size: '100px' as const,
|
||||||
clustering_threshold: 3,
|
show_details: true,
|
||||||
|
show_controls: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const timelineConfigSchema = z
|
const timelineConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
controls: z
|
|
||||||
.object({
|
|
||||||
thumbnails: z
|
|
||||||
.object({
|
|
||||||
size_pixels: z
|
|
||||||
.number()
|
|
||||||
.min(50)
|
|
||||||
.max(THUMBNAIL_WIDTH_MAX)
|
|
||||||
.default(timelineConfigDefault.controls.thumbnails.size_pixels),
|
|
||||||
overlap_pixels: z
|
|
||||||
.number()
|
|
||||||
.min(0)
|
|
||||||
.max(THUMBNAIL_WIDTH_MAX)
|
|
||||||
.default(timelineConfigDefault.controls.thumbnails.overlap_pixels),
|
|
||||||
clustering_threshold: z
|
clustering_threshold: z
|
||||||
.number()
|
.number()
|
||||||
.default(timelineConfigDefault.controls.thumbnails.clustering_threshold),
|
.optional()
|
||||||
|
.default(timelineConfigDefault.clustering_threshold),
|
||||||
|
media: z
|
||||||
|
.enum(['all', 'clips', 'snapshots'])
|
||||||
|
.optional()
|
||||||
|
.default(timelineConfigDefault.media),
|
||||||
|
window_seconds: z
|
||||||
|
.number()
|
||||||
|
.min(1 * 60)
|
||||||
|
.max(24 * 60 * 60)
|
||||||
|
.optional()
|
||||||
|
.default(timelineConfigDefault.window_seconds),
|
||||||
|
controls: z
|
||||||
|
.object({
|
||||||
|
thumbnails: thumbnailsControlSchema
|
||||||
|
.extend({
|
||||||
|
mode: thumbnailsControlSchema.shape.mode.default(
|
||||||
|
timelineConfigDefault.controls.thumbnails.mode,
|
||||||
|
),
|
||||||
|
size: thumbnailsControlSchema.shape.size.default(
|
||||||
|
timelineConfigDefault.controls.thumbnails.size,
|
||||||
|
),
|
||||||
|
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||||
|
timelineConfigDefault.controls.thumbnails.show_details,
|
||||||
|
),
|
||||||
|
show_controls: thumbnailsControlSchema.shape.show_controls.default(
|
||||||
|
timelineConfigDefault.controls.thumbnails.show_controls,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.default(timelineConfigDefault.controls.thumbnails),
|
.default(timelineConfigDefault.controls.thumbnails),
|
||||||
})
|
})
|
||||||
.default(timelineConfigDefault.controls),
|
.default(timelineConfigDefault.controls),
|
||||||
})
|
})
|
||||||
|
.merge(actionsSchema)
|
||||||
.default(timelineConfigDefault);
|
.default(timelineConfigDefault);
|
||||||
export type TimelineConfig = z.infer<typeof timelineConfigSchema>;
|
export type TimelineConfig = z.infer<typeof timelineConfigSchema>;
|
||||||
|
|
||||||
@@ -860,8 +903,8 @@ export interface ExtendedHomeAssistant {
|
|||||||
hassUrl(path?): string;
|
hassUrl(path?): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowseMediaQueryParameters {
|
export interface BrowseMediaQueryParametersBase {
|
||||||
mediaType: 'clips' | 'snapshots';
|
mediaType?: 'clips' | 'snapshots';
|
||||||
clientId: string;
|
clientId: string;
|
||||||
cameraName: string;
|
cameraName: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -871,6 +914,10 @@ export interface BrowseMediaQueryParameters {
|
|||||||
unlimited?: boolean;
|
unlimited?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowseMediaQueryParameters extends BrowseMediaQueryParametersBase {
|
||||||
|
mediaType: 'clips' | 'snapshots';
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrowseMediaNeighbors {
|
export interface BrowseMediaNeighbors {
|
||||||
previous: FrigateBrowseMediaSource | null;
|
previous: FrigateBrowseMediaSource | null;
|
||||||
previousIndex: number | null;
|
previousIndex: number | null;
|
||||||
@@ -912,9 +959,8 @@ export interface FrigateCardMediaPlayer {
|
|||||||
* Home Assistant API types.
|
* Home Assistant API types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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_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
|
||||||
@@ -932,12 +978,9 @@ interface BrowseMediaSource {
|
|||||||
children?: BrowseMediaSource[] | null;
|
children?: BrowseMediaSource[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
export interface FrigateEvent {
|
||||||
children?: FrigateBrowseMediaSource[] | null;
|
|
||||||
frigate?: {
|
|
||||||
event: {
|
|
||||||
camera: string;
|
camera: string;
|
||||||
end_time: number;
|
end_time?: number;
|
||||||
false_positive: boolean;
|
false_positive: boolean;
|
||||||
has_clip: boolean;
|
has_clip: boolean;
|
||||||
has_snapshot: boolean;
|
has_snapshot: boolean;
|
||||||
@@ -946,8 +989,13 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
|||||||
start_time: number;
|
start_time: number;
|
||||||
top_score: number;
|
top_score: number;
|
||||||
zones: string[];
|
zones: string[];
|
||||||
retain_indefinitely: boolean;
|
retain_indefinitely?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
||||||
|
children?: FrigateBrowseMediaSource[] | null;
|
||||||
|
frigate?: {
|
||||||
|
event: FrigateEvent;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,7 +1024,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.
|
|||||||
start_time: z.number(),
|
start_time: z.number(),
|
||||||
top_score: z.number(),
|
top_score: z.number(),
|
||||||
zones: z.string().array(),
|
zones: z.string().array(),
|
||||||
retain_indefinitely: z.boolean(),
|
retain_indefinitely: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
+36
-29
@@ -1,12 +1,16 @@
|
|||||||
import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js';
|
import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js';
|
||||||
import { dispatchFrigateCardEvent } from './common.js';
|
import { dispatchFrigateCardEvent } from './common.js';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface ViewContext {}
|
||||||
|
|
||||||
export interface ViewEvolveParameters {
|
export interface ViewEvolveParameters {
|
||||||
view?: FrigateCardView;
|
view?: FrigateCardView;
|
||||||
camera?: string;
|
camera?: string;
|
||||||
target?: FrigateBrowseMediaSource;
|
target?: FrigateBrowseMediaSource | null;
|
||||||
childIndex?: number;
|
childIndex?: number | null;
|
||||||
previous?: View;
|
previous?: View | null;
|
||||||
|
context?: ViewContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewParameters extends ViewEvolveParameters {
|
export interface ViewParameters extends ViewEvolveParameters {
|
||||||
@@ -17,16 +21,18 @@ export interface ViewParameters extends ViewEvolveParameters {
|
|||||||
export class View {
|
export class View {
|
||||||
view: FrigateCardView;
|
view: FrigateCardView;
|
||||||
camera: string;
|
camera: string;
|
||||||
target?: FrigateBrowseMediaSource;
|
target: FrigateBrowseMediaSource | null;
|
||||||
childIndex?: number;
|
childIndex: number | null;
|
||||||
previous?: View;
|
previous: View | null;
|
||||||
|
context: ViewContext | null;
|
||||||
|
|
||||||
constructor(params: ViewParameters) {
|
constructor(params: ViewParameters) {
|
||||||
this.view = params?.view;
|
this.view = params.view;
|
||||||
this.camera = params?.camera;
|
this.camera = params.camera;
|
||||||
this.target = params?.target;
|
this.target = params.target ?? null;
|
||||||
this.childIndex = params?.childIndex;
|
this.childIndex = params.childIndex ?? null;
|
||||||
this.previous = params?.previous;
|
this.previous = params.previous ?? null;
|
||||||
|
this.context = params.context ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +44,8 @@ export class View {
|
|||||||
camera: this.camera,
|
camera: this.camera,
|
||||||
target: this.target,
|
target: this.target,
|
||||||
childIndex: this.childIndex,
|
childIndex: this.childIndex,
|
||||||
previous: this.previous
|
previous: this.previous,
|
||||||
|
context: this.context,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +56,16 @@ export class View {
|
|||||||
*/
|
*/
|
||||||
public evolve(params: ViewEvolveParameters): View {
|
public evolve(params: ViewEvolveParameters): View {
|
||||||
return new View({
|
return new View({
|
||||||
view: params.view ?? this.view,
|
view: params.view !== undefined ? params.view : this.view,
|
||||||
camera: params.camera ?? this.camera,
|
camera: params.camera !== undefined ? params.camera : this.camera,
|
||||||
target: params.target ?? this.target,
|
target: params.target !== undefined ? params.target : this.target,
|
||||||
childIndex: params.childIndex ?? this.childIndex,
|
childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex,
|
||||||
previous: params.previous ?? this.previous,
|
context: params.context !== undefined ? params.context : this.context,
|
||||||
})
|
|
||||||
|
// Special case: Set the previous to this of the evolved view (rather than
|
||||||
|
// the previous of this).
|
||||||
|
previous: params.previous !== undefined ? params.previous : this,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,24 +86,21 @@ export class View {
|
|||||||
* Determine if a view is of a piece of media (i.e. not the gallery).
|
* Determine if a view is of a piece of media (i.e. not the gallery).
|
||||||
*/
|
*/
|
||||||
public isMediaView(): boolean {
|
public isMediaView(): boolean {
|
||||||
return !this.isGalleryView();
|
return this.isViewerView() || this.is('live');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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'].includes(
|
return ['clip', 'snapshot', 'event'].includes(this.view);
|
||||||
this.view,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a view is related to a clip or clips.
|
* Determine if a view is related to a clip or clips.
|
||||||
*/
|
*/
|
||||||
public isClipRelatedView(): boolean {
|
public isClipRelatedView(): boolean {
|
||||||
// TODO HACK HACK HACK
|
return ['clip', 'clips'].includes(this.view);
|
||||||
return ['clip', 'clips', 'timeline'].includes(this.view);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,14 +113,13 @@ export class View {
|
|||||||
/**
|
/**
|
||||||
* Get the media item that should be played.
|
* Get the media item that should be played.
|
||||||
**/
|
**/
|
||||||
get media(): FrigateBrowseMediaSource | undefined {
|
get media(): FrigateBrowseMediaSource | null {
|
||||||
if (this.target) {
|
if (this.target) {
|
||||||
if (this.target.children && this.childIndex !== undefined) {
|
if (this.target.children && this.childIndex !== null) {
|
||||||
return this.target.children[this.childIndex];
|
return this.target.children[this.childIndex] ?? null;
|
||||||
}
|
}
|
||||||
return this.target;
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user