diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2ec5442..539a242a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,4 +35,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: frigate-hass-card - path: dist/frigate-hass-card.js + path: dist/*.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a63f0bfe..c11afea5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index e233da0d..f6053173 100644 --- a/README.md +++ b/README.md @@ -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).| @@ -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. + + +### 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.| + + + +#### 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 the source code 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. + ### 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: ``` + +
+ Expand: Performance section + +Reference: [Performance Options](#performance-options). + +```yaml +performance: + profile: high + features: + animated_progress_indicator: true + style: + border_radius: true + box_shadow: true +``` +
+ ### Basic cameras configuration
diff --git a/rollup.config.js b/rollup.config.js index 028b98ec..ea5e9be6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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, diff --git a/src/card-condition.ts b/src/card-condition.ts index 26f3a941..2bdf929c 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -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 | undefined, conditionState?: Readonly, ): RawFrigateCardConfig { - const output = cloneDeep(config); + const output = structuredClone(config); let overridden = false; if (overrides) { for (const override of overrides) { diff --git a/src/card.ts b/src/card.ts index 4c491a1b..4a9ec670 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 = 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 { } } - // HA entity state is part of the condition state. - this._generateConditionState(); + 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 { + 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 = 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,77 +756,94 @@ 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) { - // 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 - // search still works if the user renames the entity_id. - const binarySensorEntities = entityList.filter( - (ent) => - ent.config_entry_id === entity?.config_entry_id && - !ent.disabled_by && - ent.entity_id.startsWith('binary_sensor.'), - ); - - try { - await getExtendedEntities( - this._hass, - binarySensorEntities.map((ent) => ent.entity_id), - cache, + 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 + // search still works if the user renames the entity_id. + const binarySensorEntities = entityList.filter( + (ent) => + ent.config_entry_id === entity?.config_entry_id && + !ent.disabled_by && + ent.entity_id.startsWith('binary_sensor.'), ); + + try { + await getExtendedEntities( + this._hass, + binarySensorEntities.map((ent) => ent.entity_id), + cache, + ); + } catch (e) { + errorToConsole(e as Error); + } + + if (config.triggers.motion) { + const motionEntity = this._getMotionSensor(cache, config); + if (motionEntity) { + config.triggers.entities.push(motionEntity); + } + } + + if (config.triggers.occupancy) { + const occupancyEntity = this._getOccupancySensor(cache, config); + if (occupancyEntity) { + config.triggers.entities.push(occupancyEntity); + } + } + } + + config.triggers.entities = [...new Set(config.triggers.entities)]; + } + + loadedCameras[index] = config; + }; + + let errorFree = true; + const cameras: Map = 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); } + } - if (config.triggers.motion) { - const motionEntity = this._getMotionSensor(cache, config); - if (motionEntity) { - config.triggers.entities.push(motionEntity); - } + // 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: loadedCamera, + }); + errorFree = false; + } else if (cameras.has(id)) { + this._setMessageAndUpdate({ + message: localize('error.duplicate_camera_id'), + type: 'error', + context: loadedCamera, + }); + errorFree = false; + } else { + cameras.set(id, loadedCamera); } - - if (config.triggers.occupancy) { - const occupancyEntity = this._getOccupancySensor(cache, config); - if (occupancyEntity) { - config.triggers.entities.push(occupancyEntity); - } - } - } - config.triggers.entities = [...new Set(config.triggers.entities)]; - - const id = getCameraID(config); - if (!id) { - this._setMessageAndUpdate({ - message: localize('error.no_camera_id'), - type: 'error', - context: config, - }); - errorFree = false; - } else if (cameras.has(id)) { - this._setMessageAndUpdate({ - message: localize('error.duplicate_camera_id'), - type: 'error', - context: config, - }); - 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); - } + }); } 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} > ` : ``} @@ -1965,6 +2052,7 @@ export class FrigateCard extends LitElement { .viewerConfig=${this._getConfig().media_viewer} .resolvedMediaCache=${this._resolvedMediaCache} .dataManager=${this._dataManager} + .cardWideConfig=${this._cardWideConfig} > ` : ``} @@ -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)}" > diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 0afb657f..2269610c 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -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'; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 06acda8a..9a4c9816 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -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` { - 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); diff --git a/src/components/image.ts b/src/components/image.ts index a8f5a743..2975f33e 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -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; 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, + }); } }} />` @@ -263,7 +273,7 @@ export class FrigateCardImage extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-image": FrigateCardImage - } + interface HTMLElementTagNameMap { + 'frigate-card-image': FrigateCardImage; + } } diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts new file mode 100644 index 00000000..2a9e8c0c --- /dev/null +++ b/src/components/live/live-ha.ts @@ -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 = 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` + `; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveFrigateStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-ha': FrigateCardLiveHA; + } +} diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts new file mode 100644 index 00000000..5f0c6da3 --- /dev/null +++ b/src/components/live/live-image.ts @@ -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` + `; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveFrigateStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-image': FrigateCardLiveImage; + } +} diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts new file mode 100644 index 00000000..e970da19 --- /dev/null +++ b/src/components/live/live-jsmpeg.ts @@ -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 { + 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 { + return new Promise((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 { + 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 => { + 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; + } +} diff --git a/src/components/live/live-webrtc.ts b/src/components/live/live-webrtc.ts new file mode 100644 index 00000000..04082148 --- /dev/null +++ b/src/components/live/live-webrtc.ts @@ -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) => 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 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; + } +} diff --git a/src/components/live.ts b/src/components/live/live.ts similarity index 61% rename from src/components/live.ts rename to src/components/live/live.ts index 4f1f32d2..67a7c823 100644 --- a/src/components/live.ts +++ b/src/components/live/live.ts @@ -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} > `, @@ -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 = {}; protected _refMediaCarousel: Ref = 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) => { 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) { - return 'ha'; + 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` { + 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(); + } + }} > - ` + ` : html``} ${provider === 'ha' ? html` ` - : html` - `} + ` + : 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 = 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` - `; - } - - /** - * 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) => 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 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 { - 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 { - return new Promise((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 { - 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 => { - 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; diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index 810d072d..f4d97538 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -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`; diff --git a/src/components/message.ts b/src/components/message.ts index 33c7469c..4d2688b1 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -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`
- - - + ${this.animated + ? html` ` + : html``} ${this.message ? html`${this.message}` : html``}
`; } @@ -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` - + `; } diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 5a7ccd67..a4943e71 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -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`
`, + { inProgressFunc: () => html`
` }, ); } diff --git a/src/components/surround.ts b/src/components/surround.ts index e63881d6..9b41f2cb 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -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 { @@ -84,7 +83,7 @@ export class FrigateCardSurround extends LitElement { this.inBackground || !this.hass || !this.view || - this.view.target || + this.view.target || !this.thumbnailConfig || this.thumbnailConfig.mode === 'none' || !this.browseMediaParams || @@ -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 { > ` : ''} - ${this.timelineConfig && !this.inBackground + ${this.timelineConfig?.mode && this.timelineConfig.mode !== 'none' && !this.inBackground ? html` embeddedThumbnail ? html`` : html``, - () => imageOff, + { inProgressFunc: () => imageOff }, ) : imageOff} `; } @@ -296,7 +297,9 @@ export class FrigateCardThumbnail extends LitElement { ? html`` : 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; diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 080652da..0982637c 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -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 ( diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 25ae366d..5e1b7ed1 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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` `; @@ -206,6 +211,9 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + protected _refMediaCarousel: Ref = 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(); } diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index f95ce8ce..9cebd3cb 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -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); }; /** diff --git a/src/const.ts b/src/const.ts index ac6b6b6d..5468ea2e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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; diff --git a/src/editor.ts b/src/editor.ts index 6d6b58f5..fd20fb9c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 >
-
${optionSet.name}
+
${optionSet.name}
${optionSet.secondary}
@@ -669,24 +714,24 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${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, })}
` : ''} @@ -730,7 +775,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${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, { - label: localize('config.common.controls.next_previous.style'), - })} + ${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()}
@@ -1453,9 +1517,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._expandedMenus[MENU_OPTIONS] === 'live' ? html`
- ${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)} `, + )}
` : ''} @@ -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)} ` : ''} + ${this._renderOptionSetHeader( + 'performance', + getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low' + ? 'warning' + : undefined, + )} + ${this._expandedMenus[MENU_OPTIONS] === 'performance' + ? html`
+ ${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, + )} + `, + )} +
` + : ''} ${this._config['overrides'] !== undefined ? html` ${this._renderOptionSetHeader('overrides')} ${this._expandedMenus[MENU_OPTIONS] === 'overrides' diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 7c2f0b57..b8b16753 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 4b652897..8364175e 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -88,7 +88,8 @@ "styles": { "chevrons": "Chevrons", "icons": "Icone", - "none": "Icone" + "none": "Nessuno", + "thumbnails": "Miniature" } }, "timeline": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 92b3a975..f93707a5 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -88,7 +88,8 @@ "styles": { "chevrons": "Setas", "icons": "Ícones", - "none": "Nenhum" + "none": "Nenhum", + "thumbnails": "Miniaturas" } }, "timeline": { diff --git a/src/localize/localize.ts b/src/localize/localize.ts index cf174c5e..e74cff88 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -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 = { + // 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 => { + 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) { diff --git a/src/performance.ts b/src/performance.ts new file mode 100644 index 00000000..d59e5132 --- /dev/null +++ b/src/performance.ts @@ -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 = ( + 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); + } + } +}; diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss index 17eaeeef..bdbd8cf7 100644 --- a/src/scss/drawer-inject.scss +++ b/src/scss/drawer-inject.scss @@ -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 { diff --git a/src/scss/editor.scss b/src/scss/editor.scss index 2ddddce6..cbce8562 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -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 { diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 662c0a78..ad22eb9d 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -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. diff --git a/src/scss/message.scss b/src/scss/message.scss index 50746fb2..953c9331 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -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%, diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss index 2d9e97b0..178a77a3 100644 --- a/src/scss/next-previous-control.scss +++ b/src/scss/next-previous-control.scss @@ -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; diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss index f4a1364e..970fb6b9 100644 --- a/src/scss/thumbnail-feature-event.scss +++ b/src/scss/thumbnail-feature-event.scss @@ -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%; diff --git a/src/scss/thumbnail-feature-recording.scss b/src/scss/thumbnail-feature-recording.scss index dab70503..459e3e22 100644 --- a/src/scss/thumbnail-feature-recording.scss +++ b/src/scss/thumbnail-feature-recording.scss @@ -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); diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index caa64791..9dbf577a 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -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 diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index a6851b2a..218d8b5c 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -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; diff --git a/src/types.ts b/src/types.ts index f6f51c37..aaf68b98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +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; @@ -735,6 +739,11 @@ export type TitleControlConfig = z.infer; /** * 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; + const webrtcCardConfigSchema = webrtcCardCameraConfigSchema.passthrough().optional(); export type WebRTCCardConfig = z.infer; @@ -794,8 +810,9 @@ export type JSMPEGConfig = z.infer; 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; +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; + +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', [ diff --git a/src/utils/basic.ts b/src/utils/basic.ts index d44142fd..a9f93620 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -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'; /** diff --git a/src/utils/data-manager.ts b/src/utils/data-manager.ts index 3d251889..4eda65c9 100644 --- a/src/utils/data-manager.ts +++ b/src/utils/data-manager.ts @@ -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; - 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, mediaType: TimelineMediaType) { + constructor(cameras: Map) { this._cameras = cameras; - this._mediaType = mediaType; } // Get the last event fetch date. diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 8fce396d..ac72e964 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -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, child: Readonly, + context?: ViewContext, ): Promise => { let parent: FrigateBrowseMediaSource; try { @@ -358,6 +360,7 @@ export const fetchChildMediaAndDispatchViewChange = async ( .evolve({ target: parent, }) + .mergeInContext(context) .dispatchChangeEvent(element); }; diff --git a/src/utils/media-to-view.ts b/src/utils/media-to-view.ts index a37cd098..c6bb6413 100644 --- a/src/utils/media-to-view.ts +++ b/src/utils/media-to-view.ts @@ -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 => { const now = new Date(); - await changeViewToRecording( - element, - hass, - dataManager, - cameras, - view, - { - ...options, + 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 }), - } - ); -} + // 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) { diff --git a/src/utils/task.ts b/src/utils/task.ts index afb91518..155958b6 100644 --- a/src/utils/task.ts +++ b/src/utils/task.ts @@ -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 = ( host: EventTarget, task: Task, 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); diff --git a/src/view.ts b/src/view.ts index 3d82ce5d..fcc5177f 100644 --- a/src/view.ts +++ b/src/view.ts @@ -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; } diff --git a/tsconfig.json b/tsconfig.json index 0b7df973..b5045a7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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,