Merge pull request #861 from dermotduffy/performance-one
Add `low` performance profile and `image` live provider
This commit is contained in:
@@ -35,4 +35,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: frigate-hass-card
|
||||
path: dist/frigate-hass-card.js
|
||||
path: dist/*.js
|
||||
|
||||
@@ -4,6 +4,7 @@ name: Release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -18,14 +19,14 @@ jobs:
|
||||
yarn install
|
||||
yarn run build
|
||||
|
||||
# Upload build file to the releas as an asset.
|
||||
# Upload build file to the release as an asset.
|
||||
- name: Upload zip to release
|
||||
uses: svenstaro/upload-release-action@v1-release
|
||||
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: dist/frigate-hass-card.js
|
||||
asset_name: frigate-hass-card.js
|
||||
file: dist/*.js
|
||||
file_glob: true
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
|
||||
|
||||
@@ -113,14 +113,14 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|
||||
|
||||
#### Available Live Providers
|
||||
|
||||
|Live Provider|Latency|Frame Rate|Installation|Description|
|
||||
| -- | -- | -- | -- | -- |
|
||||
|`ha` (default HA configuration)|Poor|High|Builtin|Use the built-in Home Assistant camera stream. The camera doesn't even need to be a Frigate camera! |
|
||||
|`ha` (when configured with LL-HLS)|Better|High|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency.|
|
||||
|`ha` (Native WebRTC)|Best|High|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser.|
|
||||
|`frigate-jsmpeg`|Better|Low|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.|
|
||||
|`webrtc-card`|Best|High|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).|
|
||||
|
||||
|Live Provider|Latency|Frame Rate|Loading Time|Installation|Description|
|
||||
| -- | -- | -- | -- | -- | -- |
|
||||
|`ha` (default HA configuration)|Poor|High|Better|Builtin|Use the built-in Home Assistant camera stream. The camera doesn't even need to be a Frigate camera! |
|
||||
|`ha` (when configured with LL-HLS)|Better|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency.|
|
||||
|`ha` (Native WebRTC)|Best|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser.|
|
||||
|`image`|Poor|Poor|Best|Builtin|Use refreshing snapshots of the built-in Home Assistant camera streams.|
|
||||
|`frigate-jsmpeg`|Better|Low|Poor|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.|
|
||||
|`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).|
|
||||
|
||||
<a name="camera-frigate-configuration"></a>
|
||||
|
||||
@@ -325,13 +325,26 @@ See the [fully expanded live configuration example](#config-expanded-live) for h
|
||||
| `lazy_unload` | `never` | :heavy_multiplication_x: | When to lazily **un**load lazyily-loaded cameras. `never` will never lazily-unload, `unselected` will lazy-unload a camera when it is unselected in the carousel, `hidden` will lazy-unload all cameras when the browser/tab becomes hidden or `all` on any opportunity to lazily unload (i.e. either case). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.|
|
||||
| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live 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 live cameras. Accepted values: `slide` or `none`. |
|
||||
| `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, a still image will be shown instead of the loading video stream. This still image will auto-refresh every 1 second and will be replaced with the live stream once loaded. |
|
||||
| `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. |
|
||||
| `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.|
|
||||
| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. |
|
||||
| `jsmpeg` | | :white_check_mark: | Configuration for the `frigate-jsmpeg` live provider. See below.|
|
||||
| `webrtc_card` | | :white_check_mark: | Configuration for the `webrtc-card` live provider. See below.|
|
||||
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
||||
|
||||
#### Live Provider: Image Configuration
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
live:
|
||||
image:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `refresh_seconds` | 1 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds`. `0` implies no refreshing. |
|
||||
|
||||
#### Live Provider: JSMPEG Configuration
|
||||
|
||||
All configuration is under:
|
||||
@@ -557,6 +570,8 @@ See the [fully expanded image configuration example](#config-expanded-image) for
|
||||
| `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.|
|
||||
|
||||
**Note**: When `mode` is set to `camera` this is effectively providing the same image as the `image` live provider would show in the live camera carousel.
|
||||
|
||||
### Timeline Options
|
||||
|
||||
The `timeline` is used to show the timing sequence of events and recordings across cameras. You can interact with the timeline in a number of ways:
|
||||
@@ -651,6 +666,88 @@ The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and
|
||||
If no aspect ratio is specified or available, but one is needed then `16:9` will
|
||||
be used by default.
|
||||
|
||||
<a name="performance-options"></a>
|
||||
|
||||
### Performance Options
|
||||
|
||||
These options control the card performance settings to enable the card to run
|
||||
(more) smoothly on lower end devices.
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
performance:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `profile` | `high` | :heavy_multiplication_x: | Whether the card is configured in full `high` performance mode, or `low` performance defaults for lower end devices. See [low performance profile](#performance-profile-low) below.|
|
||||
|
||||
#### Feature Options
|
||||
|
||||
Controls card-wide central functionality that may impact performance but which is not configurable elsewhere.
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
performance:
|
||||
features:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `animated_progress_indicator` | `true` | :heavy_multiplication_x: | Will show the animated progress indicator 'spinner' when `true` or a simple loading icon when `false`.|
|
||||
|
||||
#### Style Options
|
||||
|
||||
Style performance options request the card minimize certain expensive CSS
|
||||
stylings. This does not necessarily disable these stylings _entirely_ since that
|
||||
may break the basic expected visuals of the card (e.g. menu icons need curves),
|
||||
but rather avoids use of them in high item-count situations (e.g. avoiding
|
||||
shadows on timeline items, or curves in the event gallery items).
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
performance:
|
||||
style:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `border_radius` | `true` | :heavy_multiplication_x: | If `false` minimizes the usage of rounded corners.|
|
||||
| `box_shadow` | `true` | :heavy_multiplication_x: | If `false` minimizes the usage of shadows.|
|
||||
|
||||
<a name="performance-profile-low"></a>
|
||||
|
||||
#### Performance Profile `low`
|
||||
|
||||
In the `low` performance profile, the card attempts to lower the CPU and network
|
||||
consumption of the card by setting default option values when they have not been explicitly set by the user.
|
||||
|
||||
Principles used in the selection of options set by `low` profile mode:
|
||||
|
||||
* Get 'out of the box' performance similar to the basic "Home Assistant Picture Glance" card.
|
||||
* Only change behavior that the user can case-by-case 'reset' by explicitly setting an option elsewhere.
|
||||
* Do not break the visual aesthetic of the card.
|
||||
|
||||
**Note:**: Since the performance profile changes the _default_ value of options,
|
||||
setting the `low` profile on a pre-existing card could have no effect if there are
|
||||
considerable options already set by the user.
|
||||
|
||||
Please see <a href="src/performance.ts">the source code</a> for an exhaustive list of options set by `low` profile mode. Summary:
|
||||
|
||||
* The default live provider (`auto`) will resolve to the `image` live provider for cameras with a `camera_entity` specified. It will have a refresh period of 10 seconds (same as the stock Home Assistant Picture Glance card).
|
||||
* No event thumbnails fetched.
|
||||
* No recordings shown.
|
||||
* No automated actions (e.g. mute, play, pause) except playing in live view.
|
||||
* Always lazily unload anything that can be unloaded.
|
||||
* Carousels are not draggable and have no 'slide' effects.
|
||||
* Live image is not shown during stream loads.
|
||||
* No title popups.
|
||||
* Menu rendered outside the main body of the card, with reduced menu buttons.
|
||||
* All optional performace features and performance styles (described above) disabled.
|
||||
|
||||
<a name="overrides"></a>
|
||||
|
||||
### Overrides Options
|
||||
@@ -1366,6 +1463,8 @@ live:
|
||||
maxAudioLag: 10
|
||||
videoBufferSize: 524288
|
||||
audioBufferSize: 131072
|
||||
image:
|
||||
refresh_seconds: 1
|
||||
controls:
|
||||
next_previous:
|
||||
style: chevrons
|
||||
@@ -2085,6 +2184,23 @@ overrides:
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Expand: Performance section</summary>
|
||||
|
||||
Reference: [Performance Options](#performance-options).
|
||||
|
||||
```yaml
|
||||
performance:
|
||||
profile: high
|
||||
features:
|
||||
animated_progress_indicator: true
|
||||
style:
|
||||
border_radius: true
|
||||
box_shadow: true
|
||||
```
|
||||
</details>
|
||||
|
||||
### Basic cameras configuration
|
||||
|
||||
<details>
|
||||
|
||||
+14
-2
@@ -8,7 +8,7 @@ import styles from 'rollup-plugin-styles';
|
||||
import image from '@rollup/plugin-image';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import gitInfo from 'rollup-plugin-git-info';
|
||||
import { visualizer } from "rollup-plugin-visualizer";
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1';
|
||||
const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1';
|
||||
@@ -65,8 +65,20 @@ const plugins = [
|
||||
*/
|
||||
const config = {
|
||||
input: 'src/card.ts',
|
||||
preserveEntrySignatures: false,
|
||||
output: {
|
||||
file: 'dist/frigate-hass-card.js',
|
||||
entryFileNames: 'frigate-hass-card.js',
|
||||
dir: 'dist',
|
||||
chunkFileNames: (chunk) => {
|
||||
// Add "lang-" to the front of the language chunk names for readability.
|
||||
if (
|
||||
chunk.facadeModuleId &&
|
||||
chunk.facadeModuleId.match(/localize\/languages\/.*\.json/)
|
||||
) {
|
||||
return 'lang-[name]-[hash].js';
|
||||
}
|
||||
return '[name]-[hash].js';
|
||||
},
|
||||
format: 'es',
|
||||
...(dev && {
|
||||
sourcemap: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
RawFrigateCardConfig,
|
||||
} from './types';
|
||||
import { HassEntities } from 'home-assistant-js-websocket';
|
||||
import { cloneDeep, merge } from 'lodash-es';
|
||||
import merge from 'lodash-es/merge';
|
||||
|
||||
export interface ConditionState {
|
||||
view?: string;
|
||||
@@ -109,7 +109,7 @@ export function getOverriddenConfig(
|
||||
overrides: Readonly<RawOverrides> | undefined,
|
||||
conditionState?: Readonly<ConditionState>,
|
||||
): RawFrigateCardConfig {
|
||||
const output = cloneDeep(config);
|
||||
const output = structuredClone(config);
|
||||
let overridden = false;
|
||||
if (overrides) {
|
||||
for (const override of overrides) {
|
||||
|
||||
+137
-48
@@ -12,7 +12,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { throttle } from 'lodash-es';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import screenfull from 'screenfull';
|
||||
import { z } from 'zod';
|
||||
import { actionHandler } from './action-handler-directive.js';
|
||||
@@ -24,29 +24,20 @@ import {
|
||||
} from './card-condition.js';
|
||||
import './components/elements.js';
|
||||
import { FrigateCardElements } from './components/elements.js';
|
||||
import './components/gallery.js';
|
||||
import './components/image.js';
|
||||
import { FrigateCardImage } from './components/image.js';
|
||||
import './components/live.js';
|
||||
import { FrigateCardLive } from './components/live.js';
|
||||
import type { FrigateCardImage } from './components/image.js';
|
||||
import type { FrigateCardLive } from './components/live/live.js';
|
||||
import './components/menu.js';
|
||||
import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js';
|
||||
import './components/message.js';
|
||||
import { renderMessage, renderProgressIndicator } from './components/message.js';
|
||||
import './components/thumbnail-carousel.js';
|
||||
import './components/timeline.js';
|
||||
import './components/viewer.js';
|
||||
import { isConfigUpgradeable } from './config-mgmt.js';
|
||||
import {
|
||||
CAMERA_BIRDSEYE,
|
||||
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
|
||||
REPO_URL,
|
||||
} from './const.js';
|
||||
import './editor.js';
|
||||
import { getLanguage, localize } from './localize/localize.js';
|
||||
import './patches/ha-camera-stream.js';
|
||||
import './patches/ha-hls-player.js';
|
||||
import './patches/ha-web-rtc-player.ts';
|
||||
import { getLanguage, loadLanguages, localize } from './localize/localize.js';
|
||||
import cardStyle from './scss/card.scss';
|
||||
import {
|
||||
Actions,
|
||||
@@ -67,6 +58,7 @@ import {
|
||||
MenuButton,
|
||||
Message,
|
||||
RawFrigateCardConfig,
|
||||
CardWideConfig,
|
||||
} from './types.js';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
@@ -101,6 +93,7 @@ import { View } from './view.js';
|
||||
import pkg from '../package.json';
|
||||
import { ViewContext } from 'view';
|
||||
import { DataManager } from './utils/data-manager.js';
|
||||
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
|
||||
|
||||
/** A note on media callbacks:
|
||||
*
|
||||
@@ -165,6 +158,9 @@ export class FrigateCard extends LitElement {
|
||||
protected _config!: FrigateCardConfig;
|
||||
protected _rawConfig?: RawFrigateCardConfig;
|
||||
|
||||
@state()
|
||||
protected _cardWideConfig?: CardWideConfig;
|
||||
|
||||
@state()
|
||||
protected _overriddenConfig?: FrigateCardConfig;
|
||||
|
||||
@@ -210,11 +206,17 @@ export class FrigateCard extends LitElement {
|
||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||
|
||||
// Whether the card has been successfully initialized.
|
||||
protected _initialized = false;
|
||||
protected _loadedHAElements = false;
|
||||
protected _loadedLanguages = false;
|
||||
|
||||
protected _triggers: Map<string, Date> = new Map();
|
||||
protected _untriggerTimerID: number | null = null;
|
||||
|
||||
// Whether or not to include HA state in ConditionState. Doing so increases
|
||||
// CPU usage as HA state is pumped out very fast, so this is only enabled if
|
||||
// the configuration is likely to consume it.
|
||||
protected _needHAStateInConditionState = false;
|
||||
|
||||
/**
|
||||
* Set the Home Assistant object.
|
||||
*/
|
||||
@@ -236,8 +238,10 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (this._needHAStateInConditionState) {
|
||||
// HA entity state is part of the condition state.
|
||||
this._generateConditionState();
|
||||
}
|
||||
|
||||
// Dark mode may depend on HASS.
|
||||
this._setLightOrDarkMode();
|
||||
@@ -248,6 +252,7 @@ export class FrigateCard extends LitElement {
|
||||
* @returns A LovelaceCardEditor element.
|
||||
*/
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
await import('./editor.js');
|
||||
return document.createElement('frigate-card-editor');
|
||||
}
|
||||
|
||||
@@ -282,8 +287,10 @@ export class FrigateCard extends LitElement {
|
||||
view: this._view?.view,
|
||||
fullscreen: screenfull.isEnabled && screenfull.isFullscreen,
|
||||
camera: this._view?.camera,
|
||||
state: this._hass?.states,
|
||||
mediaLoaded: !!this._currentMediaLoadedInfo,
|
||||
...(this._needHAStateInConditionState && {
|
||||
state: this._hass?.states,
|
||||
})
|
||||
};
|
||||
|
||||
// Update the components that need the new condition state. Passed directly
|
||||
@@ -708,24 +715,38 @@ export class FrigateCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = new ExtendedEntityCache();
|
||||
const hasAutoTriggers = (config: CameraConfig): boolean => {
|
||||
return config.triggers.motion || config.triggers.occupancy;
|
||||
};
|
||||
const hasAnyTriggers = (config: CameraConfig): boolean => {
|
||||
return hasAutoTriggers(config) || !!config.triggers.entities.length;
|
||||
};
|
||||
const hasCameraName = (config: CameraConfig): boolean => {
|
||||
return !!config.frigate?.camera_name;
|
||||
};
|
||||
|
||||
// Loading cameras may require a number of calls to Home Assistant.
|
||||
//
|
||||
// - getAllEntities: Required if any camera has auto-triggers (motion or
|
||||
// occupancy sensors).
|
||||
// - getExtendedEntity: Per camera entity to autodetect the Frigate camera
|
||||
// name or to compute auto-triggers (motion or occupancy sensors).
|
||||
// - getExtendedEntities: Per binary sensor associated with a Frigate config
|
||||
// entry, to compute auto-triggers (motion or occupancy sensors).
|
||||
//
|
||||
// For loading performance these are only called when absolutely needed.
|
||||
|
||||
let entityList: EntityList | undefined;
|
||||
try {
|
||||
entityList = await getAllEntities(this._hass);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
const cache = new ExtendedEntityCache();
|
||||
const loadedCameras: CameraConfig[] = [];
|
||||
|
||||
const cameras: Map<string, CameraConfig> = new Map();
|
||||
let errorFree = true;
|
||||
|
||||
const addCameraConfig = async (config: CameraConfig) => {
|
||||
const addCameraConfig = async (config: CameraConfig, index: number) => {
|
||||
if (!this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entity: ExtendedEntity | null = null;
|
||||
if (config.camera_entity) {
|
||||
if (config.camera_entity && (hasAnyTriggers(config) || !hasCameraName(config))) {
|
||||
try {
|
||||
entity = await getExtendedEntity(this._hass, config.camera_entity, cache);
|
||||
} catch (e) {
|
||||
@@ -735,14 +756,15 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.frigate.camera_name && entity) {
|
||||
if (entity && !hasCameraName(config)) {
|
||||
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
|
||||
if (resolvedName) {
|
||||
config.frigate.camera_name = resolvedName;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity && entityList) {
|
||||
if (entity && entityList && hasAnyTriggers(config)) {
|
||||
if (hasAutoTriggers(config)) {
|
||||
// Try to find the correct entities for the motion & occupancy sensors.
|
||||
// We know they are binary_sensors, and that they'll have the same
|
||||
// config entry ID as the camera. Searching via unique_id ensures this
|
||||
@@ -778,34 +800,50 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
}
|
||||
config.triggers.entities = [...new Set(config.triggers.entities)];
|
||||
|
||||
const id = getCameraID(config);
|
||||
config.triggers.entities = [...new Set(config.triggers.entities)];
|
||||
}
|
||||
|
||||
loadedCameras[index] = config;
|
||||
};
|
||||
|
||||
let errorFree = true;
|
||||
const cameras: Map<string, CameraConfig> = new Map();
|
||||
const configCameras = this._getConfig().cameras;
|
||||
|
||||
if (configCameras && Array.isArray(configCameras)) {
|
||||
if (configCameras.some((config) => hasAutoTriggers(config))) {
|
||||
try {
|
||||
entityList = await getAllEntities(this._hass);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all cameras in parallel, but remember the order they were provided
|
||||
// (they must be added to the cameraMap in this same order).
|
||||
await Promise.all(configCameras.map((configCamera, index) => addCameraConfig(configCamera, index)))
|
||||
|
||||
loadedCameras.forEach((loadedCamera: CameraConfig) => {
|
||||
const id = getCameraID(loadedCamera);
|
||||
if (!id) {
|
||||
this._setMessageAndUpdate({
|
||||
message: localize('error.no_camera_id'),
|
||||
type: 'error',
|
||||
context: config,
|
||||
context: loadedCamera,
|
||||
});
|
||||
errorFree = false;
|
||||
} else if (cameras.has(id)) {
|
||||
this._setMessageAndUpdate({
|
||||
message: localize('error.duplicate_camera_id'),
|
||||
type: 'error',
|
||||
context: config,
|
||||
context: loadedCamera,
|
||||
});
|
||||
errorFree = false;
|
||||
} else {
|
||||
cameras.set(id, config);
|
||||
}
|
||||
};
|
||||
|
||||
if (this._getConfig().cameras && Array.isArray(this._getConfig().cameras)) {
|
||||
// Cameras are loaded sequentially rather than in parallel to preserve the
|
||||
// order of the input camera array.
|
||||
for (const camera of this._getConfig().cameras) {
|
||||
await addCameraConfig(camera);
|
||||
cameras.set(id, loadedCamera);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!cameras.size) {
|
||||
@@ -917,9 +955,9 @@ export class FrigateCard extends LitElement {
|
||||
throw new Error(localize('error.invalid_configuration'));
|
||||
}
|
||||
|
||||
const configUpgradeable = isConfigUpgradeable(inputConfig);
|
||||
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
|
||||
if (!parseResult.success) {
|
||||
const configUpgradeable = isConfigUpgradeable(inputConfig);
|
||||
const hint = this._getParseErrorPaths(parseResult.error);
|
||||
let upgradeMessage = '';
|
||||
if (configUpgradeable && getLovelace().mode !== 'yaml') {
|
||||
@@ -933,7 +971,10 @@ export class FrigateCard extends LitElement {
|
||||
: localize('error.invalid_configuration_no_hint')),
|
||||
);
|
||||
}
|
||||
const config = parseResult.data;
|
||||
const config =
|
||||
parseResult.data.performance.profile !== 'low'
|
||||
? parseResult.data
|
||||
: setLowPerformanceProfile(inputConfig, parseResult.data);
|
||||
|
||||
if (config.test_gui) {
|
||||
getLovelace().setEditMode(true);
|
||||
@@ -941,6 +982,10 @@ export class FrigateCard extends LitElement {
|
||||
|
||||
this._rawConfig = inputConfig;
|
||||
this._config = config;
|
||||
this._cardWideConfig = {
|
||||
performance: config.performance,
|
||||
};
|
||||
|
||||
this._overriddenConfig = undefined;
|
||||
this._cameras = undefined;
|
||||
this._view = undefined;
|
||||
@@ -948,6 +993,21 @@ export class FrigateCard extends LitElement {
|
||||
this._generateConditionState();
|
||||
this._setLightOrDarkMode();
|
||||
this._untrigger();
|
||||
|
||||
const hasStateCondition = (): boolean => {
|
||||
for (const override of this._config.overrides ?? []) {
|
||||
if (override.conditions.state?.length) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// For elements there can be arbitrary custom elements that not related to
|
||||
// this card so we cannot easily determine if state is used further down
|
||||
// the chain by a custom:frigate-card-conditional element. Instead, include state
|
||||
// if elements are configured at all.
|
||||
return !!this._config.elements;
|
||||
}
|
||||
|
||||
this._needHAStateInConditionState = hasStateCondition();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1042,17 +1102,34 @@ export class FrigateCard extends LitElement {
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
// Side load the necessary elements if not already initialized.
|
||||
if (!this._initialized) {
|
||||
// Side load the necessary elements if not already initialized (do not need
|
||||
// to block for the loading to complete).
|
||||
if (!this._loadedHAElements) {
|
||||
sideLoadHomeAssistantElements().then((success) => {
|
||||
if (success) {
|
||||
this._initialized = true;
|
||||
this._loadedHAElements = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) {
|
||||
this._dataManager = new DataManager(this._cameras, this._config.timeline.media);
|
||||
this._dataManager = new DataManager(this._cameras);
|
||||
}
|
||||
|
||||
if (changedProps.has('_cardWideConfig')) {
|
||||
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
|
||||
}
|
||||
|
||||
if (this._view?.is('live')) {
|
||||
import('./components/live/live.js');
|
||||
} else if (this._view?.isGalleryView()) {
|
||||
import('./components/gallery.js');
|
||||
} else if (this._view?.isViewerView()) {
|
||||
import('./components/viewer.js');
|
||||
} else if (this._view?.is('image')) {
|
||||
import('./components/image.js');
|
||||
} else if (this._view?.is('timeline')) {
|
||||
import('./components/timeline.js');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,6 +1246,15 @@ export class FrigateCard extends LitElement {
|
||||
* @returns `true` if the element should be updated.
|
||||
*/
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
// Load the relevant languages. Cannot do anything until then.
|
||||
if (!this._loadedLanguages) {
|
||||
loadLanguages().then(() => {
|
||||
this._loadedLanguages = true;
|
||||
this.requestUpdate();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get('_hass') as HomeAssistant | undefined;
|
||||
let shouldUpdate = !oldHass || changedProps.size != 1;
|
||||
|
||||
@@ -1884,7 +1970,7 @@ export class FrigateCard extends LitElement {
|
||||
this._changeView({ resetMessage: false });
|
||||
return this._render();
|
||||
})(),
|
||||
renderProgressIndicator(),
|
||||
renderProgressIndicator({cardWideConfig: this._cardWideConfig}),
|
||||
)
|
||||
: // Always want to call render even if there's a message, to
|
||||
// ensure live preload is always present (even if not displayed).
|
||||
@@ -1954,6 +2040,7 @@ export class FrigateCard extends LitElement {
|
||||
.cameras=${this._cameras}
|
||||
.galleryConfig=${this._getConfig().event_gallery}
|
||||
.dataManager=${this._dataManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
>
|
||||
</frigate-card-gallery>`
|
||||
: ``}
|
||||
@@ -1965,6 +2052,7 @@ export class FrigateCard extends LitElement {
|
||||
.viewerConfig=${this._getConfig().media_viewer}
|
||||
.resolvedMediaCache=${this._resolvedMediaCache}
|
||||
.dataManager=${this._dataManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer>`
|
||||
: ``}
|
||||
@@ -1996,6 +2084,7 @@ export class FrigateCard extends LitElement {
|
||||
.liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')}
|
||||
.cameras=${this._cameras}
|
||||
.dataManager=${this._dataManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
class="${classMap(liveClasses)}"
|
||||
>
|
||||
</frigate-card-live>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { throttle } from 'lodash-es';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import carouselStyle from '../scss/carousel.scss';
|
||||
import { TransitionEffect } from '../types';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import galleryStyle from '../scss/gallery.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
frigateCardConfigDefaults,
|
||||
GalleryConfig,
|
||||
@@ -23,12 +24,23 @@ import {
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||
} from '../utils/ha/browse-media';
|
||||
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
|
||||
import { DataManager } from '../utils/data-manager';
|
||||
import { DataManager } from '../utils/data-manager.js';
|
||||
import { View } from '../view.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import './thumbnail.js';
|
||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||
|
||||
interface GalleryViewContext {
|
||||
// Keep track of the previous view to allow returning to a higher-level folder.
|
||||
previous?: View;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
gallery?: GalleryViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-gallery')
|
||||
export class FrigateCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@@ -46,6 +58,9 @@ export class FrigateCardGallery extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
@@ -96,7 +111,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
browseMediaQueryParameters,
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator();
|
||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -192,9 +207,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
*/
|
||||
protected _showBackArrow(): boolean {
|
||||
return (
|
||||
!!this.view?.previous &&
|
||||
!!this.view.previous.target &&
|
||||
this.view.previous.view === this.view.view
|
||||
!!this.view?.context?.gallery?.previous &&
|
||||
!!this.view.context.gallery.previous.target &&
|
||||
this.view.context.gallery.previous.view === this.view.view
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,8 +254,8 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
${this._showBackArrow()
|
||||
? html` <ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.view && this.view.previous) {
|
||||
this.view.previous.dispatchChangeEvent(this);
|
||||
if (this.view && this.view.context?.gallery?.previous) {
|
||||
this.view.context.gallery.previous.dispatchChangeEvent(this);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
@@ -262,6 +277,11 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.hass,
|
||||
this.view,
|
||||
child,
|
||||
{
|
||||
gallery: {
|
||||
previous: this.view,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
+20
-10
@@ -6,7 +6,7 @@ import {
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
@@ -15,13 +15,17 @@ import { CachedValueController } from '../cached-value-controller.js';
|
||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import imageStyle from '../scss/image.scss';
|
||||
import { CameraConfig, ImageViewConfig } from '../types.js';
|
||||
import { CameraConfig, ImageViewConfig, MediaLoadedInfo } from '../types.js';
|
||||
import { isHassDifferent } from '../utils/ha';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||
import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
|
||||
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
|
||||
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
||||
@@ -48,6 +52,8 @@ export class FrigateCardImage extends LitElement {
|
||||
protected _cachedValueController?: CachedValueController<string>;
|
||||
protected _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
||||
|
||||
protected _mediaLoadedInfo: MediaLoadedInfo | null = null;
|
||||
|
||||
/**
|
||||
* Get the camera entity for the current camera configuration.
|
||||
* @returns The entity or undefined if no camera entity is available.
|
||||
@@ -234,7 +240,13 @@ export class FrigateCardImage extends LitElement {
|
||||
${ref(this._refImage)}
|
||||
src=${live(src)}
|
||||
@load=${(ev: Event) => {
|
||||
dispatchMediaLoadedEvent(this, ev);
|
||||
const mediaLoadedInfo = createMediaLoadedInfo(ev);
|
||||
// Avoid the media being reported as repeatedly loading unless the
|
||||
// media info changes.
|
||||
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
|
||||
this._mediaLoadedInfo = mediaLoadedInfo;
|
||||
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
|
||||
}
|
||||
}}
|
||||
@error=${() => {
|
||||
if (this.imageConfig?.mode === 'camera') {
|
||||
@@ -246,11 +258,9 @@ export class FrigateCardImage extends LitElement {
|
||||
} else if (this.imageConfig?.mode === 'url') {
|
||||
// In url mode, the user likely specified a URL that cannot be
|
||||
// resolved. Show an error message.
|
||||
dispatchErrorMessageEvent(
|
||||
this,
|
||||
localize('error.image_load_error'),
|
||||
{ context: this.imageConfig },
|
||||
);
|
||||
dispatchErrorMessageEvent(this, localize('error.image_load_error'), {
|
||||
context: this.imageConfig,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>`
|
||||
@@ -264,6 +274,6 @@ export class FrigateCardImage extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"frigate-card-image": FrigateCardImage
|
||||
'frigate-card-image': FrigateCardImage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import liveFrigateStyle from '../../scss/live-frigate.scss';
|
||||
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
||||
import { getStateObjOrDispatchError } from './live.js';
|
||||
import '../../patches/ha-camera-stream';
|
||||
import '../../patches/ha-hls-player.js';
|
||||
import '../../patches/ha-web-rtc-player.ts';
|
||||
|
||||
|
||||
@customElement('frigate-card-live-ha')
|
||||
export class FrigateCardLiveHA extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
protected _playerRef: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._playerRef.value?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._playerRef.value?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
this._playerRef.value?.mute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
this._playerRef.value?.unmute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
this._playerRef.value?.seek(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html` <frigate-card-ha-camera-stream
|
||||
${ref(this._playerRef)}
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
.controls=${true}
|
||||
.muted=${true}
|
||||
>
|
||||
</frigate-card-ha-camera-stream>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveFrigateStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-live-ha': FrigateCardLiveHA;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import liveFrigateStyle from '../../scss/live-frigate.scss';
|
||||
import { CameraConfig, LiveImageConfig } from '../../types.js';
|
||||
import { getStateObjOrDispatchError } from './live.js';
|
||||
import '../image.js';
|
||||
|
||||
@customElement('frigate-card-live-image')
|
||||
export class FrigateCardLiveImage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveImageConfig?: LiveImageConfig;
|
||||
|
||||
@state()
|
||||
protected _playing = true;
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._playing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._playing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
// Not implemented.
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
// Not implemented.
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public seek(_seconds: number): void {
|
||||
// Not implemented.
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.liveImageConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
|
||||
|
||||
return html` <frigate-card-image
|
||||
.imageConfig=${{
|
||||
mode: 'camera' as const,
|
||||
refresh_seconds: this._playing ? this.liveImageConfig.refresh_seconds : 0,
|
||||
// Don't need to pass layout options as FrigateCardLiveProvider has
|
||||
// already taken care of this for us.
|
||||
}}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
>
|
||||
</frigate-card-image>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveFrigateStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-live-image': FrigateCardLiveImage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { renderProgressIndicator } from '../../components/message.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
JSMPEGConfig,
|
||||
} from '../../types.js';
|
||||
import { homeAssistantSignPath } from '../../utils/ha';
|
||||
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
|
||||
import { dispatchErrorMessageEvent } from '../message.js';
|
||||
import { contentsChanged, errorToConsole } from '../../utils/basic.js';
|
||||
|
||||
// Number of seconds a signed URL is valid for.
|
||||
const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
// Number of seconds before the expiry to trigger a refresh.
|
||||
const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
|
||||
|
||||
@customElement('frigate-card-live-jsmpeg')
|
||||
export class FrigateCardLiveJSMPEG extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public jsmpegConfig?: JSMPEGConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected hass?: ExtendedHomeAssistant;
|
||||
|
||||
protected _jsmpegCanvasElement?: HTMLCanvasElement;
|
||||
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
||||
protected _refreshPlayerTimerID?: number;
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._jsmpegVideoPlayer?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._jsmpegVideoPlayer?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video (included for completeness, JSMPEG live disables audio as
|
||||
* Frigate does not encode it).
|
||||
*/
|
||||
public mute(): void {
|
||||
const player = this._jsmpegVideoPlayer?.player;
|
||||
if (player) {
|
||||
player.volume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video (included for completeness, JSMPEG live disables audio as
|
||||
* Frigate does not encode it).
|
||||
*/
|
||||
public unmute(): void {
|
||||
const player = this._jsmpegVideoPlayer?.player;
|
||||
if (player) {
|
||||
player.volume = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video (unsupported).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public seek(_seconds: number): void {
|
||||
// JSMPEG does not support seeking.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed player URL.
|
||||
* @returns A URL or null.
|
||||
*/
|
||||
protected async _getURL(): Promise<string | null> {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameraConfig?.frigate.client_id ||
|
||||
!this.cameraConfig?.frigate.camera_name
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(
|
||||
this.hass,
|
||||
`/api/frigate/${this.cameraConfig.frigate.client_id}` +
|
||||
`/jsmpeg/${this.cameraConfig.frigate.camera_name}`,
|
||||
URL_SIGN_EXPIRY_SECONDS,
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return response.replace(/^http/i, 'ws');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSMPEG player.
|
||||
* @param url The URL for the player to connect to.
|
||||
* @returns A JSMPEG player.
|
||||
*/
|
||||
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
|
||||
return new Promise<JSMpeg.VideoElement>((resolve) => {
|
||||
let videoDecoded = false;
|
||||
const player = new JSMpeg.VideoElement(
|
||||
this,
|
||||
url,
|
||||
{
|
||||
canvas: this._jsmpegCanvasElement,
|
||||
},
|
||||
{
|
||||
// The media carousel may automatically pause when the browser tab is
|
||||
// inactive, JSMPEG does not need to also do so independently.
|
||||
pauseWhenHidden: false,
|
||||
autoplay: false,
|
||||
protocols: [],
|
||||
audio: false,
|
||||
videoBufferSize: 1024 * 1024 * 4,
|
||||
|
||||
// Override with user-specified options.
|
||||
...this.jsmpegConfig?.options,
|
||||
|
||||
// Don't allow the player to internally reconnect, as it may re-use a
|
||||
// URL with a (newly) invalid signature, e.g. during a Home Assistant
|
||||
// restart.
|
||||
reconnectInterval: 0,
|
||||
onVideoDecode: () => {
|
||||
// This is the only callback that is called after the dimensions
|
||||
// are available. It's called on every frame decode, so just
|
||||
// ignore any subsequent calls.
|
||||
if (!videoDecoded && this._jsmpegCanvasElement) {
|
||||
videoDecoded = true;
|
||||
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
|
||||
resolve(player);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset / destroy the player.
|
||||
*/
|
||||
protected _resetPlayer(): void {
|
||||
if (this._refreshPlayerTimerID) {
|
||||
window.clearTimeout(this._refreshPlayerTimerID);
|
||||
this._refreshPlayerTimerID = undefined;
|
||||
}
|
||||
if (this._jsmpegVideoPlayer) {
|
||||
try {
|
||||
this._jsmpegVideoPlayer.destroy();
|
||||
} catch (err) {
|
||||
// Pass.
|
||||
}
|
||||
this._jsmpegVideoPlayer = undefined;
|
||||
}
|
||||
if (this._jsmpegCanvasElement) {
|
||||
this._jsmpegCanvasElement.remove();
|
||||
this._jsmpegCanvasElement = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.isConnected) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
if (!this.isConnected) {
|
||||
this._resetPlayer();
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the JSMPEG player.
|
||||
*/
|
||||
protected async _refreshPlayer(): Promise<void> {
|
||||
this._resetPlayer();
|
||||
|
||||
this._jsmpegCanvasElement = document.createElement('canvas');
|
||||
this._jsmpegCanvasElement.className = 'media';
|
||||
|
||||
if (!this.cameraConfig?.frigate.camera_name) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const url = await this._getURL();
|
||||
if (url) {
|
||||
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url);
|
||||
this._refreshPlayerTimerID = window.setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||
} else {
|
||||
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const _render = async (): Promise<TemplateResult | void> => {
|
||||
await this._refreshPlayer();
|
||||
|
||||
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
|
||||
}
|
||||
return html`${this._jsmpegCanvasElement}`;
|
||||
};
|
||||
return html`${until(
|
||||
_render(),
|
||||
renderProgressIndicator({
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
}),
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveJSMPEGStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import liveWebRTCStyle from '../../scss/live-webrtc.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
FrigateCardError,
|
||||
WebRTCCardConfig,
|
||||
} from '../../types.js';
|
||||
import { contentsChanged } from '../../utils/basic.js';
|
||||
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
|
||||
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
||||
import { renderTask } from '../../utils/task.js';
|
||||
|
||||
// Create a wrapper for AlexxIT's WebRTC card
|
||||
// - https://github.com/AlexxIT/WebRTC
|
||||
@customElement('frigate-card-live-webrtc-card')
|
||||
export class FrigateCardLiveWebRTCCard extends LitElement {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public webRTCConfig?: WebRTCCardConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected hass?: HomeAssistant;
|
||||
|
||||
// A task to await the load of the WebRTC component.
|
||||
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._getPlayer()
|
||||
?.play()
|
||||
.catch(() => {
|
||||
// WebRTC appears to generate additional spurious load events, which may
|
||||
// result in loads after a play() call, which causes the browser to spam
|
||||
// the logs unless the promise rejection is handled here.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._getPlayer()?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.currentTime = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying video player.
|
||||
* @returns The player or `null` if not found.
|
||||
*/
|
||||
protected _getPlayer(): HTMLVideoElement | null {
|
||||
return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
protected async _getWebRTCCardElement(): Promise<
|
||||
CustomElementConstructor | undefined
|
||||
> {
|
||||
await customElements.whenDefined('webrtc-camera');
|
||||
return customElements.get('webrtc-camera');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the WebRTC element. May throw.
|
||||
*/
|
||||
protected _createWebRTC(): HTMLElement | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const webrtcElement = this._webrtcTask.value;
|
||||
if (webrtcElement && this.hass) {
|
||||
const webrtc = new webrtcElement() as HTMLElement & {
|
||||
hass: HomeAssistant;
|
||||
setConfig: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
const config = { ...this.webRTCConfig };
|
||||
|
||||
// If the live WebRTC configuration does not specify a URL/entity to use,
|
||||
// then take values from the camera configuration instead (if there are
|
||||
// any).
|
||||
if (!config.url) {
|
||||
config.url = this.cameraConfig?.webrtc_card?.url;
|
||||
}
|
||||
if (!config.entity) {
|
||||
config.entity = this.cameraConfig?.webrtc_card?.entity;
|
||||
}
|
||||
webrtc.setConfig(config);
|
||||
webrtc.hass = this.hass;
|
||||
return webrtc;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const render = (): TemplateResult | void => {
|
||||
let webrtcElement: HTMLElement | null;
|
||||
try {
|
||||
webrtcElement = this._createWebRTC();
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(
|
||||
this,
|
||||
e instanceof FrigateCardError
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||
{ context: (e as FrigateCardError).context },
|
||||
);
|
||||
}
|
||||
if (webrtcElement) {
|
||||
// Set the id to ensure that the relevant CSS styles will have
|
||||
// sufficient specifity to overcome some styles that are otherwise
|
||||
// applied to <ha-card> in Safari.
|
||||
webrtcElement.id = 'webrtc';
|
||||
}
|
||||
return html`${webrtcElement}`;
|
||||
};
|
||||
|
||||
// Use a task to allow us to asynchronously wait for the WebRTC card to
|
||||
// load, but yet still have the card load be followed by the updated()
|
||||
// lifecycle callback (unlike just using `until`).
|
||||
return renderTask(this, this._webrtcTask, render, {
|
||||
inProgressFunc: () =>
|
||||
renderProgressIndicator({
|
||||
message: localize('error.webrtc_card_waiting'),
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updated lifecycle callback.
|
||||
*/
|
||||
public updated(): void {
|
||||
// Extract the video component after it has been rendered and generate the
|
||||
// media load event.
|
||||
this.updateComplete.then(() => {
|
||||
const video = this._getPlayer();
|
||||
if (video) {
|
||||
const onloadeddata = video.onloadeddata;
|
||||
|
||||
video.onloadeddata = (e) => {
|
||||
if (onloadeddata) {
|
||||
onloadeddata.call(video, e);
|
||||
}
|
||||
dispatchMediaLoadedEvent(this, video);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveWebRTCStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { EmblaOptionsType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import {
|
||||
@@ -15,66 +12,88 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
|
||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import liveFrigateStyle from '../scss/live-frigate.scss';
|
||||
import liveJSMPEGStyle from '../scss/live-jsmpeg.scss';
|
||||
import liveWebRTCStyle from '../scss/live-webrtc.scss';
|
||||
import liveStyle from '../scss/live.scss';
|
||||
import liveCarouselStyle from '../scss/live-carousel.scss';
|
||||
import liveProviderStyle from '../scss/live-provider.scss';
|
||||
import { ConditionState, getOverriddenConfig } from '../../card-condition.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import liveStyle from '../../scss/live.scss';
|
||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
frigateCardConfigDefaults,
|
||||
FrigateCardError,
|
||||
FrigateCardMediaPlayer,
|
||||
JSMPEGConfig,
|
||||
LiveConfig,
|
||||
LiveOverrides,
|
||||
LiveProvider,
|
||||
MediaLoadedInfo,
|
||||
Message,
|
||||
TransitionEffect,
|
||||
WebRTCCardConfig,
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
||||
import { getCameraIcon, getCameraTitle } from '../utils/camera.js';
|
||||
import { homeAssistantSignPath } from '../utils/ha';
|
||||
import { getFullDependentBrowseMediaQueryParameters } from '../utils/ha/browse-media.js';
|
||||
} from '../../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { contentsChanged } from '../../utils/basic.js';
|
||||
import { getCameraIcon, getCameraTitle } from '../../utils/camera.js';
|
||||
import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js';
|
||||
import {
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import { dispatchViewContextChangeEvent, View } from '../view.js';
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||
} from '../../utils/media-info.js';
|
||||
import { dispatchViewContextChangeEvent, View } from '../../view.js';
|
||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||
import {
|
||||
FrigateCardMediaCarousel,
|
||||
wrapMediaLoadedEventForCarousel,
|
||||
wrapMediaUnloadedEventForCarousel,
|
||||
} from './media-carousel.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import './next-prev-control.js';
|
||||
import './title-control.js';
|
||||
import './surround.js';
|
||||
import '../patches/ha-camera-stream';
|
||||
import { EmblaCarouselPlugins } from './carousel.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
} from '../media-carousel.js';
|
||||
import '../next-prev-control.js';
|
||||
import '../title-control.js';
|
||||
import '../surround.js';
|
||||
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import './image';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
import { DataManager } from '../utils/data-manager.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||
import { DataManager } from '../../utils/data-manager.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
|
||||
// Number of seconds a signed URL is valid for.
|
||||
const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||
/**
|
||||
* Get the state object or dispatch an error. Used in `ha` and `image` live
|
||||
* providers.
|
||||
* @param element HTMLElement to dispatch errors from.
|
||||
* @param hass Home Assistant object.
|
||||
* @param cameraConfig Camera configuration.
|
||||
* @returns
|
||||
*/
|
||||
export const getStateObjOrDispatchError = (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraConfig?: CameraConfig,
|
||||
): HassEntity | null => {
|
||||
if (!cameraConfig?.camera_entity) {
|
||||
dispatchErrorMessageEvent(element, localize('error.no_live_camera'), {
|
||||
context: cameraConfig,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Number of seconds before the expiry to trigger a refresh.
|
||||
const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60;
|
||||
const stateObj = hass.states[cameraConfig.camera_entity];
|
||||
if (!stateObj) {
|
||||
dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), {
|
||||
context: cameraConfig,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stateObj.state === 'unavailable') {
|
||||
dispatchMessageEvent(element, localize('error.live_camera_unavailable'), 'info', {
|
||||
icon: 'mdi:connection',
|
||||
context: getCameraTitle(hass, cameraConfig),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return stateObj;
|
||||
};
|
||||
|
||||
@customElement('frigate-card-live')
|
||||
export class FrigateCardLive extends LitElement {
|
||||
@@ -99,6 +118,9 @@ export class FrigateCardLive extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
// Whether or not the live view is currently in the background (i.e. preloaded
|
||||
// but not visible)
|
||||
@state()
|
||||
@@ -250,6 +272,7 @@ export class FrigateCardLive extends LitElement {
|
||||
.inBackground=${this._inBackground}
|
||||
.conditionState=${this.conditionState}
|
||||
.liveOverrides=${this.liveOverrides}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-live-carousel>
|
||||
</frigate-card-surround>`,
|
||||
@@ -290,6 +313,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public conditionState?: ConditionState;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
// Index between camera name and slide number.
|
||||
protected _cameraToSlide: Record<string, number> = {};
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
@@ -484,7 +510,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
|
||||
const liveProvider = slide?.querySelector(
|
||||
'frigate-card-live-provider',
|
||||
) as FrigateCardLiveProvider;
|
||||
) as FrigateCardLiveProvider | null;
|
||||
if (liveProvider) {
|
||||
liveProvider.disabled = action !== 'load';
|
||||
}
|
||||
@@ -520,6 +546,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.label=${getCameraTitle(this.hass, cameraConfig)}
|
||||
.liveConfig=${config}
|
||||
.hass=${this.hass}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
||||
wrapMediaLoadedEventForCarousel(slideIndex, ev);
|
||||
}}
|
||||
@@ -667,6 +694,9 @@ export class FrigateCardLiveProvider extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public label = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@state()
|
||||
protected _isVideoMediaLoaded = false;
|
||||
|
||||
@@ -719,15 +749,17 @@ export class FrigateCardLiveProvider extends LitElement {
|
||||
) {
|
||||
return 'webrtc-card';
|
||||
} else if (this.cameraConfig?.camera_entity) {
|
||||
if (this.cardWideConfig?.performance?.profile === 'low') {
|
||||
return 'image';
|
||||
} else {
|
||||
return 'ha';
|
||||
}
|
||||
} else if (this.cameraConfig?.frigate.camera_name) {
|
||||
return 'frigate-jsmpeg';
|
||||
}
|
||||
return frigateCardConfigDefaults.cameras.live_provider;
|
||||
}
|
||||
return (
|
||||
this.cameraConfig?.live_provider || frigateCardConfigDefaults.cameras.live_provider
|
||||
);
|
||||
return this.cameraConfig?.live_provider || 'image';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -769,6 +801,21 @@ export class FrigateCardLiveProvider extends LitElement {
|
||||
}
|
||||
if (changedProps.has('liveConfig')) {
|
||||
updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout);
|
||||
if (this.liveConfig?.show_image_during_load) {
|
||||
import('./live-image.js');
|
||||
}
|
||||
}
|
||||
if (changedProps.has('cameraConfig')) {
|
||||
const provider = this._getResolvedProvider();
|
||||
if (provider === 'frigate-jsmpeg') {
|
||||
import('./live-jsmpeg.js');
|
||||
} else if (provider === 'ha') {
|
||||
import('./live-ha.js');
|
||||
} else if (provider === 'webrtc-card') {
|
||||
import('./live-webrtc.js');
|
||||
} else if (provider === 'image') {
|
||||
import('./live-image.js');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,22 +833,29 @@ export class FrigateCardLiveProvider extends LitElement {
|
||||
this.ariaLabel = this.label;
|
||||
|
||||
const provider = this._getResolvedProvider();
|
||||
const showImage = !this._isVideoMediaLoaded && this._shouldShowImageDuringLoading();
|
||||
const showImageDuringLoading =
|
||||
!this._isVideoMediaLoaded && this._shouldShowImageDuringLoading();
|
||||
const providerClasses = {
|
||||
hidden: showImage,
|
||||
hidden: showImageDuringLoading,
|
||||
};
|
||||
|
||||
return html`
|
||||
${showImage
|
||||
? html`<frigate-card-image
|
||||
.imageConfig=${{
|
||||
mode: 'camera' as const,
|
||||
refresh_seconds: 1,
|
||||
}}
|
||||
${showImageDuringLoading || provider === 'image'
|
||||
? html`<frigate-card-live-image
|
||||
${ref(this._providerRef)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.liveImageConfig=${this.liveConfig.image}
|
||||
@frigate-card:media:loaded=${() => {
|
||||
if (provider === 'image') {
|
||||
// Only count the media has loaded if the required provider is
|
||||
// the image (not just the temporary image shown during
|
||||
// loading).
|
||||
this._videoMediaShowHandler();
|
||||
}
|
||||
}}
|
||||
>
|
||||
</frigate-card-image>`
|
||||
</frigate-card-live-image>`
|
||||
: html``}
|
||||
${provider === 'ha'
|
||||
? html` <frigate-card-live-ha
|
||||
@@ -819,18 +873,22 @@ export class FrigateCardLiveProvider extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.webRTCConfig=${this.liveConfig.webrtc_card}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-webrtc-card>`
|
||||
: html` <frigate-card-live-jsmpeg
|
||||
: provider === 'frigate-jsmpeg'
|
||||
? html` <frigate-card-live-jsmpeg
|
||||
${ref(this._providerRef)}
|
||||
class=${classMap(providerClasses)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.jsmpegConfig=${this.liveConfig.jsmpeg}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-jsmpeg>`}
|
||||
</frigate-card-live-jsmpeg>`
|
||||
: html``}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -842,507 +900,8 @@ export class FrigateCardLiveProvider extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-live-ha')
|
||||
export class FrigateCardLiveFrigate extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
protected _playerRef: Ref<FrigateCardLiveFrigate> = createRef();
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._playerRef.value?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._playerRef.value?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
this._playerRef.value?.mute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
this._playerRef.value?.unmute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
this._playerRef.value?.seek(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.cameraConfig?.camera_entity) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), {
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
|
||||
if (!stateObj) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), {
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (stateObj.state === 'unavailable') {
|
||||
// Don't treat state unavailability as an error per se.
|
||||
return dispatchMessageEvent(
|
||||
this,
|
||||
localize('error.live_camera_unavailable'),
|
||||
'info',
|
||||
{
|
||||
icon: 'mdi:connection',
|
||||
context: getCameraTitle(this.hass, this.cameraConfig),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return html` <frigate-card-ha-camera-stream
|
||||
${ref(this._playerRef)}
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
.controls=${true}
|
||||
.muted=${true}
|
||||
>
|
||||
</frigate-card-ha-camera-stream>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveFrigateStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a wrapper for AlexxIT's WebRTC card
|
||||
// - https://github.com/AlexxIT/WebRTC
|
||||
@customElement('frigate-card-live-webrtc-card')
|
||||
export class FrigateCardLiveWebRTCCard extends LitElement {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public webRTCConfig?: WebRTCCardConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
protected hass?: HomeAssistant;
|
||||
|
||||
// A task to await the load of the WebRTC component.
|
||||
protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._getPlayer()
|
||||
?.play()
|
||||
.catch(() => {
|
||||
// WebRTC appears to generate additional spurious load events, which may
|
||||
// result in loads after a play() call, which causes the browser to spam
|
||||
// the logs unless the promise rejection is handled here.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._getPlayer()?.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video.
|
||||
*/
|
||||
public mute(): void {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.muted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video.
|
||||
*/
|
||||
public unmute(): void {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video.
|
||||
*/
|
||||
public seek(seconds: number): void {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.currentTime = seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying video player.
|
||||
* @returns The player or `null` if not found.
|
||||
*/
|
||||
protected _getPlayer(): HTMLVideoElement | null {
|
||||
return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
protected async _getWebRTCCardElement(): Promise<
|
||||
CustomElementConstructor | undefined
|
||||
> {
|
||||
await customElements.whenDefined('webrtc-camera');
|
||||
return customElements.get('webrtc-camera');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the WebRTC element. May throw.
|
||||
*/
|
||||
protected _createWebRTC(): HTMLElement | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const webrtcElement = this._webrtcTask.value;
|
||||
if (webrtcElement && this.hass) {
|
||||
const webrtc = new webrtcElement() as HTMLElement & {
|
||||
hass: HomeAssistant;
|
||||
setConfig: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
const config = { ...this.webRTCConfig };
|
||||
|
||||
// If the live WebRTC configuration does not specify a URL/entity to use,
|
||||
// then take values from the camera configuration instead (if there are
|
||||
// any).
|
||||
if (!config.url) {
|
||||
config.url = this.cameraConfig?.webrtc_card?.url;
|
||||
}
|
||||
if (!config.entity) {
|
||||
config.entity = this.cameraConfig?.webrtc_card?.entity;
|
||||
}
|
||||
webrtc.setConfig(config);
|
||||
webrtc.hass = this.hass;
|
||||
return webrtc;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const render = (): TemplateResult | void => {
|
||||
let webrtcElement: HTMLElement | null;
|
||||
try {
|
||||
webrtcElement = this._createWebRTC();
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(
|
||||
this,
|
||||
e instanceof FrigateCardError
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||
{ context: (e as FrigateCardError).context },
|
||||
);
|
||||
}
|
||||
if (webrtcElement) {
|
||||
// Set the id to ensure that the relevant CSS styles will have
|
||||
// sufficient specifity to overcome some styles that are otherwise
|
||||
// applied to <ha-card> in Safari.
|
||||
webrtcElement.id = 'webrtc';
|
||||
}
|
||||
return html`${webrtcElement}`;
|
||||
};
|
||||
|
||||
// Use a task to allow us to asynchronously wait for the WebRTC card to
|
||||
// load, but yet still have the card load be followed by the updated()
|
||||
// lifecycle callback (unlike just using `until`).
|
||||
return renderTask(this, this._webrtcTask, render, () =>
|
||||
renderProgressIndicator(localize('error.webrtc_card_waiting')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updated lifecycle callback.
|
||||
*/
|
||||
public updated(): void {
|
||||
// Extract the video component after it has been rendered and generate the
|
||||
// media load event.
|
||||
this.updateComplete.then(() => {
|
||||
const video = this._getPlayer();
|
||||
if (video) {
|
||||
const onloadeddata = video.onloadeddata;
|
||||
|
||||
video.onloadeddata = (e) => {
|
||||
if (onloadeddata) {
|
||||
onloadeddata.call(video, e);
|
||||
}
|
||||
dispatchMediaLoadedEvent(this, video);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveWebRTCStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-live-jsmpeg')
|
||||
export class FrigateCardLiveJSMPEG extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public jsmpegConfig?: JSMPEGConfig;
|
||||
|
||||
protected hass?: ExtendedHomeAssistant;
|
||||
|
||||
protected _jsmpegCanvasElement?: HTMLCanvasElement;
|
||||
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
||||
protected _refreshPlayerTimerID?: number;
|
||||
|
||||
/**
|
||||
* Play the video.
|
||||
*/
|
||||
public play(): void {
|
||||
this._jsmpegVideoPlayer?.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the video.
|
||||
*/
|
||||
public pause(): void {
|
||||
this._jsmpegVideoPlayer?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the video (included for completeness, JSMPEG live disables audio as
|
||||
* Frigate does not encode it).
|
||||
*/
|
||||
public mute(): void {
|
||||
const player = this._jsmpegVideoPlayer?.player;
|
||||
if (player) {
|
||||
player.volume = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute the video (included for completeness, JSMPEG live disables audio as
|
||||
* Frigate does not encode it).
|
||||
*/
|
||||
public unmute(): void {
|
||||
const player = this._jsmpegVideoPlayer?.player;
|
||||
if (player) {
|
||||
player.volume = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek the video (unsupported).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public seek(_seconds: number): void {
|
||||
// JSMPEG does not support seeking.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed player URL.
|
||||
* @returns A URL or null.
|
||||
*/
|
||||
protected async _getURL(): Promise<string | null> {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameraConfig?.frigate.client_id ||
|
||||
!this.cameraConfig?.frigate.camera_name
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let response: string | null | undefined;
|
||||
try {
|
||||
response = await homeAssistantSignPath(
|
||||
this.hass,
|
||||
`/api/frigate/${this.cameraConfig.frigate.client_id}` +
|
||||
`/jsmpeg/${this.cameraConfig.frigate.camera_name}`,
|
||||
URL_SIGN_EXPIRY_SECONDS,
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return null;
|
||||
}
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return response.replace(/^http/i, 'ws');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSMPEG player.
|
||||
* @param url The URL for the player to connect to.
|
||||
* @returns A JSMPEG player.
|
||||
*/
|
||||
protected async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> {
|
||||
return new Promise<JSMpeg.VideoElement>((resolve) => {
|
||||
let videoDecoded = false;
|
||||
const player = new JSMpeg.VideoElement(
|
||||
this,
|
||||
url,
|
||||
{
|
||||
canvas: this._jsmpegCanvasElement,
|
||||
},
|
||||
{
|
||||
// The media carousel may automatically pause when the browser tab is
|
||||
// inactive, JSMPEG does not need to also do so independently.
|
||||
pauseWhenHidden: false,
|
||||
autoplay: false,
|
||||
protocols: [],
|
||||
audio: false,
|
||||
videoBufferSize: 1024 * 1024 * 4,
|
||||
|
||||
// Override with user-specified options.
|
||||
...this.jsmpegConfig?.options,
|
||||
|
||||
// Don't allow the player to internally reconnect, as it may re-use a
|
||||
// URL with a (newly) invalid signature, e.g. during a Home Assistant
|
||||
// restart.
|
||||
reconnectInterval: 0,
|
||||
onVideoDecode: () => {
|
||||
// This is the only callback that is called after the dimensions
|
||||
// are available. It's called on every frame decode, so just
|
||||
// ignore any subsequent calls.
|
||||
if (!videoDecoded && this._jsmpegCanvasElement) {
|
||||
videoDecoded = true;
|
||||
dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement);
|
||||
resolve(player);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset / destroy the player.
|
||||
*/
|
||||
protected _resetPlayer(): void {
|
||||
if (this._refreshPlayerTimerID) {
|
||||
window.clearTimeout(this._refreshPlayerTimerID);
|
||||
this._refreshPlayerTimerID = undefined;
|
||||
}
|
||||
if (this._jsmpegVideoPlayer) {
|
||||
try {
|
||||
this._jsmpegVideoPlayer.destroy();
|
||||
} catch (err) {
|
||||
// Pass.
|
||||
}
|
||||
this._jsmpegVideoPlayer = undefined;
|
||||
}
|
||||
if (this._jsmpegCanvasElement) {
|
||||
this._jsmpegCanvasElement.remove();
|
||||
this._jsmpegCanvasElement = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.isConnected) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
if (!this.isConnected) {
|
||||
this._resetPlayer();
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the JSMPEG player.
|
||||
*/
|
||||
protected async _refreshPlayer(): Promise<void> {
|
||||
this._resetPlayer();
|
||||
|
||||
this._jsmpegCanvasElement = document.createElement('canvas');
|
||||
this._jsmpegCanvasElement.className = 'media';
|
||||
|
||||
if (!this.cameraConfig?.frigate.camera_name) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), {
|
||||
context: this.cameraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const url = await this._getURL();
|
||||
if (url) {
|
||||
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url);
|
||||
this._refreshPlayerTimerID = window.setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||
} else {
|
||||
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const _render = async (): Promise<TemplateResult | void> => {
|
||||
await this._refreshPlayer();
|
||||
|
||||
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
||||
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
|
||||
}
|
||||
return html`${this._jsmpegCanvasElement}`;
|
||||
};
|
||||
return html`${until(_render(), renderProgressIndicator())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveJSMPEGStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG;
|
||||
'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard;
|
||||
'frigate-card-live-ha': FrigateCardLiveFrigate;
|
||||
'frigate-card-live-provider': FrigateCardLiveProvider;
|
||||
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
||||
'frigate-card-live': FrigateCardLive;
|
||||
@@ -22,7 +22,7 @@ import './next-prev-control.js';
|
||||
import './carousel.js';
|
||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||
import { FrigateCardTitleControl } from './title-control.js';
|
||||
import { debounce } from 'lodash-es';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
const getEmptyImageSrc = (width: number, height: number) =>
|
||||
`data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import messageStyle from '../scss/message.scss';
|
||||
import { FrigateCardError, Message, MessageType } from '../types.js';
|
||||
import { CardWideConfig, FrigateCardError, Message, MessageType } from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
|
||||
@customElement('frigate-card-message')
|
||||
@@ -82,11 +82,14 @@ export class FrigateCardProgressIndicator extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public message: string | TemplateResult = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
public animated = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html` <div class="message vertical">
|
||||
<span>
|
||||
<ha-circular-progress active="true" size="large"> </ha-circular-progress>
|
||||
</span>
|
||||
${this.animated
|
||||
? html`<ha-circular-progress active="true" size="large"> </ha-circular-progress>`
|
||||
: html`<ha-icon icon="mdi:timer-sand"></ha-icon>`}
|
||||
${this.message ? html`<span>${this.message}</span>` : html``}
|
||||
</div>`;
|
||||
}
|
||||
@@ -112,9 +115,16 @@ export function renderMessage(message: Message): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
|
||||
export function renderProgressIndicator(message?: string): TemplateResult {
|
||||
export function renderProgressIndicator(options?: {
|
||||
message?: string;
|
||||
cardWideConfig?: CardWideConfig;
|
||||
}): TemplateResult {
|
||||
return html`
|
||||
<frigate-card-progress-indicator .message=${message || ''}>
|
||||
<frigate-card-progress-indicator
|
||||
.message=${options?.message || ''}
|
||||
.animated=${options?.cardWideConfig?.performance?.features
|
||||
.animated_progress_indicator ?? true}
|
||||
>
|
||||
</frigate-card-progress-indicator>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
|
||||
const classes = {
|
||||
controls: true,
|
||||
previous: this.direction == 'previous',
|
||||
next: this.direction == 'next',
|
||||
thumbnails: this._controlConfig.style == 'thumbnails',
|
||||
previous: this.direction === 'previous',
|
||||
next: this.direction === 'next',
|
||||
thumbnails: this._controlConfig.style === 'thumbnails',
|
||||
icons: ['chevrons', 'icons'].includes(this._controlConfig.style),
|
||||
button: ['chevrons', 'icons'].includes(this._controlConfig.style),
|
||||
};
|
||||
@@ -62,7 +62,7 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
|
||||
let icon: string;
|
||||
if (this._controlConfig.style === 'chevrons') {
|
||||
icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
|
||||
icon = this.direction === 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
|
||||
} else {
|
||||
if (!this.icon) {
|
||||
return html``;
|
||||
@@ -91,7 +91,7 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
aria-label="${this.label}"
|
||||
/>`
|
||||
: html``,
|
||||
() => html`<div class=${classMap(classes)}></div>`,
|
||||
{ inProgressFunc: () => html`<div class=${classMap(classes)}></div>` },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
|
||||
import './surround-basic.js';
|
||||
import './timeline-core.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
interface ThumbnailViewContext {
|
||||
@@ -103,9 +102,6 @@ export class FrigateCardSurround extends LitElement {
|
||||
?.evolve({
|
||||
target: parent,
|
||||
childIndex: null,
|
||||
|
||||
// Don't carry over history of this 'empty' view.
|
||||
previous: null,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
@@ -125,6 +121,10 @@ export class FrigateCardSurround extends LitElement {
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') {
|
||||
import('./timeline.js');
|
||||
}
|
||||
|
||||
// Once the component will certainly update, dispatch a media request. Only
|
||||
// do so if properties relevant to the request have changed (as per their
|
||||
// hasChanged).
|
||||
@@ -200,7 +200,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
>
|
||||
</frigate-card-thumbnail-carousel>`
|
||||
: ''}
|
||||
${this.timelineConfig && !this.inBackground
|
||||
${this.timelineConfig?.mode && this.timelineConfig.mode !== 'none' && !this.inBackground
|
||||
? html` <frigate-card-timeline-core
|
||||
slot=${this.timelineConfig.mode}
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
import format from 'date-fns/format';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
@@ -16,7 +17,7 @@ import { getEventDurationString } from '../utils/frigate.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||
import { View } from '../view.js';
|
||||
import { MediaSeek } from './viewer.js';
|
||||
import type { MediaSeek } from './viewer.js';
|
||||
import { TaskStatus } from '@lit-labs/task';
|
||||
|
||||
import type {
|
||||
@@ -95,7 +96,7 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
this._embedThumbnailTask,
|
||||
(embeddedThumbnail: string | null) =>
|
||||
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
|
||||
() => imageOff,
|
||||
{ inProgressFunc: () => imageOff },
|
||||
)
|
||||
: imageOff} `;
|
||||
}
|
||||
@@ -296,7 +297,9 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
? html`<frigate-card-thumbnail-feature-recording
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
.cameraTitle=${this.details || !this.cameraConfig || !this.hass ? undefined : getCameraTitle(this.hass, this.cameraConfig)}
|
||||
.cameraTitle=${this.details || !this.cameraConfig || !this.hass
|
||||
? undefined
|
||||
: getCameraTitle(this.hass, this.cameraConfig)}
|
||||
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
|
||||
></frigate-card-thumbnail-feature-recording>`
|
||||
: html``}
|
||||
@@ -308,12 +311,7 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event && this.hass && clientID) {
|
||||
retainEvent(
|
||||
this.hass,
|
||||
clientID,
|
||||
event.id,
|
||||
!event.retain_indefinitely,
|
||||
)
|
||||
retainEvent(this.hass, clientID, event.id, !event.retain_indefinitely)
|
||||
.then(() => {
|
||||
if (event) {
|
||||
event.retain_indefinitely = !event.retain_indefinitely;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
add,
|
||||
differenceInSeconds,
|
||||
endOfHour,
|
||||
fromUnixTime,
|
||||
startOfHour,
|
||||
sub,
|
||||
} from 'date-fns';
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import sub from 'date-fns/sub';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -16,12 +14,12 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { isEqual, throttle } from 'lodash-es';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { ViewContext } from 'view';
|
||||
import { DataView, DataSet } from 'vis-data/esnext';
|
||||
import type { DataGroupCollectionType, IdType } from 'vis-timeline/esnext';
|
||||
import {
|
||||
DataGroupCollectionType,
|
||||
IdType,
|
||||
Timeline,
|
||||
TimelineEventPropertiesResult,
|
||||
TimelineItem,
|
||||
@@ -458,7 +456,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this.hass,
|
||||
this.dataManager,
|
||||
this.cameras,
|
||||
this.view, {
|
||||
this.view,
|
||||
{
|
||||
cameraIDs: new Set([String(properties.group)]),
|
||||
targetTime:
|
||||
properties.what === 'background' ? properties.time : window.end,
|
||||
@@ -471,7 +470,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this.hass,
|
||||
this.dataManager,
|
||||
this.cameras,
|
||||
this.view, {
|
||||
this.view,
|
||||
{
|
||||
targetTime: window.end,
|
||||
},
|
||||
);
|
||||
@@ -483,12 +483,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
this.hass,
|
||||
this.dataManager,
|
||||
this.cameras,
|
||||
this.view, {
|
||||
this.view,
|
||||
{
|
||||
cameraIDs: this._getAllCameraIDs(),
|
||||
start: startOfHour(properties.time),
|
||||
end: endOfHour(properties.time),
|
||||
targetTime: properties.time,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
BrowseMediaNeighbors,
|
||||
BrowseMediaQueryParameters,
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateCardConfigDefaults,
|
||||
@@ -97,6 +98,9 @@ export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
@@ -151,7 +155,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
}),
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator();
|
||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||
}
|
||||
|
||||
return html` <frigate-card-surround
|
||||
@@ -169,6 +173,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
</frigate-card-surround>`;
|
||||
@@ -206,6 +211,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
|
||||
// Mapping of slide # to FrigateBrowseMediaSource child #.
|
||||
@@ -645,7 +653,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
// If lazy loading is not enabled, wait for the media resolver task to
|
||||
// complete and show a progress indictator until this.
|
||||
if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) {
|
||||
return renderTask(this, this._mediaResolutionTask, this._render.bind(this));
|
||||
return renderTask(this, this._mediaResolutionTask, this._render.bind(this), {
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
});
|
||||
}
|
||||
return this._render();
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,4 +1,6 @@
|
||||
import { cloneDeep, get, isEqual, set } from 'lodash-es';
|
||||
import get from 'lodash-es/get';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import set from 'lodash-es/set';
|
||||
import {
|
||||
CONF_CAMERAS,
|
||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||
@@ -40,7 +42,7 @@ import {
|
||||
/**
|
||||
* Set a configuration value.
|
||||
* @param obj The configuration.
|
||||
* @param key The key to the property to set.
|
||||
* @param keys The key to the property to set.
|
||||
* @param value The value to set.
|
||||
*/
|
||||
|
||||
@@ -55,7 +57,8 @@ export const setConfigValue = (
|
||||
/**
|
||||
* Get a configuration value.
|
||||
* @param obj The configuration.
|
||||
* @param key The key to the property to retrieve.
|
||||
* @param keys The key to the property to retrieve.
|
||||
* @param def Default if key not found.
|
||||
* @returns The property or undefined if not found.
|
||||
*/
|
||||
export const getConfigValue = (
|
||||
@@ -104,7 +107,7 @@ export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean {
|
||||
* @returns `true` if the configuration is upgradeable.
|
||||
*/
|
||||
export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean {
|
||||
const newObj = JSON.parse(JSON.stringify(obj));
|
||||
const newObj = structuredClone(obj);
|
||||
return upgradeConfig(newObj);
|
||||
};
|
||||
|
||||
@@ -136,7 +139,7 @@ export const trimConfig = function (obj: RawFrigateCardConfig): boolean {
|
||||
* @returns A new deeply-copied configuration.
|
||||
*/
|
||||
export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig {
|
||||
return cloneDeep(obj);
|
||||
return structuredClone(obj);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,6 +139,8 @@ export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
|
||||
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
|
||||
`${CONF_LIVE}.controls.title.duration_seconds` as const;
|
||||
export const CONF_LIVE_IMAGE_REFRESH_SECONDS =
|
||||
`${CONF_LIVE}.image.refresh_seconds` as const;
|
||||
export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
|
||||
export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
|
||||
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
|
||||
@@ -192,7 +194,10 @@ export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as
|
||||
export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const;
|
||||
export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const;
|
||||
export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
|
||||
export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
|
||||
`${CONF_MENU}.buttons.media_player` as const;
|
||||
export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
|
||||
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const;
|
||||
|
||||
export const CONF_DIMENSIONS = 'dimensions' as const;
|
||||
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
|
||||
@@ -201,5 +206,11 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
|
||||
|
||||
export const CONF_OVERRIDES = 'overrides' as const;
|
||||
|
||||
export const CONF_PERFORMANCE = 'performance' as const;
|
||||
export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`;
|
||||
export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`;
|
||||
export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`;
|
||||
export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`;
|
||||
|
||||
// Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
|
||||
export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
|
||||
|
||||
+151
-27
@@ -62,6 +62,7 @@ import {
|
||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||
CONF_LIVE_DRAGGABLE,
|
||||
CONF_LIVE_IMAGE_REFRESH_SECONDS,
|
||||
CONF_LIVE_LAYOUT_FIT,
|
||||
CONF_LIVE_LAYOUT_POSITION_X,
|
||||
CONF_LIVE_LAYOUT_POSITION_Y,
|
||||
@@ -99,6 +100,10 @@ import {
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
CONF_PERFORMANCE_PROFILE,
|
||||
CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
|
||||
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
|
||||
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
@@ -125,6 +130,7 @@ import { localize } from './localize/localize.js';
|
||||
import frigate_card_editor_style from './scss/editor.scss';
|
||||
import {
|
||||
BUTTON_SIZE_MIN,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigDefaults,
|
||||
FRIGATE_MENU_PRIORITY_MAX,
|
||||
RawFrigateCardConfig,
|
||||
@@ -136,6 +142,7 @@ import { arrayMove } from './utils/basic.js';
|
||||
import { getCameraID, getCameraTitle } from './utils/camera.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from './utils/frigate.js';
|
||||
import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha';
|
||||
import { setLowPerformanceProfile } from './performance.js';
|
||||
|
||||
const MENU_BUTTONS = 'buttons';
|
||||
const MENU_CAMERAS = 'cameras';
|
||||
@@ -150,6 +157,7 @@ const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
|
||||
const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
|
||||
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
||||
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
||||
const MENU_LIVE_IMAGE = 'live.image';
|
||||
const MENU_LIVE_LAYOUT = 'live.layout';
|
||||
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
||||
const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous';
|
||||
@@ -159,6 +167,8 @@ const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
|
||||
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
|
||||
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
|
||||
const MENU_OPTIONS = 'options';
|
||||
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
||||
const MENU_PERFORMANCE_STYLE = 'performance.style';
|
||||
const MENU_VIEW_SCAN = 'scan';
|
||||
|
||||
interface EditorOptionsSet {
|
||||
@@ -226,6 +236,11 @@ const options: EditorOptions = {
|
||||
name: localize('editor.dimensions'),
|
||||
secondary: localize('editor.dimensions_secondary'),
|
||||
},
|
||||
performance: {
|
||||
icon: 'speedometer',
|
||||
name: localize('editor.performance'),
|
||||
secondary: localize('editor.performance_secondary'),
|
||||
},
|
||||
overrides: {
|
||||
icon: 'file-replace',
|
||||
name: localize('editor.overrides'),
|
||||
@@ -237,6 +252,8 @@ const options: EditorOptions = {
|
||||
export class FrigateCardEditor extends LitElement implements LovelaceCardEditor {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@state() protected _config?: RawFrigateCardConfig;
|
||||
@state() protected _defaults = structuredClone(frigateCardConfigDefaults);
|
||||
|
||||
protected _initialized = false;
|
||||
protected _configUpgradeable = false;
|
||||
|
||||
@@ -300,6 +317,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
value: 'none',
|
||||
label: localize('config.common.controls.next_previous.styles.none'),
|
||||
},
|
||||
{
|
||||
value: 'thumbnails',
|
||||
label: localize('config.common.controls.next_previous.styles.thumbnails'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _aspectRatioModes: EditorSelectOption[] = [
|
||||
@@ -438,6 +459,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: 'below', label: localize('config.common.controls.timeline.modes.below') },
|
||||
];
|
||||
|
||||
protected _performanceProfiles: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'low', label: localize('config.performance.profiles.low') },
|
||||
{ value: 'high', label: localize('config.performance.profiles.high') },
|
||||
];
|
||||
|
||||
public setConfig(config: RawFrigateCardConfig): void {
|
||||
// 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
|
||||
@@ -445,6 +472,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
// such, RawFrigateCardConfig is used as the type.
|
||||
this._config = config;
|
||||
this._configUpgradeable = isConfigUpgradeable(config);
|
||||
|
||||
let unvalidatedProfile: string | null = null;
|
||||
try {
|
||||
// this._config may not be a valid FrigateCardConfig as it has not been
|
||||
// parsed. Attempt to pull out the performance profile.
|
||||
unvalidatedProfile = (this._config as FrigateCardConfig).performance?.profile;
|
||||
} catch (_) {}
|
||||
|
||||
if (unvalidatedProfile === 'high' || unvalidatedProfile === 'low') {
|
||||
const defaults = structuredClone(frigateCardConfigDefaults);
|
||||
if (unvalidatedProfile === 'low') {
|
||||
setLowPerformanceProfile(this._config, defaults);
|
||||
}
|
||||
this._defaults = defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,7 +507,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
* @param optionSetName The name of the EditorOptionsSet.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected _renderOptionSetHeader(optionSetName: string): TemplateResult {
|
||||
protected _renderOptionSetHeader(
|
||||
optionSetName: string,
|
||||
titleClass?: string,
|
||||
): TemplateResult {
|
||||
const optionSet = options[optionSetName];
|
||||
|
||||
return html`
|
||||
@@ -477,7 +522,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
>
|
||||
<div class="row">
|
||||
<ha-icon .icon=${`mdi:${optionSet.icon}`}></ha-icon>
|
||||
<div class="title">${optionSet.name}</div>
|
||||
<div class="title ${titleClass ?? ''}">${optionSet.name}</div>
|
||||
</div>
|
||||
<div class="secondary">${optionSet.secondary}</div>
|
||||
</div>
|
||||
@@ -669,24 +714,24 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
? html` <div class="values">
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_SCAN_ENABLED,
|
||||
frigateCardConfigDefaults.view.scan.enabled,
|
||||
this._defaults.view.scan.enabled,
|
||||
{
|
||||
label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
|
||||
frigateCardConfigDefaults.view.scan.show_trigger_status,
|
||||
this._defaults.view.scan.show_trigger_status,
|
||||
{
|
||||
label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_SCAN_UNTRIGGER_RESET,
|
||||
frigateCardConfigDefaults.view.scan.untrigger_reset,
|
||||
this._defaults.view.scan.untrigger_reset,
|
||||
)}
|
||||
${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, {
|
||||
default: frigateCardConfigDefaults.view.scan.untrigger_seconds,
|
||||
default: this._defaults.view.scan.untrigger_seconds,
|
||||
})}
|
||||
</div>`
|
||||
: ''}
|
||||
@@ -730,7 +775,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
? html` <div class="values">
|
||||
${this._renderSwitch(
|
||||
`${CONF_MENU_BUTTONS}.${button}.enabled`,
|
||||
frigateCardConfigDefaults.menu.buttons[button]?.enabled ?? true,
|
||||
this._defaults.menu.buttons[button]?.enabled ?? true,
|
||||
{
|
||||
label: localize('config.menu.buttons.enabled'),
|
||||
},
|
||||
@@ -744,7 +789,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}
|
||||
${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, {
|
||||
max: FRIGATE_MENU_PRIORITY_MAX,
|
||||
default: frigateCardConfigDefaults.menu.buttons[button]?.priority,
|
||||
default: this._defaults.menu.buttons[button]?.priority,
|
||||
label: localize('config.menu.buttons.priority'),
|
||||
})}
|
||||
${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, {
|
||||
@@ -882,6 +927,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathClusteringThreshold: string,
|
||||
configPathTimelineMedia: string,
|
||||
configPathShowRecordings: string,
|
||||
showRecordingsDefault: boolean,
|
||||
): TemplateResult | void {
|
||||
return this._putInSubmenu(
|
||||
domain,
|
||||
@@ -896,7 +942,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathClusteringThreshold,
|
||||
configPathTimelineMedia,
|
||||
configPathShowRecordings,
|
||||
frigateCardConfigDefaults.mini_timeline.show_recordings,
|
||||
showRecordingsDefault,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
@@ -912,6 +958,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
domain: string,
|
||||
configPathStyle: string,
|
||||
configPathSize: string,
|
||||
options?: {
|
||||
allowIcons?: boolean;
|
||||
allowThumbnails?: boolean;
|
||||
},
|
||||
): TemplateResult | void {
|
||||
return this._putInSubmenu(
|
||||
domain,
|
||||
@@ -919,9 +969,17 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
'config.common.controls.next_previous.editor_label',
|
||||
{ name: 'mdi:arrow-right-bold-circle' },
|
||||
html`
|
||||
${this._renderOptionSelector(configPathStyle, this._nextPreviousControlStyles, {
|
||||
${this._renderOptionSelector(
|
||||
configPathStyle,
|
||||
this._nextPreviousControlStyles.filter(
|
||||
(item) =>
|
||||
(!!options?.allowThumbnails || item.value !== 'thumbnails') &&
|
||||
(!!options?.allowIcons || item.value !== 'icons'),
|
||||
),
|
||||
{
|
||||
label: localize('config.common.controls.next_previous.style'),
|
||||
})}
|
||||
},
|
||||
)}
|
||||
${this._renderNumberInput(configPathSize, {
|
||||
min: BUTTON_SIZE_MIN,
|
||||
label: localize('config.common.controls.next_previous.size'),
|
||||
@@ -983,21 +1041,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
})}
|
||||
${this._renderSwitch(
|
||||
configPathShowDetails,
|
||||
frigateCardConfigDefaults.live.controls.thumbnails.show_details,
|
||||
this._defaults.live.controls.thumbnails.show_details,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.show_details'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
configPathShowFavoriteControl,
|
||||
frigateCardConfigDefaults.live.controls.thumbnails.show_favorite_control,
|
||||
this._defaults.live.controls.thumbnails.show_favorite_control,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.show_favorite_control'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
configPathShowTimelineControl,
|
||||
frigateCardConfigDefaults.live.controls.thumbnails.show_timeline_control,
|
||||
this._defaults.live.controls.thumbnails.show_timeline_control,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.show_timeline_control'),
|
||||
},
|
||||
@@ -1051,6 +1109,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: '', label: '' },
|
||||
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
|
||||
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
|
||||
{
|
||||
value: 'image',
|
||||
label: localize('config.cameras.live_providers.image'),
|
||||
},
|
||||
{
|
||||
value: 'frigate-jsmpeg',
|
||||
label: localize('config.cameras.live_providers.frigate-jsmpeg'),
|
||||
@@ -1231,7 +1293,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS,
|
||||
cameraIndex,
|
||||
),
|
||||
frigateCardConfigDefaults.cameras.dependencies.all_cameras,
|
||||
this._defaults.cameras.dependencies.all_cameras,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
@@ -1251,11 +1313,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ name: 'mdi:magnify-scan' },
|
||||
html` ${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, cameraIndex),
|
||||
frigateCardConfigDefaults.cameras.triggers.occupancy,
|
||||
this._defaults.cameras.triggers.occupancy,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex),
|
||||
frigateCardConfigDefaults.cameras.triggers.motion,
|
||||
this._defaults.cameras.triggers.motion,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, cameraIndex),
|
||||
@@ -1365,7 +1427,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
return html``;
|
||||
}
|
||||
|
||||
const defaults = frigateCardConfigDefaults;
|
||||
const entities = getEntitiesFromHASS(this.hass);
|
||||
const cameras = (getConfigValue(this._config, CONF_CAMERAS) ||
|
||||
[]) as RawFrigateCardConfigArray;
|
||||
@@ -1415,10 +1476,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)}
|
||||
${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)}
|
||||
${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)}
|
||||
${this._renderSwitch(CONF_VIEW_UPDATE_FORCE, defaults.view.update_force)}
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_UPDATE_FORCE,
|
||||
this._defaults.view.update_force,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||
defaults.view.update_cycle_camera,
|
||||
this._defaults.view.update_cycle_camera,
|
||||
)}
|
||||
${this._renderViewScanMenu()}
|
||||
</div>
|
||||
@@ -1453,9 +1517,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._expandedMenus[MENU_OPTIONS] === 'live'
|
||||
? html`
|
||||
<div class="values">
|
||||
${this._renderSwitch(CONF_LIVE_PRELOAD, defaults.live.preload)}
|
||||
${this._renderSwitch(CONF_LIVE_DRAGGABLE, defaults.live.draggable)}
|
||||
${this._renderSwitch(CONF_LIVE_LAZY_LOAD, defaults.live.lazy_load)}
|
||||
${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)}
|
||||
${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)}
|
||||
${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_LIVE_LAZY_UNLOAD,
|
||||
this._mediaActionNegativeConditions,
|
||||
@@ -1482,7 +1546,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
||||
defaults.live.show_image_during_load,
|
||||
this._defaults.live.show_image_during_load,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_LIVE_CONTROLS,
|
||||
@@ -1494,6 +1558,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
{
|
||||
allowIcons: true,
|
||||
},
|
||||
)}
|
||||
${this._renderThumbnailsControls(
|
||||
MENU_LIVE_CONTROLS_THUMBNAILS,
|
||||
@@ -1518,6 +1585,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
this._defaults.live.controls.timeline.show_recordings,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
@@ -1528,6 +1596,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_LIVE_LAYOUT_POSITION_X,
|
||||
CONF_LIVE_LAYOUT_POSITION_Y,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_LIVE_IMAGE,
|
||||
true,
|
||||
'config.live.image.editor_label',
|
||||
{ name: 'mdi:image-sync-outline' },
|
||||
html` ${this._renderNumberInput(CONF_LIVE_IMAGE_REFRESH_SECONDS)} `,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -1564,11 +1639,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_MEDIA_VIEWER_DRAGGABLE,
|
||||
defaults.media_viewer.draggable,
|
||||
this._defaults.media_viewer.draggable,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_MEDIA_VIEWER_LAZY_LOAD,
|
||||
defaults.media_viewer.lazy_load,
|
||||
this._defaults.media_viewer.lazy_load,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||
@@ -1584,6 +1659,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
{
|
||||
allowThumbnails: true,
|
||||
},
|
||||
)}
|
||||
${this._renderThumbnailsControls(
|
||||
MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS,
|
||||
@@ -1607,6 +1685,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
this._defaults.media_viewer.controls.timeline.show_recordings,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
@@ -1642,7 +1721,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_TIMELINE_MEDIA,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
defaults.timeline.show_recordings,
|
||||
this._defaults.timeline.show_recordings,
|
||||
)}
|
||||
${this._renderThumbnailsControls(
|
||||
MENU_TIMELINE_CONTROLS_THUMBNAILS,
|
||||
@@ -1666,6 +1745,51 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderStringInput(CONF_DIMENSIONS_ASPECT_RATIO)}
|
||||
</div>`
|
||||
: ''}
|
||||
${this._renderOptionSetHeader(
|
||||
'performance',
|
||||
getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low'
|
||||
? 'warning'
|
||||
: undefined,
|
||||
)}
|
||||
${this._expandedMenus[MENU_OPTIONS] === 'performance'
|
||||
? html` <div class="values">
|
||||
${getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low'
|
||||
? this._renderInfo(localize('config.performance.warning'))
|
||||
: html``}
|
||||
${this._renderOptionSelector(
|
||||
CONF_PERFORMANCE_PROFILE,
|
||||
this._performanceProfiles,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_PERFORMANCE_FEATURES,
|
||||
true,
|
||||
'config.performance.features.editor_label',
|
||||
{ name: 'mdi:feature-search' },
|
||||
html`
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
this._defaults.performance.features.animated_progress_indicator,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_PERFORMANCE_STYLE,
|
||||
true,
|
||||
'config.performance.style.editor_label',
|
||||
{ name: 'mdi:palette-swatch-variant' },
|
||||
html`
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
|
||||
this._defaults.performance.style.border_radius,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
|
||||
this._defaults.performance.style.box_shadow,
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
${this._config['overrides'] !== undefined
|
||||
? html` ${this._renderOptionSetHeader('overrides')}
|
||||
${this._expandedMenus[MENU_OPTIONS] === 'overrides'
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"live_providers": {
|
||||
"auto": "Automatic",
|
||||
"frigate-jsmpeg": "Frigate JSMpeg",
|
||||
"ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)",
|
||||
"ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC native)",
|
||||
"image": "Home Assistant images",
|
||||
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
|
||||
},
|
||||
"title": "Title for this camera (Autodetected from entity)",
|
||||
@@ -89,7 +90,8 @@
|
||||
"styles": {
|
||||
"chevrons": "Chevrons",
|
||||
"icons": "Icons",
|
||||
"none": "None"
|
||||
"none": "None",
|
||||
"thumbnails": "Thumbnails"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
@@ -164,6 +166,10 @@
|
||||
"editor_label": "Live Controls"
|
||||
},
|
||||
"draggable": "Live cameras view can be dragged/swiped",
|
||||
"image": {
|
||||
"editor_label": "Image Live Provider Options",
|
||||
"refresh_seconds": "Number of seconds after which to refresh live image (0=never)"
|
||||
},
|
||||
"layout": "Live Layout",
|
||||
"lazy_load": "Live cameras are lazily loaded",
|
||||
"lazy_unload": "Live cameras are lazily unloaded",
|
||||
@@ -238,6 +244,23 @@
|
||||
"overrides": {
|
||||
"info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides"
|
||||
},
|
||||
"performance": {
|
||||
"warning": "This card is in low profile mode so defaults have changed to optimize performance",
|
||||
"features": {
|
||||
"editor_label": "Feature Options",
|
||||
"animated_progress_indicator": "Animated Progress Indicator"
|
||||
},
|
||||
"profile": "Performance profile",
|
||||
"profiles": {
|
||||
"low": "Low performance",
|
||||
"high": "High/full performance"
|
||||
},
|
||||
"style": {
|
||||
"editor_label": "Style Options",
|
||||
"box_shadow": "Shadows",
|
||||
"border_radius": "Curves"
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"camera_select": "View for newly selected cameras",
|
||||
"dark_mode": "Dark mode",
|
||||
@@ -295,6 +318,8 @@
|
||||
"move_up": "Move up",
|
||||
"overrides": "Overrides are active",
|
||||
"overrides_secondary": "Dynamic configuration overrides detected",
|
||||
"performance": "Performance",
|
||||
"performance_secondary": "Card performance options",
|
||||
"timeline": "Timeline",
|
||||
"timeline_secondary": "Event timeline options",
|
||||
"upgrade": "Upgrade",
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"styles": {
|
||||
"chevrons": "Chevrons",
|
||||
"icons": "Icone",
|
||||
"none": "Icone"
|
||||
"none": "Nessuno",
|
||||
"thumbnails": "Miniature"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"styles": {
|
||||
"chevrons": "Setas",
|
||||
"icons": "Ícones",
|
||||
"none": "Nenhum"
|
||||
"none": "Nenhum",
|
||||
"thumbnails": "Miniaturas"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import * as en from './languages/en.json';
|
||||
import * as pt_BR from './languages/pt-BR.json';
|
||||
import * as it from './languages/it.json';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languages: Record<string, any> = {
|
||||
// English as always loaded as it's the fallback language that will be used
|
||||
// when translations are not found or before they are loaded (via
|
||||
// loadLanguages()).
|
||||
en: en,
|
||||
pt_BR: pt_BR,
|
||||
it: it,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured language.
|
||||
*/
|
||||
export function getLanguage(): string {
|
||||
const canonicalizeLanguage = (language?: string | null): string | null => {
|
||||
if (!language) {
|
||||
@@ -39,14 +41,32 @@ export function getLanguage(): string {
|
||||
return lang || 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load required languages.
|
||||
*/
|
||||
export const loadLanguages = async (): Promise<void> => {
|
||||
const lang = getLanguage();
|
||||
if (lang === 'it') {
|
||||
languages['it'] = await import('./languages/it.json');
|
||||
} else if (lang === 'pt_BR') {
|
||||
languages['pt_BR'] = await import('./languages/pt-BR.json');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a localized version of a given string key.
|
||||
* @param string The key.
|
||||
* @param search An optional search key to be used with 'replace'.
|
||||
* @param replace An optional replacement text to be used with 'search'.
|
||||
* @returns
|
||||
*/
|
||||
export function localize(string: string, search = '', replace = ''): string {
|
||||
const lang = getLanguage();
|
||||
let translated: string;
|
||||
let translated = '';
|
||||
|
||||
try {
|
||||
translated = string.split('.').reduce((o, i) => o[i], languages[lang]);
|
||||
} catch (e) {
|
||||
translated = string.split('.').reduce((o, i) => o[i], languages['en']);
|
||||
} catch (_) {
|
||||
}
|
||||
|
||||
if (!translated) {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { deepRemoveDefaults } from './utils/zod.js';
|
||||
import {
|
||||
frigateCardConfigSchema,
|
||||
RawFrigateCardConfig,
|
||||
PerformanceConfig,
|
||||
} from './types';
|
||||
import { getArrayConfigPath, getConfigValue, setConfigValue } from './config-mgmt.js';
|
||||
import {
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_LIVE_AUTO_MUTE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||
CONF_LIVE_DRAGGABLE,
|
||||
CONF_LIVE_IMAGE_REFRESH_SECONDS,
|
||||
CONF_LIVE_LAZY_UNLOAD,
|
||||
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
||||
CONF_LIVE_TRANSITION_EFFECT,
|
||||
CONF_MEDIA_VIEWER_AUTO_MUTE,
|
||||
CONF_MEDIA_VIEWER_AUTO_PAUSE,
|
||||
CONF_MEDIA_VIEWER_AUTO_PLAY,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
|
||||
CONF_MEDIA_VIEWER_DRAGGABLE,
|
||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||
CONF_MENU_BUTTONS_FRIGATE,
|
||||
CONF_MENU_BUTTONS_MEDIA_PLAYER,
|
||||
CONF_MENU_BUTTONS_TIMELINE,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
|
||||
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
CONF_TIMELINE_SHOW_RECORDINGS,
|
||||
} from './const.js';
|
||||
|
||||
// Caution: These values are applied after parsing (since we cannot know the
|
||||
// performance profile until afterwards), so there is no validation on these
|
||||
// defaults.
|
||||
const LOW_PROFILE_DEFAULTS = {
|
||||
// Disable thumbnail carousels.
|
||||
[CONF_LIVE_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
|
||||
[CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE]: 'none' as const,
|
||||
|
||||
// Do not show recordings on timelines.
|
||||
[CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS]: false,
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS]: false,
|
||||
[CONF_TIMELINE_SHOW_RECORDINGS]: false,
|
||||
|
||||
// Take no automatic media actions.
|
||||
[CONF_LIVE_AUTO_MUTE]: 'never' as const,
|
||||
[CONF_MEDIA_VIEWER_AUTO_PLAY]: 'never' as const,
|
||||
[CONF_MEDIA_VIEWER_AUTO_PAUSE]: 'never' as const,
|
||||
[CONF_MEDIA_VIEWER_AUTO_MUTE]: 'never' as const,
|
||||
|
||||
// Always unload resources that are lazily loaded.
|
||||
[CONF_LIVE_LAZY_UNLOAD]: 'all' as const,
|
||||
|
||||
// Media carousels do not drag.
|
||||
[CONF_LIVE_DRAGGABLE]: false,
|
||||
[CONF_MEDIA_VIEWER_DRAGGABLE]: false,
|
||||
|
||||
// Media carousels have no effects.
|
||||
[CONF_LIVE_TRANSITION_EFFECT]: 'none' as const,
|
||||
[CONF_MEDIA_VIEWER_TRANSITION_EFFECT]: 'none' as const,
|
||||
|
||||
// Do not show image during load.
|
||||
[CONF_LIVE_SHOW_IMAGE_DURING_LOAD]: false,
|
||||
|
||||
// Media player next/previous are chevrons.
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE]: 'chevrons' as const,
|
||||
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE]: 'none' as const,
|
||||
[CONF_LIVE_CONTROLS_TITLE_MODE]: 'none' as const,
|
||||
|
||||
// Move the menu to outside to remove the need to interact with it with open.
|
||||
[CONF_MENU_STYLE]: 'outside',
|
||||
|
||||
// Hide several buttons that are otherwise visible by default.
|
||||
[`${CONF_MENU_BUTTONS_FRIGATE}.enabled`]: false,
|
||||
[`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false,
|
||||
[`${CONF_MENU_BUTTONS_MEDIA_PLAYER}.enabled`]: false,
|
||||
[`${CONF_MENU_BUTTONS_TIMELINE}.enabled`]: false,
|
||||
|
||||
// Disable all options in thumbnails.
|
||||
[CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
|
||||
[CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
|
||||
[CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
|
||||
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
|
||||
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
|
||||
[CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
|
||||
|
||||
// Refresh the live camera image every 10 seconds (same as stock Home
|
||||
// Assistant Picture Glance).
|
||||
[CONF_LIVE_IMAGE_REFRESH_SECONDS]: 10,
|
||||
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
|
||||
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL]: false,
|
||||
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL]: false,
|
||||
[CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS]: false,
|
||||
|
||||
// Disable all optional performance related features.
|
||||
[CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR]: false,
|
||||
|
||||
// Disable all expensive CSS features.
|
||||
[CONF_PERFORMANCE_STYLE_BORDER_RADIUS]: false,
|
||||
[CONF_PERFORMANCE_STYLE_BOX_SHADOW]: false,
|
||||
};
|
||||
|
||||
const LOW_PROFILE_CAMERA_DEFAULTS = {
|
||||
[CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY]: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Set low performance profile mode. Sets flags as defined in
|
||||
* LOW_PROFILE_DEFAULTS unless they are explicitly overriden in the
|
||||
* configuration.
|
||||
* @param inputConfig The raw unparsed input configuration.
|
||||
* @param outputConfig The output config to write to.
|
||||
* @returns A changed (in-place) parsed input configuration.
|
||||
*/
|
||||
export const setLowPerformanceProfile = <T extends RawFrigateCardConfig>(
|
||||
inputConfig: RawFrigateCardConfig,
|
||||
outputConfig: T,
|
||||
): T => {
|
||||
const setIfNotSpecified = (
|
||||
defaultLessConfig: RawFrigateCardConfig,
|
||||
outputConfig: T,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) => {
|
||||
if (getConfigValue(defaultLessConfig, key) === undefined) {
|
||||
setConfigValue(outputConfig, key, value);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultLessParseResult = deepRemoveDefaults(frigateCardConfigSchema).safeParse(
|
||||
inputConfig,
|
||||
);
|
||||
if (defaultLessParseResult.success) {
|
||||
const defaultLessConfig = defaultLessParseResult.data;
|
||||
Object.entries(LOW_PROFILE_DEFAULTS).forEach(([k, v]: [string, unknown]) =>
|
||||
setIfNotSpecified(defaultLessConfig, outputConfig, k, v),
|
||||
);
|
||||
|
||||
Object.entries(LOW_PROFILE_CAMERA_DEFAULTS).forEach(
|
||||
([rawKey, v]: [string, unknown]) => {
|
||||
defaultLessConfig.cameras.forEach((_, index: number) => {
|
||||
const indexedKey = getArrayConfigPath(rawKey, index);
|
||||
setIfNotSpecified(defaultLessConfig, outputConfig, indexedKey, v);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return outputConfig;
|
||||
};
|
||||
|
||||
const STYLE_DISABLE_MAP = {
|
||||
box_shadow: 'none',
|
||||
border_radius: '0px',
|
||||
};
|
||||
|
||||
/**
|
||||
* Set card-wide CSS variables for performance.
|
||||
* @param element The element to set the variables on.
|
||||
* @param performance The performance configuration.
|
||||
*/
|
||||
export const setPerformanceCSSStyles = (
|
||||
element: HTMLElement,
|
||||
performance?: PerformanceConfig,
|
||||
): void => {
|
||||
const styles = performance?.style ?? {};
|
||||
for (const configKey of Object.keys(styles)) {
|
||||
const CSSKey = `--frigate-card-css-${configKey.replaceAll('_', '-')}`;
|
||||
if (styles[configKey] === false) {
|
||||
element.style.setProperty(CSSKey, STYLE_DISABLE_MAP[configKey]);
|
||||
} else {
|
||||
element.style.removeProperty(CSSKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -27,16 +27,16 @@
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
:host([location=right]) #d {
|
||||
:host([location='right']) #d {
|
||||
// Position to the right.
|
||||
left: unset;
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
:host([location=right][open]) #d {
|
||||
:host([location='right'][open]) #d {
|
||||
transform: none;
|
||||
box-shadow: 0px 0px 25px 0px black;
|
||||
box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 25px 0px black);
|
||||
}
|
||||
|
||||
#ifs {
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
margin-top: -6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.title.warning {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.secondary {
|
||||
padding-left: 40px;
|
||||
color: var(--secondary-text-color);
|
||||
@@ -101,7 +104,7 @@ div.upgrade span {
|
||||
--mdc-icon-size: calc(var(--mdc-icon-button-size) / 2);
|
||||
}
|
||||
span.info {
|
||||
padding: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
ha-selector {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
text-align: center;
|
||||
color: var(--primary-text-color, white);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px));
|
||||
|
||||
// Folder background color should match the thumbnail element background
|
||||
// color.
|
||||
|
||||
@@ -54,6 +54,10 @@ div.message div.icon {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message ha-icon, ha-circular-progress {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dotdotdot:after {
|
||||
@keyframes dots {
|
||||
0%,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
border-radius: 50%;
|
||||
height: var(--frigate-card-next-prev-size);
|
||||
top: calc(50% - (var(--frigate-card-next-prev-size) / 2));
|
||||
box-shadow: 0px 0px 20px 5px black;
|
||||
box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 20px 5px black);
|
||||
transition: all 0.2s ease-out;
|
||||
opacity: 0.8;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
@@ -12,7 +12,7 @@ ha-icon {
|
||||
// Safari will occasionally not load thumbnails correctly with display block.
|
||||
display: inline-block;
|
||||
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px));
|
||||
|
||||
max-width: var(--frigate-card-thumbnail-size);
|
||||
max-height: 100%;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
border: 1px solid var(--secondary-color);
|
||||
background-color: var(--secondary-background-color);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px));
|
||||
box-sizing: border-box;
|
||||
|
||||
color: var(--primary-text-color);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
:host([details]) {
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px));
|
||||
padding: 2px;
|
||||
|
||||
// When details are enabled, use a background color so that the details have
|
||||
|
||||
@@ -49,7 +49,7 @@ div.timeline {
|
||||
.vis-item.vis-selected {
|
||||
border-color: var(--accent-color);
|
||||
background-color: var(--accent-color);
|
||||
box-shadow: 0px 0px 5px 1px var(--primary-color);
|
||||
box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 5px 1px var(--primary-color));
|
||||
}
|
||||
.vis-item.vis-background {
|
||||
background-color: var(--primary-color);
|
||||
@@ -88,7 +88,10 @@ div.timeline {
|
||||
border-style: dotted;
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--primary-background-color);
|
||||
box-shadow: 0px 0px 5px 1px var(--primary-color);
|
||||
box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 5px 1px var(--primary-color));
|
||||
}
|
||||
.vis-item.vis-range {
|
||||
border-radius: var(--frigate-card-css-border-radius, unset);
|
||||
}
|
||||
|
||||
.vis-time-axis .vis-grid.vis-minor {
|
||||
@@ -127,7 +130,7 @@ div.vis-tooltip {
|
||||
.target_bar {
|
||||
border-left: 2px solid var(--primary-color);
|
||||
opacity: 0.7;
|
||||
box-shadow: 0px 0px 3px 1px var(--primary-color);
|
||||
box-shadow: var(--frigate-card-css-box-shadow, 0px 0px 3px 1px var(--primary-color));
|
||||
|
||||
// Prevent the mouse interacting with the custom time.
|
||||
pointer-events: none;
|
||||
|
||||
+61
-7
@@ -59,7 +59,7 @@ const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS;
|
||||
export const FRIGATE_MENU_PRIORITY_DEFAULT = 50;
|
||||
export const FRIGATE_MENU_PRIORITY_MAX = 100;
|
||||
|
||||
const LIVE_PROVIDERS = ['auto', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const;
|
||||
const LIVE_PROVIDERS = ['auto', 'image', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const;
|
||||
export type LiveProvider = typeof LIVE_PROVIDERS[number];
|
||||
|
||||
const MEDIA_ACTION_NEGATIVE_CONDITIONS = [
|
||||
@@ -206,7 +206,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||
'menu_toggle',
|
||||
'diagnostics',
|
||||
'recording',
|
||||
'recordings'
|
||||
'recordings',
|
||||
] as const;
|
||||
const FRIGATE_CARD_ACTIONS = [
|
||||
...FRIGATE_CARD_GENERAL_ACTIONS,
|
||||
@@ -696,8 +696,12 @@ const timelineCoreConfigSchema = z.object({
|
||||
});
|
||||
export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>;
|
||||
|
||||
const miniTimelineConfigDefault = {
|
||||
...timelineCoreConfigDefault,
|
||||
mode: 'none' as const,
|
||||
}
|
||||
const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
|
||||
mode: z.enum(['none', 'above', 'below']),
|
||||
mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode),
|
||||
});
|
||||
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
|
||||
|
||||
@@ -735,6 +739,11 @@ export type TitleControlConfig = z.infer<typeof titleControlConfigSchema>;
|
||||
/**
|
||||
* Live view configuration section.
|
||||
*/
|
||||
|
||||
const liveImageConfigDefault = {
|
||||
refresh_seconds: 1,
|
||||
};
|
||||
|
||||
const liveConfigDefault = {
|
||||
auto_play: 'all' as const,
|
||||
auto_pause: 'never' as const,
|
||||
@@ -746,6 +755,7 @@ const liveConfigDefault = {
|
||||
draggable: true,
|
||||
transition_effect: 'slide' as const,
|
||||
show_image_during_load: true,
|
||||
image: liveImageConfigDefault,
|
||||
controls: {
|
||||
next_previous: {
|
||||
size: 48,
|
||||
@@ -759,6 +769,7 @@ const liveConfigDefault = {
|
||||
show_timeline_control: true,
|
||||
mode: 'left' as const,
|
||||
},
|
||||
timeline: miniTimelineConfigDefault,
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
duration_seconds: 2,
|
||||
@@ -766,6 +777,11 @@ const liveConfigDefault = {
|
||||
},
|
||||
};
|
||||
|
||||
const liveImageConfigSchema = z.object({
|
||||
refresh_seconds: z.number().min(0).default(liveConfigDefault.image.refresh_seconds),
|
||||
});
|
||||
export type LiveImageConfig = z.infer<typeof liveImageConfigSchema>;
|
||||
|
||||
const webrtcCardConfigSchema = webrtcCardCameraConfigSchema.passthrough().optional();
|
||||
export type WebRTCCardConfig = z.infer<typeof webrtcCardConfigSchema>;
|
||||
|
||||
@@ -794,8 +810,9 @@ export type JSMPEGConfig = z.infer<typeof jsmpegConfigSchema>;
|
||||
|
||||
const liveOverridableConfigSchema = z
|
||||
.object({
|
||||
webrtc_card: webrtcCardConfigSchema,
|
||||
image: liveImageConfigSchema.default(liveConfigDefault.image),
|
||||
jsmpeg: jsmpegConfigSchema,
|
||||
webrtc_card: webrtcCardConfigSchema,
|
||||
controls: z
|
||||
.object({
|
||||
next_previous: nextPreviousControlConfigSchema
|
||||
@@ -833,7 +850,7 @@ const liveOverridableConfigSchema = z
|
||||
.default(liveConfigDefault.controls.thumbnails.media),
|
||||
})
|
||||
.default(liveConfigDefault.controls.thumbnails),
|
||||
timeline: miniTimelineConfigSchema.optional(),
|
||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||
title: titleControlConfigSchema
|
||||
.extend({
|
||||
mode: titleControlConfigSchema.shape.mode.default(
|
||||
@@ -975,6 +992,7 @@ const viewerConfigDefault = {
|
||||
show_timeline_control: true,
|
||||
mode: 'left' as const,
|
||||
},
|
||||
timeline: miniTimelineConfigDefault,
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
duration_seconds: 2,
|
||||
@@ -1038,7 +1056,7 @@ const viewerConfigSchema = z
|
||||
),
|
||||
})
|
||||
.default(viewerConfigDefault.controls.thumbnails),
|
||||
timeline: miniTimelineConfigSchema.optional(),
|
||||
timeline: miniTimelineConfigSchema.default(viewerConfigDefault.controls.timeline),
|
||||
title: titleControlConfigSchema
|
||||
.extend({
|
||||
mode: titleControlConfigSchema.shape.mode.default(
|
||||
@@ -1206,6 +1224,41 @@ const liveOverridesSchema = z
|
||||
.optional();
|
||||
export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
|
||||
|
||||
const performanceConfigDefault = {
|
||||
profile: 'high' as const,
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
},
|
||||
style: {
|
||||
border_radius: true,
|
||||
box_shadow: true,
|
||||
},
|
||||
};
|
||||
|
||||
const performanceConfigSchema = z
|
||||
.object({
|
||||
profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
|
||||
features: z
|
||||
.object({
|
||||
animated_progress_indicator: z
|
||||
.boolean()
|
||||
.default(performanceConfigDefault.features.animated_progress_indicator),
|
||||
})
|
||||
.default(performanceConfigDefault.features),
|
||||
style: z
|
||||
.object({
|
||||
border_radius: z.boolean().default(performanceConfigDefault.style.border_radius),
|
||||
box_shadow: z.boolean().default(performanceConfigDefault.style.box_shadow),
|
||||
})
|
||||
.default(performanceConfigDefault.style),
|
||||
})
|
||||
.default(performanceConfigDefault);
|
||||
export type PerformanceConfig = z.infer<typeof performanceConfigSchema>;
|
||||
|
||||
export interface CardWideConfig {
|
||||
performance?: PerformanceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main card config.
|
||||
*/
|
||||
@@ -1221,6 +1274,7 @@ export const frigateCardConfigSchema = z.object({
|
||||
elements: pictureElementsSchema,
|
||||
dimensions: dimensionsConfigSchema,
|
||||
timeline: timelineConfigSchema,
|
||||
performance: performanceConfigSchema,
|
||||
|
||||
// Configuration overrides.
|
||||
overrides: overridesSchema,
|
||||
@@ -1245,7 +1299,7 @@ export const frigateCardConfigDefaults = {
|
||||
event_gallery: galleryConfigDefault,
|
||||
image: imageConfigDefault,
|
||||
timeline: timelineConfigDefault,
|
||||
mini_timeline: timelineCoreConfigDefault,
|
||||
performance: performanceConfigDefault,
|
||||
};
|
||||
|
||||
const menuButtonSchema = z.discriminatedUnion('type', [
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import format from 'date-fns/format';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { FrigateCardError } from '../types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { DataSet, DataView } from 'vis-data/esnext';
|
||||
import { IdType, TimelineItem } from 'vis-timeline/esnext';
|
||||
import type { IdType, TimelineItem } from 'vis-timeline/esnext';
|
||||
import { CAMERA_BIRDSEYE } from '../const.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from './frigate.js';
|
||||
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import { throttle } from 'lodash-es';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
const RECORDING_SEGMENT_TOLERANCE = 60;
|
||||
const DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
@@ -104,7 +104,6 @@ export class DataManager {
|
||||
protected _maxAgeSeconds: number = DATA_MANAGER_MAX_AGE_SECONDS;
|
||||
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
protected _mediaType: TimelineMediaType;
|
||||
|
||||
// Garbage collect segments at most once an hour.
|
||||
protected _throttledSegmentGarbageCollector = throttle(
|
||||
@@ -115,9 +114,8 @@ export class DataManager {
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
constructor(cameras: Map<string, CameraConfig>, mediaType: TimelineMediaType) {
|
||||
constructor(cameras: Map<string, CameraConfig>) {
|
||||
this._cameras = cameras;
|
||||
this._mediaType = mediaType;
|
||||
}
|
||||
|
||||
// Get the last event fetch date.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { ViewContext } from 'view';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import {
|
||||
dispatchErrorMessageEvent,
|
||||
@@ -346,6 +347,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
|
||||
hass: HomeAssistant,
|
||||
view: Readonly<View>,
|
||||
child: Readonly<FrigateBrowseMediaSource>,
|
||||
context?: ViewContext,
|
||||
): Promise<void> => {
|
||||
let parent: FrigateBrowseMediaSource;
|
||||
try {
|
||||
@@ -358,6 +360,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
|
||||
.evolve({
|
||||
target: parent,
|
||||
})
|
||||
.mergeInContext(context)
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
|
||||
+16
-16
@@ -1,4 +1,9 @@
|
||||
import { add, endOfHour, fromUnixTime, getUnixTime, startOfHour, sub } from 'date-fns';
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import sub from 'date-fns/sub';
|
||||
import { ViewContext } from 'view';
|
||||
import { dispatchMessageEvent } from '../components/message';
|
||||
import { localize } from '../localize/localize';
|
||||
@@ -6,7 +11,11 @@ import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '.
|
||||
import { View } from '../view';
|
||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||
import { getRecordingMediaContentID } from './frigate';
|
||||
import { createChild, createEventParentForChildren, sortYoungestToOldest } from './ha/browse-media';
|
||||
import {
|
||||
createChild,
|
||||
createEventParentForChildren,
|
||||
sortYoungestToOldest,
|
||||
} from './ha/browse-media';
|
||||
import {
|
||||
RecordingSegmentsItem,
|
||||
sortOldestToYoungest,
|
||||
@@ -23,7 +32,7 @@ import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
||||
* @param view The current view.
|
||||
* @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to.
|
||||
*/
|
||||
export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
dataManager: DataManager,
|
||||
@@ -35,22 +44,15 @@ import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
||||
): Promise<void> => {
|
||||
const now = new Date();
|
||||
|
||||
await changeViewToRecording(
|
||||
element,
|
||||
hass,
|
||||
dataManager,
|
||||
cameras,
|
||||
view,
|
||||
{
|
||||
await changeViewToRecording(element, hass, dataManager, cameras, view, {
|
||||
...options,
|
||||
|
||||
// Fetch 1 days worth of recordings (including recordings that are for the current hour).
|
||||
cameraIDs: getAllDependentCameras(cameras, view.camera),
|
||||
start: sub(now, { days: 1 }),
|
||||
end: add(now, { hours: 1 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the view to a recording.
|
||||
@@ -132,9 +134,7 @@ const createRecordingChildren = (
|
||||
): FrigateBrowseMediaSource[] => {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
|
||||
for (const cameraID of getTrueCameras(
|
||||
cameras, cameraIDs
|
||||
)) {
|
||||
for (const cameraID of getTrueCameras(cameras, cameraIDs)) {
|
||||
const config = cameras.get(cameraID) ?? null;
|
||||
const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID);
|
||||
if (!config?.frigate.camera_name || !recordingSummary) {
|
||||
|
||||
+12
-3
@@ -4,6 +4,7 @@ import {
|
||||
dispatchFrigateCardErrorEvent,
|
||||
renderProgressIndicator,
|
||||
} from '../components/message';
|
||||
import { CardWideConfig } from '../types';
|
||||
import { errorToConsole } from './basic';
|
||||
|
||||
/**
|
||||
@@ -18,11 +19,19 @@ export const renderTask = <R>(
|
||||
host: EventTarget,
|
||||
task: Task<unknown[], R>,
|
||||
completeFunc: (result: R) => TemplateResult | void,
|
||||
inProgressFunc?: () => TemplateResult | void,
|
||||
options?: {
|
||||
cardWideConfig?: CardWideConfig;
|
||||
inProgressFunc?: () => TemplateResult | void;
|
||||
},
|
||||
): TemplateResult => {
|
||||
const progressConfig = {
|
||||
...(options?.cardWideConfig && { cardWideConfig: options.cardWideConfig }),
|
||||
};
|
||||
return html` ${task.render({
|
||||
initial: () => inProgressFunc?.() ?? renderProgressIndicator(),
|
||||
pending: () => inProgressFunc?.() ?? renderProgressIndicator(),
|
||||
initial: () =>
|
||||
options?.inProgressFunc?.() ?? renderProgressIndicator(progressConfig),
|
||||
pending: () =>
|
||||
options?.inProgressFunc?.() ?? renderProgressIndicator(progressConfig),
|
||||
error: (e: unknown) => {
|
||||
errorToConsole(e as Error);
|
||||
dispatchFrigateCardErrorEvent(host, e as Error);
|
||||
|
||||
+1
-9
@@ -13,7 +13,6 @@ export interface ViewEvolveParameters {
|
||||
camera?: string;
|
||||
target?: FrigateBrowseMediaSource | null;
|
||||
childIndex?: number | null;
|
||||
previous?: View | null;
|
||||
context?: ViewContext | null;
|
||||
}
|
||||
|
||||
@@ -27,7 +26,6 @@ export class View {
|
||||
public camera: string;
|
||||
public target: FrigateBrowseMediaSource | null;
|
||||
public childIndex: number | null;
|
||||
public previous: View | null;
|
||||
public context: ViewContext | null;
|
||||
|
||||
constructor(params: ViewParameters) {
|
||||
@@ -35,7 +33,6 @@ export class View {
|
||||
this.camera = params.camera;
|
||||
this.target = params.target ?? null;
|
||||
this.childIndex = params.childIndex ?? null;
|
||||
this.previous = params.previous ?? null;
|
||||
this.context = params.context ?? null;
|
||||
}
|
||||
|
||||
@@ -90,7 +87,6 @@ export class View {
|
||||
camera: this.camera,
|
||||
target: this.target,
|
||||
childIndex: this.childIndex,
|
||||
previous: this.previous,
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
@@ -107,10 +103,6 @@ export class View {
|
||||
target: params.target !== undefined ? params.target : this.target,
|
||||
childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,7 +111,7 @@ export class View {
|
||||
* @param context The context to merge in.
|
||||
* @returns This view.
|
||||
*/
|
||||
public mergeInContext(context: ViewContext): View {
|
||||
public mergeInContext(context?: ViewContext): View {
|
||||
this.context = { ...this.context, ...context };
|
||||
return this;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"target": "es2021",
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["es2017", "dom", "dom.iterable"],
|
||||
"lib": ["es2021", "dom", "dom.iterable"],
|
||||
"noEmit": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
Reference in New Issue
Block a user