diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..52b044de --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16 + +# Install Docker +ENV DOCKER_BUILDKIT="1" +# https://github.com/microsoft/vscode-dev-containers/commits/main/script-library/docker-debian.sh +ARG DOCKER_SCRIPT_VERSION="364972b0d7d20ee5de40c1084e65f3f1bc6d5951" +RUN bash -c "$(curl -fsSL "https://raw.githubusercontent.com/microsoft/vscode-dev-containers/${DOCKER_SCRIPT_VERSION}/script-library/docker-debian.sh")" \ + && rm -rf /var/lib/apt/lists/* +ENTRYPOINT ["/usr/local/share/docker-init.sh"] +CMD ["sleep", "infinity"] diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml deleted file mode 100644 index 9b01ded0..00000000 --- a/.devcontainer/configuration.yaml +++ /dev/null @@ -1,4 +0,0 @@ -default_config: -lovelace: - mode: yaml -demo: \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 98c934be..75ff5b53 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,14 +1,26 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "name": "Boilerplate Card Development", - "image": "ludeeus/container:monster", - "context": "..", - "appPort": ["5000:5000", "9123:8123"], - "postCreateCommand": "npm install", - "runArgs": [ - "-v", - "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" // This is added so you can push from inside the container - ], + "dockerComposeFile": "../docker-compose.yml", + "service": "dev", + "workspaceFolder": "${localWorkspaceFolder}", + "shutdownAction": "stopCompose", + "portsAttributes": { + "10001": { + "label": "Rollup", + "onAutoForward": "silent" + }, + "hass:8123": { + "label": "Home Assistant", + "onAutoForward": "silent" + }, + "frigate:5000": { + "label": "Frigate", + "onAutoForward": "silent" + } + }, + "forwardPorts": [10001, "hass:8123", "frigate:5000"], + "initializeCommand": ".devcontainer/initialize.sh", + "postCreateCommand": "yarn install", "extensions": [ "github.vscode-pull-request-github", "eamodio.gitlens", @@ -16,17 +28,23 @@ "esbenp.prettier-vscode", "bierner.lit-html", "runem.lit-plugin", - "auchenberg.vscode-browser-preview", "davidanson.vscode-markdownlint", - "redhat.vscode-yaml" + "redhat.vscode-yaml", + "lokalise.i18n-ally", + "ms-azuretools.vscode-docker" ], "settings": { "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", + "editor.tabSize": 2, "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, - "files.trimTrailingWhitespace": true + "files.trimTrailingWhitespace": true, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } } diff --git a/.devcontainer/frigate-hass-integration b/.devcontainer/frigate-hass-integration new file mode 160000 index 00000000..16bd40d6 --- /dev/null +++ b/.devcontainer/frigate-hass-integration @@ -0,0 +1 @@ +Subproject commit 16bd40d6226150265edd3019f85a23e8e9e2ba01 diff --git a/.devcontainer/initialize.sh b/.devcontainer/initialize.sh new file mode 100755 index 00000000..c908909b --- /dev/null +++ b/.devcontainer/initialize.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -euxo pipefail + +git submodule update --init + +.devcontainer/frigate-hass-integration/.devcontainer/set_dot_env.sh diff --git a/.devcontainer/preconfig/.storage/lovelace b/.devcontainer/preconfig/.storage/lovelace new file mode 100644 index 00000000..604e5a64 --- /dev/null +++ b/.devcontainer/preconfig/.storage/lovelace @@ -0,0 +1,104 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace", + "data": { + "config": { + "title": "Frigate Card", + "views": [ + { + "theme": "Backend-selected", + "title": "Basic", + "path": "basic", + "badges": [], + "cards": [ + { + "type": "custom:frigate-card", + "name": "Frigate Card with Frigate Camera", + "cameras": [ + { + "camera_entity": "camera.big_buck_bunny", + "live_provider": "frigate-jsmpeg", + "id": "big_buck_bunny_jsmpeg" + }, + { + "camera_entity": "camera.big_buck_bunny", + "live_provider": "ha", + "id": "big_buck_bunny_ha" + }, + { + "camera_entity": "camera.demo_camera" + } + ] + } + ] + }, + { + "theme": "Backend-selected", + "title": "Menu", + "path": "menu", + "badges": [], + "cards": [ + { + "type": "custom:frigate-card", + "cameras": [ + { + "camera_entity": "camera.demo_camera", + "title": "Default" + } + ] + }, + { + "type": "custom:frigate-card", + "cameras": [ + { + "camera_entity": "camera.demo_camera", + "title": "Hidden" + } + ], + "menu": { + "style": "hidden" + } + }, + { + "type": "custom:frigate-card", + "cameras": [ + { + "camera_entity": "camera.demo_camera", + "title": "Overlay" + } + ], + "menu": { + "style": "overlay" + } + }, + { + "type": "custom:frigate-card", + "cameras": [ + { + "camera_entity": "camera.demo_camera", + "title": "Hover" + } + ], + "menu": { + "style": "hover" + } + }, + { + "type": "custom:frigate-card", + "cameras": [ + { + "camera_entity": "camera.demo_camera", + "title": "Outside" + } + ], + "menu": { + "style": "outside" + } + } + ] + } + ] + } + } +} diff --git a/.devcontainer/preconfig/.storage/lovelace_resources b/.devcontainer/preconfig/.storage/lovelace_resources new file mode 100644 index 00000000..5f9c118e --- /dev/null +++ b/.devcontainer/preconfig/.storage/lovelace_resources @@ -0,0 +1,14 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace_resources", + "data": { + "items": [ + { + "url": "http://localhost:10001/frigate-hass-card.js", + "type": "module", + "id": "00c459541bac4290af01c51bf9c06ba1" + } + ] + } +} diff --git a/.devcontainer/ui-lovelace.yaml b/.devcontainer/ui-lovelace.yaml deleted file mode 100644 index d24ca157..00000000 --- a/.devcontainer/ui-lovelace.yaml +++ /dev/null @@ -1,7 +0,0 @@ -resources: - - url: http://127.0.0.1:5000/frigate-card.js - type: module -views: - - cards: - - type: custom:frigate-card - name: Frigate Card Development diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7e6a62c..b2ec5442 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,8 +19,8 @@ jobs: - uses: actions/checkout@v3 - name: Build run: | - npm install - npm run build + yarn install + yarn run build - name: HACS build validation uses: "hacs/action@21.12.1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3541546..a63f0bfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,8 @@ jobs: # Build - name: Build the file run: | - npm install - npm run build + yarn install + yarn run build # Upload build file to the releas as an asset. - name: Upload zip to release diff --git a/.gitignore b/.gitignore index a9d7dc2f..13f0c0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /node_modules/ /.rpt2_cache/ package-lock.json -/dist \ No newline at end of file +/dist + +.env +.envrc diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..f31cd6b8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "frigate-hass-integration"] + path = .devcontainer/frigate-hass-integration + url = https://github.com/blakeblackshear/frigate-hass-integration + branch = master diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 73865446..cd04d570 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,8 +6,8 @@ "esbenp.prettier-vscode", "bierner.lit-html", "runem.lit-plugin", - "auchenberg.vscode-browser-preview", "davidanson.vscode-markdownlint", - "redhat.vscode-yaml" + "redhat.vscode-yaml", + "lokalise.i18n-ally" ] -} \ No newline at end of file +} diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml new file mode 100644 index 00000000..d7a1d8e3 --- /dev/null +++ b/.vscode/i18n-ally-custom-framework.yml @@ -0,0 +1,25 @@ +# An array of strings which contain Language Ids defined by VS Code +# You can check avaliable language ids here: https://code.visualstudio.com/docs/languages/overview#_language-id +languageIds: + - typescript + +# An array of RegExes to find the key usage. **The key should be captured in the first match group**. +# You should unescape RegEx strings in order to fit in the YAML file +# To help with this, you can use https://www.freeformatter.com/json-escape.html +usageMatchRegex: + # The following example shows how to detect `t("your.i18n.keys")` + # the `{key}` will be placed by a proper keypath matching regex, + # you can ignore it and use your own matching rules as well + - "[^\\w\\d]localize\\(['\"`]({key})['\"`]" + + +# An array of strings containing refactor templates. +# The "$1" will be replaced by the keypath specified. +# Optional: uncomment the following two lines to use + +# refactorTemplates: +# - i18n.get("$1") + + +# If set to true, only enables this custom framework (will disable all built-in frameworks) +monopoly: true diff --git a/.vscode/i18n-ally-reviews.yml b/.vscode/i18n-ally-reviews.yml new file mode 100644 index 00000000..3daa9fde --- /dev/null +++ b/.vscode/i18n-ally-reviews.yml @@ -0,0 +1,127 @@ +# Review comments generated by i18n-ally. Please commit this file. + +reviews: + error.live_camera_not_found: + locales: + pt-BR: + translation_candidate: + source: en + text: A camera_entity configurada não foi encontrada + time: '2022-07-24T05:32:49.977Z' + error.live_camera_unavailable: + locales: + pt-BR: + translation_candidate: + source: en + text: Câmera indisponível + time: '2022-07-24T05:34:22.088Z' + config.live.show_image_during_load: + locales: + it: + translation_candidate: + source: en + text: Mostra l'immagine fissa durante il caricamento del live streaming + time: '2022-08-07T23:00:31.001Z' + pt-BR: + translation_candidate: + source: en + text: >- + Mostrar imagem estática enquanto a transmissão ao vivo está + carregando + time: '2022-08-07T23:00:35.117Z' + config.common.layout.fit: + locales: + it: + translation_candidate: + source: en + text: Disposizione adatta + time: '2022-08-12T03:00:50.438Z' + pt-BR: + translation_candidate: + source: en + text: Ajuste de layout + time: '2022-08-12T03:00:54.209Z' + config.common.layout.fits.contain: + locales: + it: + translation_candidate: + source: en + text: I media sono contenuti/incartati + time: '2022-08-12T03:01:00.885Z' + pt-BR: + translation_candidate: + source: en + text: A mídia está contida/em letterbox + time: '2022-08-12T03:01:03.607Z' + config.common.layout.fits.cover: + locales: + it: + translation_candidate: + source: en + text: Il supporto si espande proporzionalmente per coprire la scheda + time: '2022-08-12T03:01:09.001Z' + pt-BR: + translation_candidate: + source: en + text: A mídia se expande proporcionalmente para cobrir o cartão + time: '2022-08-12T03:01:11.240Z' + config.common.layout.fits.fill: + locales: + it: + translation_candidate: + source: en + text: Il supporto viene allungato per riempire la scheda + time: '2022-08-12T03:01:14.657Z' + pt-BR: + translation_candidate: + source: en + text: A mídia é esticada para preencher o cartão + time: '2022-08-12T03:01:17.319Z' + config.common.layout.position.x: + locales: + it: + translation_candidate: + source: en + text: Percentuale di posizionamento orizzontale + time: '2022-08-12T03:01:20.714Z' + pt-BR: + translation_candidate: + source: en + text: Porcentagem de posicionamento horizontal + time: '2022-08-12T03:01:22.619Z' + config.common.layout.position.y: + locales: + it: + translation_candidate: + source: en + text: Percentuale di posizionamento verticale + time: '2022-08-12T03:01:26.332Z' + pt-BR: + translation_candidate: + source: en + text: Porcentagem de posicionamento vertical + time: '2022-08-12T03:01:28.398Z' + config.image.layout: + locales: + it: + translation_candidate: + source: en + text: Disposizione dell'immagine + time: '2022-08-12T03:01:33.020Z' + pt-BR: + translation_candidate: + source: en + text: Esquema de imagem + time: '2022-08-12T03:01:34.987Z' + config.media_viewer.layout: + locales: + it: + translation_candidate: + source: en + text: Layout del visualizzatore multimediale + time: '2022-08-12T03:01:45.033Z' + pt-BR: + translation_candidate: + source: en + text: Layout do visualizador de mídia + time: '2022-08-12T03:01:46.528Z' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..0c322e3a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome against Home Assistant", + "url": "http://localhost:8123", + "webRoot": "${workspaceFolder}/dist", + "preLaunchTask": "Rollup", + "resolveSourceMapLocations": ["http://localhost:1000/**"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a4a10938 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "i18n-ally.localesPaths": ["src/localize/languages"], + "i18n-ally.keystyle": "nested", + "i18n-ally.sortKeys": true, + "i18n-ally.keepFulfilled": true, + "i18n-ally.editor.preferEditor": true, + "i18n-ally.translate.saveAsCandidates": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ee855146..53d59ad8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,12 +1,26 @@ { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "start", - "problemMatcher": [], - "label": "npm: start", - "detail": "rollup -c --watch" - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "Rollup", + "type": "npm", + "script": "start", + "problemMatcher": { + "pattern": [ + { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "^bundles", + "endsPattern": "^created" + } + }, + "isBackground": true + } + ] +} diff --git a/README.md b/README.md index 4f226153..c26efa10 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![hacs](https://img.shields.io/badge/HACS-default-orange.svg?style=flat-square)](https://hacs.xyz) [![BuyMeCoffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=flat-square)](https://www.buymeacoffee.com/dermotdu) -Frigate card example +Frigate card example | 🆕 Looking for the documentation for the v4.0.0+ pre-release branch? See [v4](https://github.com/dermotduffy/frigate-hass-card/tree/release-4.0.0). | | - | @@ -40,12 +40,19 @@ See more [screenshots](#screenshots) below. ## Installation -* Use [HACS](https://hacs.xyz/) to install the card: +* [HACS](https://hacs.xyz/) is **highly** recommended to install the card -- it works for all Home Assistant variants. If you don't have [HACS](https://hacs.xyz/) installed, start there -- then come back to these instructions. + +* Find the card in HACS: ``` Home Assistant > HACS > Frontend > "Explore & Add Integrations" > Frigate Card ``` +* Click `Download this repository with HACS`. + +#### Lovelace YAML users +If Lovelace is in YAML mode then there's an additional step required to load the resource into your browser. (This mode is not the default -- you would see`mode: yaml` under `lovelace:` in your `configuration.yaml` if this applies to you), + * Add the following to `configuration.yaml` (note that `/hacsfiles/` is just an [optimized equivalent](https://hacs.xyz/docs/categories/plugins#custom-view-hacsfiles) of `/local/community/` that HACS natively supports): ```yaml @@ -62,6 +69,8 @@ lovelace: ### Advanced Users: Manual Installation +**Note:** This is very rarely needed unless you are developing the card. Please consider HACS (above)! + * Download the `frigate-hass-card.js` attachment of the desired [release](https://github.com/dermotduffy/frigate-hass-card/releases) to a location accessible by Home Assistant. * Add the location as a Lovelace resource via the UI, or via [YAML configuration](https://www.home-assistant.io/lovelace/dashboards/#resources)) such as: @@ -73,9 +82,9 @@ lovelace: type: module ``` -## Options +## Configuration -At least 1 camera must be configured in the `cameras` options, but otherwise all configuration parameters are optional. +At least 1 camera must be configured in the `cameras` section, but otherwise all configuration parameters are optional. ### Camera Options @@ -88,19 +97,20 @@ cameras: - [...camera 2...] ``` +See the [fully expanded cameras configuration example](#config-expanded-cameras) for how these parameters are structured. + + | Option | Default | Overridable | Description | | - | - | - | - | | `camera_entity` | | :heavy_multiplication_x: | The Home Assistant camera entity to use with the `frigate` live provider view. Also used to automatically detect the name of the underlying Frigate camera, and the title/icon of the camera. | -| `camera_name` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.| | `live_provider` | `auto` | :heavy_multiplication_x: | The choice of live stream provider. See [Live Providers](#live-providers) below.| -| `frigate_url` | | :heavy_multiplication_x: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. | -| `label` | | :heavy_multiplication_x: | A Frigate label / object filter used to filter events (clips & snapshots), e.g. 'person'.| -| `zone` | | :heavy_multiplication_x: | A Frigate zone used to filter events (clips & snapshots), e.g. 'front_door'.| -| `client_id` | `frigate` | :heavy_multiplication_x: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).| | `title` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | A friendly name for this camera to use in the card. | | `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | +| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | +| `frigate` | | :heavy_multiplication_x: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. | +| `dependencies` | | :heavy_multiplication_x: | Other cameras that this camera should depend upon. See [camera dependencies](#camera-dependencies-configuration) below. | +| `triggers` | | :heavy_multiplication_x: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. | | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | -| `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | @@ -114,6 +124,26 @@ cameras: |`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).| + + + +#### Camera Frigate configuration + +The `frigate` block configures options for a Frigate camera. This configuration is included as part of a camera entry in the `cameras` array. + +```yaml +cameras: + - frigate: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `camera_name` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.| +| `url` | | :heavy_multiplication_x: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. All other communication with Frigate goes via Home Assistant. | +| `label` | | :heavy_multiplication_x: | A Frigate label / object filter used to filter events (clips & snapshots), e.g. `person`.| +| `zone` | | :heavy_multiplication_x: | A Frigate zone used to filter events (clips & snapshots), e.g. `front_door`.| +| `client_id` | `frigate` | :heavy_multiplication_x: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).| + #### Camera WebRTC Card configuration The `webrtc_card` block configures only the entity/URL for this camera to be used with the WebRTC Card live provider. This configuration is included as part of a camera entry in the `cameras` array. @@ -130,11 +160,44 @@ cameras: See [Using the WebRTC Card](#webrtc) below for more details on how to use the WebRTC Card live provider. + + +#### Camera Dependency Configuration + +The `dependencies` block configures other cameras as dependents of this camera. Dependent cameras have their events fetched and merged with this camera. Configuration is under: + +```yaml +cameras: + - dependencies: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named cameras. All dependent cameras must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, to always have clips/snapshots show fully merged events across all cameras or to show events for the `birdseye` camera that otherwise would not have events itself.| +| `all_cameras` | `false` | :heavy_multiplication_x: | Shortcut to specify all other cameras as dependent cameras.| + + + +#### Camera Trigger Configuration + +The `triggers` block configures what triggers a camera. Triggering can be used to either reset to the default view / update the card, or active the camera in [scan mode](#scan-mode). + +```yaml +cameras: + - triggers: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `motion` | `false` | :heavy_multiplication_x: | Whether to not to trigger the camera by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.| +| `occupancy` | `true` | :heavy_multiplication_x: | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant.| +| `entities` | | :heavy_multiplication_x: | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.| + #### Camera IDs: Refering to cameras in card configuration -Each camera configured in the card has a single identifier (`id`). For a given camera, this will be one of the camera {`id`, `camera_entity`, `webrtc_card.entity` or `camera_name`} parameters for that camera -- in that order of precedence. These ids may be used in conditions or custom actions to refer to a given camera unambiguously. | +Each camera configured in the card has a single identifier (`id`). For a given camera, this will be one of the camera {`id`, `camera_entity`, `webrtc_card.entity` or `frigate.camera_name`} parameters for that camera -- in that order of precedence. These ids may be used in conditions, dependencies or custom actions to refer to a given camera unambiguously. | #### Example @@ -148,17 +211,48 @@ All configuration is under: view: ``` +See the [fully expanded view configuration example](#config-expanded-view) for how these parameters are structured. + | Option | Default | Overridable | Description | | - | - | - | - | -| `default` | `live` | :heavy_multiplication_x: | The view to show in the card by default. The default camera is the first one listed. See [views](#views) below.| -| `camera_select` | `current` | :heavy_multiplication_x: | The view to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. Other acceptable values may be seen at [views](#views) below.| -| `timeout_seconds` | `300` | :heavy_multiplication_x: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | -| `update_seconds` | `0` | :heavy_multiplication_x: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| -| `update_force` | `false` | :heavy_multiplication_x: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| -| `update_entities` | | :heavy_multiplication_x: | **YAML only**: A list of entity ids that should cause the view to reset to the default. See [card updates](#card-updates) below for behavior and usecases.| -| `update_cycle_camera` | `false` | :heavy_multiplication_x: | When set to `true` the selected camera is cycled on each default view change. | -| `render_entities` | | :heavy_multiplication_x: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags to not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | -| `actions` | | :heavy_multiplication_x: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| +| `default` | `live` | :white_check_mark: | The view to show in the card by default. The default camera is the first one listed. See [views](#views) below.| +| `camera_select` | `current` | :white_check_mark: | The view to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. Other acceptable values may be seen at [views](#views) below.| +| `dark_mode` | `off` | :white_check_mark: | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `timeout_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`.| +| `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | +| `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| +| `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| +| `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `triggers` for the selected camera instead, see [Trigger Configuration](#camera-triggers-configuration)). See [card updates](#card-updates) below for behavior and usecases.| +| `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. | +| `render_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | +| `scan` | | :white_check_mark: | Configuration for [scan mode](#scan-mode). | +| `actions` | | :white_check_mark: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| + + + +#### View: Scan Mode configuration + +All configuration is under: + +```yaml +view: + scan: +``` + +Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera in the `live` view when an entity changes to an active state (specifically `on` or `open`). The entities considered are defined by your camera configuration (see `triggers` parameters). An untrigger is defined as the state for all the configured entities returning to inactive (i.e. not `on` or `open`), with an optional number of seconds to wait prior to the untriggering (see `untrigger_seconds`). + +When the camera untriggers, the view will either remain as-is (if `untrigger_reset` is `false`) and the card return to normal operation, or reset to the default view (if `untrigger_reset` is `true` -- the default). + +Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. + +Scan mode tracks Home Assistant state *changes* -- when the card is first started, it takes an active change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room will). + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. | +| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). | +| `untrigger_reset` | `true` | :white_check_mark: | Whether or not to reset the view to the default after untriggering. | +| `untrigger_seconds` | `0` | :white_check_mark: | The number of seconds to wait after all entities are inactive before untriggering. | + ### Menu Options All configuration is under: @@ -167,10 +261,14 @@ All configuration is under: menu: ``` +See the [fully expanded menu configuration example](#config-expanded-menu) for how these parameters are structured. + | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `hidden-top` | :white_check_mark: | The menu mode to show by default. See [menu modes](#menu-modes) below.| -| `button_size` | `40px` | :white_check_mark: | The size of the menu buttons [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `style` | `hidden` | :white_check_mark: | The menu style to show by default, one of `none`, `hidden`, `hover`, `overlay`, or `outside`. See [menu styles](#menu-styles) below.| +| `position` | `top` | :white_check_mark: | Whether to show the menu on the `left`, `right`, `top` or `bottom` side of the card. Note that for the `outside` style only the `top` and `bottom` positions have an effect.| +| `alignment` | `left` | :white_check_mark: | Whether to align the menu buttons to the `left`, `right`, `top` or `bottom` of the menu. Some selections may have no effect depending on the value of `position` (e.g. it doesn't make sense to `left` align icons on a menu with `position` to the `left`).| +| `button_size` | 40 | :white_check_mark: | The size of the menu buttons in pixels. Must be >= `20`.| | `buttons` | | :white_check_mark: | Whether to show or hide built-in buttons. See below. | #### Menu Options: Buttons @@ -180,19 +278,33 @@ All configuration is under: ```yaml menu: buttons: + [button]: ``` +##### Buttons + +| Option | Overridable | Description | +| - | - | - | +| `frigate` | :white_check_mark: | The `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . | +| `cameras` | :white_check_mark: | The camera selection submenu. Will only appear if multiple cameras are configured. | +| `live` | :white_check_mark: | The `live` view menu button: brings the user to the `live` view. See [views](#views) below.| +| `clips` | :white_check_mark: | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| +| `snapshots` | :white_check_mark: | The `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below. This button will never be shown if the `frigate.camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `frigate.camera_name` is `birdseye`.| +| `image` | :white_check_mark: | The `image` view menu button: brings the user to the static `image` view. See [views](#views) below.| +| `download` | :white_check_mark: | The `download` menu button: allow direct download of the media being displayed.| +| `frigate_ui` | :white_check_mark: | The `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| +| `fullscreen` | :white_check_mark: | The `fullscreen` menu button: expand the card to consume the fullscreen. | +| `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. | +| `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | + +##### Configuration on each button + | Option | Default | Overridable | Description | | - | - | - | - | -| `frigate` | `true` | :white_check_mark: | Whether to show the `Frigate` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.mode` is `hidden-*` . | -| `cameras` | `true` | :white_check_mark: | Whether to show the camera selection submenu. Will only appear if multiple cameras are configured. | -| `live` | `true` | :white_check_mark: | Whether to show the `live` view menu button: brings the user to the `live` view. See [views](#views) below.| -| `clips` | `true` | :white_check_mark: | Whether to show the `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. See [views](#views) below. This button will never be shown if the `camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `camera_name` is `birdseye`.| -| `snapshots` | `true` | :white_check_mark: | Whether to show the `snapshots` view menu button: brings the user to the `clips` view on tap and the most-recent `snapshot` view on hold. See [views](#views) below. This button will never be shown if the `camera_name` for the selected camera is not auto-detected/specified (e.g. non-Frigate cameras), or if the `camera_name` is `birdseye`.| -| `image` | `false` | :white_check_mark: | Whether to show the `image` view menu button: brings the user to the static `image` view. See [views](#views) below.| -| `download` | `true` | :white_check_mark: | Whether to show the `download` menu button: allow direct download of the media being displayed.| -| `frigate_ui` | `true` | :white_check_mark: | Whether to show the `frigate_ui` menu button: brings the user to a context-appropriate page on the Frigate UI (e.g. the camera homepage). Will only appear if the `frigate.url` option is set.| -| `fullscreen` | `true` | :white_check_mark: | Whether to show the `fullscreen` menu button: expand the card to consume the fullscreen. | +| `enabled` | `true` for all buttons except `image` | :white_check_mark: | Whether or not to show the button. | +| `priority` | `50` | :white_check_mark: | The button priority. Higher priority buttons are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`, when the menu alignment is `left`). Minimum `0`, maximum `100`.| +| `icon` | | :white_check_mark: | An icon to overriding the default for that button, e.g. `mdi:camera-front`. | +| `alignment` | `matching` | :white_check_mark: | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. | ### Live Options @@ -202,18 +314,25 @@ All configuration is under: live: ``` +See the [fully expanded live configuration example](#config-expanded-live) for how these parameters are structured. + | Option | Default | Overridable | Description | | - | - | - | - | | `preload` | `false` | :heavy_multiplication_x: | Whether or not to preload the live view. Preloading causes the live view to render in the background regardless of what view is actually shown, so it's instantly available when requested. This consumes additional network/CPU resources continually. | -| `auto_unmute` | `false` | :heavy_multiplication_x: | Whether or not to automatically unmute live cameras. Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead. | +| `auto_play` | `all` | :heavy_multiplication_x: | Whether to automatically play live camera feeds. `never` will never automatically play, `selected` will automatically play when a camera is selected in the carousel, `visible` will automatically play when the browser/tab becomes visible or `all` on any opportunity to automatically play (i.e. either case). Some live providers (e.g. `webrtc-card`, `jsmpeg`) do not support the prevention of automatic play on initial load, but should still respect the value of this flag on play-after-pause.| +| `auto_pause` | `never` | :heavy_multiplication_x: | Whether to automatically pause live camera feeds. `never` will never automatically pause, `unselected` will automatically pause when a camera is unselected in the carousel, `hidden` will automatically pause when the browser/tab becomes hidden or `all` on any opportunity to automatically pause (i.e. either case). **Caution**: Some live providers (e.g. `jsmpeg`) may not offer human-accessible means to resume play if it is paused, unless the `auto_play` option (above) is used.| +| `auto_mute` | `all` | :heavy_multiplication_x: | Whether to automatically mute live camera feeds. `never` will never automatically mute, `unselected` will automatically mute when a camera is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `all` on any opportunity to automatically mute (i.e. either case).| +| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute live camera feeds. `never` will never automatically unmute, `selected` will automatically unmute when a camera is unselected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case).| | `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load cameras in the camera carousel. Setting this will `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if both `lazy_load` and `preload` are `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | -| `lazy_unload` | `false` | :heavy_multiplication_x: | Whether or not to lazily **un**load lazyily-loaded cameras in the camera carousel, or just leave the camera paused. Setting this to `true` will cause cameras to be entirely unloaded when they are no longer visible (either because the carousel has scrolled past them, or because the document has been marked hidden/inactive by the browser). 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. | +| `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. | | `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: JSMPEG Configuration @@ -257,8 +376,11 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).| -| `size` | `100px` | :white_check_mark: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| +| `size` | 100 | :white_check_mark: | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `175`. | +| `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| +| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| | `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| #### Live Controls: Next / Previous @@ -274,7 +396,7 @@ live: | Option | Default | Overridable | Description | | - | - | - | - | | `style` | `chevrons` | :white_check_mark: | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | -| `size` | `48px` | :white_check_mark: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 48 | :white_check_mark: | The size of the next/previous controls in pixels. Must be >= `20`. | @@ -293,32 +415,37 @@ live: | `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | | `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.| -### Event Viewer Options +### Media Viewer Options -The `event_viewer` is used for viewing all `clip` and `snapshot` media, in a media carousel. +The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel. All configuration is under: ```yaml -event_viewer: +media_viewer: ``` +See the [fully expanded Media viewer configuration example](#config-expanded-media-viewer) for how these parameters are structured. + | Option | Default | Overridable | Description | | - | - | - | - | -| `auto_play` | `true` | :heavy_multiplication_x: | Whether or not to autoplay clips.| -| `auto_unmute` | `false` | :heavy_multiplication_x: | Whether or not to automatically unmute clips. Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead. | -| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. | -| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the event viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. | +| `auto_play` | `all` | :heavy_multiplication_x: | Whether to automatically play events. `never` will never automatically play, `selected` will automatically play when an event is selected in the carousel, `visible` will automatically play when the browser/tab becomes visible or `all` on any opportunity to automatically play (i.e. either case).| +| `auto_pause` | `all` | :heavy_multiplication_x: | Whether to automatically pause events. `never` will never automatically pause, `unselected` will automatically pause when an event is unselected in the carousel, `hidden` will automatically pause when the browser/tab becomes hidden or `all` on any opportunity to automatically pause (i.e. either case).| +| `auto_mute` | `all` | :heavy_multiplication_x: | Whether to automatically mute events. `never` will never automatically mute, `unselected` will automatically mute when an event is unselected in the carousel, `hidden` will automatically mute when the browser/tab becomes hidden or `all` on any opportunity to automatically mute (i.e. either case).| +| `auto_unmute` | `never` | :heavy_multiplication_x: | Whether to automatically unmute events. `never` will never automatically unmute, `selected` will automatically unmute when an event is selected in the carousel, `visible` will automatically unmute when the browser/tab becomes visible or `all` on any opportunity to automatically unmute (i.e. either case). Note that some browsers will not allow automated unmute until the user has interacted with the page in some way -- if the user has not then the browser may pause the media instead.| +| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the Media viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. | +| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the Media viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. | | `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. | -| `controls` | | :heavy_multiplication_x: | Configuration for the event viewer. See below. | -| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| +| `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. | +| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| +| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| -#### Event Viewer Controls: Next / Previous +#### Media Viewer Controls: Next / Previous All configuration is under: ```yaml -event_viewer: +media_viewer: controls: next_previous: ``` @@ -326,36 +453,39 @@ event_viewer: | Option | Default | Overridable | Description | | - | - | - | - | | `style` | `thumbnails` | :heavy_multiplication_x: | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | -| `size` | `48px` | :heavy_multiplication_x: | The size of the next/previous controls [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `size` | 48 | :heavy_multiplication_x: | The size of the next/previous controls in pixels. Must be >= `20`.| -#### Event Viewer Controls: Thumbnails +#### Media Viewer Controls: Thumbnails All configuration is under: ```yaml -event_viewer: +media_viewer: controls: thumbnails: ``` | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).| -| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).| +| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel pixels. Must be >= `75` and <= `175`.| +| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| +| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| -#### Event Viewer Controls: Title +#### Media Viewer Controls: Title All configuration is under: ```yaml -event_viewer: +media_viewer: controls: title: ``` | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the event viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | +| `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the Media viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | | `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.| ### Event Gallery Options @@ -368,9 +498,14 @@ All configuration is under: event_gallery: ``` +See the [fully expanded event gallery configuration example](#config-expanded-event-gallery) for how these parameters are structured. + | Option | Default | Overridable | Description | | - | - | - | - | -| `min_columns` | `5` | :heavy_multiplication_x: | The minimum number of columns to show in the gallery -- smaller values will render fewer but larger thumbnail columns. Thumbnails will never be stretched beyond their intrinsic size (typically `175px`). All available space (in normal mode, or fullscreen mode) will be occupied by the gallery, so more columns than `min_columns` will often be rendered if space allows for more full-sized thumbnails. For normal sized Lovelace cards (`492px` wide), this typically means there'll never be fewer than 3 columns rendered (as otherwise the thumbnails would need to stretch beyond their actual size). Must be `1 <= x <= 10`. | +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the event gallery in pixels. Must be >= `75` and <= `175`.| +| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.| +| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.| | `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`). See [actions](#actions) below.| ### Image Options @@ -381,14 +516,65 @@ All configuration is under: image: ``` +See the [fully expanded image configuration example](#config-expanded-image) for how these parameters are structured. + | Option | Default | Overridable | Description | | - | - | - | - | -| `mode` | `url` | :white_check_mark: | Mode of the the `image` [view](#views). Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera using either `camera_entity` or `webrtc_card.entity` in that order of precedence), or `screensaver` (to show an [embedded stock Frigate card logo](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/images/frigate-bird-in-sky.jpg)). In either `url` or `camera` mode, the `screensaver` content is used as a fallback if a URL is not specified or cannot be derived. | -| `url` | | :white_check_mark: | A static image URL to be used when the `mode` is set to `url`. Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser.| +| `mode` | `url` | :white_check_mark: | Mode of the the `image` [view](#views). Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera using either `camera_entity` or `webrtc_card.entity` in that order of precedence), or `screensaver` (to show an [embedded stock Frigate card logo](https://github.com/dermotduffy/frigate-hass-card/blob/release-4.0.0/src/images/frigate-bird-in-sky.jpg)). In either `url` or `camera` mode, the `screensaver` content is used as a fallback if a URL is not specified or cannot be derived. | +| `url` | | :white_check_mark: | A static image URL to be used when the `mode` is set to `url` or when a temporary image is required (e.g. may appear momentarily prior to load of a camera snapshot in the `camera` mode). Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser.| | `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.| -### Dimension Options +### 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: + +* Clicking on an event will take you to the media viewer for that event. +* Clicking on the "background", or a camera title, will take you to the recordings for that camera (seeking to the clicked time). +* Clicking on the time axis will take you to recordings for all cameras (seeking to the clicked time). + +All configuration is under: + +```yaml +timeline: +``` + +See the [fully expanded timeline configuration example](#config-expanded-timeline) for how these parameters are structured. + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | +| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| +| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.| +| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).| +| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.| + +#### Timeline Controls: Thumbnails + +All configuration is under: + +```yaml +timeline: + controls: + thumbnails: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).| +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel in pixels. Must be >= `75` and <= `175`.| +| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.| +| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.| + + + +### Dimensions Options + +These options control the aspect-ratio of the entire card to make placement in +Home Assistant dashboards more stable. Aspect ratio configuration applies once +to the entire card (including the menu, thumbnails, etc), not just to displayed +media. All configuration is under: @@ -396,10 +582,13 @@ All configuration is under: dimensions: ``` +See the [fully expanded dimensions configuration example](#config-expanded-dimensions) for how these parameters are structured. + + | Option | Default | Overridable | Description | | - | - | - | - | -| `aspect_ratio_mode` | `dynamic` | :heavy_multiplication_x: | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| -| `aspect_ratio` | `16:9` | :heavy_multiplication_x: | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| +| `aspect_ratio_mode` | `dynamic` | :white_check_mark: | The aspect ratio mode to use. Acceptable values: `dynamic`, `static`, `unconstrained`. See [aspect ratios](#aspect-ratios) below.| +| `aspect_ratio` | `16:9` | :white_check_mark: | The aspect ratio to use. Acceptable values: `:` or `/`. See [aspect ratios](#aspect-ratios) below.| #### `dimensions.aspect_ratio_mode`: @@ -430,7 +619,9 @@ 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. -### Override Options + + +### Overrides Options All configuration is a list under: @@ -438,6 +629,8 @@ All configuration is a list under: overrides: ``` +See the [fully expanded overrides configuration example](#config-expanded-overrides) for how these parameters are structured. + Various parts of this configuration may conditionally (see [Frigate Card Conditions](#frigate-card-conditions)) be overridden, for example to use custom WebRTC parameters for a particular camera or to hide the menu in fullscreen mode. @@ -456,6 +649,34 @@ item, that has both of the following parameters set: | `conditions` | | :heavy_multiplication_x: | A set of conditions that must evaluate to `true` in order for the overrides to be applied. See [Frigate Card Conditions](#frigate-card-conditions). | | `overrides` | | :heavy_multiplication_x: |Configuration overrides to be applied. Any configuration parameter described in this documentation as 'Overridable' is supported. | + + +### Media Layout + +The `live`, `media_viewer` and `image` sections all support `layout` option which is used to control the fit and position of the media _within_ the card dimensions (in order to control the card dimensions themselves see [the dimensions parameter](#dimensions) ). + +As the default card behavior is for the card to always expand to fit the media, these options only make sense if `dimensions.aspect_ratio_mode` is set to `static`. + +All configuration is under: + +```yaml +live: + layout: +image: + layout: +media_viewer: + layout: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `fit` | `contain` | :white_check_mark: | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the card is fully covered. If `fill`, the media is stretched to fill the card (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. | +| `position` | | :white_check_mark: | A dictionary that contains an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover`. This can be effectively used to "pan" the media around. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the card width (in which case `x` controls the position) or the media height is larger than the card height (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technical details and a visualization. | + +If multiple cameras are configured in the card, use [overrides](#overrides) to configure different values per camera. + +See [media layout examples](#media-layout-examples). + ### Using AlexxIT's WebRTC Card @@ -464,7 +685,7 @@ WebRTC Card support blends the use of the ultra-realtime [WebRTC card live view](https://github.com/AlexxIT/WebRTC) with convenient access to Frigate events/snapshots/UI. A perfect combination! -Live viewing +Live viewing **Note**: AlexxIT's WebRTC Integration/Card must be installed and configured separately (see [details](https://github.com/AlexxIT/WebRTC)) before it can be used with this card. @@ -515,15 +736,37 @@ configuration options that can be used here. Conditions are used to apply certain configuration depending on runtime evaluations. Conditions may be used in `elements` configuration (as part of a `custom:frigate-card-conditional` element) or the `overrides` configuration (see below for both). -All variables listed are under a `conditions:` section. +All variables listed are under a `conditions:` section. | Condition | Description | | ------------- | --------------------------------------------- | | `view` | A list of [views](#views) in which this condition is satified (e.g. `clips`) | | `camera` | A list of camera ids in which this condition is satisfied. See [camera IDs](#camera-ids).| | `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.| +| `state` | A list of state conditions to compare with Home Assistant state. See below. | +| `mediaLoaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).| -See the [PTZ example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. +See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. + +### State Conditions + +```yaml +- conditions: + state: + - [entries] +``` + +The Frigate Card Condition can compare HA state against fixed string values. This is the same as the Home Assistant [Conditional Element](https://www.home-assistant.io/dashboards/picture-elements/#conditional-element) condition, but can be used outside of a Picture Element context (e.g. card configuration overrides). + +If multiple entries are provided, the results are `AND`ed. + +| Parameter | Description | +| - | - | +| `entity` | The entity ID to check the state for | +| `state` | Condition will be met if state is equal to this optional string. | +| `state_not` | Condition will be met if state is unequal to this optional string. | + +See the [Menu override example below](#frigate-card-menu-override-example) for an illustration. @@ -532,14 +775,15 @@ See the [PTZ example below](#frigate-card-conditional-example) for a real-world This card supports the [Picture Elements configuration syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly allow the user to add custom elements to the card, which may be configured to -perform a variety of actions on interaction (see [actions](#actions) below). +perform a variety of actions on interaction (see [actions](#actions) below). The configuration is fairly versatile and can be simple, or very complex depending on the desires of the user. -In the card YAML configuration, elements may be manually added under an -`elements` key. +All configuration is under: -See the [action -documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for -more information on the action options available. +```yaml +elements: +``` + +See the [fully expanded elements configuration example](#config-expanded-elements) for how these parameters are structured. **Note**: The Frigate Card allows either a single action (as in stock Home Assistant) or list of actions to be defined for each class of user interaction @@ -557,7 +801,14 @@ elements to add special Frigate card functionality. | `custom:frigate-card-menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.| | `custom:frigate-card-menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.| | `custom:frigate-card-menu-submenu` | Add a configurable submenu dropdown. See [configuration below](#frigate-card-menu-submenu).| +| `custom:frigate-card-menu-submenu-select` | Add a submenu based on a `select` or `input_select`. See [configuration below](#frigate-card-submenu-select).| | `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).| +| `custom:frigate-card-ptz` | Add a PTZ (Pan Tilt Zoom) controller overlay. See [configuration below](#frigate-card-ptz).| + + +**Note**: ℹ️ Manual positioning of custom menu icons or submenus via the `style` +parameter is not supported as the menu buttons displayed are context sensitive +so manual positioning by the user is not feasible. @@ -568,7 +819,9 @@ Parameters for the `custom:frigate-card-menu-submenu` element are identical to t | Parameter | Description | | - | - | | `type` | Must be `custom:frigate-card-menu-submenu`. | -| `items` | A list of menu items, as described below. | +| `items` | A list of menu items, as described below. | + + ##### Submenu Items @@ -579,11 +832,27 @@ Parameters for the `custom:frigate-card-menu-submenu` element are identical to t | `entity` | | An optional Home Assistant entity from which title, icon and style can be automatically computed. | | `state_color` | `true` | Whether or not the title and icon should be stylized based on state. | | `selected` | `false` | Whether or not to show this item as selected. | +| `enabled` | `true` | Whether or not to show this item as enabled / selectable. | | `style` | | Position and style the element using CSS. | | `tap_action`, `double_tap_action`, `hold_action`, `start_tap`, `end_tap` | | [Home Assistant action configuration](https://www.home-assistant.io/lovelace/actions) including the extended functionality described under [actions](#actions). | See the [Configuring a Submenu example](#configuring-a-submenu-example). + + +#### `custom:frigate-card-menu-submenu-select` + +This element allows you to easily convert a [Home Assistant Select Entity](https://www.home-assistant.io/integrations/select/) or [Home Assistant Input Select Entity](https://www.home-assistant.io/integrations/input_select/) (an entity either starting with `select` or `input_select`) into an overridable submenu. This *could* be done by hand using a regular submenu (above) -- this element is a convenience. + +Parameters for the `custom:frigate-card-menu-submenu-select` element are identical to the parameters of the [stock Home Assistant State Icon Element](https://www.home-assistant.io/dashboards/picture-elements/#state-icon) with the exception of these parameters which differ: + +| Parameter | Description | +| - | - | +| `type` | Must be `custom:frigate-card-menu-submenu-select`. | +| `options` | An optional dictionary of overrides keyed by the option name that the given select entity supports. These options can be used to set or override submenu item parameters on a per-option basis. The format is as described in [Submenu Items](#frigate-card-submenu-items) above.| + +See the [Configuring a Select Submenu example](#configuring-a-select-submenu-example). + #### `custom:frigate-card-conditional` @@ -596,13 +865,29 @@ Parameters for the `custom:frigate-card-conditional` element: `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. | | `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. See [Frigate Card Conditions](#frigate-card-conditions). | +#### `custom:frigate-card-ptz` + +Parameters for the `custom:frigate-card-ptz` element: + +| Parameter | Default | Description | +| ------------- | - | -------------------------------------------- | +| `type` | | Must be `custom:frigate-card-ptz`. | +| `style` | `translate(-50%, -50%)` | Position and style the element using CSS. See [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object). | +| `orientation` | `vertical` | Whether to show a `vertical` or `horizontal` PTZ control. | +| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | The [Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) to call when this icon is interacted with. | +| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | +| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. | + ### Special Actions #### `custom:frigate-card-action` -| Action name | Description | +| Parameter | Description | | - | - | -| `custom:frigate-card-action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`.| +| `action` | Must be `custom:frigate-card-action`. | +| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `timeline`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`.| + +##### Command descriptions | Value | Description | | - | - | @@ -610,9 +895,11 @@ Parameters for the `custom:frigate-card-conditional` element: | `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots` | Trigger the named [view](#views).| |`download`|Download the displayed media.| |`frigate_ui`|Open the Frigate UI at the configured URL.| -|`fullscreen`|Toggle fullscreen.| +|`fullscreen`|Toggle fullscreen.| |`camera_select`|Select a given camera. Takes a single additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select. Respects the value of `view.camera_select` to choose the appropriate view on the new camera.| -|`menu_toggle` | Show/hide the menu (for `hidden-*` mode menus). | +|`menu_toggle` | Show/hide the menu (for the `hidden` mode style). | +|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. | + @@ -624,9 +911,9 @@ This card supports several different views: | ------------- | --------------------------------------------- | |`live` (default)| Shows the live camera view with the configured live provider.| |`snapshots`|Shows an event gallery of snapshots for this camera/zone/label.| -|`snapshot`|Shows an event viewer for the most recent snapshot for this camera/zone/label. Can also be accessed by holding down the `snapshots` menu icon.| +|`snapshot`|Shows a Media viewer for the most recent snapshot for this camera/zone/label. Can also be accessed by holding down the `snapshots` menu icon.| |`clips`|Shows an event gallery of clips for this camera/zone/label.| -|`clip`|Shows an event viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.| +|`clip`|Shows a Media viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.| |`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| ### Navigating From A Snapshot To A Clip @@ -651,7 +938,7 @@ The format for actions is the standard Home Assistant [action format](https://www.home-assistant.io/lovelace/actions/#tap-action), with the exception of differences called out below. -### Differences in actions between Frigate Card and Home Assistant +### Differences in actions between Frigate Card and Home Assistant Both the Home Assistant frontend and the Frigate card cooperate to provide action functionality. In general, the Frigate Card functionality is a superset @@ -687,7 +974,7 @@ view. | Configuration path | Views to which it refers | | - | - | | `view.actions` | All (may be overriden by the below) | -| `event_viewer.actions` | `clip`, `snapshot` | +| `media_viewer.actions` | `clip`, `snapshot` | | `event_gallery.actions` | `clips`, `snapshots` | | `live.actions` | `live` | | `image.actions` | `image` | @@ -704,18 +991,20 @@ next/previous controls, thumbnails, etc), but in some cases this is not possible (e.g. embedded WebRTC card controls) -- in these cases duplicate actions may occur with certain configurations (e.g. `tap`). -## Menu Modes +**Note:** Card-wide actions are not supported on the timeline view, nor when a +info/error message is being displayed. -This card supports several menu configurations. +## Menu Styles + +This card supports several menu styles. | Key | Description | Screenshot | | ------------- | --------------------------------------------- | - | -|`hidden-{top,bottom,right,left}` [default: `hidden-top`]| Hide the menu by default, expandable upon clicking the Frigate button. | Menu hidden | -|`overlay-{top,bottom,right,left}`| Overlay the menu over the card contents. The Frigate button shows the default view. | Menu overlaid | -|`hover-{top,bottom,right,left}`| Overlay the menu over the card contents when the mouse is over the card / touch on the card, otherwise it is not shown. The Frigate button shows the default view. | Menu overlaid | -|`above`| Render the menu above the card. The Frigate button shows the default view. | Menu above | -|`below`| Render the menu below the card. The Frigate button shows the default view. | Menu below | -|`none`| No menu is shown. | No Menu | +|`hidden`| Hide the menu by default, expandable upon clicking the Frigate button. | Menu hidden | +|`overlay`| Overlay the menu over the card contents. The Frigate button shows the default view. | Menu overlaid | +|`hover`| Overlay the menu over the card contents when the mouse is over the menu, otherwise it is not shown. The Frigate button shows the default view. | Menu overlaid | +|`outside`| Render the menu outside the card (i.e. above it if `position` is `top`, or below it if `position` is `bottom`). The Frigate button shows the default view. | Menu above | +|`none`| No menu is shown. | No Menu | @@ -726,38 +1015,1025 @@ This card supports several menu configurations. Scroll through your live cameras, or choose from a menu. Seamlessly supports cameras of different dimensions, and custom submenus per camera. -Gallery +Gallery ### Full Viewing Of Events -Gallery +Gallery ### Live Viewing With Thumbnail Carousel -Live view with event thumbnails +Live view with event thumbnails ### Clip Viewing With Thumbnail Carousel -Viewer with event thumbnails +Viewer with event thumbnails ### Hover Menu / Thumbnail Next & Previous Controls -Viewer with event thumbnails +Viewer with event thumbnails ### Card Editing This card supports full editing via the Lovelace card editor. Additional arbitrary configuration for WebRTC Card may be specified in YAML mode. -Live viewing +Live viewing ### Configurable Submenus This card supports fully configurable submenus. -Configurable submenus +Configurable submenus + +### Select Entity Submenus + +Automatically generate submenus from `select` entities. + +Select based submenus + +### Cast media from the card + +Cast media from the card to a local player. + +Cast media + +### Scan Mode + +Automatically choose the camera with the action! + +Cast media + +### Thumbnail Drawers + +View thumbnails in side-drawers. + +Thumbnail drawers + +### Event Timeline + +View events in the timeline. + +Event Timeline + +### Single Camera Recordings + +View recordings for a camera across time: + +Recording for single camera + +### Multiple Camera Recordings + +View recordings for multiple cameras at a given time: + +Recording for multiple cameras + +### Dark Mode + +Dim the card when not used. + +Card dark mode + + + +### Card Casting + +A dashboard with the card can be cast onto a suitable device (such as the Nest Hub shown below). + +Card on Nest Hub + +### Event starring + +Retain interesting Frigate events forever: + +Retain events + +### PTZ Control + +Control a PTZ camera: + +PTZ Control + +### Media Layout + +Pan around a large camera view to only show part of the video feed in the card at a different aspect ratio: + +#### Before + +Media Layout A + +#### After + +Media Layout B ## Examples +### Illustrative Expanded Configuration Reference + +**Caution**: 🚩 Just copying this full reference into your configuration will cause you a significant maintenance burden. Don't do it! Please only specify what you need as defaults can / do change continually as this card develops. Almost all the values shown here are the defaults (except in cases where is no default, parameters are added here for illustrative purposes). + + + +
+ Expand: Cameras section + +Reference: [Camera Options](#camera-options). + +```yaml +cameras: + - camera_entity: camera.front_Door + live_provider: ha + frigate: + url: http://my.frigate.local + client_id: frigate + camera_name: front_door + label: person + zone: steps + # Show events for camera-2 when this camera is viewed. + dependencies: + all_cameras: false + cameras: + - camera-2 + triggers: + motion: false + occupancy: true + entities: + - binary_sensor.front_door_sensor + - camera_entity: camera.entrance + live_provider: webrtc-card + frigate: + url: http://my-other.frigate.local + client_id: frigate-other + camera_name: entrance + label: car + zone: driveway + icon: 'mdi:car' + title: 'Front entrance' + # Custom identifier for the camera to refer to it above. + id: 'camera-2' + webrtc_card: + entity: camera.entrance_rtsp + url: 'rtsp://username:password@camera:554/av_stream/ch0' + triggers: + motion: false + occupancy: true + entities: + - binary_sensor.entrance_sensor + dependencies: + all_cameras: false +``` +
+ + + +
+ Expand: View section + +Reference: [View Options](#view-options). + +```yaml +view: + default: live + camera_select: current + timeout_seconds: 300 + update_seconds: 0 + update_force: false + update_cycle_camera: false + update_entities: + - binary_sensor.my_motion_sensor + render_entities: + - switch.render_card + dark_mode: 'off' + scan: + enabled: false + show_trigger_status: true + untrigger_reset: true + untrigger_seconds: 0 + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none +``` +
+ + + +
+ Expand: Menu section + +Reference: [Menu Options](#menu-options). + +```yaml +menu: + style: hidden + position: top + alignment: left + buttons: + frigate: + priority: 50 + enabled: true + alignment: matching + # Default icon is an internal coded Frigate icon. Note + # absence of 'mdi' here (mdi has no Frigate icon). + icon: frigate + cameras: + priority: 50 + enabled: true + alignment: matching + icon: mdi:video-switch + live: + priority: 50 + enabled: true + alignment: matching + icon: mdi:cctv + clips: + priority: 50 + enabled: true + alignment: matching + icon: mdi:filmstrip + snapshots: + priority: 50 + enabled: true + alignment: matching + icon: mdi:camera + image: + priority: 50 + enabled: false + alignment: matching + icon: mdi:image + timeline: + priority: 50 + enabled: true + alignment: matching + icon: mdi:chart-gantt + download: + priority: 50 + enabled: true + alignment: matching + icon: mdi:download + frigate_ui: + priority: 50 + enabled: true + alignment: matching + icon: mdi:web + fullscreen: + priority: 50 + enabled: true + alignment: matching + icon: mdi:fullscreen + media_player: + priority: 50 + enabled: false + alignment: matching + icon: mdi:cast + button_size: 40 +``` +
+ + + +
+ Expand: Live section + +Reference: [Live Options](#live-options). + +```yaml +live: + auto_play: all + auto_pause: never + auto_mute: all + auto_unmute: never + preload: false + lazy_load: true + lazy_unload: never + draggable: true + transition_effect: slide + webrtc_card: + # Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration . + ui: true + jsmpeg: + options: + audio: false + video: true + pauseWhenHidden: false + disableGl: false + disableWebAssembly: false + preserveDrawingBuffer: false + progressive: true + throttled: true + chunkSize: 1048576 + maxAudioLag: 10 + videoBufferSize: 524288 + audioBufferSize: 131072 + controls: + next_previous: + style: chevrons + size: 48 + thumbnails: + media: clips + size: 100 + show_details: false + show_favorite_control: true + show_timeline_control: true + mode: none + title: + mode: popup-bottom-right + duration_seconds: 2 + layout: + fit: contain + position: + x: 50 + y: 50 + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none + +``` +
+ + + +
+ Expand: Media Viewer section + +Reference: [Media Viewer Options](#event-viewer-options). + +```yaml +media_viewer: + auto_play: all + auto_pause: all + auto_mute: all + auto_unmute: never + lazy_load: true + draggable: true + transition_effect: slide + controls: + next_previous: + size: 48 + style: thumbnails + thumbnails: + size: 100 + mode: none + show_details: false + show_favorite_control: true + show_timeline_control: true + title: + mode: popup-bottom-right + duration_seconds: 2 + layout: + fit: contain + position: + x: 50 + y: 50 + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none +``` +
+ + + +
+ Expand: Event Gallery section + +Reference: [Event Gallery Options](#event-gallery-options). + +```yaml +event_gallery: + controls: + thumbnails: + size: 100 + show_details: false + show_favorite_control: true + show_timeline_control: true + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none +``` +
+ + + +
+ Expand: Image section + +Reference: [Image Options](#image-options). + +```yaml +image: + mode: url + refresh_seconds: 0 + layout: + fit: contain + position: + x: 50 + y: 50 + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none +``` +
+ + + +
+ Expand: Elements section (stock Home Assistant elements) + +Reference: [Home Assistant Picture Elements](https://www.home-assistant.io/dashboards/picture-elements/) + +Actions are omitted for simplicity, see the next section for action examples. + +```yaml +elements: + - type: state-badge + entity: sensor.kitchen_dining_multisensor_air_temperature + style: + left: 100px + top: 50px + title: "Temperature" + - type: state-icon + entity: light.office_main_lights + icon: mdi:lamp + state_color: true + style: + left: 100px + top: 100px + - type: state-label + entity: sensor.kitchen_motion_sensor_battery + attribute: battery_voltage + prefix: Volts + title: Battery Voltage + style: + left: 100px + top: 150px + - type: state-label + entity: sensor.kitchen_motion_sensor_battery + attribute: battery_voltage + prefix: 'Volts: ' + title: Battery Voltage + style: + background-color: black + left: 100px + top: 200px + - type: service-button + title: Light on + service: homeassistant.turn_on + service_data: + entity: light.office_main_lights + style: + left: 100px + top: 250px + - type: icon + icon: mdi:cow + title: Moo + style: + left: 100px + top: 300px + - type: image + entity: light.office_main_lights + title: Image + state_image: + on: "https://picsum.photos/id/1003/1181/1772" + off: "https://picsum.photos/id/102/4320/3240" + state_filter: + "on": brightness(110%) saturate(1.2) + "off": brightness(50%) hue-rotate(45deg) + style: + left: 100px + top: 350px + height: 50px + width: 100px + - type: conditional + conditions: + - entity: light.office_main_lights + state: on + state_not: off + elements: + - type: icon + icon: mdi:dog + title: Woof + style: + left: 100px + top: 400px +``` +
+ +
+ Expand: Elements section (stock Home Assistant actions) + +Reference: [Home Assistant Actions](https://www.home-assistant.io/dashboards/actions/), [Frigate Card Actions](#actions). + +```yaml +elements: + - type: icon + icon: mdi:numeric-1-box + title: More info action + style: + left: 200px + top: 50px + entity: light.office_main_lights + tap_action: + action: more-info + - type: icon + icon: mdi:numeric-2-box + title: Toggle action + style: + left: 200px + top: 100px + entity: light.office_main_lights + tap_action: + action: toggle + - type: icon + icon: mdi:numeric-3-box + title: Call Service action + style: + left: 200px + top: 150px + tap_action: + action: call-service + service: homeassistant.toggle + service_data: + entity_id: light.office_main_lights + - type: icon + icon: mdi:numeric-4-box + title: Navigate action + style: + left: 200px + top: 200px + tap_action: + action: navigate + navigation_path: /lovelace/2 + - type: icon + icon: mdi:numeric-5-box + title: URL action + style: + left: 200px + top: 250px + tap_action: + action: url + url_path: https://www.home-assistant.io/ + - type: icon + icon: mdi:numeric-6-box + title: None action + style: + left: 200px + top: 300px + tap_action: + action: none + - type: icon + icon: mdi:numeric-7-box + title: Custom action + style: + left: 200px + top: 350px + tap_action: + action: fire-dom-event + key: value +``` +
+ +
+ Expand: Elements section (custom elements) + +Reference: [Custom Frigate Card Elements](#special-elements) + +```yaml +elements: + - type: custom:frigate-card-menu-icon + icon: mdi:car + title: Vroom + - type: custom:frigate-card-menu-state-icon + entity: light.office_main_lights + title: Office lights + icon: mdi:chair-rolling + state_color: true + - type: custom:frigate-card-menu-submenu + icon: mdi:menu + items: + - title: Lights + icon: mdi:lightbulb + entity: light.office_main_lights + tap_action: + action: toggle + - title: Google + icon: mdi:google + enabled: false + tap_action: + action: url + url_path: https://www.google.com + - type: custom:frigate-card-menu-submenu-select + icon: mdi:lamps + entity: input_select.kitchen_scene + options: + scene.kitchen_cooking_scene: + icon: mdi:chef-hat + title: Cooking time! + scene.kitchen_tv_scene: + icon: mdi:television + title: TV! + # Show a pig icon if the card is in the live view, in fullscreen mode, light.office_main_lights is on and the media has been loaded. + - type: custom:frigate-card-conditional + elements: + - type: icon + icon: mdi:pig + title: Oink + style: + left: 300px + top: 100px + conditions: + view: + - live + fullscreen: true + camera: + - camera.front_door + state: + - entity: light.office_main_lights + state: on + state_not: off + mediaLoaded: true + # Full form PTZ actions (only left button shown). + - type: custom:frigate-card-ptz + orientation: vertical + style: + transform: none + right: 5% + top: 50% + actions_left: + tap_action: + action: call-service + service: sonoff.send_command + service_data: + device: '048123' + cmd: left + # Equivalent short form PTZ actions (only left button shown) + - type: custom:frigate-card-ptz + orientation: vertical + style: + transform: none + right: 20px + top: 180px + service: sonoff.send_command + data_left: + device: '048123' + cmd: left +``` +
+ +
+ Expand: Elements section (custom actions) + +Reference: [Custom Frigate Card Actions](#special-actions) + +```yaml +elements: + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-a-circle + title: Show default view + tap_action: + action: custom:frigate-card-action + frigate_card_action: default + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-b-circle + title: Show most recent clip + tap_action: + action: custom:frigate-card-action + frigate_card_action: clip + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-c-circle + title: Show clips + tap_action: + action: custom:frigate-card-action + frigate_card_action: clips + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-d-circle + title: Show image view + tap_action: + action: custom:frigate-card-action + frigate_card_action: image + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-e-circle + title: Show live view + tap_action: + action: custom:frigate-card-action + frigate_card_action: live + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-f-circle + title: Show most recent snapshot + tap_action: + action: custom:frigate-card-action + frigate_card_action: snapshot + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-g-circle + title: Show snapshots + tap_action: + action: custom:frigate-card-action + frigate_card_action: snapshots + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-h-circle + title: Download media + tap_action: + action: custom:frigate-card-action + frigate_card_action: download + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-i-circle + title: Open Frigate UI + tap_action: + action: custom:frigate-card-action + frigate_card_action: frigate_ui + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-j-circle + title: Change to fullscreen + tap_action: + action: custom:frigate-card-action + frigate_card_action: fullscreen + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-k-circle + title: Toggle hidden menu + tap_action: + action: custom:frigate-card-action + frigate_card_action: menu_toggle + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-l-circle + title: Select Front Door + tap_action: + action: custom:frigate-card-action + frigate_card_action: camera_select + camera: camera.front_door + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-m-circle + title: Media player play + tap_action: + action: custom:frigate-card-action + frigate_card_action: media_player + media_player: media_player.nesthub50be + media_player_action: play + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-n-circle + title: Media player stop + tap_action: + action: custom:frigate-card-action + frigate_card_action: media_player + media_player: media_player.nesthub + media_player_action: stop +``` +
+ + + +
+ Expand: Dimensions section + +Reference: [Dimension Options](#dimensions-options). + +```yaml +dimensions: + aspect_ratio_mode: dynamic + aspect_ratio: 16:9 +``` +
+ + + +
+ Expand: Timeline section + +Reference: [Timeline Options](#timeline-options). + +```yaml +timeline: + clustering_threshold: 3 + media: all + show_recordings: true + window_seconds: 3600 + controls: + thumbnails: + mode: left + size: 100 + show_details: true + show_favorite_control: true + show_timeline_control: true +``` +
+ + + +
+ Expand: Overrides section + +Reference: [Override Options](#overrides-options). + +Overrides allow overriding certain (many) configuration parameters when a given +condition is met. The below is a fully expanded set of those overridable +parameters. This is really just repeating the above expansions of the relevant +sections, rather than indicating new or different parameters, i.e. this +repetition is included for illustrative purposes of what is overridable. + +```yaml +overrides: + - conditions: + view: + - live + fullscreen: true + camera: + - camera.front_door + state: + - entity: light.office_main_lights + state: on + state_not: off + overrides: + live: + webrtc_card: + ui: true + jsmpeg: + options: + audio: false + video: true + pauseWhenHidden: false + disableGl: false + disableWebAssembly: false + preserveDrawingBuffer: false + progressive: true + throttled: true + chunkSize: 1048576 + maxAudioLag: 10 + videoBufferSize: 524288 + audioBufferSize: 131072 + controls: + next_previous: + style: chevrons + size: 48 + thumbnails: + media: clips + size: 100 + show_details: false + show_favorite_control: true + show_timeline_control: true + mode: none + title: + mode: popup-bottom-right + duration_seconds: 2 + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none + menu: + style: hidden + position: top + alignment: left + buttons: + frigate: + priority: 50 + enabled: true + alignment: matching + # Default icon is an internal coded Frigate icon. Note + # absence of 'mdi' here (mdi has no Frigate icon). + icon: frigate + cameras: + priority: 50 + enabled: true + alignment: matching + icon: mdi:video-switch + live: + priority: 50 + enabled: true + alignment: matching + icon: mdi:cctv + clips: + priority: 50 + enabled: true + alignment: matching + icon: mdi:filmstrip + snapshots: + priority: 50 + enabled: true + alignment: matching + icon: mdi:camera + image: + priority: 50 + # Disable the image button. + enabled: false + alignment: matching + icon: mdi:image + timeline: + # Ensure the timeline button is ordered earlier. + priority: 100 + enabled: true + alignment: matching + icon: mdi:chart-gantt + download: + priority: 50 + enabled: true + alignment: matching + icon: mdi:download + frigate_ui: + priority: 50 + enabled: true + alignment: matching + icon: mdi:web + fullscreen: + priority: 50 + enabled: true + alignment: matching + icon: mdi:fullscreen + media_player: + priority: 50 + enabled: false + alignment: matching + icon: mdi:cast + button_size: 40 + image: + mode: url + refresh_seconds: 0 + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: none + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none + view: + default: live + camera_select: current + timeout_seconds: 300 + update_seconds: 0 + update_force: false + update_cycle_camera: false + update_entities: + - binary_sensor.my_motion_sensor + render_entities: + - switch.render_card + dark_mode: 'off' + actions: + entity: light.office_main_lights + tap_action: + action: none + hold_action: + action: toggle + double_tap_action: + action: none + start_tap_action: + action: none + end_tap_action: + action: none +``` +
+ ### Basic cameras configuration
@@ -892,7 +2168,7 @@ elements: opacity: 0.5 ``` -Picture elements temperature example +Picture elements temperature example
### Adding State Badges @@ -924,12 +2200,12 @@ elements: ### Restricting Icons To Certain Views You can restrict icons to only show for certain [views](#views) using a -`custom:frigate-card-conditional` element (e.g. PTZ controls) +`custom:frigate-card-conditional` element
- Expand: View-based conditions (e.g. PTZ controls) + Expand: View-based conditions -This example shows PTZ icons that call a PTZ service, but only in the `live` view. +This example shows a car icon that calls a service but only in the `live` view. ```yaml [...] @@ -940,7 +2216,7 @@ elements: - live elements: - type: icon - icon: mdi:arrow-up + icon: mdi:car style: background: rgba(255, 255, 255, 0.25) border-radius: 5px @@ -952,45 +2228,6 @@ elements: service_data: entity_id: camera.kitchen movement: up - - type: icon - icon: mdi:arrow-down - style: - background: rgba(255, 255, 255, 0.25) - border-radius: 5px - right: 25px - bottom: 0px - tap_action: - action: call-service - service: amcrest.ptz_control - service_data: - entity_id: camera.kitchen - movement: down - - type: icon - icon: mdi:arrow-left - style: - background: rgba(255, 255, 255, 0.25) - border-radius: 5px - right: 50px - bottom: 25px - tap_action: - action: call-service - service: amcrest.ptz_control - service_data: - entity_id: camera.kitchen - movement: left - - type: icon - icon: mdi:arrow-right - style: - background: rgba(255, 255, 255, 0.25) - border-radius: 5px - right: 0px - bottom: 25px - tap_action: - action: call-service - service: amcrest.ptz_control - service_data: - entity_id: camera.kitchen - movement: right ```
@@ -1124,6 +2361,53 @@ elements: + + +### Configuring a `select` submenu + +You can easily add a submenu to the menu based on a `select` or `input_select` entity. + +
+ Expand: Adding a select submenu + +This example imagines the user has an `input_select` entity configured in their Home Assistant configuration like so: + +```yaml +input_select: + kitchen_scene: + name: Kitchen Scene Select + options: + - scene.kitchen_cooking_scene + - scene.kitchen_tv_scene + icon: mdi:lightbulb +``` + +The following will convert this entity into a submenu: + +```yaml +[...] +elements: + - type: custom:frigate-card-menu-submenu-select + entity: input_select.kitchen_scene +``` + +To override 1 or more individual options (e.g. to set custom icons and titles) + +```yaml +[...] +elements: + - type: custom:frigate-card-menu-submenu-select + icon: mdi:lamps + entity: input_select.kitchen_scene + options: + scene.kitchen_cooking_scene: + icon: mdi:chef-hat + title: Cooking time! + scene.kitchen_tv_scene: + icon: mdi:television + title: TV! +``` +
### Overriding card behavior @@ -1146,7 +2430,7 @@ overrides: fullscreen: true overrides: menu: - mode: none + style: none ``` @@ -1174,6 +2458,56 @@ overrides: + + +
+ Expand: Change the menu position based on HA state + +```yaml +overrides: + - conditions: + state: + - entity: light.office_lights + state: 'on' + overrides: + menu: + position: bottom +``` +
+ + + + +
+ Expand: Change the default view based on HA state + +This example changes the default card view from `live` to `image` depending on +the value of the `binary_sensor.alarm_armed` sensor. Note that the override +alone will only change the _default_ when the card next is requested to change +to the default view. By also including the `update_entities` parameter, we ask +the card to trigger a card update based on that entity -- which causes it to use +the new overriden default immediately. Alternatives to trigger the card to +change view but without `update_entities` would just be having an +`update_seconds` parameter which reloads the default view that many seconds +after user interaction stops or through the use of the `triggers` option for a +given camera (see [Trigger Configuration](#camera-triggers-configuration)). + +```yaml +view: + default: live + update_entities: + - binary_sensor.alarm_armed +overrides: + - conditions: + state: + - entity: binary_sensor.alarm_armed + state: 'off' + overrides: + view: + default: image +``` +
+ ### Refreshing a static image
@@ -1214,6 +2548,47 @@ elements: ```
+### Using a PTZ picture element + +The card supports a custom PTZ element (`custom:frigate-card-ptz`) to conveniently control pan, tilt and zoom for cameras. + +
+ Expand: Using the native PTZ picture element + +This example shows the native PTZ element when the `live` or `image` view is displayed and the stream (media) has loaded. + +```yaml +[...] +elements: + - type: custom:frigate-card-conditional + conditions: + mediaLoaded: true + view: + - live + - image + elements: + - type: custom:frigate-card-ptz + orientation: horizontal + style: + transform: none + right: 20px + top: 180px + service: sonoff.send_command + data_left: + device: '048123' + cmd: left + data_right: + device: '048123' + cmd: right + data_up: + device: '048123' + cmd: up + data_down: + device: '048123' + cmd: down +``` +
+ ### Using `card-mod` to style the card @@ -1245,6 +2620,179 @@ card_mod: ``` +### Overriding default menu behavior + +
+ Expand: Overriding default menu behavior + +This example moves the fullscreen button into its own group aligned to the `left`, enables the `image` button and orders it furthest to the `right`. + +```yaml +[...] +menu: + alignment: right + buttons: + image: + enabled: true + priority: 100 + fullscreen: + alignment: opposing +``` +
+ +### Using a dependent camera + +`dependencies.cameras` allows events for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own). + +
+ Expand: Using dependent cameras with birdseye + +This example shows events for two other cameras when `birdseye` is selected. + +```yaml +[...] +cameras: + - camera_entity: camera.kitchen + - camera_entity: camera.sitting_room + - camera_name: birdseye + dependencies: + cameras: + - camera.kitchen + - camera.sitting_room +``` +
+ +
+ Expand: Using all dependent cameras with birdseye + +This example shows events for all other cameras when `birdseye` is selected. This is just a shortcut for naming all other cameras. + +```yaml +[...] +cameras: + - camera_entity: camera.kitchen + - camera_entity: camera.sitting_room + - frigate: + camera_name: birdseye + dependencies: + all_cameras: true +``` +
+ +### Using Scan Mode + +Have your card follow the action with Scan Mode. + +
+ Expand: Using scan mode + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + # This camera will automatically trigger by occupancy. + - camera_entity: camera.front_door + triggers: + occupancy: false + motion: true + entities: + - binary_sensor.door_opened +view: + scan: + enabled: true + trigger_show_border: true +``` +
+ + + +### Changing the Media Layout + +Change how the media fits and is positioned within the card dimensions. + +
+ Expand: Stretching an image + +Stretch the stock Frigate card image into a 4:4 square. + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.landing +view: + default: image +dimensions: + aspect_ratio_mode: static + aspect_ratio: '4:4' +image: + layout: + fit: fill +``` +
+ +
+ Expand: Convert a landscape camera to a portrait live view + +Take the left-hand side (position with x == `0` and use that as the basis of a `9:16` (i.e. portrait) live view. + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.landing +dimensions: + aspect_ratio_mode: static + aspect_ratio: '9:16' +live: + layout: + fit: cover + position: + x: 0 +``` +
+ +
+ Expand: Complex example to vary the media layouts for different cameras + +Configure three cameras (all natively landscape): the first uses automatic expansion of dimensions (the default), the second uses a portrait (`9:16`) card focused on the left side (`x` == `0`) of the media, the third uses a portrait (`9:16`) card focused on the right side (`x` == `100`) of the media. + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.landing + - camera_entity: camera.living_room + - camera_entity: camera.sitting_room +overrides: + - conditions: + camera: + - camera.living_room + - camera.sitting_room + overrides: + dimensions: + aspect_ratio_mode: static + aspect_ratio: '9:16' + live: + layout: + fit: cover + - conditions: + camera: + - camera.living_room + overrides: + live: + layout: + position: + x: 0 + - conditions: + camera: + - camera.sitting_room + overrides: + live: + layout: + position: + x: 100 +``` +
+ + ## Card Refreshes @@ -1256,7 +2804,11 @@ The following table describes the behavior these flags have. ### Card Update Truth Table -| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | `view . update_entities` | Behavior | +Note that no (other) automated updates are permitted when [scan mode](#scan-mode) is being triggered. + +In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `triggers.entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `triggers.occupancy` and `triggers.motion` options are enabled, see [Trigger Configuration](#camera-triggers-configuration)). + +| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | Trigger Entities | Behavior | | :-: | :-: | :-: | :-: | - | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | | `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. | @@ -1280,13 +2832,12 @@ view: ``` * Using `clip` or `snapshot` as the default view (for the most recent clip or snapshot respectively) and having the card automatically refresh (to fetch a - newer clip/snapshot) when an entity state changes. Use the Frigate - binary_sensor for that camera (or any other entity at your discretion) to - trigger the update: + newer clip/snapshot) on motion. ```yaml -view: - update_entities: - - binary_sensor.office_person_motion +cameras: + - entity: camera.office + triggers: + motion: true ``` * Cycle the live view of the camera every 60 seconds ```yaml @@ -1302,8 +2853,57 @@ view: timeout_seconds: 30 ``` +### Casting the Card + +This card can be (Chrome) casted to a device (such as a [Nest Hub](https://store.google.com/us/product/nest_hub_2nd_gen)) through the use of [Home Assistant Cast](https://cast.home-assistant.io/). + +#### Instructions to Cast + +* Visit [Home Assistant Cast](https://cast.home-assistant.io/) and click `Start Casting` +* Enter your Home Assistant URL, and authorize your account. +* Click `Start Casting` and choose the device to cast to from the browser menu. +* Choose which view/dashboard to display. +* If successful, the view will be cast to the device. + +#### Limitations + +Casting Home Assistant dashboards comes with a number of caveats: + +* Home Assistant Casting does not support the HA `streaming` component ([source](https://cast.home-assistant.io/faq.html)). This means clips playing and the `ha` live provider can not work. Other live providers such as `jsmpeg` and `webrtc-card` function correctly. +* The Javascript fullscreen API does not work (so the fullscreen button does not work, but see below for an equivalent). + +#### Recommended configuration for a Nest Hub + +Using a `panel` dashboard with the following base configuration will result in the card consuming the entire device screen: + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.front_door + live_provider: frigate-jsmpeg +dimensions: + aspect_ratio: 1024:600 + aspect_ratio_mode: static +``` + +See [screenshot above](#screenshots-card-casting). + ## Troubleshooting + + +### v4 doesn't show recordings / clips + +You must be using a version of the [Frigate integration](https://github.com/blakeblackshear/frigate-hass-integration) >= 3.0.0-rc.2 to see recordings. Using an older version of the integration may also show blank thumbnails in the events viewer. Please upgrade your integration accordingly. + +### Chrome autoplays when a tab becomes visible again + +Even if `live.auto_play` or `media_viewer.auto_play` is set to `never`, Chrome itself will still auto play a video that was previously playing prior to the tab being hidden, once that tab is visible again. This behavior cannot be influenced by the card. Other browsers (e.g. Firefox, Safari) do not exhibit this behavior. + +### The `live` view just shows a blank white image during loading + +For some slowly loading cameras, for which [Home Assistant stream preloading](https://www.home-assistant.io/integrations/camera/) is not enabled, Home Assistant may return a blank white image when asked for a still. These stills are used during initial Frigate card load of the `live` view if the `live.show_image_during_load` option is enabled. Disabling this option should show the default media loading controls (e.g. a spinner or empty video player) instead of the blank white image. + ### JSMPEG Live Camera Only Shows A 'spinner' @@ -1312,6 +2912,14 @@ You must be using a version of the [Frigate integration](https://github.com/blak to use JSMPEG proxying. The `frigate-jsmpeg` live provider will not work with earlier integration versions. + +### Timeline shows error message + +If the timeline shows a message such as `Failed to receive response from Home +Assistant for request.` make sure you are running a version of the [Frigate +integration](https://github.com/blakeblackshear/frigate-hass-integration) >= +3.0.0-rc.1. + ### Fullscreen Button Does Not Appear On iPhone Unfortunately, [iOS does not support the Javascript fullscreen @@ -1355,10 +2963,20 @@ Dragging the Safari video controls "progress bar" conflicts with carousel "dragg ```yaml live: draggable: false -event_viewer: +media_viewer: draggable: false ``` +### `webrtc_card` unloads in the background + +[AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) which is embedded by the `webrtc_card` live provider internally disconnects the stream when the browser tab is changed (regardless of any Frigate card configuration settings, e.g. `lazy_unload`). To allow the stream to continue running in the background, pass the `background` argument to the `webrtc_card` live provider as shown below. This effectively allows the Frigate card to decide whether or not to unload the stream. + +```yaml +live: + webrtc_card: + background: true +``` + ### `double_tap` does not work for [card-wide actions](#actions) on Android The Android video player swallows `double_tap` interactions in order to @@ -1368,34 +2986,22 @@ rewind or fast-forward. Workarounds: * Use a [Frigate Card Element](#frigate-card-elements) or menu icon to trigger the action instead. -### Console shows 'Offset is out of bounds' / 'Out of bounds memory access' - -This is an issue with the JSMPEG live provider and cameras with high-resolutions -/ high-bitrates. [This bug](https://github.com/dermotduffy/frigate-hass-card/issues/189) has much -more discussion on this topic. Some users report success in disabling the WASM-option for JSMPEG like so: - -```yaml -live: - jsmpeg: - options: - disableWebAssembly: true -``` - -Lowering the camera bitrate, and/or increasing the `videoBufferSize` for JSMPEG may also help: - -```yaml -live: - jsmpeg: - options: - videoBufferSize: 41943040 -``` - ### Home Assistant iOS App not updating after card version change Try resetting the app frontend cache: * `Configuration -> Companion App -> Debugging -> Reset frontend cache` +### Casting to a remote media player does not work + +This could be for any number of reasons. Chromecast devices can be quite picky on network, DNS and certificate issues, as well as audio and video codecs. Check your Home Assistant log as there may be more information in there. + +**NOTE**: In particular, for Frigate to support casting of clips, the default ffmpeg settings for Frigate must be modified, i.e. Frigate does not encode clips in a Chromecast compatible format out of the box (specifically: audio must be enabled in the AAC codec, whether your camera supports audio or not). See the [Frigate Home Assistant documentation](https://docs.frigate.video/integrations/home-assistant) or [this issue](https://github.com/blakeblackshear/frigate/issues/3175) for more. + +### Javascript console shows `[Violation] Added non-passive event listener to a scroll-blocking [...] event` + +This card heavily uses [Embla Carousel](https://www.embla-carousel.com/) -- a light-weight performant carousel library -- to show media. This carousel library uses non-passive event-listeners in a considered and performant way, but one that still causes occasional and unhelpful Chrome warnings. These warnings can be safely ignored in this instance, and cannot easily be fixed in the underlying library as it heavily relies on non-passive event listeners ([see this bug comment for explanation](https://github.com/davidjerleke/embla-carousel/issues/62#issuecomment-628569509)). + ## Development ### Building @@ -1403,17 +3009,47 @@ Try resetting the app frontend cache: ```sh $ git clone https://github.com/dermotduffy/frigate-hass-card $ cd frigate-hass-card -$ npm install -$ npm run build +$ yarn install +$ yarn run build ``` Resultant build will be at `dist/frigate-hass-card.js`. This could be installed via the [manual installation instructions above](#manual-installation). +### Dev Container + +[![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode&style=flat-square)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/dermotduffy/frigate-hass-card) + +You can use the [VS Code Remote - Containers](https://code.visualstudio.com/docs/remote/containers) extension to speed up the development environment creation. Simply: + +1. Clone the repository to your machine +1. Open VS Code on it +1. Reopen the folder in the Dev Container +1. Once done, press `F5` to start debugging + +Everything should just work without any additional configuration. Under the hood, the dev container setup takes care of bringing up: + +* Home Assistant (port `48123:8123`) +* Frigate (ports `45000:5000`, `41935:1935`) +* MQTT (port `41883:1883`) + +As docker-compose containers. + +* The Frigate Home Assistant Integration is registered as a `git submodule` at `.devcontainer/frigate-hass-integration`, and VS Code will initialize/clone it for you before opening the dev container. + +Some environment variables are supported in a `.env` file: + +* `FRIGATE_VERSION`: The version of Frigate to use. Defaults to the latest stable version. +* `HA_VERSION`: The version of Home Assistant to use. Defaults to the latest stable version. + +> **NOTE**: When not specifying any version, it's recommended that you `docker-compose pull` the stack from time to time to ensure you have the latest versions of the images. + +The Home Assistant container will get preconfigured during first initialization, therefore, if you changed the Home Assistant configuration, you will need to remove the HA container and start another. + ### Releasing - 1. Merge a PR that contains only a `package.json` and `const.ts` version number bump (see [this example](https://github.com/dermotduffy/frigate-hass-card/commit/a854187d4a354f8841ad284d75b0afbed7b634c4)). - 1. Go to the [releases page](https://github.com/dermotduffy/frigate-hass-card/releases). - 1. A release draft will automatically have been created, click 'Edit'. - 1. Use the same version number for the release title and tag. - 1. Choose 'This is a pre-release' for a beta version. - 1. Hit 'Publish release'. +1. Merge a PR that contains only a `package.json` and `const.ts` version number bump (see [this example](https://github.com/dermotduffy/frigate-hass-card/commit/a854187d4a354f8841ad284d75b0afbed7b634c4)). +1. Go to the [releases page](https://github.com/dermotduffy/frigate-hass-card/releases). +1. A release draft will automatically have been created, click 'Edit'. +1. Use the same version number for the release title and tag. +1. Choose 'This is a pre-release' for a beta version. +1. Hit 'Publish release'. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..3940d253 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +--- +version: '3' +services: + dev: + user: node + init: true + build: .devcontainer + env_file: + - .env + volumes: + - /var/run/docker.sock:/var/run/docker-host.sock + - .:${LOCAL_WORKSPACE_FOLDER}:cached + hass: + extends: + file: .devcontainer/frigate-hass-integration/docker-compose.yml + service: hass + volumes: + - .devcontainer/preconfig:/preconfig.d/02-card:ro + frigate: + extends: + file: .devcontainer/frigate-hass-integration/docker-compose.yml + service: frigate + mqtt: + extends: + file: .devcontainer/frigate-hass-integration/docker-compose.yml + service: mqtt diff --git a/images/card-on-nest-hub.jpg b/images/card-on-nest-hub.jpg new file mode 100644 index 00000000..7a911edb Binary files /dev/null and b/images/card-on-nest-hub.jpg differ diff --git a/images/cast-your-events.gif b/images/cast-your-events.gif new file mode 100644 index 00000000..5db40e96 Binary files /dev/null and b/images/cast-your-events.gif differ diff --git a/images/dark-mode.gif b/images/dark-mode.gif new file mode 100644 index 00000000..cf21c4f8 Binary files /dev/null and b/images/dark-mode.gif differ diff --git a/images/media-layout-a.png b/images/media-layout-a.png new file mode 100644 index 00000000..b9a049a0 Binary files /dev/null and b/images/media-layout-a.png differ diff --git a/images/media-layout-b.png b/images/media-layout-b.png new file mode 100644 index 00000000..1e21e1d8 Binary files /dev/null and b/images/media-layout-b.png differ diff --git a/images/native-ptz.gif b/images/native-ptz.gif new file mode 100644 index 00000000..4a0c9d35 Binary files /dev/null and b/images/native-ptz.gif differ diff --git a/images/recording-seek-all-cameras.gif b/images/recording-seek-all-cameras.gif new file mode 100644 index 00000000..4878c5a8 Binary files /dev/null and b/images/recording-seek-all-cameras.gif differ diff --git a/images/recording-seek.gif b/images/recording-seek.gif new file mode 100644 index 00000000..26573faf Binary files /dev/null and b/images/recording-seek.gif differ diff --git a/images/scan-mode.gif b/images/scan-mode.gif new file mode 100644 index 00000000..ba6e2182 Binary files /dev/null and b/images/scan-mode.gif differ diff --git a/images/star.gif b/images/star.gif new file mode 100644 index 00000000..4d04777f Binary files /dev/null and b/images/star.gif differ diff --git a/images/submenu-select.gif b/images/submenu-select.gif new file mode 100644 index 00000000..b17c460e Binary files /dev/null and b/images/submenu-select.gif differ diff --git a/images/thumbnails-in-drawer.gif b/images/thumbnails-in-drawer.gif new file mode 100644 index 00000000..2c0122a3 Binary files /dev/null and b/images/thumbnails-in-drawer.gif differ diff --git a/images/timeline.gif b/images/timeline.gif new file mode 100644 index 00000000..cae668d8 Binary files /dev/null and b/images/timeline.gif differ diff --git a/package.json b/package.json index 6f8cf9ee..0cbdd131 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frigate-hass-card", - "version": "3.0.0", + "version": "4.0.0", "description": "Frigate Lovelace Card for Home Assistant", "keywords": [ "frigate", @@ -15,51 +15,64 @@ "author": "Dermot Duffy ", "license": "MIT", "dependencies": { - "@cycjimmy/jsmpeg-player": "^5.1.1", - "@lit-labs/task": "^1.0.0", - "@material/image-list": "^13.0.0", - "@material/mwc-menu": "^0.25.3", - "@material/rtl": "^13.0.0", - "custom-card-helpers": "^1.8.0", - "dayjs": "^1.11.0", - "embla-carousel": "^6.1.1", - "home-assistant-js-websocket": "^6.1.1", - "lit": "^2.2.1", + "@cycjimmy/jsmpeg-player": "^6.0.4", + "@egjs/hammerjs": "^2.0.17", + "@lit-labs/task": "^1.1.3", + "@types/bluebird": "^3.5.36", + "component-emitter": "^1.3.0", + "crypto": "^1.0.1", + "custom-card-helpers": "^1.9.0", + "date-fns": "^2.29.2", + "embla-carousel": "^7.0.2", + "embla-carousel-wheel-gestures": "^3.0.0", + "home-assistant-js-websocket": "^8.0.0", + "keycharm": "^0.4.0", + "lit": "^2.3.1", "lodash-es": "^4.17.21", + "moment": "^2.29.4", + "propagating-hammerjs": "^2.0.1", "quick-lru": "^6.1.0", - "screenfull": "^6.0.1", - "zod": "^3.13.4" + "screenfull": "^6.0.2", + "side-drawer": "^3.1.0", + "ts-toolbelt": "^9.6.0", + "uuid": "^8.3.2", + "vis-data": "^7.1.3", + "vis-timeline": "^7.7.0", + "vis-util": "^5.0.2", + "xss": "^1.0.14", + "zod": "^3.19.0" }, "devDependencies": { - "@babel/core": "^7.17.7", - "@babel/plugin-proposal-class-properties": "^7.14.5", - "@babel/plugin-proposal-decorators": "^7.15.8", + "@babel/core": "^7.19.0", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-decorators": "^7.19.0", "@rollup/plugin-babel": "^5.3.1", - "@rollup/plugin-commonjs": "^21.0.2", + "@rollup/plugin-commonjs": "^22.0.2", "@rollup/plugin-image": "^2.1.1", "@rollup/plugin-json": "^4.1.0", - "@rollup/plugin-node-resolve": "^13.1.3", + "@rollup/plugin-node-resolve": "^13.3.0", + "@rollup/plugin-replace": "^4.0.0", "@types/lodash-es": "^4.17.5", - "@typescript-eslint/eslint-plugin": "^5.15.0", - "@typescript-eslint/parser": "^5.15.0", - "eslint": "^8.11.0", + "@typescript-eslint/eslint-plugin": "^5.36.2", + "@typescript-eslint/parser": "^5.36.2", + "eslint": "^8.23.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", - "eslint-plugin-import": "^2.24.2", - "eslint-plugin-prettier": "^4.0.0", - "npm-check-updates": "^12.5.3", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-prettier": "^4.2.1", "prettier": "^2.6.0", - "rollup": "^2.70.1", - "rollup-plugin-serve": "^1.1.0", + "rollup": "^2.79.0", + "rollup-plugin-git-info": "^1.0.0", + "rollup-plugin-serve": "^2.0.1", "rollup-plugin-styles": "^4.0.0", "rollup-plugin-terser": "^7.0.2", - "rollup-plugin-typescript2": "^0.31.2", - "sass": "^1.49.9", - "typescript": "^4.6.2" + "rollup-plugin-typescript2": "^0.33.0", + "sass": "^1.54.9", + "typescript": "^4.8.3" }, "scripts": { - "start": "rollup -c rollup.config.dev.js --watch", - "build": "npm run lint && npm run rollup", + "start": "rollup -c --watch", + "build": "yarn run lint && yarn run rollup", "lint": "eslint 'src/**/*.ts'", "rollup": "rollup -c" } diff --git a/rollup.config.js b/rollup.config.js index 8bb3fc68..98cf2331 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,24 +2,35 @@ import typescript from 'rollup-plugin-typescript2'; import commonjs from '@rollup/plugin-commonjs'; import nodeResolve from '@rollup/plugin-node-resolve'; import babel from '@rollup/plugin-babel'; +import { terser } from 'rollup-plugin-terser'; import serve from 'rollup-plugin-serve'; import json from '@rollup/plugin-json'; 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'; -const dev = process.env.ROLLUP_WATCH; +const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; +const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1'; +/** + * @type {import('rollup-plugin-serve').ServeOptions} + */ const serveopts = { contentBase: ['./dist'], host: '0.0.0.0', - port: 5000, + port: 10001, allowCrossOrigin: true, headers: { 'Access-Control-Allow-Origin': '*', }, }; +/** + * @type {import('rollup').RollupOptions['plugins']} + */ const plugins = [ + gitInfo({ enableBuildDate: true, updateVersion: false }), styles({ modules: false, // Behavior of inject mode, without actually injecting style @@ -30,27 +41,47 @@ const plugins = [ }, }), image(), - nodeResolve({}), + nodeResolve({ + browser: true, + }), commonjs({ - include: 'node_modules/**' + include: 'node_modules/**', }), typescript(), - json(), + json({ exclude: 'package.json' }), babel({ babelHelpers: 'bundled', exclude: 'node_modules/**', }), - dev && serve(serveopts), - //!dev && terser(), + replace({ + preventAssignment: true, + values: { + 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'), + }, + }), + watch && serve(serveopts), + !dev && terser(), ]; -export default [ - { - input: 'src/card.ts', - output: { - file: 'dist/frigate-hass-card.js', - format: 'es', - }, - plugins: [...plugins], +/** + * @type {import('rollup').RollupOptions} + */ +const config = { + input: 'src/card.ts', + output: { + file: 'dist/frigate-hass-card.js', + format: 'es', + ...(dev && { + sourcemap: true, + }), }, -]; + plugins: plugins, + // These two files use this at the toplevel, which causes rollup warning + // spam on build: `this` has been rewritten to `undefined` + moduleContext: { + './node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window', + './node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window', + }, +}; + +export default config; diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index a79a8fc9..b1e52948 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -1,3 +1,8 @@ +import { fireEvent } from 'custom-card-helpers'; +import type { + ActionHandlerDetail, + ActionHandlerOptions, +} from 'custom-card-helpers/dist/types.d.js'; import { noChange } from 'lit'; import { AttributePart, @@ -5,13 +10,7 @@ import { Directive, DirectiveParameters, } from 'lit/directive.js'; - -import type { - ActionHandlerDetail, - ActionHandlerOptions, -} from 'custom-card-helpers/dist/types.d.js'; -import { fireEvent } from 'custom-card-helpers'; -import { stopEventFromActivatingCardWideActions } from './common'; +import { stopEventFromActivatingCardWideActions } from './utils/action.js'; interface ActionHandler extends HTMLElement { holdTime: number; @@ -21,12 +20,6 @@ interface ActionHandlerElement extends HTMLElement { actionHandlerOptions?: FrigateCardActionHandlerOptions; } -declare global { - interface HASSDomEvents { - action: ActionHandlerDetail; - } -} - interface FrigateCardActionHandlerOptions extends ActionHandlerOptions { allowPropagation?: boolean; } @@ -142,10 +135,9 @@ class ActionHandler extends HTMLElement implements ActionHandler { }; const handleEnter = (ev: KeyboardEvent): void => { - if (ev.keyCode !== 13) { - return; + if (ev.key === 'Enter') { + end(ev); } - end(ev); }; element.addEventListener('touchstart', start, { passive: true }); @@ -195,3 +187,12 @@ export const actionHandler = directive( render(_options?: FrigateCardActionHandlerOptions) {} }, ); + +declare global { + interface HTMLElementTagNameMap { + 'action-handler-frigate-card': ActionHandler; + } + interface HASSDomEvents { + action: ActionHandlerDetail; + } +} diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts deleted file mode 100644 index 336dcc1c..00000000 --- a/src/browse-media-util.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import dayjs from 'dayjs'; -import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; - -import type { - BrowseMediaQueryParameters, - BrowseMediaSource, - CameraConfig, - ExtendedHomeAssistant, -} from './types.js'; -import { View } from './view.js'; -import { browseMediaSourceSchema } from './types.js'; -import { - dispatchErrorMessageEvent, - dispatchMessageEvent, - homeAssistantWSRequest, -} from './common.js'; -import { localize } from './localize/localize.js'; - -dayjs.extend(dayjs_custom_parse_format); - -export class BrowseMediaUtil { - /** - * Return the Frigate event_id given a BrowseMediaSource object. - * @param media The event to extract the id from. - * @returns The `event_id` or `null` if not successfully parsed. - */ - static extractEventID(media: BrowseMediaSource): string | null { - const result = media.media_content_id.match( - /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/, - ); - return result && result.groups ? result.groups['id'] : null; - } - - /** - * Return the event start time given a BrowseMediaSource object. - * @param browseMedia The media object to extract the start time from. - * @returns The start time in unix/epoch time, or null if it cannot be determined. - */ - static extractEventStartTime(browseMedia: BrowseMediaSource): number | null { - // Example: 2021-08-27 20:57:22 [10s, Person 76%] - const result = browseMedia.title.match(/^(?.+) \[/); - if (result && result.groups) { - const iso_datetime_str = result.groups['iso_datetime']; - if (iso_datetime_str) { - const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true); - if (iso_datetime.isValid()) { - return iso_datetime.unix(); - } - } - } - return null; - } - - /** - * Determine if a BrowseMediaSource object is truly a media item (vs a folder). - * @param media The media object. - * @returns `true` if it's truly a media item, `false` otherwise. - */ - static isTrueMedia(media: BrowseMediaSource): boolean { - return !media.can_expand; - } - - /** - * From a BrowseMediaSource item extract the first true media item from the - * children (i.e. a clip/snapshot, not a folder). - * @param media The media object with children. - * @returns The first true media item found. - */ - static getFirstTrueMediaChildIndex(media: BrowseMediaSource | null): number | null { - if (!media || !media.children) { - return null; - } - const index = media.children.findIndex((child) => this.isTrueMedia(child)); - return index >= 0 ? index : null; - } - - /** - * Browse Frigate media with a media content id. May throw. - * @param hass The HomeAssistant object. - * @param media_content_id The media content id to browse. - * @returns A BrowseMediaSource object or null on malformed. - */ - static async browseMedia( - hass: HomeAssistant & ExtendedHomeAssistant, - media_content_id: string, - ): Promise { - const request = { - type: 'media_source/browse_media', - media_content_id: media_content_id, - }; - return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); - } - - // Browse Frigate media with query parameters. - - /** - * Browse Frigate media with a media query. May throw. - * @param hass The HomeAssistant object. - * @param params The search parameters to use to search for media. - * @returns A BrowseMediaSource object or null on malformed. - */ - static async browseMediaQuery( - hass: HomeAssistant & ExtendedHomeAssistant, - params: BrowseMediaQueryParameters, - ): Promise { - return this.browseMedia( - hass, - // Defined in: - // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py - [ - 'media-source://frigate', - params.clientId, - 'event-search', - params.mediaType, - '', // Name/Title to render (not necessary here) - params.after ? String(params.after) : '', - params.before ? String(params.before) : '', - params.cameraName, - params.label, - params.zone, - ].join('/'), - ); - } - - /** - * Get the parameters to search for media. - * @returns A BrowseMediaQueryParameters object. - */ - static getBrowseMediaQueryParameters( - mediaType: 'clips' | 'snapshots', - cameraConfig?: CameraConfig, - ): BrowseMediaQueryParameters | undefined { - if (!cameraConfig || !cameraConfig.camera_name) { - return undefined; - } - return { - mediaType: mediaType, - clientId: cameraConfig.client_id, - cameraName: cameraConfig.camera_name, - label: cameraConfig.label, - zone: cameraConfig.zone, - }; - } - - /** - * Get the parameters to search for media related to the current view. - * @returns A BrowseMediaQueryParameters object. - */ - static getBrowseMediaQueryParametersOrDispatchError( - node: HTMLElement, - view: View, - cameraConfig: CameraConfig, - ): BrowseMediaQueryParameters | undefined { - if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { - return undefined; - } - - // Verify there is a camera name, otherwise getBrowseMediaQueryParameters() - // will return undefined. - if (!cameraConfig.camera_name) { - dispatchErrorMessageEvent( - node, - localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`, - ); - return undefined; - } - - return BrowseMediaUtil.getBrowseMediaQueryParameters( - view.isClipRelatedView() ? 'clips' : 'snapshots', - cameraConfig, - ); - } - - /** - * Fetch the latest media and dispatch a change view event to reflect the - * results. If no media is found a suitable message event will be triggered - * instead. - * @param node The HTMLElement to dispatch events from. - * @param hass The Home Assistant object. - * @param view The current view to evolve. - * @param browseMediaQueryParameters The media parameters to query with. - * @returns - */ - static async fetchLatestMediaAndDispatchViewChange( - node: HTMLElement, - hass: HomeAssistant & ExtendedHomeAssistant, - view: Readonly, - browseMediaQueryParameters: BrowseMediaQueryParameters, - ): Promise { - let parent: BrowseMediaSource | null; - try { - parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters); - } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); - } - const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); - if (!parent || !parent.children || childIndex == null) { - return dispatchMessageEvent( - node, - browseMediaQueryParameters.mediaType == 'clips' - ? localize('common.no_clip') - : localize('common.no_snapshot'), - browseMediaQueryParameters.mediaType == 'clips' - ? 'mdi:filmstrip-off' - : 'mdi:camera-off', - ); - } - - view - .evolve({ - target: parent, - childIndex: childIndex, - }) - .dispatchChangeEvent(node); - } - - /** - * Fetch the media of a child BrowseMediaSource object and dispatch a change - * view event to reflect the results. - * @param node The HTMLElement to dispatch events from. - * @param hass The Home Assistant object. - * @param view The current view to evolve. - * @param child The BrowseMediaSource child to query for. - * @returns - */ - static async fetchChildMediaAndDispatchViewChange( - node: HTMLElement, - hass: HomeAssistant & ExtendedHomeAssistant, - view: Readonly, - child: Readonly, - ): Promise { - let parent: BrowseMediaSource; - try { - parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id); - } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); - } - - view - .evolve({ - target: parent, - previous: view, - }) - .dispatchChangeEvent(node); - } -} diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index 80119e76..9e39e06d 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -17,6 +17,7 @@ export class CachedValueController implements ReactiveController { * Remove the controller for the host. */ public removeController(): void { + this.stopTimer(); this._host.removeController(this); } @@ -28,11 +29,10 @@ export class CachedValueController implements ReactiveController { } /** - * Update the cached value (and reset the timer). + * Update the cached value. */ public updateValue(): void { this._value = this._callback(); - this._startTimer(); } /** @@ -40,22 +40,24 @@ export class CachedValueController implements ReactiveController { */ public clearValue(): void { this._value = undefined; - this._stopTimer(); } /** * Disable the timer. */ - protected _stopTimer(): void { - clearInterval(this._timerID); + public stopTimer(): void { + if (this._timerID !== undefined) { + window.clearInterval(this._timerID); + } this._timerID = undefined; } /** * Enable the timer. Repeated calls will have no effect. */ - protected _startTimer(): void { - this._stopTimer(); + public startTimer(): void { + this.stopTimer(); + if (this._timerSeconds > 0) { this._timerID = window.setInterval(() => { this.updateValue(); @@ -69,6 +71,7 @@ export class CachedValueController implements ReactiveController { */ hostConnected(): void { this.updateValue(); + this.startTimer(); this._host.requestUpdate(); } @@ -77,5 +80,6 @@ export class CachedValueController implements ReactiveController { */ hostDisconnected(): void { this.clearValue(); + this.stopTimer(); } } diff --git a/src/card-condition.ts b/src/card-condition.ts index fb21ae0d..26f3a941 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -3,12 +3,15 @@ import type { OverrideConfigurationKey, RawFrigateCardConfig, } from './types'; -import { merge, cloneDeep } from 'lodash-es'; +import { HassEntities } from 'home-assistant-js-websocket'; +import { cloneDeep, merge } from 'lodash-es'; export interface ConditionState { view?: string; fullscreen?: boolean; camera?: string; + state?: HassEntities; + mediaLoaded?: boolean; } class ConditionStateRequestEvent extends Event { @@ -34,6 +37,22 @@ export function evaluateCondition( if (condition?.camera?.length) { result &&= !!state.camera && condition.camera.includes(state.camera); } + if (condition?.state?.length) { + for (const stateTest of condition?.state) { + result &&= + !!state.state && + ((!stateTest.state && !stateTest.state_not) || + (stateTest.entity in state.state && + (!stateTest.state || + state.state[stateTest.entity].state === stateTest.state) && + (!stateTest.state_not || + state.state[stateTest.entity].state !== stateTest.state_not))); + } + } + if (condition?.mediaLoaded !== undefined) { + result &&= + state.mediaLoaded !== undefined && condition.mediaLoaded == state.mediaLoaded; + } return result; } @@ -42,7 +61,7 @@ export function evaluateCondition( * @returns A boolean indicating whether the condition is met. */ export function fetchStateAndEvaluateCondition( - node: HTMLElement, + element: HTMLElement, condition?: FrigateCardCondition, ): boolean { if (!condition) { @@ -69,7 +88,7 @@ export function fetchStateAndEvaluateCondition( * synchronously, the state will be added to the event before the flow * proceeds. */ - node.dispatchEvent(stateEvent); + element.dispatchEvent(stateEvent); return evaluateCondition(condition, stateEvent.conditionState); } diff --git a/src/card.ts b/src/card.ts index 7b38ac74..7aa66404 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1,77 +1,20 @@ +import { getLovelace, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; import { CSSResultGroup, + html, LitElement, PropertyValues, TemplateResult, - html, unsafeCSS, } from 'lit'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { customElement, state } from 'lit/decorators.js'; 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 { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers'; +import { throttle } from 'lodash-es'; import screenfull from 'screenfull'; import { z } from 'zod'; - -import { - Actions, - ActionType, - CameraConfig, - FrigateCardView, - RawFrigateCardConfig, - entitySchema, - frigateCardConfigSchema, - FrigateCardCustomAction, -} from './types.js'; -import type { - Entity, - ExtendedHomeAssistant, - FrigateCardConfig, - MediaShowInfo, - MenuButton, - Message, -} from './types.js'; - -import { CARD_VERSION, REPO_URL } from './const.js'; -import { FrigateCardElements } from './components/elements.js'; -import { FrigateCardImage } from './components/image.js'; -import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; -import { View } from './view.js'; -import { - contentsChanged, - convertActionToFrigateCardCustomAction, - createFrigateCardCustomAction, - frigateCardHandleAction, - frigateCardHasAction, - getActionConfigGivenAction, - getCameraIcon, - getCameraTitle, - homeAssistantSignPath, - homeAssistantWSRequest, - isValidMediaShowInfo, - shouldUpdateBasedOnHass, -} from './common.js'; -import { localize } from './localize/localize.js'; -import { renderMessage, renderProgressIndicator } from './components/message.js'; - -import './editor.js'; -import './components/elements.js'; -import './components/gallery.js'; -import './components/image.js'; -import './components/live.js'; -import './components/menu.js'; -import './components/message.js'; -import './components/viewer.js'; -import './components/thumbnail-carousel.js'; -import './patches/ha-camera-stream.js'; -import './patches/ha-hls-player.js'; -import './patches/ha-web-rtc-player.ts'; - -import cardStyle from './scss/card.scss'; -import { ResolvedMediaCache } from './resolved-media.js'; -import { BrowseMediaUtil } from './browse-media-util.js'; -import { isConfigUpgradeable } from './config-mgmt.js'; import { actionHandler } from './action-handler-directive.js'; import { ConditionState, @@ -79,6 +22,84 @@ import { getOverriddenConfig, getOverridesByKey, } 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 './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 cardStyle from './scss/card.scss'; +import { + Actions, + ActionType, + CameraConfig, + EntityList, + ExtendedEntity, + ExtendedHomeAssistant, + FrigateCardConfig, + frigateCardConfigSchema, + FrigateCardCustomAction, + FrigateCardView, + FRIGATE_CARD_VIEWS_USER_SPECIFIED, + MediaLoadedInfo, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, + MESSAGE_TYPE_PRIORITIES, + MenuButton, + Message, + RawFrigateCardConfig, +} from './types.js'; +import { + convertActionToFrigateCardCustomAction, + createFrigateCardCustomAction, + frigateCardHandleAction, + frigateCardHasAction, + getActionConfigGivenAction, +} from './utils/action.js'; +import { contentsChanged, errorToConsole } from './utils/basic.js'; +import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js'; +import { + getEntityIcon, + getEntityTitle, + getHassDifferences, + homeAssistantSignPath, + isHassDifferent, + isTriggeredState, + sideLoadHomeAssistantElements, +} from './utils/ha'; +import { getEventID } from './utils/ha/browse-media.js'; +import { DeviceList, getAllDevices } from './utils/ha/device-registry.js'; +import { + ExtendedEntityCache, + getAllEntities, + getExtendedEntities, + getExtendedEntity, +} from './utils/ha/entity-registry.js'; +import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; +import { supportsFeature } from './utils/ha/update.js'; +import { isValidMediaLoadedInfo } from './utils/media-info.js'; +import { View } from './view.js'; +import pkg from '../package.json'; +import { ViewContext } from 'view'; /** A note on media callbacks: * @@ -108,7 +129,10 @@ import { /* eslint no-console: 0 */ console.info( - `%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `, + `%c FRIGATE-HASS-CARD \n` + + `%c ${localize('common.version')} ` + + `${pkg.version} ` + + `${process.env.NODE_ENV === 'development' ? `(${pkg['buildDate']})` : ''}`, 'color: pink; font-weight: bold; background: black', 'color: white; font-weight: bold; background: dimgray', ); @@ -129,8 +153,8 @@ console.info( */ @customElement('frigate-card') export class FrigateCard extends LitElement { - @property({ attribute: false }) - protected _hass?: HomeAssistant & ExtendedHomeAssistant; + @state() + protected _hass?: ExtendedHomeAssistant; // The main base configuration object. For most usecases use getConfig() to // get the correct configuration (which will return overrides as appropriate). @@ -138,24 +162,21 @@ export class FrigateCard extends LitElement { // card-mod. @state() protected _config!: FrigateCardConfig; + protected _rawConfig?: RawFrigateCardConfig; @state() protected _overriddenConfig?: FrigateCardConfig; - @property({ attribute: false }) + @state() protected _view?: View; - @state() protected _conditionState?: ConditionState; - @query('frigate-card-menu') - protected _menu!: FrigateCardMenu; - - @query('frigate-card-elements') - protected _elements?: FrigateCardElements; - - @query('frigate-card-image') - protected _image?: FrigateCardImage; + protected _refMenu: Ref = createRef(); + protected _refMain: Ref = createRef(); + protected _refElements: Ref = createRef(); + protected _refImage: Ref = createRef(); + protected _refLive: Ref = createRef(); // user interaction timer ("screensaver" functionality, return to default // view after user interaction). @@ -164,8 +185,9 @@ export class FrigateCard extends LitElement { // Automated refreshes of the default view. protected _updateTimerID: number | null = null; - // Information about the most recently loaded media item. - protected _mediaShowInfo: MediaShowInfo | null = null; + // Information about loaded media items. + protected _currentMediaLoadedInfo: MediaLoadedInfo | null = null; + protected _lastValidMediaLoadedInfo: MediaLoadedInfo | null = null; // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; @@ -179,26 +201,42 @@ export class FrigateCard extends LitElement { // A cache of resolved media URLs/mimetypes for use in the whole card. protected _resolvedMediaCache = new ResolvedMediaCache(); + // The mouse handler may be called continually, throttle it to at most once + // per second for performance reasons. + protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000); + + // Whether the card has been successfully initialized. + protected _initialized = false; + + protected _triggers: Map = new Map(); + protected _untriggerTimerID: number | null = null; + /** * Set the Home Assistant object. */ - set hass(hass: HomeAssistant & ExtendedHomeAssistant) { + set hass(hass: ExtendedHomeAssistant) { this._hass = hass; // Manually set hass in the menu, elements and image. This is to allow these // to update, without necessarily re-rendering the entire card (re-rendering - // interrupts clip playing). + // is expensive). if (this._hass) { - if (this._menu) { - this._menu.hass = this._hass; + if (this._refMenu.value) { + this._refMenu.value.hass = this._hass; } - if (this._elements) { - this._elements.hass = this._hass; + if (this._refElements.value) { + this._refElements.value.hass = this._hass; } - if (this._image) { - this._image.hass = this._hass; + if (this._refImage.value) { + this._refImage.value.hass = this._hass; } } + + // HA entity state is part of the condition state. + this._generateConditionState(); + + // Dark mode may depend on HASS. + this._setLightOrDarkMode(); } /** @@ -240,8 +278,21 @@ 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, }; + // Update the components that need the new condition state. Passed directly + // to them to avoid the performance hit of a entire card re-render (esp. + // when using card-mod). + // https://github.com/dermotduffy/frigate-hass-card/issues/678 + if (this._refLive.value) { + this._refLive.value.conditionState = this._conditionState; + } + if (this._refElements.value) { + this._refElements.value.conditionState = this._conditionState; + } + const overriddenConfig = getOverriddenConfig( this._config, this._config.overrides, @@ -265,6 +316,53 @@ export class FrigateCard extends LitElement { }; } + /** + * Given a button determine if the style should be emphasized by examining all + * of the actions sequentially. + * @param button The button to examine. + * @returns A StyleInfo object. + */ + protected _getStyleFromActions(button: MenuButton): StyleInfo { + for (const actionSet of [ + button.tap_action, + button.double_tap_action, + button.hold_action, + button.start_tap_action, + button.end_tap_action, + ]) { + const actions = Array.isArray(actionSet) ? actionSet : [actionSet]; + for (const action of actions) { + // All frigate card actions will have action of 'fire-dom-event' and + // styling only applies to those. + if ( + !action || + action.action !== 'fire-dom-event' || + !('frigate_card_action' in action) + ) { + continue; + } + const frigateCardAction = action as FrigateCardCustomAction; + if ( + FRIGATE_CARD_VIEWS_USER_SPECIFIED.some( + (view) => + view === frigateCardAction.frigate_card_action && + this._view?.is(frigateCardAction.frigate_card_action), + ) || + (frigateCardAction.frigate_card_action === 'default' && + this._view?.is(this._getConfig().view.default)) || + (frigateCardAction.frigate_card_action === 'fullscreen' && + screenfull.isEnabled && + screenfull.isFullscreen) || + (frigateCardAction.frigate_card_action === 'camera_select' && + this._view?.camera === frigateCardAction.camera) + ) { + return this._getEmphasizedStyle(); + } + } + } + return {}; + } + /** * Get the menu buttons to display. * @returns An array of menu buttons. @@ -272,67 +370,70 @@ export class FrigateCard extends LitElement { protected _getMenuButtons(): MenuButton[] { const buttons: MenuButton[] = []; - if (this._getConfig().menu.buttons.frigate) { - buttons.push({ - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.frigate'), - // Use a magic icon value that the menu will use to render the icon as - // it deems appropriate (certain menu configurations change the menu - // icon for the 'Frigate' button). - icon: FRIGATE_BUTTON_MENU_ICON, - tap_action: FrigateCardMenu.isHidingMenu(this._getConfig().menu) - ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) - : (createFrigateCardCustomAction('default') as FrigateCardCustomAction), - }); - } + buttons.push({ + // Use a magic icon value that the menu will use to render the custom + // Frigate icon. + icon: FRIGATE_BUTTON_MENU_ICON, + ...this._getConfig().menu.buttons.frigate, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.frigate'), + tap_action: FrigateCardMenu.isHidingMenu(this._getConfig().menu) + ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) + : (createFrigateCardCustomAction('default') as FrigateCardCustomAction), + hold_action: createFrigateCardCustomAction( + 'diagnostics', + ) as FrigateCardCustomAction, + }); - if ( - this._getConfig().menu.buttons.cameras && - this._cameras && - this._cameras.size > 1 - ) { + if (this._cameras && this._cameras.size > 1) { const menuItems = Array.from(this._cameras, ([camera, config]) => { + const action = createFrigateCardCustomAction('camera_select', { + camera: camera, + }); return { + enabled: true, icon: getCameraIcon(this._hass, config), entity: config.camera_entity, state_color: true, title: getCameraTitle(this._hass, config), selected: this._view?.camera === camera, - tap_action: createFrigateCardCustomAction('camera_select', camera), + ...(action && { tap_action: action }), }; }); buttons.push({ + icon: 'mdi:video-switch', + ...this._getConfig().menu.buttons.cameras, type: 'custom:frigate-card-menu-submenu', title: localize('config.menu.buttons.cameras'), - icon: 'mdi:video-switch', items: menuItems, }); } - if (this._getConfig().menu.buttons.live) { - buttons.push({ - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.live'), - icon: 'mdi:cctv', - style: this._view?.is('live') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, - }); - } + buttons.push({ + icon: 'mdi:cctv', + ...this._getConfig().menu.buttons.live, + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.live'), + style: this._view?.is('live') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, + }); const cameraConfig = this._getSelectedCameraConfig(); // Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye. + // cameras), or is birdseye (unless there are dependent cameras). if ( - this._getConfig().menu.buttons.clips && - cameraConfig?.camera_name && - cameraConfig?.camera_name !== 'birdseye' + cameraConfig?.frigate.camera_name && + (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || + cameraConfig.dependencies.cameras.length || + cameraConfig.dependencies.all_cameras) ) { buttons.push({ + icon: 'mdi:filmstrip', + ...this._getConfig().menu.buttons.clips, type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.clips'), - icon: 'mdi:filmstrip', style: this._view?.is('clips') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction('clips') as FrigateCardCustomAction, hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction, @@ -340,16 +441,18 @@ export class FrigateCard extends LitElement { } // Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye. + // cameras), or is birdseye (unless there are dependent cameras). if ( - this._getConfig().menu.buttons.snapshots && - cameraConfig?.camera_name && - cameraConfig?.camera_name !== 'birdseye' + cameraConfig?.frigate.camera_name && + (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || + cameraConfig?.dependencies.cameras.length || + cameraConfig?.dependencies.all_cameras) ) { buttons.push({ + icon: 'mdi:camera', + ...this._getConfig().menu.buttons.snapshots, type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.snapshots'), - icon: 'mdi:camera', style: this._view?.is('snapshots') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction( 'snapshots', @@ -360,48 +463,134 @@ export class FrigateCard extends LitElement { }); } - if (this._getConfig().menu.buttons.image) { + buttons.push({ + icon: 'mdi:image', + ...this._getConfig().menu.buttons.image, + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.image'), + style: this._view?.is('image') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction, + }); + + // Don't show the timeline button unless there's at least one non-birdseye + // camera with a Frigate camera name. + if ( + this._cameras && + [...this._cameras.values()].some( + (config) => + config.frigate.camera_name && config.frigate.camera_name !== CAMERA_BIRDSEYE, + ) + ) { buttons.push({ + icon: 'mdi:chart-gantt', + ...this._getConfig().menu.buttons.timeline, type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.image'), - icon: 'mdi:image', - style: this._view?.is('image') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction, + title: localize('config.view.views.timeline'), + style: this._view?.is('timeline') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('timeline') as FrigateCardCustomAction, }); } - if (this._getConfig().menu.buttons.download && this._view?.isViewerView()) { + if ( + !this._isBeingCasted() && + (this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media)) + ) { buttons.push({ + icon: 'mdi:download', + ...this._getConfig().menu.buttons.download, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.download'), - icon: 'mdi:download', tap_action: createFrigateCardCustomAction('download') as FrigateCardCustomAction, }); } - if (this._getConfig().menu.buttons.frigate_ui && cameraConfig?.frigate_url) { + if (cameraConfig?.frigate.url) { buttons.push({ + icon: 'mdi:web', + ...this._getConfig().menu.buttons.frigate_ui, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.frigate_ui'), - icon: 'mdi:web', tap_action: createFrigateCardCustomAction( 'frigate_ui', ) as FrigateCardCustomAction, }); } - if (this._getConfig().menu.buttons.fullscreen && screenfull.isEnabled) { + if (screenfull.isEnabled && !this._isBeingCasted()) { buttons.push({ + icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', + ...this._getConfig().menu.buttons.fullscreen, type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.fullscreen'), - icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', tap_action: createFrigateCardCustomAction( 'fullscreen', ) as FrigateCardCustomAction, style: screenfull.isFullscreen ? this._getEmphasizedStyle() : {}, }); } - return buttons.concat(this._dynamicMenuButtons); + + const isValidMediaPlayer = (entity: string): boolean => { + if (entity.startsWith('media_player.')) { + const stateObj = this._hass?.states[entity]; + if ( + stateObj && + stateObj.state !== 'unavailable' && + supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA) + ) { + return true; + } + } + return false; + }; + + const mediaPlayers = Object.keys(this._hass?.states || {}).filter( + isValidMediaPlayer, + ); + if ( + mediaPlayers.length && + (this._view?.isViewerView() || + (this._view?.is('live') && cameraConfig?.camera_entity)) + ) { + const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { + const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; + const state = this._hass?.states[playerEntityID]; + const playAction = createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'play', + }); + const stopAction = createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'stop', + }); + + return { + enabled: true, + selected: false, + icon: getEntityIcon(this._hass, playerEntityID) || 'mdi:cast', + entity: playerEntityID, + state_color: false, + title: title, + disabled: !state || state.state === 'unavailable', + ...(playAction && { tap_action: playAction }), + ...(stopAction && { hold_action: stopAction }), + }; + }); + + buttons.push({ + icon: 'mdi:cast', + ...this._getConfig().menu.buttons.media_player, + type: 'custom:frigate-card-menu-submenu', + title: localize('config.menu.buttons.media_player'), + items: mediaPlayerItems, + }); + } + + const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ + style: this._getStyleFromActions(button), + ...button, + })); + + return buttons.concat(styledDynamicButtons); } /** @@ -412,7 +601,9 @@ export class FrigateCard extends LitElement { if (!this._dynamicMenuButtons.includes(button)) { this._dynamicMenuButtons = [...this._dynamicMenuButtons, button]; } - this._menu.buttons = this._getMenuButtons(); + if (this._refMenu.value) { + this._refMenu.value.buttons = this._getMenuButtons(); + } } /** @@ -423,32 +614,146 @@ export class FrigateCard extends LitElement { this._dynamicMenuButtons = this._dynamicMenuButtons.filter( (button) => button != target, ); - this._menu.buttons = this._getMenuButtons(); + if (this._refMenu.value) { + this._refMenu.value.buttons = this._getMenuButtons(); + } + } + + /** + * Get the motion sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the motion sensor or null. + */ + protected _getMotionSensor( + cache: ExtendedEntityCache, + cameraConfig: CameraConfig, + ): string | null { + if (cameraConfig.frigate.camera_name) { + return ( + cache.getMatch( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:motion_sensor:${ + cameraConfig.frigate.zone || cameraConfig.frigate.camera_name + }`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + + /** + * Get the occupancy sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the occupancy sensor or null. + */ + protected _getOccupancySensor( + cache: ExtendedEntityCache, + cameraConfig: CameraConfig, + ): string | null { + if (cameraConfig.frigate.camera_name) { + return ( + cache.getMatch( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:occupancy_sensor:${ + cameraConfig.frigate.zone || cameraConfig.frigate.camera_name + }_${cameraConfig.frigate.label || 'all'}`, + ), + ), + )?.entity_id ?? null + ); + } + return null; } /** * Fully load the configured cameras. */ protected async _loadCameras(): Promise { + if (!this._hass) { + return; + } + + const cache = new ExtendedEntityCache(); + let entityList: EntityList | undefined; + try { + entityList = await getAllEntities(this._hass); + } catch (e) { + errorToConsole(e as Error); + } + const cameras: Map = new Map(); let errorFree = true; const addCameraConfig = async (config: CameraConfig) => { - if (!config.camera_name && config.camera_entity) { - const resolvedName = await this._getFrigateCameraNameFromEntity( - config.camera_entity, - ); - if (resolvedName) { - config.camera_name = resolvedName; + if (!this._hass) { + return; + } + + let entity: ExtendedEntity | null = null; + if (config.camera_entity) { + try { + entity = await getExtendedEntity(this._hass, config.camera_entity, cache); + } catch (e) { + // Silently ignore errors here, as non-Frigate camera entities may not + // necessarily have a registry entry and otherwise this would cause + // log spam for those cases. } } - const id = - config.id || - config.camera_entity || - config.webrtc_card?.entity || - config.camera_name; + if (!config.frigate.camera_name && entity) { + 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, + ); + } 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)]; + + const id = getCameraID(config); if (!id) { this._setMessageAndUpdate({ message: localize('error.no_camera_id'), @@ -469,7 +774,11 @@ export class FrigateCard extends LitElement { }; if (this._getConfig().cameras && Array.isArray(this._getConfig().cameras)) { - await Promise.all(this._getConfig().cameras.map(addCameraConfig.bind(this))); + // 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) { @@ -497,37 +806,16 @@ export class FrigateCard extends LitElement { } /** - * Get the Frigate camera name from an entity name. + * Get the Frigate camera name from an entity. * @returns The Frigate camera name or null if unavailable. */ - protected async _getFrigateCameraNameFromEntity( - entity: string, - ): Promise { - if (!this._hass) { - return null; - } - - // Find entity unique_id in registry. - const request = { - type: 'config/entity_registry/get', - entity_id: entity, - }; - try { - const entityResult = await homeAssistantWSRequest( - this._hass, - entitySchema, - request, - ); - if (entityResult && entityResult.platform == 'frigate') { - const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); - if (match && match.groups) { - return match.groups['camera']; - } + protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null { + if (entity.unique_id && entity.platform === 'frigate') { + const match = entity.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; } - } catch (e: unknown) { - // Pass. } - return null; } @@ -624,11 +912,15 @@ export class FrigateCard extends LitElement { getLovelace().setEditMode(true); } + this._rawConfig = inputConfig; this._config = config; + this._overriddenConfig = undefined; this._cameras = undefined; this._view = undefined; this._message = null; this._generateConditionState(); + this._setLightOrDarkMode(); + this._untrigger(); } /** @@ -640,16 +932,24 @@ export class FrigateCard extends LitElement { } protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { + const changeView = (view: View): void => { + if (View.isMediaChange(this._view, view)) { + this._currentMediaLoadedInfo = null; + } + this._view = view; + this._generateConditionState(); + this._resetMainScroll(); + }; + if (args?.resetMessage ?? true) { this._message = null; } - if (args?.view === undefined) { + if (!args?.view) { // Load the default view. let camera; if (this._cameras?.size) { - if (this._view?.camera && - this._getConfig().view.update_cycle_camera) { + if (this._view?.camera && this._getConfig().view.update_cycle_camera) { const keys = Array.from(this._cameras.keys()); const currentIndex = keys.indexOf(this._view.camera); const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; @@ -661,23 +961,35 @@ export class FrigateCard extends LitElement { } if (camera) { - this._view = new View({ - view: this._getConfig().view.default, - camera: camera, - }); - this._generateConditionState(); - - // The default view has been loaded, so can abandon any running - // 'screensaver' timer. - this._clearInteractionTimer(); + changeView( + new View({ + view: this._getConfig().view.default, + camera: camera, + }), + ); // Restart the update timer, so the default view is refreshed at a fixed // interval from now (if so configured). this._startUpdateTimer(); } } else { - this._view = args.view; - this._generateConditionState(); + changeView(args.view); + } + } + + /** + * Set the light or dark mode. + */ + protected _setLightOrDarkMode(): void { + const needDarkMode = + this._getConfig().view.dark_mode === 'on' || + (this._getConfig().view.dark_mode === 'auto' && + (!this._interactionTimerID || this._hass?.themes.darkMode)); + + if (needDarkMode) { + this.setAttribute('dark', ''); + } else { + this.removeAttribute('dark'); } } @@ -689,6 +1001,137 @@ export class FrigateCard extends LitElement { this._changeView({ view: e.detail }); } + /** + * Add view context to the current view. + * @param ev A ViewContext event. + */ + protected _addViewContextHandler(ev: CustomEvent): void { + this._changeView({ + view: this._view?.clone().mergeInContext(ev.detail), + }); + } + + /** + * Called before each update. + */ + protected willUpdate(): void { + // Side load the necessary elements if not already initialized. + if (!this._initialized) { + sideLoadHomeAssistantElements().then((success) => { + if (success) { + this._initialized = true; + } + }); + } + } + + /** + * Get the most recent triggered camera. + */ + protected _getMostRecentTrigger(): string | null { + const sorted = [...this._triggers.entries()].sort( + (a: [string, Date], b: [string, Date]) => b[1].getTime() - a[1].getTime(), + ); + return sorted.length ? sorted[0][0] : null; + } + + /** + * Determine if a camera has been triggered. + * @param oldHass The old HA object. + * @returns A boolean indicating whether the camera was changed. + */ + protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean { + if (!this._view || !this._isAutomatedViewUpdateAllowed(true)) { + return false; + } + + const now = new Date(); + let changedCamera = false; + let triggerChanges = false; + + for (const [camera, config] of this._cameras?.entries() ?? []) { + const triggerEntities = config.triggers.entities ?? []; + const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { + stateOnly: true, + }); + const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState)); + const shouldUntrigger = triggerEntities.every( + (entity) => !isTriggeredState(this._hass?.states[entity]), + ); + if (shouldTrigger) { + this._triggers.set(camera, now); + triggerChanges = true; + } else if (shouldUntrigger && this._triggers.has(camera)) { + this._triggers.delete(camera); + triggerChanges = true; + } + } + + if (triggerChanges) { + if (!this._triggers.size) { + this._startUntriggerTimer(); + } else { + const targetCamera = this._getMostRecentTrigger(); + if ( + targetCamera && + (this._view.camera !== targetCamera || !this._view.is('live')) + ) { + this._changeView({ view: new View({ view: 'live', camera: targetCamera }) }); + changedCamera = true; + } + } + } + return changedCamera; + } + + /** + * Determine if the scan mode is currently triggered. + * @returns + */ + protected _isTriggered(): boolean { + return !!this._triggers.size || !!this._untriggerTimerID; + } + + /** + * Untrigger the card. + */ + protected _untrigger(): void { + const wasTriggered = this._isTriggered(); + this._triggers.clear(); + this._clearUntriggerTimer(); + + if (wasTriggered) { + this.requestUpdate(); + } + } + + /** + * Start the untrigger timer. + */ + protected _startUntriggerTimer(): void { + this._clearUntriggerTimer(); + + this._untriggerTimerID = window.setTimeout(() => { + this._untrigger(); + if ( + this._isAutomatedViewUpdateAllowed() && + this._getConfig().view.scan.untrigger_reset + ) { + this._changeView(); + } + }, this._getConfig().view.scan.untrigger_seconds * 1000); + } + + /** + * Clear the user interaction ('screensaver') timer. + */ + protected _clearUntriggerTimer(): void { + if (this._untriggerTimerID) { + window.clearTimeout(this._untriggerTimerID); + this._untriggerTimerID = null; + } + } + /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. @@ -698,28 +1141,52 @@ export class FrigateCard extends LitElement { const oldHass = changedProps.get('_hass') as HomeAssistant | undefined; let shouldUpdate = !oldHass || changedProps.size != 1; + // If this is the first HA state and it is disconnected, or there's been a + // change in connection state, then show the reconnection message. + if ( + (!oldHass && !this._hass?.connected) || + (!!oldHass && oldHass.connected !== !!this._hass?.connected) + ) { + if (!this._hass?.connected) { + this._setMessageAndUpdate( + { + message: localize('error.reconnecting'), + icon: 'mdi:lan-disconnect', + type: 'connection', + dotdotdot: true, + }, + true, + ); + } else { + this._changeView(); + } + return true; + } + if (oldHass) { - // Home Assistant pumps a lot of updates through. Re-rendering the card is - // necessary at times (e.g. to update the 'clip' view as new clips - // arrive), but also is a jarring experience for the user (e.g. if they - // are browsing the mini-gallery). Do not allow re-rendering from a Home - // Assistant update if there's been recent interaction (e.g. clicks on the - // card) or if there is media active playing. - if ( + const selectedCamera = this._getSelectedCameraConfig(); + if (this._getConfig().view.scan.enabled && this._updateTriggeredCameras(oldHass)) { + shouldUpdate = true; + } else if ( + // Home Assistant pumps a lot of updates through. Re-rendering the card is + // necessary at times (e.g. to update the 'clip' view as new clips + // arrive), but also is a jarring experience for the user (e.g. if they + // are browsing the mini-gallery). Do not allow re-rendering from a Home + // Assistant update if there's been recent interaction (e.g. clicks on the + // card) or if there is media active playing. this._isAutomatedViewUpdateAllowed() && - shouldUpdateBasedOnHass( - this._hass, - oldHass, - this._getConfig().view.update_entities || [], - ) + isHassDifferent(this._hass, oldHass, [ + ...(this._getConfig().view.update_entities || []), + ...(selectedCamera?.triggers.entities || []), + ]) ) { // If entities being monitored have changed then reset the view to the // default. Note that as per the Lit lifecycle, the setting of the view // itself will not trigger an *additional* re-render here. this._changeView(); - shouldUpdate ||= true; + shouldUpdate = true; } else { - shouldUpdate ||= shouldUpdateBasedOnHass( + shouldUpdate ||= isHassDifferent( this._hass, oldHass, this._getConfig().view.render_entities || [], @@ -733,19 +1200,23 @@ export class FrigateCard extends LitElement { * Download media being displayed in the viewer. */ protected async _downloadViewerMedia(): Promise { - if (!this._hass || !this._view?.isViewerView()) { + if (!this._hass || !(this._view?.isViewerView() || this._view?.is('timeline'))) { // Should not occur. return; } - if (!this._view.media) { + if ( + !this._view.media || + (this._view.media.media_content_type !== MEDIA_TYPE_VIDEO && + this._view.media.media_content_type !== MEDIA_TYPE_IMAGE) + ) { this._setMessageAndUpdate({ message: localize('error.download_no_media'), type: 'error', }); return; } - const event_id = BrowseMediaUtil.extractEventID(this._view.media); + const event_id = getEventID(this._view.media); if (!event_id) { this._setMessageAndUpdate({ message: localize('error.download_no_event_id'), @@ -760,15 +1231,19 @@ export class FrigateCard extends LitElement { } const path = - `/api/frigate/${cameraConfig.client_id}` + + `/api/frigate/${cameraConfig.frigate.client_id}` + `/notifications/${event_id}/` + - `${this._view.isClipRelatedView() ? 'clip.mp4' : 'snapshot.jpg'}` + + `${ + this._view.media.media_content_type === MEDIA_TYPE_VIDEO + ? 'clip.mp4' + : 'snapshot.jpg' + }` + `?download=true`; let response: string | null | undefined; try { response = await homeAssistantSignPath(this._hass, path); } catch (e) { - console.error(e, (e as Error).stack); + errorToConsole(e as Error); } if (!response) { @@ -801,15 +1276,59 @@ export class FrigateCard extends LitElement { } } + /** + * Take a media player action. + * @param mediaPlayer The entity ID of the media player. + * @param action The action to take (currently only 'play' is supported). + * @returns + */ + protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void { + if (!['play', 'stop'].includes(action)) { + return; + } + + let media_content_id: string; + let media_content_type: string; + const extra = {}; + const cameraConfig = this._getSelectedCameraConfig(); + const cameraEntity = cameraConfig?.camera_entity ?? null; + + if (this._view?.isViewerView() && this._view.media) { + media_content_id = this._view.media.media_content_id; + media_content_type = this._view.media.media_content_type; + extra['thumb'] = this._view.media.thumbnail; + extra['title'] = this._view.media.title; + } else if (this._view?.is('live') && cameraEntity) { + if (this._hass?.states && cameraEntity in this._hass.states) { + extra['thumb'] = + this._hass.states[cameraEntity].attributes.entity_picture ?? null; + } + extra['title'] = getCameraTitle(this._hass, cameraConfig); + media_content_id = `media-source://camera/${cameraEntity}`; + media_content_type = 'application/vnd.apple.mpegurl'; + } else { + return; + } + + if (action === 'play') { + this._hass?.callService('media_player', 'play_media', { + entity_id: mediaPlayer, + media_content_id: media_content_id, + media_content_type: media_content_type, + extra: extra, + }); + } else if (action === 'stop') { + this._hass?.callService('media_player', 'media_stop', { + entity_id: mediaPlayer, + }); + } + } + /** * Handle a request for a card action. * @param ev The action requested. */ protected _cardActionHandler(ev: CustomEvent): void { - // These interactions should only be handled by the card, as nothing - // upstream has the user-provided configuration. - ev.stopPropagation(); - const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); if (!frigateCardAction) { return; @@ -826,6 +1345,7 @@ export class FrigateCard extends LitElement { case 'live': case 'snapshot': case 'snapshots': + case 'timeline': if (this._view) { this._changeView({ view: new View({ @@ -853,43 +1373,105 @@ export class FrigateCard extends LitElement { // This is a rare code path: this would only be used if someone has a // menu toggle action configured outside of the menu itself (e.g. // picture elements). - this._menu.toggleMenu(); + this._refMenu.value?.toggleMenu(); break; case 'camera_select': const camera = frigateCardAction.camera; if (this._cameras?.has(camera) && this._view) { + const targetView = View.selectBestViewForUserSpecified( + this._getConfig().view.camera_select === 'current' + ? this._view.view + : (this._getConfig().view.camera_select as FrigateCardView), + ); this._changeView({ view: new View({ - view: - this._getConfig().view.camera_select === 'current' - ? this._view.view - : (this._getConfig().view.camera_select as FrigateCardView), + view: this._cameras?.get(camera)?.frigate.camera_name + ? targetView + : // Fallback to supported views for non-Frigate cameras. + View.selectBestViewForNonFrigateCameras(targetView), camera: camera, }), }); } break; + case 'media_player': + this._mediaPlayerAction( + frigateCardAction.media_player, + frigateCardAction.media_player_action, + ); + break; + case 'diagnostics': + this._diagnostics(); + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } } + /** + * Generate diagnostics for issue reports. + */ + protected async _diagnostics(): Promise { + if (this._hass) { + let devices: DeviceList = []; + try { + devices = await getAllDevices(this._hass); + } catch (e) { + // Pass. This is optional. + } + + // Get the Frigate devices in order to extract the Frigate integration and + // server version numbers. + const frigateDevices = devices.filter( + (device) => device.manufacturer === 'Frigate', + ); + const frigateVersionMap: Map = new Map(); + frigateDevices.forEach((device) => { + device.config_entries.forEach((configEntry) => { + if (device.model) { + frigateVersionMap.set(configEntry, device.model); + } + }); + }); + + this._setMessageAndUpdate({ + message: localize('error.diagnostics'), + type: 'diagnostics', + icon: 'mdi:information', + context: { + ha_version: this._hass.config.version, + card_version: pkg.version, + browser: navigator.userAgent, + date: new Date(), + frigate_version: Object.fromEntries(frigateVersionMap), + lang: getLanguage(), + git: { + ...(pkg['gitVersion'] && { build_version: pkg['gitVersion'] }), + ...(pkg['buildDate'] && { build_date: pkg['buildDate'] }), + ...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }), + }, + ...(this._rawConfig && { config: this._rawConfig }), + }, + }); + } + } + /** * Get the Frigate UI URL from context. * @returns The URL or null if unavailable. */ protected _getFrigateURLFromContext(): string | null { const cameraConfig = this._getSelectedCameraConfig(); - if (!cameraConfig || !cameraConfig.frigate_url || !this._view) { + if (!cameraConfig || !cameraConfig.frigate.url || !this._view) { return null; } - if (!cameraConfig.camera_name) { - return cameraConfig.frigate_url; + if (!cameraConfig.frigate.camera_name) { + return cameraConfig.frigate.url; } if (this._view.isViewerView() || this._view.isGalleryView()) { - return `${cameraConfig.frigate_url}/events?camera=${cameraConfig.camera_name}`; + return `${cameraConfig.frigate.url}/events?camera=${cameraConfig.frigate.camera_name}`; } - return `${cameraConfig.frigate_url}/cameras/${cameraConfig.camera_name}`; + return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`; } /** @@ -920,6 +1502,13 @@ export class FrigateCard extends LitElement { this._startInteractionTimer(); } + /** + * Handle mouse movements. + */ + protected _mouseHandler(): void { + this._startInteractionTimer(); + } + /** * Clear the user interaction ('screensaver') timer. */ @@ -936,11 +1525,20 @@ export class FrigateCard extends LitElement { */ protected _startInteractionTimer(): void { this._clearInteractionTimer(); + + // Interactions reset the trigger state. + this._untrigger(); + if (this._getConfig().view.timeout_seconds) { this._interactionTimerID = window.setTimeout(() => { - this._changeView(); + this._clearInteractionTimer(); + if (this._isAutomatedViewUpdateAllowed()) { + this._changeView(); + this._setLightOrDarkMode(); + } }, this._getConfig().view.timeout_seconds * 1000); } + this._setLightOrDarkMode(); } /** @@ -969,8 +1567,11 @@ export class FrigateCard extends LitElement { * Determine if an automated view update is allowed. * @returns `true` if it's allowed, `false` otherwise. */ - protected _isAutomatedViewUpdateAllowed(): boolean { - return this._getConfig().view.update_force || !this._interactionTimerID; + protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean { + return ( + (ignoreTriggers || !this._isTriggered()) && + (this._getConfig().view.update_force || !this._interactionTimerID) + ); } /** @@ -980,10 +1581,10 @@ export class FrigateCard extends LitElement { protected _renderMenu(): TemplateResult | void { return html` `; } @@ -994,15 +1595,34 @@ export class FrigateCard extends LitElement { * @param skipUpdate If true an update request is skipped. */ protected _setMessageAndUpdate(message: Message, skipUpdate?: boolean): void { - // Register the first message, or prioritize errors if there's pre-render competition. - if (!this._message || (message.type == 'error' && this._message.type != 'error')) { + const currentPriority = this._message + ? MESSAGE_TYPE_PRIORITIES[this._message.type] ?? 0 + : 0; + const newPriority = MESSAGE_TYPE_PRIORITIES[message.type] ?? 0; + + if (!this._message || newPriority >= currentPriority) { this._message = message; + + // When a message is displayed it is effectively unloading the media. + this._mediaUnloadedHandler(); + if (!skipUpdate) { this.requestUpdate(); + this._resetMainScroll(); } } } + /** + * Reset the scroll of the main pane to the top (example usecase: scrolling + * half way down the gallery, then viewing diagnostics should result in + * diagnostics starting at the top). + */ + protected _resetMainScroll(): void { + // Reset the scroll on the main div to the top. + this._refMain.value?.scroll({ top: 0 }); + } + /** * Handle a message event to render to the user. * @param e The message event. @@ -1013,34 +1633,35 @@ export class FrigateCard extends LitElement { /** * Handle a new piece of media being shown. - * @param e Event with MediaShowInfo details for the media. + * @param ev Event with MediaLoadedInfo details for the media. */ - protected _mediaShowHandler(e: CustomEvent): void { - const mediaShowInfo = e.detail; + protected _mediaLoadedHandler(ev: CustomEvent): void { + const mediaLoadedInfo = ev.detail; // In Safari, with WebRTC, 0x0 is occasionally returned during loading, // so treat anything less than a safety cutoff as bogus. - if (!isValidMediaShowInfo(mediaShowInfo)) { + if (!isValidMediaLoadedInfo(mediaLoadedInfo)) { return; } - let requestRefresh = false; - if ( - this._view?.isGalleryView() && - (mediaShowInfo.width != this._mediaShowInfo?.width || - mediaShowInfo.height != this._mediaShowInfo?.height) - ) { - requestRefresh = true; - } - this._mediaShowInfo = mediaShowInfo; - if (requestRefresh) { - this.requestUpdate(); - } + this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo; + + // An update may be required to draw elements. + this._generateConditionState(); + this.requestUpdate(); + } + + /** + * Unload a media item. + */ + protected _mediaUnloadedHandler(): void { + this._currentMediaLoadedInfo = null; + this._generateConditionState(); } /** * Handler called when fullscreen is toggled. */ - protected _fullScreenHandler(): void { + protected _fullscreenHandler(): void { this._generateConditionState(); // Re-render after a change to fullscreen mode to take advantage of // the expanded screen real-estate (vs staying in aspect-ratio locked @@ -1054,20 +1675,33 @@ export class FrigateCard extends LitElement { connectedCallback(): void { super.connectedCallback(); if (screenfull.isEnabled) { - screenfull.on('change', this._fullScreenHandler.bind(this)); + screenfull.on('change', this._fullscreenHandler.bind(this)); } + this.addEventListener('mousemove', this._boundMouseHandler); } /** * Component disconnected callback. */ disconnectedCallback(): void { + // When the dashboard 'tab' is changed, the media is effectively unloaded. + this._mediaUnloadedHandler(); + if (screenfull.isEnabled) { - screenfull.off('change', this._fullScreenHandler.bind(this)); + screenfull.off('change', this._fullscreenHandler.bind(this)); } + this.removeEventListener('mousemove', this._boundMouseHandler); super.disconnectedCallback(); } + /** + * Determine if the card is currently being casted. + * @returns + */ + protected _isBeingCasted(): boolean { + return !!navigator.userAgent.match(/CrKey\//); + } + /** * Determine if the aspect ratio should be enforced given the current view and * context. @@ -1078,14 +1712,14 @@ export class FrigateCard extends LitElement { // Do not artifically constrain aspect ratio if: // - It's fullscreen. // - Aspect ratio enforcement is disabled. - // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the gallery). - // - There is a message to display to the user. + // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the + // gallery) or timeline. return !( (screenfull.isEnabled && screenfull.isFullscreen) || aspectRatioMode == 'unconstrained' || - (aspectRatioMode == 'dynamic' && this._view?.isMediaView()) || - this._message != null + (aspectRatioMode == 'dynamic' && + (this._view?.isAnyMediaView() || this._view?.is('timeline'))) ); } @@ -1094,21 +1728,21 @@ export class FrigateCard extends LitElement { * required). * @returns A padding percentage. */ - protected _getAspectRatioPadding(): number | null { + protected _getAspectRatioStyle(): string { if (!this._isAspectRatioEnforced()) { - return null; + return 'auto'; } const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; - if (aspectRatioMode == 'dynamic' && this._mediaShowInfo) { - return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100; + if (aspectRatioMode == 'dynamic' && this._lastValidMediaLoadedInfo) { + return `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}`; } const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio; if (defaultAspectRatio) { - return (defaultAspectRatio[1] / defaultAspectRatio[0]) * 100; + return `${defaultAspectRatio[0]} / ${defaultAspectRatio[1]}`; } else { - return (9 / 16) * 100; + return '16 / 9'; } } @@ -1117,14 +1751,23 @@ export class FrigateCard extends LitElement { * @returns A combined set of action. */ protected _getMergedActions(): Actions { - let specificActions: Actions | undefined = undefined; + if (this._message || this._view?.is('timeline')) { + // Timeline does not support actions as it is not possible to prevent + // touch actions on the timeline surface from triggering card-wide actions + // inappropriately, whilst also maintaining touch interaction with the + // timeline itself (and almost the entire timeline surface can be + // interacted with). This causes duplicate/inappropriate card-wide actions + // on touch surfaces. + return {}; + } + let specificActions: Actions | undefined = undefined; if (this._view?.is('live')) { specificActions = this._getConfig().live.actions; } else if (this._view?.isGalleryView()) { specificActions = this._getConfig().event_gallery?.actions; } else if (this._view?.isViewerView()) { - specificActions = this._getConfig().event_viewer.actions; + specificActions = this._getConfig().media_viewer.actions; } else if (this._view?.is('image')) { specificActions = this._getConfig().image?.actions; } @@ -1139,78 +1782,92 @@ export class FrigateCard extends LitElement { return; } - const padding = this._getAspectRatioPadding(); - const outerStyle = {}; + const cardStyle = { + 'aspect-ratio': this._getAspectRatioStyle(), + }; + const cardClasses = { + triggered: + !!this._isTriggered() && this._getConfig().view.scan.show_trigger_status, + }; - // Padding to force a particular aspect ratio. - if (padding != null) { - outerStyle['padding-top'] = `${padding}%`; - } - - const contentClasses = { - 'frigate-card-contents': true, - absolute: padding != null, + const mainClasses = { + main: true, + 'curve-top': + this._getConfig().menu.style !== 'outside' || + this._getConfig().menu.position !== 'top', + 'curve-bottom': + this._getConfig().menu.style !== 'outside' || + this._getConfig().menu.position === 'top', }; const actions = this._getMergedActions(); + const renderMenuAbove = + this._getConfig().menu.style === 'outside' && + this._getConfig().menu.position === 'top'; + + // Caution: Keep the main div and the menu next to one another in order to + // ensure the hover menu styling continues to work. return html` this._actionHandler(ev, actions)} @ll-custom=${this._cardActionHandler.bind(this)} - @frigate-card:message=${this._messageHandler} - @frigate-card:change-view=${this._changeViewHandler} - @frigate-card:media-show=${this._mediaShowHandler} + @frigate-card:message=${this._messageHandler.bind(this)} + @frigate-card:view:change=${this._changeViewHandler.bind(this)} + @frigate-card:view:change-context=${this._addViewContextHandler.bind(this)} + @frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)} + @frigate-card:media:unloaded=${this._mediaUnloadedHandler.bind(this)} @frigate-card:render=${() => this.requestUpdate()} > - ${this._getConfig().menu.mode == 'above' ? this._renderMenu() : ''} -
-
- ${this._cameras === undefined - ? until( - (async () => { - await this._loadCameras(); - // Don't reset messages as errors may have been generated - // during the camera load. - this._changeView({ resetMessage: false }); - return this._render(); - })(), - renderProgressIndicator(), - ) - : // Always want to call render even if there's a message, to - // ensure live preload is always present (even if not displayed). - this._render()} - ${this._getConfig().elements - ? // Always show elements to allow for custom menu items (etc.) to - // be present even if a particular view has an error. Elements - // need to render after the main views so it can render 'on top'. - html` { - this._addDynamicMenuButton(e.detail); - }} - @frigate-card:menu-remove=${(e) => { - this._removeDynamicMenuButton(e.detail); - }} - @frigate-card:condition-state-request=${(ev) => { - conditionStateRequestHandler(ev, this._conditionState); - }} - > - ` - : ``} - ${ - // Keep message rendering to last to show messages that may have - // been generated during the render. - this._message ? renderMessage(this._message) : '' - } -
+ ${renderMenuAbove ? this._renderMenu() : ''} +
+ ${this._cameras === undefined && !this._message + ? until( + (async () => { + await this._loadCameras(); + // Don't reset messages as errors may have been generated + // during the camera load. + this._changeView({ resetMessage: false }); + return this._render(); + })(), + renderProgressIndicator(), + ) + : // Always want to call render even if there's a message, to + // ensure live preload is always present (even if not displayed). + this._render()} + ${ + // Keep message rendering to last to show messages that may have + // been generated during the render. + this._message ? renderMessage(this._message) : '' + }
- ${this._getConfig().menu.mode != 'above' ? this._renderMenu() : ''} + ${!renderMenuAbove ? this._renderMenu() : ''} + ${this._getConfig().elements + ? // Elements need to render after the main views so it can render 'on + // top'. + html` { + this._addDynamicMenuButton(e.detail); + }} + @frigate-card:menu-remove=${(e) => { + this._removeDynamicMenuButton(e.detail); + }} + @frigate-card:condition-state-request=${(ev) => { + conditionStateRequestHandler(ev, this._conditionState); + }} + > + ` + : ``} `; } @@ -1234,6 +1891,7 @@ export class FrigateCard extends LitElement { return html` ${!this._message && this._view.is('image') ? html` ` @@ -1254,12 +1912,22 @@ export class FrigateCard extends LitElement { ? html` ` : ``} + ${!this._message && this._view.is('timeline') + ? html` + ` + : ``} ${ // Note: Subtle difference in condition below vs the other views in order // to always render the live view for live.preload mode. @@ -1270,13 +1938,13 @@ export class FrigateCard extends LitElement { this._getConfig().live.preload || (!this._message && this._view.is('live')) ? html` @@ -1298,9 +1966,15 @@ export class FrigateCard extends LitElement { * @returns The Lovelace card size in units of 50px. */ public getCardSize(): number { - if (this._mediaShowInfo) { - return this._mediaShowInfo.height / 50; + if (this._lastValidMediaLoadedInfo) { + return this._lastValidMediaLoadedInfo.height / 50; } return 6; } } + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card': FrigateCard; + } +} diff --git a/src/common.ts b/src/common.ts deleted file mode 100644 index 77eb7e30..00000000 --- a/src/common.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { HassEntity, MessageBase } from 'home-assistant-js-websocket'; -import { - HomeAssistant, - computeStateDomain, - handleActionConfig, - hasAction, - stateIcon, -} from 'custom-card-helpers'; -import { StyleInfo } from 'lit/directives/style-map.js'; -import { ZodSchema, z } from 'zod'; -import { isEqual } from 'lodash-es'; - -import { localize } from './localize/localize.js'; -import { - Actions, - ActionsConfig, - ActionType, - CameraConfig, - ExtendedHomeAssistant, - FrigateCardAction, - FrigateCardCustomAction, - frigateCardCustomActionSchema, - MediaShowInfo, - Message, - SignedPath, - signedPathSchema, - StateParameters, -} from './types.js'; - -const MEDIA_INFO_HEIGHT_CUTOFF = 50; -const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF; - -/** - * Get the keys that didn't parse from a ZodError. - * @param error The zoderror to extract the keys from. - * @returns An array of error keys. - */ -export function getParseErrorKeys(error: z.ZodError): string[] { - const errors = error.format(); - return Object.keys(errors).filter((v) => !v.startsWith('_')); -} - -/** - * Make a HomeAssistant websocket request. May throw. - * @param hass The HomeAssistant object to send the request with. - * @param schema The expected Zod schema of the response. - * @param request The request to make. - * @returns The parsed valid response or null on malformed. - */ -export async function homeAssistantWSRequest( - hass: HomeAssistant & ExtendedHomeAssistant, - schema: ZodSchema, - request: MessageBase, -): Promise { - const response = await hass.callWS(request); - - if (!response) { - const error_message = `${localize('error.empty_response')}: ${JSON.stringify( - request, - )}`; - console.warn(error_message); - throw new Error(error_message); - } - const parseResult = schema.safeParse(response); - if (!parseResult.success) { - const keys = getParseErrorKeys(parseResult.error); - const error_message = - `${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` + - localize('error.invalid_keys') + - `: '${keys}'`; - console.warn(error_message); - throw new Error(error_message); - } - return parseResult.data; -} - -/** - * Request that HA sign a path. May throw. - * @param hass The HomeAssistant object used to request the signature. - * @param path The path to sign. - * @param expires An optional number of seconds to sign the path for. - * @returns The signed URL, or null if the response was malformed. - */ -export async function homeAssistantSignPath( - hass: HomeAssistant & ExtendedHomeAssistant, - path: string, - expires?: number, -): Promise { - const request = { - type: 'auth/sign_path', - path: path, - expires: expires, - }; - const response = await homeAssistantWSRequest( - hass, - signedPathSchema, - request, - ); - if (!response) { - return null; - } - return hass.hassUrl(response.path); -} - -/** - * Dispatch a Frigate Card event. - * @param element The element to send the event. - * @param name The name of the Frigate card event to send. - * @param detail An optional detail object to attach. - */ -export function dispatchFrigateCardEvent( - element: HTMLElement, - name: string, - detail?: T, -): void { - element.dispatchEvent( - new CustomEvent(`frigate-card:${name}`, { - bubbles: true, - composed: true, - detail: detail, - }), - ); -} - -/** - * Create a MediaShowInfo object. - * @param source An event or HTMLElement that should be used as a source. - * @returns A new MediaShowInfo object or null if one could not be created. - */ -export function createMediaShowInfo(source: Event | HTMLElement): MediaShowInfo | null { - let target: HTMLElement | EventTarget; - if (source instanceof Event) { - target = source.composedPath()[0]; - } else { - target = source; - } - - if (target instanceof HTMLImageElement) { - return { - width: (target as HTMLImageElement).naturalWidth, - height: (target as HTMLImageElement).naturalHeight, - }; - } else if (target instanceof HTMLVideoElement) { - return { - width: (target as HTMLVideoElement).videoWidth, - height: (target as HTMLVideoElement).videoHeight, - }; - } else if (target instanceof HTMLCanvasElement) { - return { - width: (target as HTMLCanvasElement).width, - height: (target as HTMLCanvasElement).height, - }; - } - return null; -} - -/** - * Dispatch a Frigate card media show event. - * @param element The element to send the event. - * @param source An event or HTMLElement that should be used as a source. - */ -export function dispatchMediaShowEvent( - element: HTMLElement, - source: Event | HTMLElement, -): void { - const mediaShowInfo = createMediaShowInfo(source); - if (mediaShowInfo) { - dispatchExistingMediaShowInfoAsEvent(element, mediaShowInfo); - } -} - -/** - * Dispatch a pre-existing MediaShowInfo object as an event. - * @param element The element to send the event. - * @param mediaShowInfo The MediaShowInfo object to send. - */ -export function dispatchExistingMediaShowInfoAsEvent( - element: HTMLElement, - mediaShowInfo: MediaShowInfo, -): void { - dispatchFrigateCardEvent(element, 'media-show', mediaShowInfo); -} - -/** - * Dispatch an event with a message to show to the user. - * @param element The element to send the event. - * @param message The message to show. - * @param icon An optional icon to attach to the message. - */ -export function dispatchMessageEvent( - element: HTMLElement, - message: string, - icon?: string, - context?: unknown, -): void { - dispatchFrigateCardEvent(element, 'message', { - message: message, - type: 'info', - icon: icon, - context: context, - }); -} - -/** - * Dispatch an event with an error message to show to the user. - * @param element The element to send the event. - * @param message The message to show. - */ -export function dispatchErrorMessageEvent( - element: HTMLElement, - message: string, - context?: unknown, -): void { - dispatchFrigateCardEvent(element, 'message', { - message: message, - type: 'error', - context: context, - }); -} - -/** - * Determine whether the card should be updated based on Home Assistant changes. - * @param newHass The new HA object. - * @param oldHass The old HA object. - * @param entities The entities to examine for changes. - * @returns A boolean indicating whether or not to allow an update. - */ -export function shouldUpdateBasedOnHass( - newHass: HomeAssistant | undefined | null, - oldHass: HomeAssistant | undefined | null, - entities: string[] | null, -): boolean { - if (!newHass || !entities || !entities.length) { - return false; - } - if (!oldHass) { - return true; - } - - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (entity && oldHass.states[entity] !== newHass.states[entity]) { - return true; - } - } - return false; -} - -/** - * Determine if a MediaShowInfo object is valid/acceptable. - * @param info The MediaShowInfo object. - * @returns True if the object is valid, false otherwise. - */ -export function isValidMediaShowInfo(info: MediaShowInfo): boolean { - return ( - info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF - ); -} - -/** - * Convert a generic Action to a FrigateCardCustomAction if it parses correctly. - * @param action The generic action configuration. - * @returns A FrigateCardCustomAction or null if it cannot be converted. - */ -export function convertActionToFrigateCardCustomAction( - action: ActionType | null, -): FrigateCardCustomAction | null { - if (!action) { - return null; - } - // Parse a custom event as other things could generate ll-custom events that - // are not related to Frigate Card. - const parseResult = frigateCardCustomActionSchema.safeParse(action); - return parseResult.success ? parseResult.data : null; -} - -/** - * Create a Frigate card custom action. - * @param action The Frigate card action string (e.g. 'fullscreen') - * @returns A FrigateCardCustomAction for that action string. - */ -export function createFrigateCardCustomAction( - action: FrigateCardAction, - camera?: string, -): FrigateCardCustomAction | undefined { - if (action == 'camera_select') { - if (!camera) { - return undefined; - } - return { - action: 'fire-dom-event', - frigate_card_action: action, - camera: camera, - }; - } - return { - action: 'fire-dom-event', - frigate_card_action: action, - }; -} - -/** - * Get an action configuration given a config and an interaction (e.g. 'tap'). - * @param interaction The interaction: `tap`, `hold` or `double_tap` - * @param config The configuration containing multiple actions. - * @returns The relevant action configuration or null if none found. - */ -export function getActionConfigGivenAction( - interaction?: string, - config?: Actions, -): ActionType | ActionType[] | undefined { - if (!interaction || !config) { - return undefined; - } - if (interaction == 'tap' && config.tap_action) { - return config.tap_action; - } else if (interaction == 'hold' && config.hold_action) { - return config.hold_action; - } else if (interaction == 'double_tap' && config.double_tap_action) { - return config.double_tap_action; - } else if (interaction == 'end_tap' && config.end_tap_action) { - return config.end_tap_action; - } else if (interaction == 'start_tap' && config.start_tap_action) { - return config.start_tap_action; - } - return undefined; -} - -/** - * Calculate a style brightness from a hass state. - * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296 - * @param state The hass state object. - * @returns A CSS brightness string. - */ -function computeBrightnessFromState(state: HassEntity): string { - if (state.state === 'off' || !state.attributes.brightness) { - return ''; - } - const brightness = state.attributes.brightness; - return `brightness(${(brightness + 245) / 5}%)`; -} - -/** - * Calculate a style color from a hass state. - * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304 - * @param state The hass state object. - * @returns A CSS color string. - */ -function computeColorFromState(state: HassEntity): string { - if (state.state === 'off') { - return ''; - } - return state.attributes.rgb_color - ? `rgb(${state.attributes.rgb_color.join(',')})` - : ''; -} - -/** - * Get the style of emphasized menu items. - * @returns A StyleInfo. - */ -function computeStyle(state: HassEntity): StyleInfo { - return { - color: computeColorFromState(state), - filter: computeBrightnessFromState(state), - }; -} - -/** - * Determine the string state of a given stateObj. - * From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_active_state.ts - * @param stateObj The HassEntity object from `hass.states`. - * @returns A string state, e.g. 'on'. - */ -export const computeActiveState = (stateObj: HassEntity): string => { - const domain = stateObj.entity_id.split('.')[0]; - let state = stateObj.state; - - if (domain === 'climate') { - state = stateObj.attributes.hvac_action; - } - - return state; -}; - -/** - * Use Home Assistant state to refresh state parameters for an item to be rendered. - * @param hass Home Assistant object. - * @param params A StateParameters object to modify in place. - * @returns A StateParameters object updated based on HASS state. - */ -export function refreshDynamicStateParameters( - hass: HomeAssistant, - params: StateParameters, -): StateParameters { - if (!params.entity) { - return params; - } - const state = hass.states[params.entity]; - if (!!state && !!params.state_color) { - params.style = { ...computeStyle(state), ...params.style }; - } - params.title = params.title ?? (state?.attributes?.friendly_name || params.entity); - params.icon = params.icon ?? stateIcon(state); - - const domain = state ? computeStateDomain(state) : undefined; - params.data_domain = - params.state_color || (domain === 'light' && params.state_color !== false) - ? domain - : undefined; - if (state) { - params.data_state = computeActiveState(state); - } - return params; -} - -/** - * Prettify a Frigate name by converting '_' to spaces and capitalizing words. - * @param input The input Frigate (camera/label/zone) name. - * @returns A prettified name. - */ -export function prettifyFrigateName(input?: string): string | undefined { - if (!input) { - return undefined; - } - const words = input.split(/[_\s]+/); - return words - .map((word) => { - return word[0].toUpperCase() + word.substring(1); - }) - .join(' '); -} - -/** - * Get the title of an entity. - * @param entity The entity id. - * @param hass The Home Assistant object. - * @returns The title or undefined. - */ -export function getEntityTitle( - hass?: HomeAssistant, - entity?: string, -): string | undefined { - return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined; -} - -/** - * Get the icon of an entity. - * @param entity The entity id. - * @param hass The Home Assistant object. - * @returns The icon or undefined. - */ -export function getEntityIcon( - hass?: HomeAssistant, - entity?: string, -): string | undefined { - return hass && entity ? stateIcon(hass.states[entity]) : undefined; -} - -/** - * Get a camera text title. - * @param hass The Home Assistant object. - * @param config The camera config. - * @returns A title string. - */ -export function getCameraTitle( - hass?: HomeAssistant, - config?: CameraConfig | null, -): string { - return ( - config?.title || - (config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') || - (config?.camera_name ? prettifyFrigateName(config.camera_name) : '') || - '' - ); -} - -/** - * Get a camera icon. - * @param hass The Home Assistant object. - * @param config The camera config. - * @returns An icon string. - */ -export function getCameraIcon( - hass?: HomeAssistant, - config?: CameraConfig | null, -): string { - return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video'; -} - -/** - * Move an element within an array. - * @param target Target array. - * @param from From index. - * @param to To index. - */ -export function arrayMove(target: unknown[], from: number, to: number): void { - const element = target[from]; - target.splice(from, 1); - target.splice(to, 0, element); -} - -/** - * Determine if the contents of the n(ew) and o(ld) values have changed. For use - * in lit web components that may have a value that changes address but not - * contents -- and for which a re-render is expensive/jarring. - * @param n The new value. - * @param o The old value. - * @returns `true` is the contents have changed. - */ -export function contentsChanged(n: unknown, o: unknown): boolean { - return !isEqual(n, o); -} - -/** - * Frigate card custom version of handleAction - * (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts) - * that handles the custom action events the card supports. - * @param node The node that fired the event. - * @param hass The Home Assistant object. - * @param config The multi-action configuration. - * @param action The action string (e.g. 'hold') - * @returns Whether or not an action was executed. - */ -export const frigateCardHandleAction = ( - node: HTMLElement, - hass: HomeAssistant, - config: ActionsConfig, - action: string, -): boolean => { - return frigateCardHandleActionConfig( - node, - hass, - config, - action, - getActionConfigGivenAction(action, config), - ); -}; - -/** - * Handle an ActionConfig or array of ActionConfigs. - * @param node The node that fired the event. - * @param hass The Home Assistant object. - * @param actionConfig A single action config, array of action configs or - * undefined for the default action config for 'tap'. - * @param action The action string (e.g. 'hold') - * @returns Whether or not an action was executed. - */ -export const frigateCardHandleActionConfig = ( - node: HTMLElement, - hass: HomeAssistant, - config: { - camera_image?: string; - entity?: string; - }, - action: string, - actionConfig: ActionType | ActionType[] | undefined, -): boolean => { - if (actionConfig || action == 'tap') { - // Only allow a tap action to use a default non-config (the more-info config). - if (Array.isArray(actionConfig)) { - actionConfig.forEach((action) => handleActionConfig(node, hass, config, action)); - } else { - handleActionConfig(node, hass, config, actionConfig); - } - return true; - } - return false; -}; - -/** - * Determine if an action config has a real action. A modified version of - * custom-card-helpers hasAction to also work with arrays of action configs. - * @param config The action config in question. - * @returns `true` if there's a real action defined, `false` otherwise. - */ -export const frigateCardHasAction = ( - config?: ActionType | ActionType[] | undefined, -): boolean => { - if (Array.isArray(config)) { - return !!config.find((item) => hasAction(item)); - } - return hasAction(config); -}; - -/** - * Stop an event from activating card wide actions. - */ -export const stopEventFromActivatingCardWideActions = (ev: Event): void => { - ev.stopPropagation(); -}; diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 217506d9..4a018780 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,37 +1,197 @@ -import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit'; -import EmblaCarousel, { - EmblaCarouselType, - EmblaOptionsType, - EmblaPluginType, -} from 'embla-carousel'; - -import { TransitionEffect } from '../types'; -import { dispatchFrigateCardEvent } from '../common'; - +import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; +import { EmblaNodesType } from 'embla-carousel/components'; +import { + CreatePluginType, + EmblaPluginsType, + LoosePluginType, +} from 'embla-carousel/components/Plugins'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { throttle } from 'lodash-es'; import carouselStyle from '../scss/carousel.scss'; +import { TransitionEffect } from '../types'; +import { dispatchFrigateCardEvent } from '../utils/basic.js'; export interface CarouselSelect { index: number; + element: HTMLElement; } +export type EmblaCarouselPlugins = CreatePluginType< + LoosePluginType, + Record +>[]; + +@customElement('frigate-card-carousel') export class FrigateCardCarousel extends LitElement { + @property({ attribute: true, reflect: true }) + public direction: 'vertical' | 'horizontal' = 'horizontal'; + + @property({ attribute: false }) + public carouselOptions?: EmblaOptionsType; + + @property({ attribute: false }) + public carouselPlugins?: EmblaCarouselPlugins; + + @property({ attribute: true }) + public transitionEffect?: TransitionEffect; + + // An override to the startIndex, used to preserve the current carousel + // position after the carousel is destroyed (so it can be restored if + // recreated). + // See: https://github.com/dermotduffy/frigate-hass-card/issues/775 + protected _savedStartIndex: number | null = null; + + protected _refSlot: Ref = createRef(); + protected _carousel?: EmblaCarouselType; - protected _plugins: Record = {}; + + // Whether the carousel is actively scrolling. + protected _scrolling = false; + + // Whether to reinit the carousel when it settles. + protected _reInitOnSettle = false; + + protected _carouselReInitInPlace = throttle( + this._carouselReInitInPlaceInternal.bind(this), + 500, + { trailing: true }, + ); + + connectedCallback(): void { + super.connectedCallback(); + + // Guarantee a re-render if the component is reconnected. See note in + // disconnectedCallback(). + this.requestUpdate(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + // Destroy the carousel when the component is disconnected, which forces the + // plugins (which may have registered event handlers) to also be destroyed. + // The carousel will automatically reconstruct if the component is re-rendered. + this._destroyCarousel({ savePosition: true }); + super.disconnectedCallback(); + } + + /** + * Destroy the carousel if certain properties change. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + const destroyProperties = [ + 'direction', + 'carouselOptions', + 'carouselPlugins', + ] as const; + if (destroyProperties.some((prop) => changedProps.has(prop))) { + this._destroyCarousel({ savePosition: true }); + } + } /** * Scroll to a particular slide. * @param index Slide number. */ - carouselScrollTo(index: number): void { - this._carousel?.scrollTo(index, this._getTransitionEffect() === 'none'); + public carouselScrollTo(index: number): void { + this._carousel?.scrollTo(index, this.transitionEffect === 'none'); + } + + /** + * Scroll to the previous slide. + */ + public carouselScrollPrevious(): void { + this._carousel?.scrollPrev(this.transitionEffect === 'none'); + } + + /** + * Scroll to the next slide. + */ + public carouselScrollNext(): void { + this._carousel?.scrollNext(this.transitionEffect === 'none'); } /** * Get the selected slide. - * @returns The slide index or undefined if the carousel is not loaded. + * @returns A CarouselSelect object (index & element). */ - carouselSelected(): number | undefined { - return this._carousel?.selectedScrollSnap(); + public getCarouselSelected(): CarouselSelect | null { + const index = this._carousel?.selectedScrollSnap(); + const element = + index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null; + if (index !== undefined && element) { + return { + index: index, + element: element, + }; + } + return null; + } + + /** + * Get the carousel. + */ + public carouselClickAllowed(): boolean { + return this._carousel?.clickAllowed() ?? true; + } + + /** + * Get the carousel. + */ + public carousel(): EmblaCarouselType | null { + return this._carousel ?? null; + } + + /** + * ReInit the carousel. + */ + protected _carouselReInit(options?: EmblaOptionsType): void { + // Allow the browser a moment to paint components that are inflight, to + // ensure accurate measurements are taken during the carousel + // reinitialization. + window.requestAnimationFrame(() => { + this._carousel?.reInit({ ...options }); + }); + } + /** + * ReInit the carousel but stay on the current slide. + */ + protected _carouselReInitInPlaceInternal(): void { + const selected = this.getCarouselSelected(); + + this._carouselReInit({ + ...(selected && { startIndex: selected.index }), + }); + } + + /** + * ReInit the carousel when it is safe to do so without disturbing the + * appearance (i.e. cutting off a scroll in progress). + */ + public carouselReInitWhenSafe(): void { + if (this._scrolling) { + this._reInitOnSettle = true; + } else { + this._carouselReInitInPlace(); + } + } + + /** + * Get the live carousel plugins. + */ + public getCarouselPlugins(): EmblaPluginsType | null { + return this._carousel?.plugins() ?? null; } /** @@ -42,44 +202,21 @@ export class FrigateCardCarousel extends LitElement { super.updated(changedProperties); if (!this._carousel) { - this.updateComplete.then(() => { - // Re-check for the carousel to prevent a double init. - if (!this._carousel) { - this._initCarousel(); - } - }); + this._initCarousel(); } } /** - * Get the transition effect to use. - * @returns An TransitionEffect object. + * Destroy the carousel. + * @param options If `savePosition` is set the existing carousel position + * will be saved so it can be restored if the carousel is recreated. */ - protected _getTransitionEffect(): TransitionEffect | undefined { - return 'slide'; - } - - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType | undefined { - return undefined; - } - - /** - * Get the Embla plugins to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getPlugins(): EmblaPluginType[] | undefined { - return undefined; - } - - protected _destroyCarousel(): void { + protected _destroyCarousel(options?: { savePosition: boolean }): void { + this._savedStartIndex = + (options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null; if (this._carousel) { this._carousel.destroy(); } - this._plugins = {}; this._carousel = undefined; } @@ -91,26 +228,88 @@ export class FrigateCardCarousel extends LitElement { '.embla__viewport', ) as HTMLElement; - if (carouselNode) { - const plugins = this._getPlugins() ?? []; - this._plugins = plugins.reduce((acc, cur) => { - acc[cur.name] = cur; - return acc; - }, {}); + const nodes: EmblaNodesType = { + root: carouselNode, + // As the slides are slotted, need to explicitly pull them out and pass + // them to Embla. + slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[], + }; - this._carousel = EmblaCarousel(carouselNode, this._getOptions(), plugins); + if (carouselNode && nodes.slides) { + this._carousel = EmblaCarousel( + nodes, + { + axis: this.direction == 'horizontal' ? 'x' : 'y', + speed: 20, + ...this.carouselOptions, + ...(this._savedStartIndex && { startIndex: this._savedStartIndex }), + }, + this.carouselPlugins, + ); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { - const selected = this.carouselSelected(); - if (selected !== undefined) { - dispatchFrigateCardEvent(this, 'carousel:select', { - index: selected, - }); + const selected = this.getCarouselSelected(); + if (selected) { + dispatchFrigateCardEvent(this, 'carousel:select', selected); + } + + // Make sure every select causes a refresh to allow for re-paint of the + // next/previous controls. + this.requestUpdate(); + }); + + this._carousel.on('scroll', () => { + this._scrolling = true; + }); + this._carousel.on('settle', () => { + // Reinitialize the carousel if a request to reinitialize was made + // during scrolling (instead the request is handled after the scrolling + // has settled). + this._scrolling = false; + if (this._reInitOnSettle) { + this._reInitOnSettle = false; + this._carouselReInitInPlace(); + } + }); + this._carousel.on('settle', () => { + const selected = this.getCarouselSelected(); + if (selected) { + dispatchFrigateCardEvent(this, 'carousel:settle', selected); } }); } } + /** + * Called when the slotted children in the carousel change. + */ + protected _slotChanged(): void { + // Cannot just re-init, because the slide elements themselves may have + // changed, and only a carousel init can pass in new (slotted) children. If + // the slides themselves change, any position the user has set is assumed to + // be abandoned and so the startIndex is reset to whatever the carousel was + // originally configured with. + this._destroyCarousel({ savePosition: false }); + this.requestUpdate(); + } + + protected render(): TemplateResult | void { + const slides = this._refSlot.value?.assignedElements({ flatten: true }) || []; + const currentSlide = this._carousel?.selectedScrollSnap() ?? 0; + const showPrevious = this.carouselOptions?.loop || currentSlide > 0; + const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length; + + return html`
+ ${showPrevious ? html`` : ``} +
+
+ +
+
+ ${showNext ? html`` : ``} +
`; + } + /** * Get element styles. */ @@ -118,3 +317,9 @@ export class FrigateCardCarousel extends LitElement { return unsafeCSS(carouselStyle); } } + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-carousel': FrigateCardCarousel; + } +} diff --git a/src/components/drawer.ts b/src/components/drawer.ts new file mode 100644 index 00000000..dfff296a --- /dev/null +++ b/src/components/drawer.ts @@ -0,0 +1,144 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import 'side-drawer'; +import { SideDrawer } from 'side-drawer'; +import drawerInjectStyle from '../scss/drawer-inject.scss'; +import drawerStyle from '../scss/drawer.scss'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { isHoverableDevice } from '../utils/basic'; + +@customElement('frigate-card-drawer') +export class FrigateCardDrawer extends LitElement { + @property({ attribute: true, reflect: true }) + public location: 'left' | 'right' = 'left'; + + @property({ attribute: true, reflect: true, type: Boolean }) + public control = true; + + @property({ type: Boolean, reflect: true, attribute: true }) + public open = false; + + // The 'empty' attribute is used in the styling to change the drawer + // visibility and that of all descendants if there is no content. Styling is + // used rather than display or hidden in order to ensure the contents continue + // to have a measurable size. + @property({ type: Boolean, reflect: true, attribute: true }) + public empty = true; + + protected _refDrawer: Ref = createRef(); + protected _refSlot: Ref = createRef(); + + protected _resizeObserver = new ResizeObserver(() => this._hideDrawerIfNecessary()); + + protected readonly _isHoverableDevice = isHoverableDevice(); + + /** + * Called on the first update. + * @param changedProps The changed properties. + */ + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + + // The `side-drawer` component (and the material drawer for that matter) + // only do fixed drawers (i.e. a drawer for the whole viewport). Hackily + // override the style to customize the drawer to be absolute within the div. + const style = document.createElement('style'); + style.innerHTML = drawerInjectStyle; + this._refDrawer.value?.shadowRoot?.appendChild(style); + } + + /** + * Called when the slotted children in the drawer change. + */ + protected _slotChanged(): void { + const elements = this._refSlot.value?.assignedElements({ flatten: true }); + + // Watch all slot children for size changes. + this._resizeObserver.disconnect(); + for (const element of elements ?? []) { + this._resizeObserver.observe(element); + } + this._hideDrawerIfNecessary(); + } + + /** + * Hide the drawer if there is nothing to show. + * @returns + */ + protected _hideDrawerIfNecessary(): void { + if (!this._refDrawer.value) { + return; + } + + const elements = this._refSlot.value?.assignedElements({ flatten: true }); + this.empty = + !elements || + !elements.length || + elements.every((element) => { + const box = element.getBoundingClientRect(); + return !box.width || !box.height; + }); + } + + protected render(): TemplateResult { + return html` + { + if (this.open) { + this.open = false; + } + }} + > + ${this.control + ? html` +
{ + stopEventFromActivatingCardWideActions(ev); + this.open = !this.open; + }} + > + { + // Only open the drawer on mousenter when the device + // supports hover (otherwise iOS may end up passing on + // subsequent click events to a different element, see: + // https://github.com/dermotduffy/frigate-hass-card/issues/801 + if (this._isHoverableDevice && !this.open) { + this.open = true; + } + }} + > + +
+ ` + : ''} + +
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(drawerStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-drawer': FrigateCardDrawer; + 'side-drawer': SideDrawer; + } +} diff --git a/src/components/elements.ts b/src/components/elements.ts index 9915a931..6061e181 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -1,24 +1,39 @@ -import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit'; -import { HomeAssistant } from 'custom-card-helpers'; -import { customElement, property, query } from 'lit/decorators.js'; - +import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { - ExtendedHomeAssistant, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js'; +import { localize } from '../localize/localize.js'; +import elementsStyle from '../scss/elements.scss'; +import ptzStyle from '../scss/elements-ptz.scss'; +import { + Actions, + ActionsConfig, + FrigateCardError, + FrigateCardPTZConfig, FrigateConditional, MenuButton, MenuIcon, MenuStateIcon, - PictureElements, MenuSubmenu, + MenuSubmenuSelect, + PictureElements, } from '../types.js'; +import { dispatchFrigateCardEvent } from '../utils/basic.js'; +import { dispatchFrigateCardErrorEvent } from './message.js'; +import { actionHandler } from '../action-handler-directive.js'; import { - dispatchErrorMessageEvent, - dispatchFrigateCardEvent, -} from '../common.js'; - -import elementsStyle from '../scss/elements.scss'; -import { localize } from '../localize/localize.js'; -import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js'; + frigateCardHandleActionConfig, + frigateCardHasAction, + getActionConfigGivenAction, +} from '../utils/action.js'; +import { classMap } from 'lit/directives/class-map.js'; /* A note on picture element rendering: * @@ -53,33 +68,29 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio * upper layers to handle correctly. */ +interface HuiConditionalElement extends HTMLElement { + hass: HomeAssistant; + setConfig(config: unknown): void; +} + // A small wrapper around a HA conditional element used to render a set of // picture elements. @customElement('frigate-card-elements-core') -class FrigateCardElementsCore extends LitElement { +export class FrigateCardElementsCore extends LitElement { @property({ attribute: false }) - protected elements: PictureElements; + public elements: PictureElements; /** * Need to ensure card re-renders when conditionState changes, hence having it * as a property even though it is not currently directly used by this class. */ @property({ attribute: false }) - protected conditionState?: ConditionState; + public conditionState?: ConditionState; - protected _root: HTMLElement | null = null; - protected _hass?: HomeAssistant & ExtendedHomeAssistant; + protected _root: HuiConditionalElement | null = null; - /** - * Set Home Assistant object. - */ - set hass(hass: HomeAssistant & ExtendedHomeAssistant) { - if (this._root) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._root as any).hass = hass; - } - this._hass = hass; - } + @property({ attribute: false }) + public hass?: HomeAssistant; /** * Create a transparent render root. @@ -90,17 +101,16 @@ class FrigateCardElementsCore extends LitElement { /** * Create the root node for our picture elements. - * @returns + * @returns The newly created root. */ - protected _createRoot(): HTMLElement { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const elementConstructor = customElements.get('hui-conditional-element') as any; - if (!elementConstructor || !this._hass) { + protected _createRoot(): HuiConditionalElement { + const elementConstructor = customElements.get('hui-conditional-element'); + if (!elementConstructor || !this.hass) { throw new Error(localize('error.could_not_render_elements')); } - const element = new elementConstructor(); - element.hass = this._hass; + const element = new elementConstructor() as HuiConditionalElement; + element.hass = this.hass; const config = { type: 'conditional', conditions: [], @@ -109,26 +119,43 @@ class FrigateCardElementsCore extends LitElement { try { element.setConfig(config); } catch (e) { - console.error(e, (e as Error).stack); - throw new Error(localize('error.invalid_elements_config')); + console.error(e); + throw new FrigateCardError(localize('error.invalid_elements_config')); } return element; } + /** + * Create the root as necessary prior to rendering. + */ + protected willUpdate(changedProps: PropertyValues): void { + try { + // The root is only created once per elements configuration change, to + // avoid the elements being continually re-created & destroyed (for some + // elements, e.g. image, recreation causes a flicker). + if (this.elements && (!this._root || changedProps.has('elements'))) { + this._root = this._createRoot(); + } + } catch (e) { + return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); + } + } + /** * Render the elements. * @returns A rendered template or void. */ protected render(): TemplateResult | void { - try { - // Recreate the root on each render to ensure conditional ancestors - // re-fire events as necessary. - this._root = this._createRoot(); - } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); - } return html`${this._root || ''}`; } + + protected updated(): void { + if (this.hass && this._root) { + // Always update hass. It is used as a trigger to re-evaluate conditions + // down the chain, see the note on FrigateCardElementsConditional. + this._root.hass = this.hass; + } + } } /** @@ -137,16 +164,15 @@ class FrigateCardElementsCore extends LitElement { @customElement('frigate-card-elements') export class FrigateCardElements extends LitElement { @property({ attribute: false }) - public hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: HomeAssistant; @property({ attribute: false }) - protected elements: PictureElements; + public conditionState?: ConditionState; @property({ attribute: false }) - protected conditionState?: ConditionState; + public elements: PictureElements; - @query('frigate-card-elements-core') - _core!: FrigateCardElementsCore; + protected _boundMenuRemoveHandler = this._menuRemoveHandler.bind(this); /** * Handle a picture element to be removed from the menu. @@ -178,13 +204,10 @@ export class FrigateCardElements extends LitElement { // Ensure listener is only attached 1 time by removing it first. path[0].removeEventListener( 'frigate-card:menu-remove', - this._menuRemoveHandler.bind(this), + this._boundMenuRemoveHandler, ); - path[0].addEventListener( - 'frigate-card:menu-remove', - this._menuRemoveHandler.bind(this), - ); + path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler); } /** @@ -235,20 +258,13 @@ export class FrigateCardElements extends LitElement { @customElement('frigate-card-conditional') export class FrigateCardElementsConditional extends LitElement { protected _config?: FrigateConditional; - protected _hass?: HomeAssistant & ExtendedHomeAssistant; - @query('frigate-card-elements-core') - _core?: FrigateCardElementsCore; - - /** - * Set the Home Assistant object. - */ - set hass(hass: HomeAssistant & ExtendedHomeAssistant) { - if (this._core) { - this._core.hass = hass; - } - this._hass = hass; - } + // Every set of hass is treated as a reason to re-evaluate. Given that this + // node may be buried down the DOM (as a descendent of non-Frigate card + // elements), the hass object is used as the (only) trigger for condition + // re-fetch even if hass itself has not changed. + @property({ attribute: false, hasChanged: () => true }) + public hass?: HomeAssistant; /** * Set the card configuration. @@ -260,7 +276,7 @@ export class FrigateCardElementsConditional extends LitElement { /** * Create a root into which to render. This card is "transparent". - * @returns + * @returns */ createRenderRoot(): LitElement { return this; @@ -284,7 +300,7 @@ export class FrigateCardElementsConditional extends LitElement { protected render(): TemplateResult | void { if (fetchStateAndEvaluateCondition(this, this._config.conditions)) { return html` `; @@ -294,7 +310,7 @@ export class FrigateCardElementsConditional extends LitElement { // A base class for rendering menu icons / menu state icons. export class FrigateCardElementsBaseMenuIcon extends LitElement { - @property({ attribute: false }) + @state() protected _config: T | null = null; /** @@ -333,4 +349,128 @@ export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon {} @customElement('frigate-card-menu-submenu') -export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon {} \ No newline at end of file +export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon {} + +@customElement('frigate-card-menu-submenu-select') +export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon {} + +@customElement('frigate-card-ptz') +export class FrigateCardPTZ extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @state() + protected _config: FrigateCardPTZConfig | null = null; + + /** + * Set the card config. + * @param config The configuration. + */ + public setConfig(config: FrigateCardPTZConfig): void { + this._config = config; + } + + /** + * Called before each update. + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('_config')) { + this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical'); + } + } + + /** + * Handle a PTZ action. + * @param ev The actionHandler event. + * @param config The action configuration. + */ + protected _actionHandler( + ev: HASSDomEvent<{ action: string }>, + config?: ActionsConfig, + ): void { + // Nothing else has the configuration for this action, so don't let it + // propagate further. + ev.stopPropagation(); + + const interaction: string = ev.detail.action; + const action = getActionConfigGivenAction(interaction, config); + if (config && action && this.hass) { + frigateCardHandleActionConfig(this, this.hass, config, interaction, action); + } + } + + /** + * Render the elements. + * @returns A rendered template or void. + */ + protected render(): TemplateResult | void { + if (!this._config) { + return; + } + const renderIcon = ( + name: string, + icon: string, + actions?: Actions, + ): TemplateResult => { + const hasHold = frigateCardHasAction(actions?.hold_action); + const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action); + const classes = { + [name]: true, + disabled: !actions, + }; + + return html` this._actionHandler(ev, actions)} + >`; + }; + + return html`
+
+ ${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)} + ${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)} + ${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)} + ${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)} +
+ ${this._config.actions_zoom_in || this._config.actions_zoom_out + ? html`
+ ${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)} + ${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)} +
` + : html``} + ${this._config.actions_home + ? html` +
+ ${renderIcon('home', 'mdi:home', this._config.actions_home)} +
+ ` + : html``} +
`; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(ptzStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-conditional': FrigateCardElementsConditional; + 'frigate-card-elements': FrigateCardElements; + 'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect; + 'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu; + 'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon; + 'frigate-card-menu-icon': FrigateCardElementsMenuIcon; + 'frigate-card-elements-core': FrigateCardElementsCore; + 'frigate-card-ptz': FrigateCardPTZ; + } +} diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts index ce407027..078eac90 100644 --- a/src/components/embla-plugins/automedia.ts +++ b/src/components/embla-plugins/automedia.ts @@ -1,34 +1,65 @@ -import { EmblaCarouselType, EmblaPluginType } from 'embla-carousel'; -import { FrigateCardMediaPlayer } from '../../types.js'; +import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel'; +import { CreateOptionsType } from 'embla-carousel/components/Options.js'; +import { CreatePluginType } from 'embla-carousel/components/Plugins.js'; +import { + AutoMuteCondition, + AutoPauseCondition, + AutoPlayCondition, + AutoUnmuteCondition, + FrigateCardMediaPlayer, +} from '../../types.js'; -export type AutoMediaPluginOptionsType = { - playerSelector: string; - autoPlayWhenVisible?: boolean; - autoUnmuteWhenVisible?: boolean; +type OptionsType = CreateOptionsType<{ + playerSelector?: string; + + // Note: Neither play nor unmute will activate on selection. The caller is + // expected to call the `play()` or `unmute()` methods manually when the media + // is actually loaded (and not just when the slide is visible -- the browser + // cannot play media that is not actually loaded yet, e.g. lazy loading). + autoPlayCondition?: AutoPlayCondition; + autoUnmuteCondition?: AutoUnmuteCondition; + autoPauseCondition?: AutoPauseCondition; + autoMuteCondition?: AutoMuteCondition; +}>; + +const defaultOptions: OptionsType = { + active: true, + breakpoints: {}, }; -export const defaultOptions: Partial = { - autoPlayWhenVisible: true, - autoUnmuteWhenVisible: true, -}; +export type AutoMediaOptionsType = Partial -export type AutoMediaPluginType = EmblaPluginType & { - play: () => void; - pause: () => void; - mute: () => void; - unmute: () => void; +export type AutoMediaType = CreatePluginType< + { + play: () => void; + pause: () => void; + mute: () => void; + unmute: () => void; + }, + AutoMediaOptionsType +>; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + autoMedia?: AutoMediaType + } } /** * An Embla plugin to take automated actions on media (e.g. pause, unmute, etc). - * @param userOptions - * @returns + * @param userOptions + * @returns */ export function AutoMediaPlugin( - userOptions?: AutoMediaPluginOptionsType, -): AutoMediaPluginType { - const options = Object.assign({}, defaultOptions, userOptions); + userOptions?: AutoMediaOptionsType, +): AutoMediaType { + const optionsHandler = EmblaCarousel.optionsHandler(); + const optionsBase = optionsHandler.merge( + defaultOptions, + AutoMediaPlugin.globalOptions, + ); + let options: AutoMediaType['options']; let carousel: EmblaCarouselType; let slides: HTMLElement[]; @@ -37,15 +68,26 @@ export function AutoMediaPlugin( */ function init(embla: EmblaCarouselType): void { carousel = embla; + options = optionsHandler.atMedia(self.options); slides = carousel.slideNodes(); // Frigate card media autoplays when the media loads not necessarily when the // slide is selected, so only pause (and not play/unmute) based on carousel // events. carousel.on('destroy', pause); - carousel.on('select', pausePrevious); + if ( + options.autoPauseCondition && + ['all', 'unselected'].includes(options.autoPauseCondition) + ) { + carousel.on('select', pausePrevious); + } carousel.on('destroy', mute); - carousel.on('select', mutePrevious); + if ( + options.autoMuteCondition && + ['all', 'unselected'].includes(options.autoMuteCondition) + ) { + carousel.on('select', mutePrevious); + } document.addEventListener('visibilitychange', visibilityHandler); } @@ -55,9 +97,19 @@ export function AutoMediaPlugin( */ function destroy(): void { carousel.off('destroy', pause); - carousel.off('select', pausePrevious); + if ( + options.autoPauseCondition && + ['all', 'unselected'].includes(options.autoPauseCondition) + ) { + carousel.off('select', pausePrevious); + } carousel.off('destroy', mute); - carousel.off('select', mutePrevious); + if ( + options.autoMuteCondition && + ['all', 'unselected'].includes(options.autoMuteCondition) + ) { + carousel.off('select', mutePrevious); + } document.removeEventListener('visibilitychange', visibilityHandler); } @@ -65,15 +117,31 @@ export function AutoMediaPlugin( /** * Handle document visibility changes. */ - function visibilityHandler(): void { - if (document.visibilityState == 'hidden') { - pause(); - mute(); - } else if (document.visibilityState == 'visible') { - if (options.autoPlayWhenVisible) { + function visibilityHandler(): void { + if (document.visibilityState === 'hidden') { + if ( + options.autoPauseCondition && + ['all', 'hidden'].includes(options.autoPauseCondition) + ) { + pauseAll(); + } + if ( + options.autoMuteCondition && + ['all', 'hidden'].includes(options.autoMuteCondition) + ) { + muteAll(); + } + } else if (document.visibilityState === 'visible') { + if ( + options.autoPlayCondition && + ['all', 'visible'].includes(options.autoPlayCondition) + ) { play(); - } - if (options.autoUnmuteWhenVisible) { + } + if ( + options.autoUnmuteCondition && + ['all', 'visible'].includes(options.autoUnmuteCondition) + ) { unmute(); } } @@ -85,7 +153,9 @@ export function AutoMediaPlugin( * @returns A FrigateCardMediaPlayer object or `null`. */ function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null { - return slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null; + return options.playerSelector + ? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null) + : null; } /** @@ -109,6 +179,15 @@ export function AutoMediaPlugin( getPlayer(slides[carousel.previousScrollSnap()])?.pause(); } + /** + * Pause all slides. + */ + function pauseAll(): void { + for (const slide of slides) { + getPlayer(slide)?.pause(); + } + } + /** * Unmute the current slide. */ @@ -126,13 +205,22 @@ export function AutoMediaPlugin( /** * Mute the previous slide. */ - function mutePrevious(): void { + function mutePrevious(): void { getPlayer(slides[carousel.previousScrollSnap()])?.mute(); } - const self: AutoMediaPluginType = { - name: 'AutoMediaPlugin', - options, + /** + * Mute all slides. + */ + function muteAll(): void { + for (const slide of slides) { + getPlayer(slide)?.mute(); + } + } + + const self: AutoMediaType = { + name: 'autoMedia', + options: optionsHandler.merge(optionsBase, userOptions), init, destroy, play, @@ -142,3 +230,5 @@ export function AutoMediaPlugin( }; return self; } + +AutoMediaPlugin.globalOptions = undefined; diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 40a74282..3fbdb9f4 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -1,28 +1,47 @@ -import { EmblaCarouselType, EmblaEventType, EmblaPluginType } from 'embla-carousel'; +import { CreateOptionsType } from 'embla-carousel/components/Options'; +import { CreatePluginType } from 'embla-carousel/components/Plugins'; +import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; +import { LazyUnloadCondition } from '../../types'; -export type LazyloadOptionsType = { +export type OptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). - lazyloadCount?: number; + lazyLoadCount?: number; + lazyUnloadCondition?: LazyUnloadCondition; - lazyloadCallback?: (index: number, slide: HTMLElement) => void; - lazyunloadCallback?: (index: number, slide: HTMLElement) => void; + lazyLoadCallback?: (index: number, slide: HTMLElement) => void; + lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; +}>; + +export const defaultOptions: OptionsType = { + active: true, + breakpoints: {}, + lazyLoadCount: 0, }; -export const defaultOptions: Partial = { - lazyloadCount: 0, -}; +export type LazyloadOptionsType = Partial; -export type LazyloadType = EmblaPluginType & { - hasLazyloaded: (index: number) => boolean; -}; +export type LazyloadType = CreatePluginType< + { + hasLazyloaded(index: number): boolean; + }, + LazyloadOptionsType +>; + +declare module 'embla-carousel/components/Plugins' { + interface EmblaPluginsType { + lazyload?: LazyloadType; + } +} export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { - const options = Object.assign({}, defaultOptions, userOptions); + const optionsHandler = EmblaCarousel.optionsHandler(); + const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions); + let options: LazyloadType['options']; let carousel: EmblaCarouselType; let slides: HTMLElement[]; - const isSlideLazyloaded: Record = {}; + const lazyLoadedSlides: Set = new Set(); const loadEvents: EmblaEventType[] = ['init', 'select', 'resize']; const unloadEvents: EmblaEventType[] = ['select']; @@ -32,13 +51,18 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { */ function init(embla: EmblaCarouselType): void { carousel = embla; + options = optionsHandler.atMedia(self.options); slides = carousel.slideNodes(); - if (options.lazyloadCallback) { - loadEvents.forEach((evt) => carousel.on(evt, lazyloadHandler)); + if (options.lazyLoadCallback) { + loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler)); } - if (options.lazyunloadCallback) { - unloadEvents.forEach((evt) => carousel.on(evt, lazyunloadHandler)); + if ( + options.lazyUnloadCallback && + options.lazyUnloadCondition && + ['all', 'unselected'].includes(options.lazyUnloadCondition) + ) { + unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler)); } document.addEventListener('visibilitychange', visibilityHandler); } @@ -47,11 +71,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { * Destroy the plugin. */ function destroy(): void { - if (options.lazyloadCallback) { - loadEvents.forEach((evt) => carousel.off(evt, lazyloadHandler)); + if (options.lazyLoadCallback) { + loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler)); } - if (options.lazyunloadCallback) { - unloadEvents.forEach((evt) => carousel.off(evt, lazyunloadHandler)); + if (options.lazyUnloadCallback) { + unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler)); } document.removeEventListener('visibilitychange', visibilityHandler); } @@ -60,10 +84,15 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { * Handle document visibility changes. */ function visibilityHandler(): void { - if (document.visibilityState == 'hidden' && lazyunloadHandler) { - lazyunloadHandler(); - } else if (document.visibilityState == 'visible' && lazyloadHandler) { - lazyloadHandler(); + if ( + document.visibilityState === 'hidden' && + options.lazyUnloadCallback && + options.lazyUnloadCondition && + ['all', 'hidden'].includes(options.lazyUnloadCondition) + ) { + lazyUnloadAllHandler(); + } else if (document.visibilityState === 'visible' && options.lazyLoadCallback) { + lazyLoadHandler(); } } @@ -73,14 +102,14 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { * @returns `true` if the slide has been lazily loaded. */ function hasLazyloaded(index: number): boolean { - return !!isSlideLazyloaded[index]; + return lazyLoadedSlides.has(index); } /** * Lazily load media in the carousel. */ - function lazyloadHandler(): void { - const lazyLoadCount = options.lazyloadCount ?? 0; + function lazyLoadHandler(): void { + const lazyLoadCount = options.lazyLoadCount ?? 0; const currentIndex = carousel.selectedScrollSnap(); const slidesToLoad = new Set(); @@ -94,39 +123,45 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType { } slidesToLoad.forEach((index) => { - // Only lazy load slides that are not already loaded. - if (isSlideLazyloaded[index]) { - return; - } - if (options.lazyloadCallback) { - isSlideLazyloaded[index] = true; - options.lazyloadCallback(index, slides[index]); + if (!hasLazyloaded(index) && options.lazyLoadCallback) { + lazyLoadedSlides.add(index); + options.lazyLoadCallback(index, slides[index]); } }); } /** - * Lazily unload media in the carousel. + * Lazily unload all media in the carousel. */ - function lazyunloadHandler(): void { + function lazyUnloadAllHandler(): void { + lazyLoadedSlides.forEach((index) => { + if (options.lazyUnloadCallback) { + options.lazyUnloadCallback(index, slides[index]); + lazyLoadedSlides.delete(index); + } + }); + } + + /** + * Lazily unload the previously selected media in the carousel. + */ + function lazyUnloadPreviousHandler(): void { const index = carousel.previousScrollSnap(); - // Only lazy unload slides that are lazy loaded. - if (!isSlideLazyloaded[index]) { - return; - } - if (options.lazyunloadCallback) { - options.lazyunloadCallback(index, slides[index]); - isSlideLazyloaded[index] = false; + if (hasLazyloaded(index) && options.lazyUnloadCallback) { + options.lazyUnloadCallback(index, slides[index]); + lazyLoadedSlides.delete(index); } } const self: LazyloadType = { - name: 'Lazyload', - options, + name: 'lazyload', + options: optionsHandler.merge(optionsBase, userOptions), init, destroy, hasLazyloaded, }; return self; } + +Lazyload.globalOptions = undefined; diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 13fa510f..d0391272 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -1,59 +1,78 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; -import { HomeAssistant } from 'custom-card-helpers'; -import { customElement, property, state } from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; - +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import galleryStyle from '../scss/gallery.scss'; import { CameraConfig, ExtendedHomeAssistant, - GalleryConfig, frigateCardConfigDefaults, + GalleryConfig, + THUMBNAIL_WIDTH_MAX, } from '../types.js'; -import { BrowseMediaUtil } from '../browse-media-util.js'; +import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { + fetchChildMediaAndDispatchViewChange, + fetchLatestMediaAndDispatchViewChange, + getFullDependentBrowseMediaQueryParametersOrDispatchError, +} from '../utils/ha/browse-media'; import { View } from '../view.js'; import { renderProgressIndicator } from './message.js'; -import { stopEventFromActivatingCardWideActions } from '../common.js'; - -import galleryStyle from '../scss/gallery.scss'; - -const MAX_THUMBNAIL_WIDTH = 175; +import './thumbnail.js'; +import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: Readonly; + public view?: Readonly; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public galleryConfig?: GalleryConfig; @property({ attribute: false }) - protected galleryConfig?: GalleryConfig; + public cameras?: Map; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig) { + const mediaType = this.view?.getMediaType(); + if ( + !this.hass || + !this.view || + !this.cameras || + !this.view.isGalleryView() || + !mediaType + ) { return; } if (!this.view.target) { const browseMediaQueryParameters = - BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + getFullDependentBrowseMediaQueryParametersOrDispatchError( this, - this.view, - this.cameraConfig, + this.hass, + this.cameras, + this.view.camera, + mediaType, ); + if (!browseMediaQueryParameters) { return; } - BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( + fetchLatestMediaAndDispatchViewChange( this, this.hass, this.view, @@ -67,6 +86,7 @@ export class FrigateCardGallery extends LitElement { .hass=${this.hass} .view=${this.view} .galleryConfig=${this.galleryConfig} + .cameras=${this.cameras} > `; @@ -76,26 +96,32 @@ export class FrigateCardGallery extends LitElement { * Get element styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(galleryStyle); + return css` + :host { + display: block; + width: 100%; + height: 100%; + } + `; } } @customElement('frigate-card-gallery-core') export class FrigateCardGalleryCore extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: Readonly; + public view?: Readonly; @property({ attribute: false }) - protected galleryConfig?: GalleryConfig; + public galleryConfig?: GalleryConfig; + + @property({ attribute: false }) + public cameras?: Map; protected _resizeObserver: ResizeObserver; - @state() - protected _columns = frigateCardConfigDefaults.event_gallery.min_columns; - constructor() { super(); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); @@ -117,17 +143,64 @@ export class FrigateCardGalleryCore extends LitElement { super.disconnectedCallback(); } + /** + * Set gallery columns. + */ + protected _setColumnCount(): void { + const thumbnailSize = + this.galleryConfig?.controls.thumbnails.size ?? + frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; + const columns = this.galleryConfig?.controls.thumbnails.show_details + ? Math.max(1, Math.floor(this.clientWidth / THUMBNAIL_DETAILS_WIDTH_MIN)) + : Math.max( + 1, + Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX), + Math.ceil(this.clientWidth / thumbnailSize), + ); + + this.style.setProperty('--frigate-card-gallery-columns', String(columns)); + } + /** * Handle gallery resize. */ protected _resizeHandler(): void { - this._columns = Math.max( - this.galleryConfig?.min_columns ?? - frigateCardConfigDefaults.event_gallery.min_columns, - Math.ceil(this.clientWidth / MAX_THUMBNAIL_WIDTH), + this._setColumnCount(); + } + + /** + * Determine whether the back arrow should be displayed. + * @returns `true` if the back arrow should be displayed, `false` otherwise. + */ + protected _showBackArrow(): boolean { + return ( + !!this.view?.previous && + !!this.view.previous.target && + this.view.previous.view === this.view.view ); } + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('galleryConfig')) { + if (this.galleryConfig?.controls.thumbnails.show_details) { + this.setAttribute('details', ''); + } else { + this.removeAttribute('details'); + } + this._setColumnCount(); + if (this.galleryConfig?.controls.thumbnails.size) { + this.style.setProperty( + '--frigate-card-thumbnail-size', + `${this.galleryConfig.controls.thumbnails.size}px`, + ); + } + } + } + /** * Master render method. * @returns A rendered template. @@ -138,93 +211,78 @@ export class FrigateCardGalleryCore extends LitElement { !this.view || !this.view.target || !this.view.target.children || - !(this.view.is('clips') || this.view.is('snapshots')) + !(this.view.is('clips') || this.view.is('snapshots')) || + !this.cameras ) { return html``; } - const itemStyle = { - // Controls the number of columns in the gallery (allows for 5px gutter). - width: `calc(${100 / this._columns}% - 5.25px)`, - }; - - const folderStyle = { - // Values derived from experimentation on typical Lovelace card sizes. - 'font-size': `${Math.min( - 1.1, - (0.6 * (this.clientWidth / this._columns)) / 50.0, - )}em`, - }; - - return html` `; + `; } /** @@ -234,3 +292,10 @@ export class FrigateCardGalleryCore extends LitElement { return unsafeCSS(galleryStyle); } } + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-gallery-core': FrigateCardGalleryCore; + 'frigate-card-gallery': FrigateCardGallery; + } +} diff --git a/src/components/image.ts b/src/components/image.ts index 621a0aa9..a8f5a743 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,29 +1,29 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; import { CSSResultGroup, + html, LitElement, PropertyValues, TemplateResult, - html, - unsafeCSS, + unsafeCSS } from 'lit'; -import { HomeAssistant } from 'custom-card-helpers'; -import { customElement, property, query, state } from 'lit/decorators.js'; - +import { customElement, property } from 'lit/decorators.js'; +import { live } from 'lit/directives/live.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CachedValueController } from '../cached-value-controller.js'; -import { CameraConfig, ImageViewConfig } from '../types.js'; -import { View } from '../view.js'; -import { - dispatchErrorMessageEvent, - dispatchMediaShowEvent, - shouldUpdateBasedOnHass, -} from '../common.js'; -import { localize } from '../localize/localize.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 { isHassDifferent } from '../utils/ha'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import { View } from '../view.js'; +import { dispatchErrorMessageEvent } from './message.js'; +import { contentsChanged } from '../utils/basic.js'; -// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101 +// 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; @customElement('frigate-card-image') @@ -32,40 +32,31 @@ export class FrigateCardImage extends LitElement { public hass?: HomeAssistant; @property({ attribute: false }) - protected view?: Readonly; + public view?: Readonly; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public cameraConfig?: CameraConfig; - @state() - protected _imageConfig?: ImageViewConfig; + // Using contentsChanged to ensure overridden configs (e.g. when the + // 'show_image_during_load' option is true for live views, an overridden + // config may be used here). + @property({ attribute: false, hasChanged: contentsChanged }) + public imageConfig?: ImageViewConfig; - @query('img') - protected _image?: HTMLImageElement; + protected _refImage: Ref = createRef(); protected _cachedValueController?: CachedValueController; protected _boundVisibilityHandler = this._visibilityHandler.bind(this); - /** - * Set the image configuration. - */ - set imageConfig(imageConfig: ImageViewConfig) { - this._imageConfig = imageConfig; - if (this._cachedValueController) { - this._cachedValueController.removeController(); - } - this._cachedValueController = new CachedValueController( - this, - this._imageConfig.refresh_seconds, - this._getImageSource.bind(this), - ); - } /** * Get the camera entity for the current camera configuration. * @returns The entity or undefined if no camera entity is available. */ - protected _getCameraEntity(): string | undefined { - return this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity; + protected _getCameraEntity(): string | null { + return ( + (this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity) ?? + null + ); } /** @@ -78,30 +69,14 @@ export class FrigateCardImage extends LitElement { return false; } - // If camera mode is enabled, reject all updates if hass is older than - // HASS_REJECTION_CUTOFF_MS or if HASS is not currently connected. By using - // an older hass (even if it is not the property being updated), we run the - // risk that the JS has an old access token for the camera, and that results - // in a notification on the HA UI about a failed login. See - // https://github.com/dermotduffy/frigate-hass-card/issues/398 . const cameraEntity = this._getCameraEntity(); - const state = cameraEntity ? this.hass.states[cameraEntity] : undefined; - if ( - this._imageConfig?.mode === 'camera' && - (!this.hass.connected || - !state || - Date.now() - Date.parse(state.last_updated) >= HASS_REJECTION_CUTOFF_MS) - ) { - return false; - } - if ( changedProps.has('hass') && changedProps.size == 1 && - this._imageConfig?.mode === 'camera' && + this.imageConfig?.mode === 'camera' && cameraEntity ) { - if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) { + if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) { // If the state of the camera entity has changed, remove the cached // value (will be re-calculated in willUpdate). This is important to // ensure a changed access token is immediately used. @@ -117,25 +92,73 @@ export class FrigateCardImage extends LitElement { * Ensure there is a cached value before an update. * @param _changedProps The changed properties */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected willUpdate(_changedProps: PropertyValues): void { + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('imageConfig')) { + if (this._cachedValueController) { + this._cachedValueController.removeController(); + } + if (this.imageConfig) { + this._cachedValueController = new CachedValueController( + this, + this.imageConfig.refresh_seconds, + this._getImageSource.bind(this), + ); + } + updateElementStyleFromMediaLayoutConfig(this, this.imageConfig?.layout); + } + + // If the camera or view changed, immediately discard the old value (view to + // allow pressing of the image button to fetch a fresh image). Likewise, if + // the state is not acceptable, discard the old value (to allow a stock or + // backup image to be displayed). + if ( + changedProps.has('cameraConfig') || + changedProps.has('view') || + (this.imageConfig?.mode === 'camera' && + !this._getAcceptableState(this._getCameraEntity())) + ) { + this._cachedValueController?.clearValue(); + } + if (!this._cachedValueController?.value) { this._cachedValueController?.updateValue(); } } + /** + * Determine if a given entity is acceptable as the basis for an image render + * (detects old or disconnected states). Using an old state is problematic as + * it runs the risk that the JS has an old access token for the camera, and + * that results in a notification on the HA UI about a failed login. See: + * https://github.com/dermotduffy/frigate-hass-card/issues/398 . + * @param entity The entity. + * @returns The state or null if not acceptable. + */ + protected _getAcceptableState(entity: string | null): HassEntity | null { + const state = (entity ? this.hass?.states[entity] : null) ?? null; + + return !!this.hass && + this.hass.connected && + !!state && + Date.now() - Date.parse(state.last_updated) < HASS_REJECTION_CUTOFF_MS + ? state + : null; + } + /** * Component connected callback. */ connectedCallback(): void { super.connectedCallback(); document.addEventListener('visibilitychange', this._boundVisibilityHandler); + this._cachedValueController?.startTimer(); } /** * Component disconnected callback. */ disconnectedCallback(): void { + this._cachedValueController?.stopTimer(); document.removeEventListener('visibilitychange', this._boundVisibilityHandler); super.disconnectedCallback(); } @@ -144,7 +167,7 @@ export class FrigateCardImage extends LitElement { * Handle document visibility changes. */ protected _visibilityHandler(): void { - if (!this._image) { + if (!this._refImage.value) { return; } if (document.visibilityState === 'hidden') { @@ -155,13 +178,15 @@ export class FrigateCardImage extends LitElement { // re-generation of a new URL would generate an unauthorized request // (401), see: // https://github.com/dermotduffy/frigate-hass-card/issues/398 + this._cachedValueController?.stopTimer(); this._cachedValueController?.clearValue(); - this._forceStockImage(); + this._forceSafeImage(); } else { // If the document is freshly re-visible, immediately re-render it to // restore the image src. If the HASS object is old (i.e. browser tab was // inactive for some time) this update request may be (correctly) // rejected. + this._cachedValueController?.startTimer(); this.requestUpdate(); } } @@ -178,49 +203,53 @@ export class FrigateCardImage extends LitElement { } protected _getImageSource(): string { - if (this._imageConfig?.mode === 'url' && this._imageConfig?.url) { - return this._buildImageURL(this._imageConfig.url); - } else if (this.hass && this._imageConfig?.mode === 'camera') { - const entity = this._getCameraEntity(); - if (entity) { - const state = this.hass.states[entity]; - if (state && state.attributes.entity_picture) { - return this._buildImageURL(state.attributes.entity_picture); - } + if (this.hass && this.imageConfig?.mode === 'camera') { + const state = this._getAcceptableState(this._getCameraEntity()); + if (state?.attributes.entity_picture) { + return this._buildImageURL(state.attributes.entity_picture); } } + if (this.imageConfig?.mode !== 'screensaver' && this.imageConfig?.url) { + return this._buildImageURL(this.imageConfig.url); + } return defaultImage; } /** - * Force the img element to the stock image. + * Force the img element to a safe image. */ - protected _forceStockImage(): void { - if (this._image) { - this._image.src = defaultImage; + protected _forceSafeImage(stockOnly?: boolean): void { + if (this._refImage.value) { + this._refImage.value.src = + !stockOnly && this.imageConfig?.url ? this.imageConfig.url : defaultImage; } } protected render(): TemplateResult | void { const src = this._cachedValueController?.value; + // Note the use of live() below to ensure the update will restore the image + // src if it's been changed via _forceSafeImage(). return src ? html` { - dispatchMediaShowEvent(this, ev); + ${ref(this._refImage)} + src=${live(src)} + @load=${(ev: Event) => { + dispatchMediaLoadedEvent(this, ev); }} @error=${() => { - if (this._imageConfig?.mode === 'camera') { + if (this.imageConfig?.mode === 'camera') { // In camera mode, the user has likely not made an error, but HA - // may be unavailble, so show the stock image. - this._forceStockImage(); - } else if (this._imageConfig?.mode === 'url') { + // may be unavailble, so show the stock image. Don't let the URL + // override the stock image in this case, as this could create an + // error loop if that URL subsequently failed to load. + this._forceSafeImage(true); + } 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'), - this._imageConfig, + { context: this.imageConfig }, ); } }} @@ -232,3 +261,9 @@ export class FrigateCardImage extends LitElement { return unsafeCSS(imageStyle); } } + +declare global { + interface HTMLElementTagNameMap { + "frigate-card-image": FrigateCardImage + } +} diff --git a/src/components/live.ts b/src/components/live.ts index cb54870a..9b67c980 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,66 +1,73 @@ +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 { CSSResultGroup, - LitElement, - TemplateResult, html, - unsafeCSS, + LitElement, PropertyValues, + TemplateResult, + unsafeCSS, } from 'lit'; -import { - BrowseMediaSource, - ExtendedHomeAssistant, - CameraConfig, - JSMPEGConfig, - LiveConfig, - MediaShowInfo, - WebRTCCardConfig, - FrigateCardError, - FrigateCardMediaPlayer, - LiveOverrides, - LiveProvider, - TransitionEffect, - frigateCardConfigDefaults, -} from '../types.js'; -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; -import { HomeAssistant } from 'custom-card-helpers'; -import JSMpeg from '@cycjimmy/jsmpeg-player'; -import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import { Task } from '@lit-labs/task'; -import { customElement, property, query, state } from 'lit/decorators.js'; +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 { styleMap } from 'lit/directives/style-map.js'; - -import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js'; -import { BrowseMediaUtil } from '../browse-media-util.js'; import { ConditionState, getOverriddenConfig } from '../card-condition.js'; -import { FrigateCardMediaCarousel } from './media-carousel.js'; -import { FrigateCardNextPreviousControl } from './next-prev-control.js'; -import { - FrigateCardThumbnailCarousel, - ThumbnailCarouselTap, -} from './thumbnail-carousel.js'; -import { Lazyload } from './embla-plugins/lazyload.js'; -import { View } from '../view.js'; +import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import { localize } from '../localize/localize.js'; -import { - contentsChanged, - dispatchErrorMessageEvent, - dispatchExistingMediaShowInfoAsEvent, - dispatchMediaShowEvent, - getCameraIcon, - getCameraTitle, - homeAssistantSignPath, - stopEventFromActivatingCardWideActions, -} from '../common.js'; -import { renderProgressIndicator } from '../components/message.js'; - -import './next-prev-control.js'; -import './title-control.js'; - -import liveStyle from '../scss/live.scss'; 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 { + CameraConfig, + 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'; +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'; +import { + FrigateCardMediaCarousel, + wrapMediaLoadedEventForCarousel, + wrapMediaUnloadedEventForCarousel, +} from './media-carousel.js'; +import { dispatchErrorMessageEvent } from './message.js'; +import './next-prev-control.js'; +import './title-control.js'; +import './surround-thumbnails'; +import '../patches/ha-camera-stream'; +import { EmblaCarouselPlugins } from './carousel.js'; +import { renderTask } from '../utils/task.js'; +import { classMap } from 'lit/directives/class-map.js'; +import './image'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; // Number of seconds a signed URL is valid for. const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -71,120 +78,97 @@ const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; @customElement('frigate-card-live') export class FrigateCardLive extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public conditionState?: ConditionState; @property({ attribute: false }) - protected view?: Readonly; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected cameras?: Map; + public view?: Readonly; @property({ attribute: false }) - protected liveConfig?: LiveConfig; + public cameras?: Map; @property({ attribute: false }) - protected liveOverrides?: LiveOverrides; + public liveConfig?: LiveConfig; - @property({ attribute: false }) - protected conditionState?: ConditionState; + @property({ attribute: false, hasChanged: contentsChanged }) + public liveOverrides?: LiveOverrides; - set preloaded(preloaded: boolean) { - this._preloaded = preloaded; - - if (!preloaded && this._savedMediaShowInfo) { - dispatchExistingMediaShowInfoAsEvent(this, this._savedMediaShowInfo); - } - } - - // Whether or not the live view is currently being preloaded. + // Whether or not the live view is currently in the background (i.e. preloaded + // but not visible) @state() - protected _preloaded?: boolean; + protected _inBackground?: boolean = true; - // MediaShowInfo object from the underlying live object. In the case of - // pre-loading it may be propagated upwards later. - protected _savedMediaShowInfo?: MediaShowInfo; + // Intersection handler is used to detect when the live view flips between + // foreground and background (in preload mode). + protected _intersectionObserver: IntersectionObserver; - @query('frigate-card-thumbnail-carousel') - protected _thumbnailCarousel?: FrigateCardThumbnailCarousel; + // MediaLoadedInfo object and message from the underlying live object. In the + // case of pre-loading these may be propagated upwards later. + protected _backgroundMediaLoadedInfo: MediaLoadedInfo | null = null; + protected _messageReceivedPostRender = false; + protected _renderKey = 0; + + constructor() { + super(); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + } /** - * Handler for media show events that special cases preloaded live views. - * @param e The media show event. + * Called when the live view intersects with the viewport. + * @param entries The IntersectionObserverEntry entries (should be only 1). */ - protected _mediaShowHandler(e: CustomEvent): void { - this._savedMediaShowInfo = e.detail; - if (this._preloaded) { - // If live is being pre-loaded, don't let the event propagate upwards yet - // as the media is not really being shown. - e.stopPropagation(); + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + this._inBackground = entries.every((entry) => !entry.isIntersecting); + + if ( + !this._inBackground && + !this._messageReceivedPostRender && + this._backgroundMediaLoadedInfo + ) { + // If this isn't being rendered in the background, the last render did not + // generate a message and there's a saved MediaInfo, dispatch it upwards. + dispatchExistingMediaLoadedInfoAsEvent(this, this._backgroundMediaLoadedInfo); + this._backgroundMediaLoadedInfo = null; + } + + // Trigger a re-render which may be necessary if the prior render resulted + // in a message. + if (this._messageReceivedPostRender && !this._inBackground) { + this.requestUpdate(); } } /** - * Render thumbnails carousel. - * @returns A rendered template or void. + * Determine whether the element should be updated. + * @param _changedProps The changed properties if any. + * @returns `true` if the element should be updated. */ - protected renderThumbnails(config: LiveConfig): TemplateResult | void { - if (!this.liveConfig || !this.view) { - return; - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldUpdate(_changedProps: PropertyValues): boolean { + // Don't process updates if it's in the background and a message was + // received (otherwise an error message thrown by the background live + // component may continually be re-spammed hitting performance). + return !this._inBackground || !this._messageReceivedPostRender; + } - const fetchThumbnailsThenRender = async (): Promise => { - if (!this.hass || !this.cameras || !this.view) { - return; - } - const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( - config.controls.thumbnails.media, - this.cameras.get(this.view.camera), - ); - if (!browseMediaParams) { - return; - } - let parent: BrowseMediaSource | null; - try { - parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams); - } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); - } + /** + * Component connected callback. + */ + connectedCallback(): void { + this._intersectionObserver.observe(this); + super.connectedCallback(); + } - if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { - return html`) => { - const mediaType = browseMediaParams.mediaType; - if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { - new View({ - view: mediaType === 'clips' ? 'clip' : 'snapshot', - camera: this.view.camera, - target: ev.detail.target, - childIndex: ev.detail.childIndex, - }).dispatchChangeEvent(this); - } - }} - > - `; - } - }; - - const fillerStyle = { - height: config.controls.thumbnails.size, - }; - - // As the live carousel moves, thumbnails are re-fetched. This is an async - // request, so it can jarring to the user to have the main camera view nudge - // up/down as the thumbnails disappear and re-appear. Instead, if there was - // previously a thumbnail carousel rendered, use a filler that is the same - // size until it is replaced with a real carousel (or empty, if no carousel - // is rendered for the next camera). - return html`${until( - fetchThumbnailsThenRender(), - this._thumbnailCarousel - ? html`
` - : html``, - )}`; + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this._intersectionObserver.disconnect(); } /** @@ -192,7 +176,7 @@ export class FrigateCardLive extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.liveConfig || !this.cameras) { + if (!this.hass || !this.liveConfig || !this.cameras || !this.view) { return; } @@ -202,34 +186,70 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; - // Note use of liveConfig and not config below -- the carousel will - // independently override the liveconfig to reflect the camera in the - // carousel (not necessarily the selected camera). - return html` - ${config.controls.thumbnails.mode === 'above' ? this.renderThumbnails(config) : ''} - { - if (this._preloaded) { - // Don't allow change-view events to propagate upwards if the card - // is only preloaded rather than being live displayed. These events - // could be triggered if the camera is switched and the carousel - // moves to focus on that camera -- as the card isn't actually being - // displayed, do not allow the view to actually be updated. + ?fetch=${!this._inBackground} + @frigate-card:message=${(ev: CustomEvent) => { + this._renderKey++; + this._messageReceivedPostRender = true; + if (this._inBackground) { + ev.stopPropagation(); + } + }} + @frigate-card:media:loaded=${(ev: CustomEvent) => { + if (this._inBackground) { + this._backgroundMediaLoadedInfo = ev.detail; + ev.stopPropagation(); + } + }} + @frigate-card:view:change=${(ev: CustomEvent) => { + if (this._inBackground) { ev.stopPropagation(); } }} > - - ${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''} - `; + + + `, + )}`; + + this._messageReceivedPostRender = false; + return result; } /** @@ -241,76 +261,72 @@ export class FrigateCardLive extends LitElement { } @customElement('frigate-card-live-carousel') -export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { +export class FrigateCardLiveCarousel extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: Readonly; + public view?: Readonly; @property({ attribute: false }) - protected cameras?: Map; + public cameras?: Map; @property({ attribute: false }) - protected liveConfig?: LiveConfig; + public liveConfig?: LiveConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public liveOverrides?: LiveOverrides; @property({ attribute: false }) - protected liveOverrides?: LiveOverrides; + public inBackground?: boolean; @property({ attribute: false }) - protected preloaded?: boolean; - - @property({ attribute: false }) - protected conditionState?: ConditionState; + public conditionState?: ConditionState; // Index between camera name and slide number. protected _cameraToSlide: Record = {}; + protected _refMediaCarousel: Ref = createRef(); /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - if ( - this._carousel && - (changedProperties.has('cameras') || changedProperties.has('liveConfig')) - ) { - // All of these properties may fundamentally change the contents/size of - // the DOM, and the carousel should be reset when they change. - this._destroyCarousel(); - } - super.updated(changedProperties); + const frigateCardMediaCarousel = this._refMediaCarousel.value; + const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel(); + if (changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; if ( - this._carousel && - oldView && + frigateCardCarousel && this.view?.camera && - this.view?.camera != oldView.camera + (!oldView || this.view?.camera !== oldView.camera) ) { const slide: number | undefined = this._cameraToSlide[this.view.camera]; - if (slide !== undefined && slide !== this.carouselSelected()) { - this.carouselScrollTo(slide); + if ( + slide !== undefined && + slide !== frigateCardCarousel.getCarouselSelected()?.index + ) { + frigateCardCarousel.carouselScrollTo(slide); } } } - if (changedProperties.has('preloaded')) { - const automedia = this._plugins['AutoMediaPlugin'] as - | AutoMediaPluginType - | undefined; - if (automedia) { - // If this has changed to preloaded then pause & mute, otherwise play - // and potentially unmute (depending on configuration). - if (this.preloaded) { - automedia.pause(); - automedia.mute(); - } else { - automedia.play(); - this._autoUnmuteHandler(); - } + if ( + frigateCardMediaCarousel && + frigateCardCarousel && + changedProperties.has('inBackground') + ) { + // If this has changed to be in the background (i.e. preloaded but not + // visible) take the appropriate play/pause/mute/unmute actions. + if (this.inBackground) { + frigateCardMediaCarousel.autoPause(); + frigateCardMediaCarousel.autoMute(); + } else { + frigateCardMediaCarousel.autoPlay(); + frigateCardMediaCarousel.autoUnmute(); } } } @@ -319,8 +335,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { * Get the transition effect to use. * @returns An TransitionEffect object. */ - protected _getTransitionEffect(): TransitionEffect | undefined { - return this.liveConfig?.transition_effect; + protected _getTransitionEffect(): TransitionEffect { + return ( + this.liveConfig?.transition_effect ?? + frigateCardConfigDefaults.live.transition_effect + ); } /** @@ -328,13 +347,11 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { * @returns An EmblaOptionsType object or undefined for no options. */ protected _getOptions(): EmblaOptionsType { - let startIndex = -1; - if (this.cameras && this.view) { - startIndex = Array.from(this.cameras.keys()).indexOf(this.view.camera); - } - return { - startIndex: startIndex < 0 ? undefined : startIndex, + startIndex: + this.cameras && this.view + ? Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera)) + : 0, draggable: this.liveConfig?.draggable, loop: true, }; @@ -342,34 +359,48 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { /** * Get the Embla plugins to use. - * @returns An EmblaOptionsType object or undefined for no options. + * @returns A list of EmblaOptionsTypes. */ - protected _getPlugins(): EmblaPluginType[] | undefined { + protected _getPlugins(): EmblaCarouselPlugins { return [ + // Only enable wheel plugin if there is more than one camera. + ...(this.cameras && this.cameras.size > 1 + ? [ + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', + }), + ] + : []), Lazyload({ - lazyloadCallback: this.liveConfig?.lazy_load - ? (...args) => this._lazyloadOrUnloadSlide('load', ...args) - : undefined, - lazyunloadCallback: this.liveConfig?.lazy_unload - ? (...args) => this._lazyloadOrUnloadSlide('unload', ...args) - : undefined, + ...(this.liveConfig?.lazy_load && { + lazyLoadCallback: (index, slide) => + this._lazyloadOrUnloadSlide('load', index, slide), + }), + + lazyUnloadCondition: this.liveConfig?.lazy_unload, + lazyUnloadCallback: (index, slide) => + this._lazyloadOrUnloadSlide('unload', index, slide), }), AutoMediaPlugin({ playerSelector: 'frigate-card-live-provider', - autoUnmuteWhenVisible: !!this.liveConfig?.auto_unmute, + ...(this.liveConfig?.auto_play && { + autoPlayCondition: this.liveConfig.auto_play, + }), + ...(this.liveConfig?.auto_pause && { + autoPauseCondition: this.liveConfig.auto_pause, + }), + ...(this.liveConfig?.auto_mute && { + autoMuteCondition: this.liveConfig.auto_mute, + }), + ...(this.liveConfig?.auto_unmute && { + autoUnmuteCondition: this.liveConfig.auto_unmute, + }), }), ]; } - /** - * Unmute the media on the selected slide. - */ - protected _autoUnmuteHandler(): void { - if (this.liveConfig?.auto_unmute) { - super._autoUnmuteHandler(); - } - } - /** * Returns the number of slides to lazily load. 0 means all slides are lazy * loaded, 1 means that 1 slide on each side of the currently selected slide @@ -408,17 +439,25 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { /** * Handle the user selecting a new slide in the carousel. */ - protected _selectSlideSetViewHandler(): void { - if (!this._carousel || !this.view || !this.cameras) { + protected _setViewHandler(): void { + const selectedCameraIndex = this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.getCarouselSelected()?.index; + if (selectedCameraIndex === undefined || !this.view || !this.cameras) { return; } - const selectedSnap = this._carousel.selectedScrollSnap(); this.view .evolve({ - camera: Array.from(this.cameras.keys())[selectedSnap], - previous: this.view, + camera: Array.from(this.cameras.keys())[selectedCameraIndex], + + // Reset the target. + target: null, + childIndex: null, }) + // Don't yet fetch thumbnails (they will be fetched when the carousel + // settles). + .mergeInContext({ thumbnails: { fetch: false } }) .dispatchChangeEvent(this); } @@ -430,13 +469,17 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { protected _lazyloadOrUnloadSlide( action: 'load' | 'unload', _index: number, - slide: HTMLElement, + slide: Element, ): void { - const liveProvider = slide.querySelector( + if (slide instanceof HTMLSlotElement) { + slide = slide.assignedElements({ flatten: true })[0]; + } + + const liveProvider = slide?.querySelector( 'frigate-card-live-provider', ) as FrigateCardLiveProvider; if (liveProvider) { - liveProvider.disabled = action == 'load' ? false : true; + liveProvider.disabled = action !== 'load'; } } @@ -451,10 +494,10 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { // The conditionState object contains the currently live camera, which (in // the carousel for example) is not necessarily the live camera this // is rendering right now. - const conditionState = Object.assign({ + const conditionState = { ...this.conditionState, camera: camera, - }); + }; const config = getOverriddenConfig( this.liveConfig, @@ -462,18 +505,24 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { conditionState, ) as LiveConfig; - return html`
- ) => - this._mediaShowEventHandler(slideIndex, e)} - > - -
`; + return html` +
+ ) => { + wrapMediaLoadedEventForCarousel(slideIndex, ev); + }} + @frigate-card:media:unloaded=${(ev: CustomEvent) => { + wrapMediaUnloadedEventForCarousel(slideIndex, ev); + }} + > + +
+ `; } protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] { @@ -498,30 +547,6 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { return [prev, next]; } - /** - * Handle updating of the next/previous controls when the carousel is moved. - */ - protected _selectSlideNextPreviousHandler(): void { - const updateNextPreviousControl = ( - control: FrigateCardNextPreviousControl, - direction: 'previous' | 'next', - ): void => { - const [prev, next] = this._getCameraNeighbors(); - const target = direction == 'previous' ? prev : next; - - control.disabled = target == null; - control.title = getCameraTitle(this.hass, target); - control.icon = getCameraIcon(this.hass, target); - }; - - if (this._previousControlRef.value) { - updateNextPreviousControl(this._previousControlRef.value, 'previous'); - } - if (this._nextControlRef.value) { - updateNextPreviousControl(this._nextControlRef.value, 'next'); - } - } - /** * Render the element. * @returns A template to display to the user. @@ -529,7 +554,7 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { protected render(): TemplateResult | void { const [slides, cameraToSlide] = this._getSlides(); this._cameraToSlide = cameraToSlide; - if (!slides || !this.liveConfig || !this.cameras || !this.view) { + if (!slides.length || !this.liveConfig || !this.cameras || !this.view) { return; } @@ -542,59 +567,89 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel { const [prev, next] = this._getCameraNeighbors(); const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera)); + // Notes on the below: + // - guard() is used to avoid reseting the carousel unless the + // options/plugins actually change. + // - the 'carousel:settle' event is listened for (instead of + // 'carousel:select') to only trigger the view change (which subsequently + // fetches thumbnails) after the carousel has stopped moving. This gives a + // much smoother carousel experience since network fetches are not at the + // same time as carousel movement (at a cost of fetching thumbnails a + // little later). + return html` -
+ { + // Fetch the thumbnails after the carousel has settled. + dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } }); + }} + > { - this._nextPreviousHandler('previous'); + this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.carouselScrollPrevious(); stopEventFromActivatingCardWideActions(ev); }} > -
-
${slides}
-
+ ${slides} { - this._nextPreviousHandler('next'); + this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); stopEventFromActivatingCardWideActions(ev); }} > -
- - + `; } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveCarouselStyle); + } } @customElement('frigate-card-live-provider') export class FrigateCardLiveProvider extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public cameraConfig?: CameraConfig; @property({ attribute: false }) - protected liveConfig?: LiveConfig; + public liveConfig?: LiveConfig; // Whether or not to disable this entity. If `true`, no contents are rendered // until this attribute is set to `false` (this is useful for lazy loading). @@ -605,6 +660,9 @@ export class FrigateCardLiveProvider extends LitElement { @property({ attribute: false }) public label = ''; + @state() + protected _isVideoMediaLoaded = false; + protected _providerRef: Ref = createRef(); /** @@ -635,7 +693,18 @@ export class FrigateCardLiveProvider extends LitElement { this._providerRef.value?.unmute(); } - protected _getResolvedProvider(): LiveProvider { + /** + * Seek the video. + */ + public seek(seconds: number): void { + this._providerRef.value?.seek(seconds); + } + + /** + * Get the fully resolved live provider. + * @returns A live provider (that is not 'auto'). + */ + protected _getResolvedProvider(): Omit { if (this.cameraConfig?.live_provider === 'auto') { if ( this.cameraConfig?.webrtc_card?.entity || @@ -644,7 +713,7 @@ export class FrigateCardLiveProvider extends LitElement { return 'webrtc-card'; } else if (this.cameraConfig?.camera_entity) { return 'ha'; - } else if (this.cameraConfig?.camera_name) { + } else if (this.cameraConfig?.frigate.camera_name) { return 'frigate-jsmpeg'; } return frigateCardConfigDefaults.cameras.live_provider; @@ -654,6 +723,48 @@ export class FrigateCardLiveProvider extends LitElement { ); } + /** + * Determine if a camera image should be shown in lieu of the real stream + * whilst loading. + * @returns`true` if an image should be shown. + */ + protected _shouldShowImageDuringLoading(): boolean { + return ( + !!this.cameraConfig?.camera_entity && + !!this.hass && + !!this.liveConfig?.show_image_during_load + ); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + this._isVideoMediaLoaded = false; + } + + /** + * Record that video media is being shown. + */ + protected _videoMediaShowHandler(): void { + this._isVideoMediaLoaded = true; + } + + /** + * Called before each update. + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('disabled')) { + if (this.disabled) { + this._isVideoMediaLoaded = false; + dispatchMediaUnloadedEvent(this); + } + } + if (changedProps.has('liveConfig')) { + updateElementStyleFromMediaLayoutConfig(this, this.liveConfig?.layout); + } + } + /** * Master render method. * @returns A rendered template. @@ -668,41 +779,69 @@ export class FrigateCardLiveProvider extends LitElement { this.ariaLabel = this.label; const provider = this._getResolvedProvider(); + const showImage = !this._isVideoMediaLoaded && this._shouldShowImageDuringLoading(); + const providerClasses = { + hidden: showImage, + }; return html` - ${provider == 'ha' - ? html` +
` + : html``} + ${provider === 'ha' + ? html` ` - : provider == 'webrtc-card' + : provider === 'webrtc-card' ? html` ` : html` `} `; } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveProviderStyle); + } } @customElement('frigate-card-live-ha') export class FrigateCardLiveFrigate extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: HomeAssistant; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public cameraConfig?: CameraConfig; protected _playerRef: Ref = createRef(); @@ -734,6 +873,13 @@ export class FrigateCardLiveFrigate extends LitElement { this._playerRef.value?.unmute(); } + /** + * Seek the video. + */ + public seek(seconds: number): void { + this._playerRef.value?.seek(seconds); + } + /** * Master render method. * @returns A rendered template. @@ -744,19 +890,28 @@ export class FrigateCardLiveFrigate extends LitElement { } if (!this.cameraConfig?.camera_entity) { - return dispatchErrorMessageEvent( - this, - localize('error.no_live_camera'), - this.cameraConfig, - ); + return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), { + context: this.cameraConfig, + }); } const stateObj = this.hass.states[this.cameraConfig.camera_entity]; - if (!stateObj || stateObj.state === 'unavailable') { - return dispatchErrorMessageEvent( + 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'), - this.cameraConfig, + 'info', + { + icon: 'mdi:connection', + context: getCameraTitle(this.hass, this.cameraConfig), + }, ); } @@ -783,12 +938,12 @@ export class FrigateCardLiveFrigate extends LitElement { @customElement('frigate-card-live-webrtc-card') export class FrigateCardLiveWebRTCCard extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) - protected webRTCConfig?: WebRTCCardConfig; + public webRTCConfig?: WebRTCCardConfig; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public cameraConfig?: CameraConfig; - protected hass?: HomeAssistant & ExtendedHomeAssistant; + protected hass?: HomeAssistant; // A task to await the load of the WebRTC component. protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]); @@ -833,6 +988,16 @@ export class FrigateCardLiveWebRTCCard extends LitElement { } } + /** + * Seek the video. + */ + public seek(seconds: number): void { + const player = this._getPlayer(); + if (player) { + player.currentTime = seconds; + } + } + /** * Get the underlying video player. * @returns The player or `null` if not found. @@ -851,7 +1016,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { /** * Create the WebRTC element. May throw. */ - protected _createWebRTC(): HTMLElement | undefined { + protected _createWebRTC(): HTMLElement | null { // eslint-disable-next-line @typescript-eslint/no-explicit-any const webrtcElement = this._webrtcTask.value; if (webrtcElement && this.hass) { @@ -874,7 +1039,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { webrtc.hass = this.hass; return webrtc; } - return undefined; + return null; } /** @@ -883,29 +1048,33 @@ export class FrigateCardLiveWebRTCCard extends LitElement { */ protected render(): TemplateResult | void { const render = (): TemplateResult | void => { - let webrtcElement: HTMLElement | undefined; + let webrtcElement: HTMLElement | null; try { webrtcElement = this._createWebRTC(); } catch (e) { return dispatchErrorMessageEvent( this, e instanceof FrigateCardError - ? (e as FrigateCardError).message + ? 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 html`${this._webrtcTask.render({ - initial: () => renderProgressIndicator(localize('error.webrtc_card_waiting')), - pending: () => renderProgressIndicator(localize('error.webrtc_card_waiting')), - error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message), - complete: () => render(), - })}`; + return renderTask(this, this._webrtcTask, render, () => + renderProgressIndicator(localize('error.webrtc_card_waiting')), + ); } /** @@ -917,13 +1086,13 @@ export class FrigateCardLiveWebRTCCard extends LitElement { this.updateComplete.then(() => { const video = this._getPlayer(); if (video) { - const onloadedmetadata = video.onloadedmetadata; + const onloadeddata = video.onloadeddata; - video.onloadedmetadata = (e) => { - if (onloadedmetadata) { - onloadedmetadata.call(video, e); + video.onloadeddata = (e) => { + if (onloadeddata) { + onloadeddata.call(video, e); } - dispatchMediaShowEvent(this, video); + dispatchMediaLoadedEvent(this, video); }; } }); @@ -940,12 +1109,12 @@ export class FrigateCardLiveWebRTCCard extends LitElement { @customElement('frigate-card-live-jsmpeg') export class FrigateCardLiveJSMPEG extends LitElement { @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public cameraConfig?: CameraConfig; @property({ attribute: false, hasChanged: contentsChanged }) - protected jsmpegConfig?: JSMPEGConfig; + public jsmpegConfig?: JSMPEGConfig; - protected hass?: HomeAssistant & ExtendedHomeAssistant; + protected hass?: ExtendedHomeAssistant; protected _jsmpegCanvasElement?: HTMLCanvasElement; protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; @@ -987,12 +1156,24 @@ export class FrigateCardLiveJSMPEG extends LitElement { } } + /** + * Seek the video (unsupported). + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public seek(_seconds: number): void { + // JSMPEG does not support seeking. + } + /** * Get a signed player URL. * @returns A URL or null. */ protected async _getURL(): Promise { - if (!this.hass || !this.cameraConfig?.client_id || !this.cameraConfig?.camera_name) { + if ( + !this.hass || + !this.cameraConfig?.frigate.client_id || + !this.cameraConfig?.frigate.camera_name + ) { return null; } @@ -1000,12 +1181,12 @@ export class FrigateCardLiveJSMPEG extends LitElement { try { response = await homeAssistantSignPath( this.hass, - `/api/frigate/${this.cameraConfig.client_id}` + - `/jsmpeg/${this.cameraConfig.camera_name}`, + `/api/frigate/${this.cameraConfig.frigate.client_id}` + + `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, URL_SIGN_EXPIRY_SECONDS, ); - } catch (err) { - console.warn(err); + } catch (e) { + errorToConsole(e as Error); return null; } if (!response) { @@ -1029,9 +1210,10 @@ export class FrigateCardLiveJSMPEG extends LitElement { canvas: this._jsmpegCanvasElement, }, { - // The media carousel automatically pauses when the browser tab is + // 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, @@ -1049,7 +1231,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { // ignore any subsequent calls. if (!videoDecoded && this._jsmpegCanvasElement) { videoDecoded = true; - dispatchMediaShowEvent(this, this._jsmpegCanvasElement); + dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement); resolve(player); } }, @@ -1109,11 +1291,10 @@ export class FrigateCardLiveJSMPEG extends LitElement { this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; - if (!this.cameraConfig?.camera_name) { - return dispatchErrorMessageEvent( - this, - localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`, - ); + if (!this.cameraConfig?.frigate.camera_name) { + return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { + context: this.cameraConfig, + }); } const url = await this._getURL(); @@ -1149,3 +1330,14 @@ export class FrigateCardLiveJSMPEG extends LitElement { 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 fa6afa4f..8f7c7280 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -1,59 +1,240 @@ -import { CSSResultGroup, unsafeCSS } from 'lit'; -import { EmblaCarouselType } from 'embla-carousel'; -import { createRef, Ref } from 'lit/directives/ref.js'; -import { customElement } from 'lit/decorators.js'; - -import { AutoMediaPluginType } from './embla-plugins/automedia.js'; -import { FrigateCardCarousel } from './carousel.js'; +import { EmblaOptionsType } from 'embla-carousel'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import mediaCarouselStyle from '../scss/media-carousel.scss'; +import type { + MediaLoadedInfo, + NextPreviousControlConfig, + TitleControlConfig, + TransitionEffect, +} from '../types.js'; +import { dispatchFrigateCardEvent } from '../utils/basic'; +import { + createMediaLoadedInfo, + dispatchExistingMediaLoadedInfoAsEvent, + isValidMediaLoadedInfo, +} from '../utils/media-info.js'; +import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel'; +import { AutoMediaType } from './embla-plugins/automedia.js'; +import './next-prev-control.js'; +import './carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardTitleControl } from './title-control.js'; -import type { MediaShowInfo } from '../types.js'; -import { - dispatchExistingMediaShowInfoAsEvent, - isValidMediaShowInfo, -} from '../common.js'; - -import './next-prev-control.js'; - -import mediaCarouselStyle from '../scss/media-carousel.scss'; 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`; export const IMG_EMPTY = getEmptyImageSrc(16, 9); +export interface CarouselMediaLoadedInfo { + slide: number; + mediaLoadedInfo: MediaLoadedInfo; +} + +export interface CarouselMediaUnloadedInfo { + slide: number; +} + +/** + * Dispatch a carousel media loaded event. + * @param target The target to send it from. + * @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo. + */ +const dispatchFrigateCardCarouselMediaLoaded = ( + target: EventTarget, + carouselMediaLoadedInfo: CarouselMediaLoadedInfo, +): void => { + dispatchFrigateCardEvent( + target, + 'carousel:media:loaded', + carouselMediaLoadedInfo, + ); +}; + +/** + * Dispatch a carousel media UNloaded event. + * @param target The target to send it from. + * @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo. + */ +const dispatchFrigateCardCarouselMediaUnloaded = ( + target: EventTarget, + carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo, +): void => { + dispatchFrigateCardEvent( + target, + 'carousel:media:unloaded', + carouselMediaUnloadedInfo, + ); +}; + +/** + * Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo. + * @param slide The slide number. + * @param event The MediaShowEvent. + */ +export const wrapMediaLoadedEventForCarousel = ( + slide: number, + event: CustomEvent, +) => { + event.stopPropagation(); + dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], { + slide: slide, + mediaLoadedInfo: event.detail, + }); +}; + +/** + * Turn a (raw, e.g. img) media load event into a CarouselMediaLoadedInfo. + * @param slide The slide number. + * @param event The MediaShowEvent. + */ +export const wrapRawMediaLoadedEventForCarousel = (slide: number, event: Event) => { + const mediaLoadedInfo = createMediaLoadedInfo(event); + if (mediaLoadedInfo) { + dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], { + slide: slide, + mediaLoadedInfo: mediaLoadedInfo, + }); + } +}; + +/** + * Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo. + * @param slide The slide number. + * @param event The MediaUnloadedEvent. + */ +export const wrapMediaUnloadedEventForCarousel = ( + slide: number, + event: CustomEvent, +) => { + event.stopPropagation(); + dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], { + slide: slide, + }); +}; + @customElement('frigate-card-media-carousel') -export class FrigateCardMediaCarousel extends FrigateCardCarousel { - // A "map" from slide number to MediaShowInfo object. - protected _mediaShowInfo: Record = {}; +export class FrigateCardMediaCarousel extends LitElement { + @property({ attribute: false }) + public nextPreviousConfig?: NextPreviousControlConfig; + + @property({ attribute: false }) + public carouselOptions?: EmblaOptionsType; + + @property({ attribute: false }) + public carouselPlugins?: EmblaCarouselPlugins; + + @property({ attribute: true }) + public transitionEffect?: TransitionEffect; + + @property({ attribute: false }) + public label?: string; + + @property({ attribute: false }) + public titlePopupConfig?: TitleControlConfig; + + // A "map" from slide number to MediaLoadedInfo object. + protected _mediaLoadedInfo: Record = {}; protected _nextControlRef: Ref = createRef(); protected _previousControlRef: Ref = createRef(); protected _titleControlRef: Ref = createRef(); protected _titleTimerID: number | null = null; + protected _boundAutoPlayHandler = this.autoPlay.bind(this); + protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); + protected _boundAdaptContainerHeightToSlide = + this._adaptContainerHeightToSlide.bind(this); + protected _boundTitleHandler = this._titleHandler.bind(this); + // This carousel may be resized by Lovelace resizes, window resizes, // fullscreen, etc. Always call the adaptive height handler when the size // changes. protected _resizeObserver: ResizeObserver; + protected _slideResizeObserver: ResizeObserver; + protected _intersectionObserver: IntersectionObserver; + + protected _refCarousel: Ref = createRef(); constructor() { super(); - this._resizeObserver = new ResizeObserver(this._adaptiveHeightHandler.bind(this)); + // Need to watch both changes in this element (e.g. caused by a window + // resize or fullscreen change) and changes in the selected slide itself + // (e.g. changing from a progress indicator to a loaded media). + this._resizeObserver = new ResizeObserver(this._reInitAndAdjustHeight.bind(this)); + this._slideResizeObserver = new ResizeObserver( + this._reInitAndAdjustHeight.bind(this), + ); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); } /** - * Play the media on the selected slide. May be overridden to control when - * autoplay should happen. + * Get the underlying carousel. */ - protected _autoPlayHandler(): void { - (this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.play(); + public frigateCardCarousel(): FrigateCardCarousel | null { + return this._refCarousel.value ?? null; } /** - * Unmute the media on the selected slide. May be overridden to control when - * autoplay should happen. + * Get the AutoMedia plugin (if any). + * @returns The plugin or `null`. */ - protected _autoUnmuteHandler(): void { - (this._plugins['AutoMediaPlugin'] as AutoMediaPluginType | undefined)?.unmute(); + protected _getAutoMediaPlugin(): AutoMediaType | null { + return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null; + } + + /** + * Play the media on the selected slide. + */ + public autoPlay(): void { + const automediaOptions = this._getAutoMediaPlugin()?.options; + if ( + automediaOptions?.autoPlayCondition && + ['all', 'selected'].includes(automediaOptions?.autoPlayCondition) + ) { + this._getAutoMediaPlugin()?.play(); + } + } + + /** + * Pause the media on the selected slide. + */ + public autoPause(): void { + const automediaOptions = this._getAutoMediaPlugin()?.options; + if ( + automediaOptions?.autoPauseCondition && + ['all', 'selected'].includes(automediaOptions.autoPauseCondition) + ) { + this._getAutoMediaPlugin()?.pause(); + } + } + + /** + * Unmute the media on the selected slide. + */ + public autoUnmute(): void { + const automediaOptions = this._getAutoMediaPlugin()?.options; + if ( + automediaOptions?.autoUnmuteCondition && + ['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition) + ) { + this._getAutoMediaPlugin()?.unmute(); + } + } + + /** + * Mute the media on the selected slide. + */ + public autoMute(): void { + const automediaOptions = this._getAutoMediaPlugin()?.options; + if ( + automediaOptions?.autoMuteCondition && + ['all', 'selected'].includes(automediaOptions?.autoMuteCondition) + ) { + this._getAutoMediaPlugin()?.mute(); + } } /** @@ -77,8 +258,7 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { // Allow a brief pause after the media loads, but before the title is // displayed. This allows for a pleasant appearance/disappear of the title, - // and allows for the browser to finish rendering the carousel (inc. - // adaptive height which has `0.5s ease`, see `media-carousel.scss`). + // and allows for the browser to finish rendering the carousel. this._titleTimerID = window.setTimeout(show, 0.5 * 1000); } @@ -87,83 +267,88 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { */ connectedCallback(): void { super.connectedCallback(); - this.addEventListener('frigate-card:media-show', this._autoPlayHandler); - this.addEventListener('frigate-card:media-show', this._autoUnmuteHandler); - this.addEventListener('frigate-card:media-show', this._adaptiveHeightHandler); - this.addEventListener('frigate-card:media-show', this._titleHandler); + + this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler); + this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); + this.addEventListener( + 'frigate-card:media:loaded', + this._boundAdaptContainerHeightToSlide, + ); + this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler); this._resizeObserver.observe(this); + this._intersectionObserver.observe(this); } /** * Component disconnected callback. */ disconnectedCallback(): void { - super.disconnectedCallback(); - this.removeEventListener('frigate-card:media-show', this._autoPlayHandler); - this.removeEventListener('frigate-card:media-show', this._autoUnmuteHandler); - this.removeEventListener('frigate-card:media-show', this._adaptiveHeightHandler); - this.removeEventListener('frigate-card:media-show', this._titleHandler); + this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler); + this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); + this.removeEventListener( + 'frigate-card:media:loaded', + this._boundAdaptContainerHeightToSlide, + ); + this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler); this._resizeObserver.disconnect(); - } + this._intersectionObserver.disconnect(); - protected _destroyCarousel(): void { - super._destroyCarousel(); - - // Notes on instance variables: - // * this._mediaShowInfo: This is set when the media in the DOM loads. If a - // new View included the same media, the DOM would not change and so the - // prior contents would still be valid and would not re-appear (as the - // media would not reload) -- as such, leave this alone on carousel - // destroy. New media in that slide will replace the prior contents on - // load. + this._mediaLoadedInfo = {}; + super.disconnectedCallback(); } /** - * Initialize the carousel. + * ReInit the carousel and adapt the container height. */ - protected _initCarousel(): void { - super._initCarousel(); - - // Necessary because typescript local type narrowing is not paying attention - // to the side-effect of the call to super._initCarousel(). - const carousel = this._carousel as EmblaCarouselType | undefined; - - // Update the view object as the carousel is moved. - carousel?.on('select', this._selectSlideSetViewHandler.bind(this)); - - // Update the next/previous controls as the carousel is moved. - carousel?.on('select', this._selectSlideNextPreviousHandler.bind(this)); - - // Dispatch MediaShow events as the carousel is moved. - carousel?.on('init', this._selectSlideMediaShowHandler.bind(this)); - carousel?.on('select', this._selectSlideMediaShowHandler.bind(this)); + protected _reInitAndAdjustHeight(): void { + this.frigateCardCarousel()?.carouselReInitWhenSafe(); + this._adaptContainerHeightToSlide(); } /** - * Set the the height of the container on media load in case the dimensions + * Called when the carousel intersects with the viewport. + * @param entries The IntersectionObserverEntry entries (should be only 1). + */ + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + /** + * - If the DOM that contains this carousel changes such that it causes + * slides to entirely appear/disappear (e.g. `display: none` or hidden), + * then the displayed slide sizes will significantly change and the + * carousel will need to be reinitialized. Without this, odd bugs may + * occur for some users in some circumstances causing the carousel to + * appear 'stuck'. + * - Example bug when this reinitialization is not performed: + * https://github.com/dermotduffy/frigate-hass-card/issues/651 + */ + if (entries.some((entry) => entry.isIntersecting)) { + this._reInitAndAdjustHeight(); + } + } + + /** + * Set the the height of the component on media load in case the dimensions * have changed. This handler is not triggered from carousel events, as it's * actually the media load/show that will change the dimensions, and that is * async from carousel actions (e.g. lazy-loaded media). + * + * This component does not use the stock Embla auto-height plugin as it + * resizes the container on selection rather than media load. */ - protected _adaptiveHeightHandler(): void { + protected _adaptContainerHeightToSlide(): void { const adaptCarouselHeight = (): void => { - if (!this._carousel) { - return; - } - const slide = this._carousel?.selectedScrollSnap(); - if (slide !== undefined) { - this._carousel.containerNode().style.removeProperty('max-height'); - const slides = this._carousel.slideNodes(); - const height = slides[slide].getBoundingClientRect().height; - if (height > 0) { - this._carousel.containerNode().style.maxHeight = `${height}px`; + const selected = this.frigateCardCarousel()?.getCarouselSelected(); + if (selected) { + this.style.removeProperty('max-height'); + const height = selected.element.getBoundingClientRect().height; + if (height !== undefined && height > 0) { + this.style.maxHeight = `${height}px`; } } }; // Hack: This method attempts to measure the height of the selected slide in // order to set the overall carousel height to match. This method is - // triggered from `frigate-card:media-show` events, which are usually in + // triggered from `frigate-card:media:loaded` events, which are usually in // turn triggered from media/metadata load events from media players. // Sufficient time needs to be allowed after these metadata load events to // allow the browser to repaint the element heights, so that we can get the @@ -171,117 +356,103 @@ export class FrigateCardMediaCarousel extends FrigateCardCarousel { window.requestAnimationFrame(adaptCarouselHeight); } - /** - * Handle the user selecting a new slide in the carousel. - */ - protected _selectSlideSetViewHandler(): void { - // To be overridden in children. - } - - /** - * Handle updating of the next/previous controls when the carousel is moved. - */ - protected _selectSlideNextPreviousHandler(): void { - // To be overridden in children. - } - - /** - * Handle a next/previous control interaction. - * @param direction The direction requested, previous or next. - */ - protected _nextPreviousHandler(direction: 'previous' | 'next'): void { - if (direction == 'previous') { - this._carousel?.scrollPrev(this._getTransitionEffect() === 'none'); - } else if (direction == 'next') { - this._carousel?.scrollNext(this._getTransitionEffect() === 'none'); - } - } - /** * Fire a media show event when a slide is selected. */ - protected _selectSlideMediaShowHandler(): void { - if (!this._carousel) { - return; - } - - const slideIndex = this._carousel.selectedScrollSnap(); - if (slideIndex in this._mediaShowInfo) { - dispatchExistingMediaShowInfoAsEvent(this, this._mediaShowInfo[slideIndex]); + protected _dispatchMediaLoadedInfo(): void { + const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index; + if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) { + dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]); } } /** - * Handle a media-show event that is generated by a child component, saving the + * Handle a media:loaded event that is generated by a child component, saving the * contents for future use when the relevant slide is actually shown. * @param slideIndex The relevant slide index. - * @param event The media-show event from the child component. + * @param event The media:loaded event from the child component. */ - protected _mediaShowEventHandler( - slideIndex: number, - event: CustomEvent, - ): void { + protected _storeMediaLoadedInfo(event: CustomEvent): void { // Don't allow the inbound event to propagate upwards, that will be // automatically done at the appropriate time as the slide is shown. event.stopPropagation(); - this._mediaLoadedHandler(slideIndex, event.detail); + const mediaLoadedInfo = event.detail.mediaLoadedInfo; + const slideIndex = event.detail.slide; + + // isValidMediaLoadedInfo is used to prevent saving media info that will be + // rejected upstream (empty 1x1 images will be rejected here). + if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) { + this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo; + if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) { + dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); + } + } } /** - * Handle a MediaShowInfo object that is generated on media load, by saving it - * for future, or immediate use, when the relevant slide is displayed. - * @param slideIndex The relevant slide index. - * @param mediaShowInfo The MediaShowInfo object generated by the media. + * Remove a media loaded info (i.e. a media item has unloaded). + * @param event The CarouselMediaUnloadedInfo event. */ - protected _mediaLoadedHandler( - slideIndex: number, - mediaShowInfo?: MediaShowInfo | null, - ): void { - // isValidMediaShowInfo is used to prevent saving media info that will be - // rejected upstream (empty 1x1 images will be rejected here). - if (mediaShowInfo && isValidMediaShowInfo(mediaShowInfo)) { - this._mediaShowInfo[slideIndex] = mediaShowInfo; - if (this._carousel && this._carousel?.selectedScrollSnap() == slideIndex) { - dispatchExistingMediaShowInfoAsEvent(this, mediaShowInfo); - } + protected _removeMediaLoadedInfo(event: CustomEvent): void { + const slideIndex = event.detail.slide; + delete this._mediaLoadedInfo[slideIndex]; - /** - * Images need a width/height from initial load, and browsers will assume - * that the aspect ratio of the initial dummy-image load will persist. In - * lazy-loading, this can cause a 1x1 pixel dummy image to cause the - * browser to assume all images will be square, so the whole carousel will - * have the wrong aspect-ratio until every single image has been lazily - * loaded. Adaptive height helps in that the carousel gets resized on each - * img display to the correct size, but it still causes a minor noticeable - * flicker until the height change is complete. - * - * To avoid this, we use a 16:9 dummy image at first (most - * likely?) and once the first piece of real media has been loaded, all - * dummy images are replaced with dummy images that match the aspect ratio - * of the real image. It still might be wrong, but it's the best option - * available. - */ - const firstMediaLoad = !Object.keys(this._mediaShowInfo).length; - if (firstMediaLoad) { - const replacementImageSrc = getEmptyImageSrc( - mediaShowInfo.width, - mediaShowInfo.height, - ); - - this.renderRoot.querySelectorAll('.embla__container img').forEach((img) => { - const imageElement = img as HTMLImageElement; - if (imageElement.src === IMG_EMPTY) { - imageElement.src = replacementImageSrc; - } - }); - } + // If the slide that unloaded is not visible, don't propagate the event upwards. + if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) { + event.stopPropagation(); } } + protected render(): TemplateResult | void { + return html` ) => { + this._slideResizeObserver.disconnect(); + this._slideResizeObserver.observe(ev.detail.element); + + // Pass up the media-carousel select event first to allow parents to + // initialize/reset before the media info is dispatched. + dispatchFrigateCardEvent( + this, + 'media-carousel:select', + ev.detail, + ); + + // Dispatch media info. + this._dispatchMediaLoadedInfo(); + }} + @frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)} + @frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)} + > + + + + + ${this.label && this.titlePopupConfig + ? html` + ` + : ``}`; + } + /** * Get element styles. */ static get styles(): CSSResultGroup { - return [super.styles, unsafeCSS(mediaCarouselStyle)]; + return unsafeCSS(mediaCarouselStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-carousel': FrigateCardMediaCarousel; } } diff --git a/src/components/menu.ts b/src/components/menu.ts index 51af65a6..6201f4a7 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,53 +1,37 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS +} from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; - import { actionHandler } from '../action-handler-directive.js'; - -import './submenu.js'; - +import menuStyle from '../scss/menu.scss'; import type { ActionsConfig, ActionType, - ExtendedHomeAssistant, MenuButton, MenuConfig, - StateParameters, + MenuItem, + StateParameters } from '../types.js'; import { convertActionToFrigateCardCustomAction, frigateCardHandleActionConfig, frigateCardHasAction, - getActionConfigGivenAction, - refreshDynamicStateParameters, -} from '../common.js'; - -import menuStyle from '../scss/menu.scss'; + getActionConfigGivenAction +} from '../utils/action.js'; +import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js'; +import { refreshDynamicStateParameters } from '../utils/ha'; +import './submenu.js'; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; -export const FRIGATE_ICON_FILLED = - 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + - '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + - '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + - '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + - '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + - '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + - '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + - '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + - '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + - '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + - '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + - '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + - '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + - '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + - '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + - 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + - '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + - '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + - '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; /** * A menu for the FrigateCard. @@ -55,22 +39,28 @@ export const FRIGATE_ICON_FILLED = @customElement('frigate-card-menu') export class FrigateCardMenu extends LitElement { @property({ attribute: false }) - public hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: HomeAssistant; + + @property({ attribute: true, type: Boolean, reflect: true }) + public expanded = false; set menuConfig(menuConfig: MenuConfig) { this._menuConfig = menuConfig; if (menuConfig) { - this.style.setProperty('--frigate-card-menu-button-size', menuConfig.button_size); + this.style.setProperty( + '--frigate-card-menu-button-size', + `${menuConfig.button_size}px`, + ); } - // Store the menu mode as an attribute (used for CSS attribute selectors). - this.setAttribute('data-mode', menuConfig.mode); + // Store the menu style, position and alignment as attributes (used for + // styling). + this.setAttribute('data-style', menuConfig.style); + this.setAttribute('data-position', menuConfig.position); + this.setAttribute('data-alignment', menuConfig.alignment); } @state() protected _menuConfig?: MenuConfig; - @property({ attribute: true, type: Boolean, reflect: true }) - protected expanded = false; - @property({ attribute: false }) public buttons: MenuButton[] = []; @@ -80,7 +70,7 @@ export class FrigateCardMenu extends LitElement { * @returns `true` if the menu is hiding, `false` otherwise. */ static isHidingMenu(menuConfig: MenuConfig | undefined): boolean { - return menuConfig?.mode.startsWith('hidden-') ?? false; + return menuConfig?.style === 'hidden' ?? false; } /** @@ -193,24 +183,71 @@ export class FrigateCardMenu extends LitElement { } } + /** + * Ensure menu buttons are sorted before the render. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + const style = this._menuConfig?.style; + const sortButtons = (a: MenuItem, b: MenuItem): number => { + // If the menu is hidden, the Frigate button must come first. + if (style === 'hidden') { + if (a.icon === FRIGATE_BUTTON_MENU_ICON) { + return -1; + } else if (b.icon === FRIGATE_BUTTON_MENU_ICON) { + return 1; + } + } + + // Otherwise sort by priority. + if ( + a.priority === undefined || + (b.priority !== undefined && b.priority > a.priority) + ) { + return 1; + } + if ( + b.priority === undefined || + (a.priority !== undefined && b.priority < a.priority) + ) { + return -1; + } + return 0; + }; + + if (changedProps.has('_menuConfig') || changedProps.has('buttons')) { + this.buttons.sort(sortButtons); + } + } + /** * Render a button. * @param button The button configuration to render. * @returns A rendered template or void. */ protected _renderButton(button: MenuButton): TemplateResult | void { - if (button.type == 'custom:frigate-card-menu-submenu') { + if (button.enabled === false) { + return; + } + if (button.type === 'custom:frigate-card-menu-submenu') { return html` `; + } else if (button.type === 'custom:frigate-card-menu-submenu-select') { + return html` + `; } let stateParameters: StateParameters = { ...button }; const svgPath = - stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_FILLED : ''; + stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : ''; if (this.hass && button.type === 'custom:frigate-card-menu-state-icon') { stateParameters = refreshDynamicStateParameters(this.hass, stateParameters); @@ -263,18 +300,37 @@ export class FrigateCardMenu extends LitElement { if (!this._menuConfig) { return; } - const mode = this._menuConfig.mode; - - if (mode == 'none') { + const style = this._menuConfig.style; + if (style === 'none') { return; } // If the hidden menu isn't expanded, only show the Frigate button. - const buttons = - !mode.startsWith('hidden-') || this.expanded - ? this.buttons + const matchingButtons = + style !== 'hidden' || this.expanded + ? this.buttons.filter( + (button) => !button.alignment || button.alignment === 'matching', + ) : this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON); - return html` ${buttons.map((button) => this._renderButton(button))} `; + + const opposingButtons = + style !== 'hidden' || this.expanded + ? this.buttons.filter((button) => button.alignment === 'opposing') + : []; + + const matchingStyle = { + flex: String(matchingButtons.length), + }; + const opposingStyle = { + flex: String(opposingButtons.length), + }; + + return html`
+ ${matchingButtons.map((button) => this._renderButton(button))} +
+
+ ${opposingButtons.map((button) => this._renderButton(button))} +
`; } /** @@ -284,3 +340,9 @@ export class FrigateCardMenu extends LitElement { return unsafeCSS(menuStyle); } } + +declare global { + interface HTMLElementTagNameMap { + "frigate-card-menu": FrigateCardMenu + } +} diff --git a/src/components/message.ts b/src/components/message.ts index cc947653..33c7469c 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -1,38 +1,51 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; - -import { Message } from '../types.js'; +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 { dispatchFrigateCardEvent } from '../utils/basic.js'; @customElement('frigate-card-message') export class FrigateCardMessage extends LitElement { @property({ attribute: false }) - protected message = ''; + public message: string | TemplateResult<1> = ''; @property({ attribute: false }) - protected context?: unknown; + public context?: unknown; @property({ attribute: false }) - protected icon?: string; + public icon?: string; + + @property({ attribute: true, type: Boolean }) + public dotdotdot?: boolean; // Render the menu. protected render(): TemplateResult { const icon = this.icon ? this.icon : 'mdi:information-outline'; - return html` -
-
-
- -
-
- ${this.message ? html`${this.message}` : ''} - ${this.context ? html`
${JSON.stringify(this.context, null, 2)}
` : ''} -
+ const classes = { + dotdotdot: !!this.dotdotdot, + }; + return html`
+
+
+
-
`; +
+ + ${this.message + ? html`${this.message}${this.context && typeof this.context === 'string' + ? ': ' + this.context + : ''}` + : ''} + + ${this.context && typeof this.context !== 'string' + ? html`
${JSON.stringify(this.context, null, 2)}
` + : ''} +
+
+
`; } static get styles(): CSSResultGroup { @@ -43,7 +56,7 @@ export class FrigateCardMessage extends LitElement { @customElement('frigate-card-error-message') export class FrigateCardErrorMessage extends LitElement { @property({ attribute: false }) - protected message?: Message; + public message?: Message; protected render(): TemplateResult | void { if (!this.message) { @@ -54,6 +67,7 @@ export class FrigateCardErrorMessage extends LitElement { ${localize('error.troubleshooting')}.`} .icon=${'mdi:alert-circle'} .context=${this.message.context} + .dotdotdot=${this.message.dotdotdot} > `; } @@ -66,7 +80,7 @@ export class FrigateCardErrorMessage extends LitElement { @customElement('frigate-card-progress-indicator') export class FrigateCardProgressIndicator extends LitElement { @property({ attribute: false }) - protected message = ''; + public message: string | TemplateResult = ''; protected render(): TemplateResult { return html`
@@ -83,15 +97,16 @@ export class FrigateCardProgressIndicator extends LitElement { } export function renderMessage(message: Message): TemplateResult { - if (message.type == 'error') { + if (message.type === 'error') { return html` `; - } else if (message.type == 'info') { + } else { return html` `; } return html``; @@ -103,3 +118,64 @@ export function renderProgressIndicator(message?: string): TemplateResult { `; } + +/** + * Dispatch an event with a message to show to the user. + * @param element The element to send the event. + * @param message The message to show. + * @param options Optional icon and context to include. + */ +export function dispatchMessageEvent( + element: EventTarget, + message: string, + type: MessageType, + options?: { + icon?: string; + context?: unknown; + }, +): void { + dispatchFrigateCardEvent(element, 'message', { + message: message, + type: type, + icon: options?.icon, + context: options?.context, + }); +} + +/** + * Dispatch an event with an error message to show to the user. + * @param element The element to send the event. + * @param message The message to show. + * @param options Optional context to include. + */ +export function dispatchErrorMessageEvent( + element: EventTarget, + message: string, + options?: { + context?: unknown; + }, +): void { + dispatchMessageEvent(element, message, 'error', { + context: options?.context, + }); +} + +/** + * Dispatch an event with an error message to show to the user. + * @param element The element to send the event. + * @param message The message to show. + */ +export function dispatchFrigateCardErrorEvent( + element: EventTarget, + error: FrigateCardError, +): void { + dispatchErrorMessageEvent(element, error.message, { context: error.context }); +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-progress-indicator': FrigateCardProgressIndicator; + 'frigate-card-error-message': FrigateCardErrorMessage; + 'frigate-card-message': FrigateCardMessage; + } +} diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 6c37f651..5a7ccd67 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -5,6 +5,9 @@ import { classMap } from 'lit/directives/class-map.js'; import { NextPreviousControlConfig } from '../types.js'; import controlStyle from '../scss/next-previous-control.scss'; +import { createFetchThumbnailTask } from '../utils/thumbnail.js'; +import { HomeAssistant } from 'custom-card-helpers'; +import { renderTask } from '../utils/task.js'; @customElement('frigate-card-next-previous-control') export class FrigateCardNextPreviousControl extends LitElement { @@ -13,11 +16,14 @@ export class FrigateCardNextPreviousControl extends LitElement { set controlConfig(controlConfig: NextPreviousControlConfig | undefined) { if (controlConfig?.size) { - this.style.setProperty('--frigate-card-next-prev-size', controlConfig.size); + this.style.setProperty('--frigate-card-next-prev-size', `${controlConfig.size}px`); } this._controlConfig = controlConfig; } + @property({ attribute: false }) + public hass?: HomeAssistant; + @state() protected _controlConfig?: NextPreviousControlConfig; @@ -31,7 +37,13 @@ export class FrigateCardNextPreviousControl extends LitElement { public disabled = false; // Label that is used for ARIA support and as tooltip. - @property() label = ""; + @property() label = ''; + + protected _embedThumbnailTask = createFetchThumbnailTask( + this, + () => this.hass, + () => this.thumbnail, + ); protected render(): TemplateResult { if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') { @@ -50,18 +62,15 @@ 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``; } - icon = this.icon + icon = this.icon; } - return html` + return html` `; } @@ -69,15 +78,30 @@ export class FrigateCardNextPreviousControl extends LitElement { if (!this.thumbnail) { return html``; } - return html``; + + return renderTask( + this, + this._embedThumbnailTask, + (embeddedThumbnail: string | null) => + embeddedThumbnail + ? html`` + : html``, + () => html`
`, + ); } static get styles(): CSSResultGroup { return unsafeCSS(controlStyle); } } + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-next-previous-control': FrigateCardNextPreviousControl; + } +} diff --git a/src/components/submenu.ts b/src/components/submenu.ts index 97a1d835..a1bd6247 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -1,43 +1,65 @@ -import type { Corner } from '@material/mwc-menu'; -import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { HomeAssistant } from 'custom-card-helpers'; -import { customElement, property } from 'lit/decorators.js'; import { - frigateCardHasAction, - refreshDynamicStateParameters, - stopEventFromActivatingCardWideActions, -} from '../common.js'; + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; - -import { ExtendedHomeAssistant, MenuSubmenu, MenuSubmenuItem } from '../types.js'; import { actionHandler } from '../action-handler-directive.js'; - import submenuStyle from '../scss/submenu.scss'; +import { + MenuSubmenu, + MenuSubmenuItem, + MenuSubmenuSelect, + StateParameters, +} from '../types.js'; +import { + frigateCardHasAction, + stopEventFromActivatingCardWideActions, +} from '../utils/action.js'; +import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha'; +import { domainIcon } from '../utils/icons/domain-icon.js'; @customElement('frigate-card-submenu') export class FrigateCardSubmenu extends LitElement { @property({ attribute: false }) - public hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: HomeAssistant; @property({ attribute: false }) public submenu?: MenuSubmenu; - @property({ attribute: false }) - public corner?: Corner; - protected _renderItem(item: MenuSubmenuItem): TemplateResult | void { if (!this.hass) { return; } const stateParameters = refreshDynamicStateParameters(this.hass, { ...item }); + const getIcon = (stateParameters: StateParameters): TemplateResult => { + if (stateParameters.icon) { + return html` + `; + } + return html``; + }; return html` { // Attach the action config so ascendants have access to it. @@ -48,17 +70,9 @@ export class FrigateCardSubmenu extends LitElement { hasDoubleClick: frigateCardHasAction(item.double_tap_action), })} > - ${stateParameters.title || ''} - ${stateParameters.icon - ? html` - ` - : ``} + ${stateParameters.title || ''} + ${item.subtitle ? html`${item.subtitle}` : ''} + ${getIcon(stateParameters)} `; } @@ -69,7 +83,7 @@ export class FrigateCardSubmenu extends LitElement { } return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-submenu': FrigateCardSubmenu; + 'frigate-card-submenu-select': FrigateCardSubmenuSelect; + } +} diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts new file mode 100644 index 00000000..2b6978a8 --- /dev/null +++ b/src/components/surround-thumbnails.ts @@ -0,0 +1,194 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import surroundThumbnailsStyle from '../scss/surround.scss'; +import { + BrowseMediaQueryParameters, + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + FrigateCardError, + FrigateCardView, + ThumbnailsControlConfig, +} from '../types.js'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; +import { + getFirstTrueMediaChildIndex, + multipleBrowseMediaQueryMerged, +} from '../utils/ha/browse-media'; +import { View } from '../view.js'; +import { dispatchFrigateCardErrorEvent } from './message.js'; +import './surround.js'; +import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; + +interface ThumbnailViewContext { + // Whetherr or not to fetch thumbnails. + fetch?: boolean; +} + +declare module 'view' { + interface ViewContext { + thumbnails?: ThumbnailViewContext; + } +} + +@customElement('frigate-card-surround-thumbnails') +export class FrigateCardSurround extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false, hasChanged: contentsChanged }) + public config?: ThumbnailsControlConfig; + + @property({ attribute: false }) + public targetView?: FrigateCardView; + + @property({ attribute: true, type: Boolean }) + public fetch?: boolean; + + @property({ attribute: false, hasChanged: contentsChanged }) + public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[]; + + @property({ attribute: false }) + public cameras?: Map; + + /** + * Fetch thumbnail media when a target is not specified in the view (e.g. for + * the live view). + * @param param Task parameters. + * @returns + */ + protected async _fetchMedia(): Promise { + if ( + !this.fetch || + !this.hass || + !this.view || + !this.config || + this.config.mode === 'none' || + this.view.target || + !this.browseMediaParams || + !(this.view.context?.thumbnails?.fetch ?? true) + ) { + return; + } + let parent: FrigateBrowseMediaSource | null; + try { + parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams); + } catch (e) { + return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); + } + if (getFirstTrueMediaChildIndex(parent) !== null) { + this.view + ?.evolve({ + ...(this.targetView && { view: this.targetView }), + target: parent, + childIndex: null, + + // Don't carry over history of this 'empty' view. + previous: null, + }) + .dispatchChangeEvent(this); + } + } + + /** + * Determine if a drawer is being used. + * @returns `true` if a drawer is used, `false` otherwise. + */ + protected _hasDrawer(): boolean { + return !!this.config && ['left', 'right'].includes(this.config.mode); + } + + /** + * Called before each update. + */ + protected willUpdate(changedProperties: PropertyValues): void { + // 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). + if ( + ['view', 'targetView', 'fetch', 'browseMediaParams'].some((prop) => + changedProperties.has(prop), + ) + ) { + this._fetchMedia(); + } + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view || !this.config) { + return; + } + + const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => { + // The event catch/re-dispatch below protect encapsulation: Catches the + // request to view thumbnails and re-dispatches a request to open the drawer + // (if the thumbnails are in a drawer). The new event needs to be dispatched + // from the origin of the inbound event, so it can be handled by + // . + if (this.config && this._hasDrawer()) { + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { + drawer: this.config.mode, + }); + } + }; + + return html` changeDrawer(ev, 'open')} + @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} + > + ${this.config && this.config.mode !== 'none' + ? html` changeDrawer(ev, 'close')} + @frigate-card:thumbnail-carousel:tap=${(ev: CustomEvent) => { + // Send the view change from the source of the tap event, so the + // view change will be caught by the handler above (to close the drawer). + this.view + ?.evolve({ + view: this.targetView || 'media', + target: ev.detail.target, + childIndex: ev.detail.childIndex, + context: null, + }) + .dispatchChangeEvent(ev.composedPath()[0]); + }} + > + ` + : ''} + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(surroundThumbnailsStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-surround-thumbnails': FrigateCardSurround; + } +} diff --git a/src/components/surround.ts b/src/components/surround.ts new file mode 100644 index 00000000..414058ab --- /dev/null +++ b/src/components/surround.ts @@ -0,0 +1,77 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { customElement } from 'lit/decorators.js'; + +import { FrigateCardDrawer } from './drawer.js'; + +import './drawer.js'; + +import surroundStyle from '../scss/surround.scss'; + +interface FrigateCardDrawerOpen { + drawer: 'left' | 'right'; +} + +@customElement('frigate-card-surround') +export class FrigateCardSurround extends LitElement { + protected _refDrawerLeft: Ref = createRef(); + protected _refDrawerRight: Ref = createRef(); + protected _boundDrawerHandler = this._drawerHandler.bind(this); + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler); + this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler); + this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler); + } + + protected _drawerHandler(ev: Event) { + const drawer = (ev as CustomEvent).detail.drawer; + const open = ev.type.endsWith(':open'); + if (drawer === 'left' && this._refDrawerLeft.value) { + this._refDrawerLeft.value.open = open; + } else if (drawer === 'right' && this._refDrawerRight.value) { + this._refDrawerRight.value.open = open; + } + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + return html` + + + + + + + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(surroundStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + "frigate-card-surround": FrigateCardSurround + } +} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 3b109deb..9136a54d 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,46 +1,113 @@ -import { BrowseMediaUtil } from '../browse-media-util.js'; -import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit'; -import { EmblaOptionsType } from 'embla-carousel'; -import { customElement, property } from 'lit/decorators.js'; - -import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; -import { FrigateCardCarousel } from './carousel.js'; -import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js'; - +import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; +import { + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + ThumbnailsControlConfig, +} from '../types.js'; +import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; +import { isTrueMedia } from '../utils/ha/browse-media'; +import { View } from '../view.js'; +import { FrigateCardCarousel } from './carousel.js'; +import './thumbnail.js'; +import './carousel.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; export interface ThumbnailCarouselTap { slideIndex: number; - target: BrowseMediaSource; + target: FrigateBrowseMediaSource; childIndex: number; } @customElement('frigate-card-thumbnail-carousel') -export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { +export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) - protected target?: BrowseMediaSource; - - protected _tapSelected?; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - set config(config: ThumbnailsControlConfig | undefined) { - if (config) { - if (config && config.size !== undefined && config.size !== null) { - this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size); - } - this._config = config; - } + public view?: Readonly; + + // Use contentsChanged here to avoid the carousel rebuilding and resetting in + // front of the user, unless the contents have actually changed. + @property({ attribute: false, hasChanged: contentsChanged }) + public target?: FrigateBrowseMediaSource | null; + + @property({ attribute: false }) + public cameras?: Map; + + protected _refCarousel: Ref = createRef(); + + // Thumbnail carousels can expand (e.g. drawer-based carousels after the main + // media loads). The carousel must be re-initialized in these cases, or the + // dynamic sizing fails (and users can scroll past the end of the carousel). + protected _resizeObserver: ResizeObserver; + + @property({ attribute: false }) + public config?: ThumbnailsControlConfig; + + @state() + protected _selected: number | null = null; + + protected _carouselOptions?: EmblaOptionsType; + protected _carouselPlugins: EmblaPluginType[] = [ + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', + }), + ]; + + constructor() { + super(); + this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); } - protected _config?: ThumbnailsControlConfig; @property({ attribute: false }) - set highlightSelected(value: boolean) { + set selected(selected: number | null) { + this._selected = selected; this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', - value ? '0.6' : '1.0', + selected === null ? '1.0' : '0.4', ); } + /** + * Handle gallery resize. + */ + protected _resizeHandler(): void { + this._refCarousel.value?.carouselReInitWhenSafe(); + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + this._resizeObserver.observe(this); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + this._resizeObserver.disconnect(); + super.disconnectedCallback(); + } + /** * Get the Embla options to use. * @returns An EmblaOptionsType object or undefined for no options. @@ -49,28 +116,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return { containScroll: 'keepSnaps', dragFree: true, + startIndex: this._selected ?? 0, }; } - - /** - * Scroll to a particular slide. - * @param index Slide number. - */ - carouselScrollTo(index: number): void { - if (!this._carousel) { - return; - } - - if (this._tapSelected !== undefined) { - this._carousel.slideNodes()[this._tapSelected].classList.remove('slide-selected'); - } - - super.carouselScrollTo(index); - - this._carousel.slideNodes()[index].classList.add('slide-selected'); - this._tapSelected = index; - } - /** * Get slides to include in the render. * @returns The slides to include in the render. @@ -90,44 +138,111 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return slides; } + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('config')) { + if (this.config?.size) { + this.style.setProperty('--frigate-card-thumbnail-size', `${this.config.size}px`); + } + const direction = this._getDirection(); + if (direction) { + this.setAttribute('direction', direction); + } else { + this.removeAttribute('direction'); + } + } + + if (!this._carouselOptions) { + // Want to set the initial carousel options just before the first render + // in order to get the startIndex correct in the options. It is not safe + // to rely on carouselScrollTo() post update, since the nested carousel + // may not yet be actual rendered/created. + this._carouselOptions = this._getOptions(); + } + } + + /** + * The updated lifecycle callback for this element. + * @param changedProperties The properties that were changed in this render. + */ + updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('_selected')) { + this.updateComplete.then(() => { + if (this._selected !== null) { + this._refCarousel.value?.carouselScrollTo(this._selected); + } + }); + } + } + /** * Render a given thumbnail. * @param mediaToRender The media item to render. * @returns A template or void if the item could not be rendered. */ protected _renderThumbnail( - parent: BrowseMediaSource, + parent: FrigateBrowseMediaSource, childIndex: number, slideIndex: number, ): TemplateResult | void { - if (!parent.children || !parent.children.length) { + if ( + !parent.children || + !parent.children.length || + !isTrueMedia(parent.children[childIndex]) + ) { return; } - const mediaToRender = parent.children[childIndex]; - if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { - return; - } + const classes = { + embla__slide: true, + 'slide-selected': this._selected === childIndex, + }; - return html`
{ - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); + if (this._refCarousel.value?.carouselClickAllowed()) { + dispatchFrigateCardEvent( + this, + 'thumbnail-carousel:tap', + { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }, + ); } stopEventFromActivatingCardWideActions(ev); }} > - -
`; + `; + } + + /** + * Get the direction of the thumbnail carousel. + * @returns `vertical`, `horizontal` or undefined. + */ + protected _getDirection(): 'horizontal' | 'vertical' | undefined { + if (this.config?.mode === 'left' || this.config?.mode === 'right') { + return 'vertical'; + } else if (this.config?.mode === 'above' || this.config?.mode === 'below') { + return 'horizontal'; + } + return undefined; } /** @@ -136,21 +251,30 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { */ protected render(): TemplateResult | void { const slides = this._getSlides(); - if (!slides || !this._config || this._config.mode == 'none') { + if (!slides.length || !this.config || this.config.mode === 'none') { return; } - return html`
-
-
${slides}
-
-
`; + return html` + ${slides} + `; } /** * Get element styles. */ static get styles(): CSSResultGroup { - return [super.styles, unsafeCSS(thumbnailCarouselStyle)]; + return unsafeCSS(thumbnailCarouselStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-thumbnail-carousel': FrigateCardThumbnailCarousel; } } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts new file mode 100644 index 00000000..30450b60 --- /dev/null +++ b/src/components/thumbnail.ts @@ -0,0 +1,326 @@ +import { format, fromUnixTime } from 'date-fns'; +import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { localize } from '../localize/localize.js'; +import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; +import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; +import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; +import thumbnailStyle from '../scss/thumbnail.scss'; +import type { + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + FrigateEvent, + FrigateRecording, +} from '../types.js'; +import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { errorToConsole, prettifyTitle } from '../utils/basic.js'; +import { retainEvent } from '../utils/frigate.js'; +import { getEventDurationString } from '../utils/ha/browse-media.js'; +import { renderTask } from '../utils/task.js'; +import { createFetchThumbnailTask } from '../utils/thumbnail.js'; +import { View } from '../view.js'; + +// The minimum width of a thumbnail with details enabled. +export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; + +@customElement('frigate-card-thumbnail-feature-event') +export class FrigateCardThumbnailFeatureEvent extends LitElement { + @property({ attribute: false }) + public thumbnail?: string; + + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + protected _embedThumbnailTask = createFetchThumbnailTask( + this, + () => this.hass, + () => this.thumbnail, + ); + + protected render(): TemplateResult | void { + return html` + ${this.thumbnail + ? renderTask( + this, + this._embedThumbnailTask, + (embeddedThumbnail: string | null) => + embeddedThumbnail + ? html`` + : html`` + ) + : html` `} + `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailFeatureEventStyle); + } +} + +@customElement('frigate-card-thumbnail-feature-recording') +export class FrigateCardThumbnailFeatureRecording extends LitElement { + @property({ attribute: false }) + public date?: Date; + + protected render(): TemplateResult | void { + if (!this.date) { + return; + } + return html` +
${format(this.date, 'HH:mm')}
+
${format(this.date, 'MMM do')}
+ `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailFeatureRecordingStyle); + } +} + +@customElement('frigate-card-thumbnail-details-event') +export class FrigateCardThumbnailDetailsEvent extends LitElement { + @property({ attribute: false }) + public event?: FrigateEvent; + + protected render(): TemplateResult | void { + if (!this.event) { + return; + } + const score = (this.event.top_score * 100).toFixed(2) + '%'; + return html`
+
${prettifyTitle(this.event.label)}
+
+ ${localize('event.start')}: + ${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')} +
+
+ ${localize('event.duration')}: + ${getEventDurationString(this.event)} +
+
+
+ ${score} +
`; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailDetailsStyle); + } +} + +@customElement('frigate-card-thumbnail-details-recording') +export class FrigateCardThumbnailDetailsRecording extends LitElement { + @property({ attribute: false }) + public recording?: FrigateRecording; + + protected render(): TemplateResult | void { + if (!this.recording) { + return; + } + return html`
+
${prettifyTitle(this.recording.camera) || ''}
+ ${this.recording.seek_time + ? html`
+ ${localize('recording.seek')} + ${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')} +
` + : html``} +
+
+ ${this.recording.events} + ${localize('recording.events')} +
`; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailDetailsStyle); + } +} + +@customElement('frigate-card-thumbnail') +export class FrigateCardThumbnail extends LitElement { + @property({ attribute: true, type: Boolean }) + public details = false; + + @property({ attribute: true, type: Boolean }) + public show_favorite_control = false; + + @property({ attribute: true, type: Boolean }) + public show_timeline_control = false; + + // ====================== + // Target-based interface + // ====================== + @property({ attribute: false }) + public target?: FrigateBrowseMediaSource | null; + + @property({ attribute: false }) + public childIndex?: number; + + // =================================================== + // Raw interface (can override target-based interface) + // =================================================== + @property({ attribute: true }) + public thumbnail?: string; + + @property({ attribute: true }) + public label?: string; + + @property({ attribute: false }) + public event?: FrigateEvent; + + // ================================ + // Optional parameters for controls + // ================================ + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public clientID?: string; + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + let event: FrigateEvent | null = null; + let recording: FrigateRecording | null = null; + let thumbnail: string | null = null; + let label: string | null = null; + + // Take the event / thumbnail / label from the data-bound media (if specified). + if (this.target && this.target.children && this.childIndex !== undefined) { + const media = this.target.children[this.childIndex]; + event = media.frigate?.event ?? null; + recording = media.frigate?.recording ?? null; + thumbnail = media.thumbnail; + label = media.title; + } + + // Always give the overrides preference (if specified). + if (this.event) { + event = this.event; + } + thumbnail = this.thumbnail ? this.thumbnail : thumbnail; + label = this.label ? this.label : label; + + if (!event && !recording) { + return; + } + + const starClasses = { + star: true, + starred: !!event?.retain_indefinitely, + }; + + return html` ${event + ? html`` + : html``} + ${this.show_favorite_control && event && this.hass && this.clientID + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (event && this.hass && this.clientID) { + retainEvent( + this.hass, + this.clientID, + event.id, + !event.retain_indefinitely, + ) + .then(() => { + if (event) { + event.retain_indefinitely = !event.retain_indefinitely; + this.requestUpdate(); + } + }) + .catch((e) => { + errorToConsole(e); + }); + } + }} + />` + : ``} + ${this.details && event + ? html`` + : this.details && recording + ? html`` + : html``} + ${this.show_timeline_control + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (event) { + this.view + ?.evolve({ + view: 'timeline', + target: this.target, + childIndex: this.childIndex ?? null, + }) + .removeContext('timeline') + .dispatchChangeEvent(this); + } else if (recording) { + this.view + ?.evolve({ + view: 'timeline', + target: null, + childIndex: null, + }) + .mergeInContext({ + timeline: { + window: { + start: fromUnixTime(recording.start_time), + end: fromUnixTime(recording.end_time), + }, + }, + }) + .dispatchChangeEvent(this); + } + }} + >` + : ''}`; + } + + /** + * Get element styles. + */ + static get styles(): CSSResult { + return unsafeCSS(thumbnailStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-thumbnail': FrigateCardThumbnail; + 'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording; + 'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent; + 'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording; + 'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent; + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts new file mode 100644 index 00000000..ca009e4e --- /dev/null +++ b/src/components/timeline.ts @@ -0,0 +1,1347 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { + add, + differenceInSeconds, + endOfHour, + format, + fromUnixTime, + getUnixTime, + startOfHour, + sub, +} from 'date-fns'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { isEqual } from 'lodash-es'; +import { ViewContext } from 'view'; +import { DataSet } from 'vis-data/esnext'; +import { + DataGroupCollectionType, + Timeline, + TimelineEventPropertiesResult, + TimelineItem, + TimelineOptions, + TimelineOptionsCluster, + TimelineWindow, +} from 'vis-timeline/esnext'; +import { CAMERA_BIRDSEYE } from '../const'; +import { localize } from '../localize/localize'; +import timelineCoreStyle from '../scss/timeline-core.scss'; +import timelineStyle from '../scss/timeline.scss'; +import { + BrowseMediaQueryParameters, + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + frigateCardConfigDefaults, + FrigateCardError, + FrigateEvent, + TimelineConfig, +} from '../types'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { dispatchFrigateCardEvent, errorToConsole, isHoverableDevice, prettifyTitle } from '../utils/basic'; +import { getCameraTitle } from '../utils/camera.js'; +import { + getRecordingSegments, + getRecordingsSummary, + getUniqueFrigateCameraEventsID, + getUniqueFrigateCameraID, + RecordingSegments, + RecordingSummary, +} from '../utils/frigate'; +import { + createEventParentForChildren, + createVideoChild, + generateRecordingIdentifier, + getBrowseMediaQueryParameters, + isTrueMedia, + multipleBrowseMediaQuery, +} from '../utils/ha/browse-media'; +import { View } from '../view'; +import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js'; +import './surround-thumbnails.js'; + +const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; + +interface FrigateCardGroupData { + id: string; + content: string; +} +interface FrigateCardTimelineItem extends TimelineItem { + start: number; + end?: number; + event?: FrigateEvent; + source?: FrigateBrowseMediaSource; +} + +interface TimelineViewContext { + // The selected timeline window. + window?: TimelineWindow; + + // The date of the last event fetch. + dateFetch?: Date; +} + +declare module 'view' { + interface ViewContext { + timeline?: TimelineViewContext; + } +} + +type TimelineMediaType = 'all' | 'clips' | 'snapshots'; + +interface CameraRecordings { + segments: RecordingSegments; + summary: RecordingSummary; +} + +// An event used to fetch the HASS object. See "Special note" below. +class HASSRequestEvent extends Event { + public hass?: ExtendedHomeAssistant; +} + +/** + * A manager to maintain/fetch timeline events. + */ +class TimelineDataManager { + protected _dataset = new DataSet(); + + // The earliest date managed. + protected _dateStart?: Date; + + // The latest date managed. + protected _dateEnd?: Date; + + // The last fetch date. + protected _dateFetch?: Date; + + // The maximum allowable age of fetch data (will not fetch more frequently + // than this). + protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS; + + // Get the last event fetch date. + get lastFetchDate(): Date | null { + return this._dateFetch ?? null; + } + + /** + * Retrieve the underlying dataset. + */ + get dataset(): DataSet { + return this._dataset; + } + + /** + * Determine if the dataset is empty. + * @returns + */ + public isEmpty(): boolean { + return this._dataset.length === 0; + } + + /** + * Clear the dataset. + */ + public clear(): void { + this._dataset.clear(); + } + + /** + * Add a FrigateBrowseMediaSource object to the managed timeline. + * @param camera The id the camera this object is from. + * @param target The FrigateBrowseMediaSource to add. + */ + protected _addMediaSource( + camera: string, + mediaPriority: TimelineMediaType, + target: FrigateBrowseMediaSource, + ): void { + const items: FrigateCardTimelineItem[] = []; + target.children?.forEach((child) => { + const event = child.frigate?.event; + if ( + event && + isTrueMedia(child) && + ['video', 'image'].includes(child.media_content_type) + ) { + let item = this._dataset.get(event.id); + if (!item) { + item = { + id: event.id, + group: camera, + content: '', + start: event.start_time * 1000, + event: event, + }; + } + if ( + (child.media_content_type === 'video' && + ['all', 'clips'].includes(mediaPriority)) || + (!item.source && + child.media_content_type === 'image' && + ['all', 'snapshots'].includes(mediaPriority)) + ) { + item.source = child; + } + if (event.end_time) { + item['end'] = event.end_time * 1000; + item['type'] = 'range'; + } else { + item['type'] = 'point'; + } + items.push(item); + } + }); + this._dataset.update(items); + } + + /** + * Determine if the timeline has coverage for a given range of dates. + * @param start The start of the date range. + * @param end An optional end of the date range. + * @returns + */ + public hasCoverage(now: Date, start: Date, end?: Date): boolean { + // Never fetched: no coverage. + if (!this._dateFetch || !this._dateStart || !this._dateEnd) { + return false; + } + + // If the most recent fetch is older than maxAgeSeconds: no coverage. + if ( + this._maxAgeSeconds && + now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 + ) { + return false; + } + + // If the most requested data is earlier than the earliest stored: no + // coverage. + if (start < this._dateStart) { + return false; + } + + // If there's no end time specified: there IS coverage. + if (!end) { + return true; + } + // If the requested end time is older than the oldest requested: there IS + // coverage. + if (end.getTime() < this._dateEnd.getTime()) { + return true; + } + // If there's no maxAgeSeconds specified: no coverage. + if (!this._maxAgeSeconds) { + return false; + } + // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. + if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) { + return false; + } + + // End time is within `_maxAgeSeconds` of the latest data: there IS + // coverage. + return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime(); + } + + /** + * Fetch events if no coverage in given range. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param cameras The cameras map. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + * @returns `true` if events were fetched, `false` otherwise. + */ + public async fetchIfNecessary( + element: HTMLElement, + hass: ExtendedHomeAssistant, + cameras: Map, + eventMedia: TimelineMediaType, + start: Date, + end: Date, + recordings?: boolean, + ): Promise { + // Cannot fetch the future, always clip the end date to now so as to avoid + // checking for coverage that could not possibly exist yet. + const now = new Date(); + end = end > now ? now : end; + + if (this.hasCoverage(now, start, end)) { + return false; + } + + if (!this._dateStart || start < this._dateStart) { + this._dateStart = start; + } + if (!this._dateEnd || end > this._dateEnd) { + this._dateEnd = end; + } + this._dateFetch = new Date(); + + await Promise.all([ + // Events are always fetched for the maximum extent of the managed + // range. This is because events may change at any point in time + // (e.g. a long-running event that ends). + this._fetchEvents( + element, + hass, + cameras, + eventMedia, + this._dateStart, + this._dateEnd, + ), + ...(recordings ? [this._fetchRecordings(hass, cameras)] : []), + ]); + + return true; + } + + /** + * Fetch recording hours for the timeline. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param cameras The cameras map. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + */ + protected async _fetchRecordings( + hass: ExtendedHomeAssistant, + cameras: Map, + ): Promise { + const items: FrigateCardTimelineItem[] = []; + const now = new Date(); + + const storeRecordings = async ( + camera: string, + config: CameraConfig, + ): Promise => { + if (!config.frigate.camera_name) { + return; + } + let summary: RecordingSummary = []; + try { + summary = await getRecordingsSummary( + hass, + config.frigate.client_id, + config.frigate.camera_name, + ); + } catch (e) { + // Recording failure should not disrupt the rest of the timeline + // experience. + errorToConsole(e as Error); + } + + for (const dayData of summary) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const endHour = endOfHour(hour); + items.push({ + id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`, + group: camera, + start: getUnixTime(startOfHour(hour)) * 1000, + + // Don't let the recordings show off into the future (even though it + // is intended to be indicative of any recordings within that hour + // -- it still looks strange!) + end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000, + type: 'background', + content: '', + }); + } + } + }; + + await Promise.all( + Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) => + storeRecordings(camera, config), + ), + ); + + this._dataset.update(items); + } + + /** + * Fetch events for the timeline. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param cameras The cameras map. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + */ + protected async _fetchEvents( + element: HTMLElement, + hass: HomeAssistant, + cameras: Map, + media: TimelineMediaType, + start: Date, + end: Date, + ): Promise { + const params: BrowseMediaQueryParameters[] = []; + cameras.forEach((cameraConfig, cameraID) => { + (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { + if (cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE) { + const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, { + before: end.getTime() / 1000, + after: start.getTime() / 1000, + unlimited: true, + mediaType: mediaType as 'clips' | 'snapshots', + }); + if (param) { + params.push(param); + } + } + }); + }); + + if (!params.length) { + return; + } + + let results: Map; + try { + results = await multipleBrowseMediaQuery(hass, params); + } catch (e) { + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); + } + + for (const [query, result] of results.entries()) { + if (query.cameraID) { + this._addMediaSource(query.cameraID, media, result); + } + } + } +} + +/** + * A simgple thumbnail wrapper class for use in the timeline where LIT data + * bindings are not available. + */ +@customElement('frigate-card-timeline-thumbnail') +export class FrigateCardTimelineThumbnail extends LitElement { + @property({ attribute: true }) + public thumbnail?: string; + + @property({ attribute: true, type: Boolean }) + public details = false; + + @property({ attribute: true }) + public event?: string; + + @property({ attribute: true }) + public label?: string; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + if (!this.thumbnail || !this.event) { + return html``; + } + + /* Special note on what's going on here: + * + * This component does not have access to HASS, as there's no way to pass it + * in via the string-based tooltip that timeline supports. Instead dispatch + * an event to request HASS which the timeline adds to the event object + * before execution continues. + */ + const hassRequest = new HASSRequestEvent(`frigate-card:timeline:hass-request`, { + composed: true, + bubbles: true, + }); + this.dispatchEvent(hassRequest); + if (!hassRequest.hass) { + return html``; + } + + return html` + `; + } +} + +@customElement('frigate-card-timeline') +export class FrigateCardTimeline extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameras?: Map; + + @property({ attribute: false }) + public timelineConfig?: TimelineConfig; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.timelineConfig) { + return html``; + } + + return html` + + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(timelineStyle); + } +} + +@customElement('frigate-card-timeline-core') +export class FrigateCardTimelineCore extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameras?: Map; + + @property({ attribute: false }) + public timelineConfig?: TimelineConfig; + + protected _data = new TimelineDataManager(); + + protected _refTimeline: Ref = createRef(); + protected _timeline?: Timeline; + + // Need a way to separate when a user clicks (to pan the timeline) vs when a + // user clicks (to choose a recording (non-event) to play). + protected _pointerHeld = false; + protected _ignoreClick = false; + + protected readonly _isHoverableDevice = isHoverableDevice(); + + /** + * Get a tooltip for a given timeline event. + * @param source The FrigateBrowseMediaSource in question. + * @returns The tooltip as a string to render. + */ + protected _getTooltip(item: TimelineItem): string { + const source = (item).source; + if (!this._isHoverableDevice || !source) { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + return ''; + } + + const eventAttr = source.frigate?.event + ? `event='${JSON.stringify(source.frigate.event)}'` + : ''; + const detailsAttr = this.timelineConfig?.controls.thumbnails.show_details + ? 'details' + : ''; + + // Cannot use Lit data-bindings as visjs requires a string for tooltips. + // Note that changes to attributes here must be mirrored in the xss + // whitelist in `_getOptions()` . + return ` + + `; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view || !this.timelineConfig) { + return; + } + + const thumbnailsConfig = this.timelineConfig.controls.thumbnails; + const timelineClasses = { + timeline: true, + 'left-margin': thumbnailsConfig.mode === 'left', + 'right-margin': thumbnailsConfig.mode === 'right', + }; + + return html`
{ + request.hass = this.hass; + }} + class="${classMap(timelineClasses)}" + ${ref(this._refTimeline)} + >
`; + } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param time Target time. + * @param segments A RecordingSegments object. + * @returns + */ + protected _getSeekTime(time: Date, segments: RecordingSegments): number | null { + if (!segments.length) { + return null; + } + const target = getUnixTime(time); + const hourStart = getUnixTime(startOfHour(time)); + let seekSeconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + if (segment.start_time > target) { + break; + } + const start = segment.start_time < hourStart ? hourStart : segment.start_time; + const end = segment.end_time > target ? target : segment.end_time; + seekSeconds += end - start; + } + return seekSeconds; + } + + /** + * Create recording objects. + * @param results A map of camera ID to a CameraRecordings object. + * @param time The target time for the recordings. + * @param onlyMatchingHour If `true` only shows the hour matching the target + * for the provided cameras, otherwise shows all hours. + * @returns + */ + protected _createRecordingChildren( + results: Map, + time: Date, + onlyMatchingHour: boolean, + ): FrigateBrowseMediaSource[] { + const children: FrigateBrowseMediaSource[] = []; + const processedCameras: Set = new Set(); + + // Get results in the order the cameras are specified in the configuration. + for (const camera of this.cameras?.keys() || []) { + const recording = results.get(camera); + const config = this.cameras?.get(camera); + if (!recording || !config?.frigate.camera_name) { + continue; + } + + // There is a single set of recordings for a given Frigate camera name. + // Zones on that same camera do not get separate recordings. The card may + // have multiple instances of the same camera for different zones, so + // need to enforce uniqueness here. + const uniqueID = getUniqueFrigateCameraID(config); + if (processedCameras.has(uniqueID)) { + continue; + } + processedCameras.add(uniqueID); + + const seekSeconds = this._getSeekTime(time, recording.segments); + if (seekSeconds === null) { + continue; + } + + for (const dayData of recording.summary) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const startHour = startOfHour(hour); + const endHour = endOfHour(hour); + const isMatchingHour = time >= startHour && time <= endHour; + + if (!onlyMatchingHour || isMatchingHour) { + children.push( + createVideoChild( + `${prettifyTitle(config.frigate.camera_name)} ${format( + hour, + 'yyyy-MM-dd HH:mm', + )}`, + generateRecordingIdentifier({ + clientId: config.frigate.client_id, + year: dayData.day.getFullYear(), + month: dayData.day.getMonth() + 1, + day: dayData.day.getDate(), + hour: hourData.hour, + cameraName: config.frigate.camera_name, + }), + { + recording: { + camera: config.frigate.camera_name, + start_time: getUnixTime(startHour), + end_time: getUnixTime(endHour), + events: hourData.events, + ...(isMatchingHour && { + seek_seconds: seekSeconds, + seek_time: time.getTime() / 1000, + }), + }, + }, + ), + ); + } + } + } + } + return children; + } + + /** + * Change the view to a recording. + * @param time The time of the recording to show. + * @param camera An optional camera to show a recording of, otherwise all + * cameras are shown at the given time. + */ + protected async _changeViewToRecording(time: Date, camera?: string): Promise { + if (!this.hass) { + return; + } + + const before = endOfHour(time); + const after = startOfHour(time); + const results: Map = new Map(); + + const fetch = async (camera: string, config?: CameraConfig): Promise => { + if (!config || !config.frigate.camera_name || !this.hass) { + return; + } + + try { + const cameraResults = await Promise.all([ + getRecordingSegments( + this.hass, + config.frigate.client_id, + config.frigate.camera_name, + before, + after, + ), + getRecordingsSummary( + this.hass, + config.frigate.client_id, + config.frigate.camera_name, + ), + ]); + results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] }); + } catch (e) { + errorToConsole(e as Error); + } + }; + const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])]; + await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera)))); + + const children = this._createRecordingChildren(results, time, !camera); + if (!children.length) { + return; + } + + let childIndex = 0; + if (camera) { + childIndex = children.findIndex( + (child) => + child.frigate?.recording && + child.frigate.recording.start_time * 1000 === after.getTime(), + ); + if (childIndex < 0) { + return; + } + } + + this.view + ?.evolve({ + view: 'media', + target: createEventParentForChildren(localize('common.recordings'), children), + childIndex: childIndex, + }) + .dispatchChangeEvent(this); + } + + /** + * Called whenever the range is in the process of being changed. + * @param properties + */ + protected _timelineRangeChangeHandler( + properties: TimelineEventPropertiesResult, + ): void { + if (properties.event && this._pointerHeld) { + // An event will have been set when it's a human changes the range. + this._ignoreClick = true; + } + } + + /** + * Called whenever the timeline is clicked. + * @param properties The properties of the timeline click event. + */ + protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { + // Calls to stopEventFromActivatingCardWideActions() are included for + // completeness. Timeline does not support card-wide events and they are + // disabled in card.ts in `_getMergedActions`. + if (properties.what === 'item' || this._ignoreClick) { + stopEventFromActivatingCardWideActions(properties.event); + } + + if (!this._ignoreClick && properties.what && this.timelineConfig?.show_recordings) { + if (['background', 'group-label'].includes(properties.what)) { + stopEventFromActivatingCardWideActions(properties.event); + this._changeViewToRecording(properties.time, String(properties.group)); + } else if (properties.what === 'axis') { + stopEventFromActivatingCardWideActions(properties.event); + this._changeViewToRecording(properties.time); + } + } + + this._ignoreClick = false; + } + + /** + * Get a broader prefetch window from a start and end basis. + * @param start The earlier date. + * @param end The later date. + * @returns An object with a `start` and `end` key to prefetch. + */ + protected _getPrefetchWindow(start: Date, end: Date): [Date, Date] { + const delta = differenceInSeconds(end, start); + return [sub(start, { seconds: delta }), add(end, { seconds: delta })]; + } + + /** + * Handle a range change in the timeline. + * @param properties vis.js provided range information. + */ + protected _timelineRangeHandler(properties: { + start: Date; + end: Date; + byUser: boolean; + event: Event; + }): void { + if (!properties.byUser) { + return; + } + if (this.hass && this.cameras && this._timeline && this.timelineConfig) { + const [prefetchStart, prefetchEnd] = this._getPrefetchWindow( + properties.start, + properties.end, + ); + this._data + .fetchIfNecessary( + this, + this.hass, + this.cameras, + this.timelineConfig.media, + prefetchStart, + prefetchEnd, + this.timelineConfig.show_recordings, + ) + .then(() => { + if (this._timeline) { + const thumbnails = this._generateThumbnails(); + // Update the view to reflect the new thumbnails and the timeline + // window in the context. + this.view + ?.evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext(true)) + .dispatchChangeEvent(this); + } + }); + } + } + + /** + * Called when an object on the timeline is selected. + * @param data The data about the selection. + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { + if (!this.view?.target || !this.view?.target.children) { + return; + } + + const childIndex = data.items.length + ? this.view.target.children.findIndex( + (child) => child.frigate?.event?.id === data.items[0], + ) + : null; + + this.view + ?.evolve({ + childIndex: childIndex, + }) + .dispatchChangeEvent(this); + + if (childIndex !== null && childIndex >= 0) { + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } else { + dispatchFrigateCardEvent(this, 'thumbnails:close'); + } + } + + /** + * Regenerate the thumbnails from the timeline events. + * @returns An object with two keys, or null on error. The keys are `target` + * containing all the thumbnails, and `childIndex` to refer to the currently + * selected thumbnail. + */ + protected _generateThumbnails(): { + target: FrigateBrowseMediaSource; + childIndex: number | null; + } | null { + if (!this._timeline) { + return null; + } + + /** + * Sort the timeline items most recent to least recent. + * @param a The first item. + * @param b The second item. + * @returns -1, 0, 1 (standard array sort function configuration). + */ + const sortEvent = ( + a: FrigateCardTimelineItem, + b: FrigateCardTimelineItem, + ): number => { + if (a.start < b.start) { + return 1; + } + if (a.start > b.start) { + return -1; + } + return 0; + }; + + const selected = this._timeline.getSelection(); + let childIndex = -1; + const children: FrigateBrowseMediaSource[] = []; + this._data.dataset.get({ order: sortEvent }).forEach((item) => { + if (item.event && item.source) { + children.push(item.source); + if (selected.includes(item.event.id)) { + childIndex = children.length - 1; + } + } + }); + if (!children.length) { + return null; + } + + return { + target: createEventParentForChildren('Timeline events', children), + childIndex: childIndex < 0 ? null : childIndex, + }; + } + + /** + * Build the visjs dataset to render on the timeline. + * @returns The dataset. + */ + protected _getGroups(): DataGroupCollectionType { + const groups: FrigateCardGroupData[] = []; + const processedCameras: Set = new Set(); + + this.cameras?.forEach((cameraConfig, camera) => { + const frigateCameraID = getUniqueFrigateCameraEventsID(cameraConfig); + if ( + cameraConfig.frigate.camera_name && + cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE && + !processedCameras.has(frigateCameraID) + ) { + processedCameras.add(frigateCameraID); + groups.push({ + id: camera, + content: getCameraTitle(this.hass, cameraConfig), + }); + } + }); + return new DataSet(groups); + } + + /** + * Given an event get an appropriate start/end time window around the event. + * @param event The FrigateEvent to consider. + * @returns A tuple of start/end date. + */ + protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] { + const windowSeconds = this._getConfiguredWindowSeconds(); + if (event.end_time) { + if (event.end_time - event.start_time > windowSeconds) { + // If the event is larger than the configured window, only show the most + // recent portion of the event that fits in the window. + return [ + sub(fromUnixTime(event.end_time), { seconds: windowSeconds }), + fromUnixTime(event.end_time), + ]; + } else { + // If the event is shorter than the configured window, center the event + // in the window. + const gap = windowSeconds - (event.end_time - event.start_time); + return [ + sub(fromUnixTime(event.start_time), { seconds: gap / 2 }), + add(fromUnixTime(event.end_time), { seconds: gap / 2 }), + ]; + } + } + // If there's no end-time yet, place the start-time in the center of the + // time window. + return [ + sub(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), + add(fromUnixTime(event.start_time), { seconds: windowSeconds / 2 }), + ]; + } + + /** + * Get the configured window length in seconds. + */ + protected _getConfiguredWindowSeconds(): number { + return ( + this.timelineConfig?.window_seconds ?? + frigateCardConfigDefaults.timeline.window_seconds + ); + } + + /** + * Get desired timeline start/end time. + * @returns A tuple of start/end date. + */ + protected _getStartEnd(): [Date, Date] { + const event = this.view?.target?.frigate?.event; + if (event) { + return this._getStartEndFromEvent(event); + } + const end = new Date(); + const start = sub(end, { + seconds: this._getConfiguredWindowSeconds(), + }); + return [start, end]; + } + + /** + * Determine if the timeline should use clustering. + * @returns `true` if the timeline should cluster, `false` otherwise. + */ + protected _isClustering(): boolean { + return ( + !!this.timelineConfig?.clustering_threshold && + this.timelineConfig.clustering_threshold > 0 + ); + } + + /** + * Handle timeline resize. + */ + protected _getOptions(): TimelineOptions | void { + if (!this.timelineConfig) { + return; + } + + const [start, end] = this._getStartEnd(); + + // Configuration for the Timeline, see: + // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options + return { + cluster: this._isClustering() + ? { + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: this.timelineConfig.clustering_threshold, + + clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { + // Never include the target media in a cluster, and never group + // different object types together (e.g. person and car). + return ( + [first.type, second.type].every((type) => type !== 'background') && + first.type === second.type && + !!first.id && + first.id !== this.view?.media?.frigate?.event?.id && + !!second.id && + second.id != this.view?.media?.frigate?.event?.id && + (first).event?.label === + (second).event?.label + ); + }, + } + : (false as TimelineOptionsCluster), + minHeight: '100%', + maxHeight: '100%', + zoomMax: 1 * 24 * 60 * 60 * 1000, + zoomMin: 1 * 1000, + selectable: true, + start: start, + end: end, + groupHeightMode: 'fixed', + tooltip: { + followMouse: true, + overflowMethod: 'cap', + template: this._getTooltip.bind(this), + }, + xss: { + disabled: false, + filterOptions: { + whiteList: { + 'frigate-card-timeline-thumbnail': [ + 'details', + 'thumbnail', + 'label', + 'event', + ], + div: ['title'], + span: ['style'], + }, + }, + }, + }; + } + + /** + * Determine if the component should be updated. + * @param _changedProps The changed properties. + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldUpdate(_changedProps: PropertyValues): boolean { + return !!this.hass && !!this.cameras && this.cameras.size > 0; + } + + /** + * Update the timeline from the view object. + */ + protected async _updateTimelineFromView(): Promise { + if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) { + return; + } + + const event = this.view?.media?.frigate?.event; + const [windowStart, windowEnd] = event + ? this._getStartEndFromEvent(event) + : this._getStartEnd(); + + const [prefetchStart, prefetchEnd] = this._getPrefetchWindow(windowStart, windowEnd); + const fetched = await this._data.fetchIfNecessary( + this, + this.hass, + this.cameras, + this.timelineConfig.media, + prefetchStart, + prefetchEnd, + this.timelineConfig.show_recordings, + ); + + if (!this._timeline) { + return; + } + + this._timeline.setSelection(event ? [event.id] : [], { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + + // Regenerate the thumbnails after the selection, to allow the new selection + // to be in the generated view. + const context = this.view.context?.timeline; + const timelineWindow = this._timeline.getWindow(); + + if (context?.window) { + if (!isEqual(context.window, timelineWindow)) { + this._timeline.setWindow(context.window.start, context.window.end); + } + } else if (event) { + const eventStart = new Date(event.start_time * 1000); + const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; + + if ( + eventStart < timelineWindow.start || + eventStart > timelineWindow.end || + (eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) + ) { + this._timeline.setWindow(windowStart, windowEnd); + } + + if (this._isClustering()) { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. + const item = this._data.dataset.get(event.id); + if (item) { + this._data.dataset.updateOnly(item); + } + } + } else { + this._timeline.setWindow(windowStart, windowEnd); + } + + // Only generate thumbnails if an actual fetch occurred, to avoid getting + // stuck in a loop (the subsequent fetches will not actually fetch since the + // data will have been cached). + // + // Timeline receives a new `view` + // -> Events fetched + // -> Thumbnails generated + // -> New view dispatched (to load thumbnails into outer carousel). + // -> New view received ... [loop] + + if (fetched) { + const thumbnails = this._generateThumbnails(); + this.view + ?.evolve({ + target: thumbnails?.target ?? null, + childIndex: thumbnails?.childIndex ?? null, + }) + .mergeInContext(this._generateTimelineContext(false)) + .dispatchChangeEvent(this); + } + } + + /** + * Generate the context for timeline views. + * @param addWindow Whether or not to include the timeline window. If `false` + * the window is preserved if it is already in the context. + * @returns The TimelineViewContext object. + */ + protected _generateTimelineContext(addWindow: boolean): ViewContext { + const currentContext = this.view?.context?.timeline; + const newContext: TimelineViewContext = {} + + if (addWindow && this._timeline) { + newContext.window = this._timeline.getWindow(); + } else if (currentContext?.window) { + newContext.window = currentContext.window; + } + if (this._data.lastFetchDate) { + newContext.dateFetch = this._data.lastFetchDate; + } + return Object.keys(newContext) ? {timeline: newContext} : {}; + } + + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('timelineConfig')) { + if (this.timelineConfig?.controls.thumbnails.size) { + this.style.setProperty( + '--frigate-card-thumbnail-size', + `${this.timelineConfig.controls.thumbnails.size}px`, + ); + } + if (this.timelineConfig?.show_recordings) { + this.setAttribute('recordings', ''); + } else { + this.removeAttribute('recordings'); + } + } + } + + /** + * Called when the component is updated. + * @param changedProperties The changed properties if any. + */ + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('cameras')) { + this._data.clear(); + this._timeline?.destroy(); + this._timeline = undefined; + } + + const options = this._getOptions(); + if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) { + if (this._timeline) { + this._timeline.setOptions(options); + } else { + // Don't show an empty timeline, show a message instead. + const groups = this._getGroups(); + if (!groups.length) { + dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { + icon: 'mdi:chart-gantt', + }); + return; + } + + this._timeline = new Timeline( + this._refTimeline.value, + this._data.dataset, + groups, + options, + ); + this._timeline.on('select', this._timelineSelectHandler.bind(this)); + this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); + this._timeline.on('click', this._timelineClickHandler.bind(this)); + this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); + + // This complexity exists to ensure we can tell between a click that + // causes the timeline zoom/range to change, and a 'static' click on the + // // timeline (which may need to trigger a card wide event). + this._timeline.on('mouseDown', () => { + this._pointerHeld = true; + this._ignoreClick = false; + }); + this._timeline.on('mouseUp', () => { + this._pointerHeld = false; + }); + } + } + + if (changedProperties.has('view')) { + this._updateTimelineFromView(); + } + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(timelineCoreStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-timeline-thumbnail': FrigateCardTimelineThumbnail; + 'frigate-card-timeline-core': FrigateCardTimelineCore; + 'frigate-card-timeline': FrigateCardTimeline; + } +} diff --git a/src/components/title-control.ts b/src/components/title-control.ts index 254a2b98..bdd524e1 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -83,3 +83,9 @@ export class FrigateCardTitleControl extends LitElement { return unsafeCSS(titleStyle); } } + +declare global { + interface HTMLElementTagNameMap { + "frigate-card-title-control": FrigateCardTitleControl + } +} diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 21dbc54f..6518a123 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,107 +1,131 @@ +import { Task } from '@lit-labs/task'; +import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, + html, LitElement, PropertyValues, TemplateResult, - html, unsafeCSS, } from 'lit'; -import { BrowseMediaUtil } from '../browse-media-util.js'; -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; -import { HomeAssistant } from 'custom-card-helpers'; -import { Task } from '@lit-labs/task'; -import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { guard } from 'lit/directives/guard.js'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; - -import { AutoMediaPlugin } from './embla-plugins/automedia.js'; -import type { +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { renderProgressIndicator } from '../components/message.js'; +import viewerStyle from '../scss/viewer.scss'; +import viewerCarouselStyle from '../scss/viewer-carousel.scss'; +import { BrowseMediaNeighbors, BrowseMediaQueryParameters, - BrowseMediaSource, CameraConfig, ExtendedHomeAssistant, - MediaShowInfo, + FrigateBrowseMediaSource, + frigateCardConfigDefaults, + FrigateCardMediaPlayer, + MediaLoadedInfo, TransitionEffect, ViewerConfig, } from '../types.js'; -import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; -import { FrigateCardNextPreviousControl } from './next-prev-control.js'; +import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; +import { contentsChanged } from '../utils/basic.js'; import { - FrigateCardThumbnailCarousel, - ThumbnailCarouselTap, -} from './thumbnail-carousel.js'; -import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; -import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; + fetchLatestMediaAndDispatchViewChange, + getEventStartTime, + getFullDependentBrowseMediaQueryParametersOrDispatchError, + isTrueMedia, + multipleBrowseMediaQueryMerged, + overrideMultiBrowseMediaQueryParameters, +} from '../utils/ha/browse-media.js'; +import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view.js'; +import { AutoMediaPlugin } from './embla-plugins/automedia.js'; +import { Lazyload } from './embla-plugins/lazyload.js'; import { - contentsChanged, - createMediaShowInfo, - dispatchErrorMessageEvent, - stopEventFromActivatingCardWideActions, -} from '../common.js'; -import { renderProgressIndicator } from '../components/message.js'; - + FrigateCardMediaCarousel, + IMG_EMPTY, + wrapRawMediaLoadedEventForCarousel, + wrapMediaLoadedEventForCarousel, +} from './media-carousel.js'; import './next-prev-control.js'; import './title-control.js'; - -import viewerStyle from '../scss/viewer.scss'; -import viewerCoreStyle from '../scss/viewer-core.scss'; +import '../patches/ha-hls-player'; +import './surround-thumbnails'; +import { EmblaCarouselPlugins } from './carousel.js'; +import { renderTask } from '../utils/task.js'; +import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: Readonly; + public view?: Readonly; @property({ attribute: false }) - protected viewerConfig?: ViewerConfig; + public viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + public cameras?: Map; @property({ attribute: false }) - protected resolvedMediaCache?: ResolvedMediaCache; + public resolvedMediaCache?: ResolvedMediaCache; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig) { + if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) { return; } const browseMediaQueryParameters = - BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + getFullDependentBrowseMediaQueryParametersOrDispatchError( this, - this.view, - this.cameraConfig, + this.hass, + this.cameras, + this.view.camera, ); - if (!browseMediaQueryParameters) { - return; - } if (!this.view.target) { - BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( + // If the target is not specified, the view must tell us which mediaType + // to search for. When the target *is* specified, the view is not required + // to indicate the media type (e.g. the mixed 'events' view from the + // timeline). + const mediaType = this.view.getMediaType(); + if (!browseMediaQueryParameters || !mediaType) { + return; + } + + fetchLatestMediaAndDispatchViewChange( this, this.hass, this.view, - browseMediaQueryParameters, + overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, { + mediaType: mediaType, + }), ); return renderProgressIndicator(); } - return html` - `; + + + `; } /** @@ -112,89 +136,15 @@ export class FrigateCardViewer extends LitElement { } } -@customElement('frigate-card-viewer-core') -export class FrigateCardViewerCore extends LitElement { - @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; - - @property({ attribute: false }) - protected view?: Readonly; - - // See note on viewerConfig in . - @property({ attribute: false, hasChanged: contentsChanged }) - protected viewerConfig?: ViewerConfig; - - @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; - - @property({ attribute: false }) - protected resolvedMediaCache?: ResolvedMediaCache; - - protected _viewerCarouselRef: Ref = createRef(); - protected _thumbnailCarouselRef: Ref = createRef(); - - protected _syncThumbnailCarousel(): void { - const mediaSelected = this._viewerCarouselRef.value?.carouselSelected(); - if (mediaSelected !== undefined) { - this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected); - } - } - - protected _renderThumbnails(): TemplateResult { - if (!this.view || !this.viewerConfig) { - return html``; - } - - return html` ) => { - this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex); - }} - @frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)} - > - `; - } - - protected render(): TemplateResult | void { - if (!this.view || !this.viewerConfig) { - return html``; - } - return html` ${this.viewerConfig && - this.viewerConfig.controls.thumbnails.mode === 'above' - ? this._renderThumbnails() - : ''} - - - ${this.viewerConfig && this.viewerConfig.controls.thumbnails.mode === 'below' - ? this._renderThumbnails() - : ''}`; - } - - /** - * Get element styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(viewerCoreStyle); - } -} +const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player'; @customElement('frigate-card-viewer-carousel') -export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { +export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant & ExtendedHomeAssistant; + public hass?: ExtendedHomeAssistant; @property({ attribute: false }) - protected view?: Readonly; + public view?: Readonly; // Resetting the viewer configuration causes a full reset so ensure the config // has actually changed with a full comparison (dynamic configuration @@ -202,22 +152,27 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // could lead to the address of the viewerConfig changing without it being // semantically different). @property({ attribute: false, hasChanged: contentsChanged }) - protected viewerConfig?: ViewerConfig; + public viewerConfig?: ViewerConfig; @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null; @property({ attribute: false }) - protected resolvedMediaCache?: ResolvedMediaCache; + public resolvedMediaCache?: ResolvedMediaCache; - // Mapping of slide # to BrowseMediaSource child #. + protected _refMediaCarousel: Ref = createRef(); + + // Mapping of slide # to FrigateBrowseMediaSource child #. // (Folders are not media items that can be rendered). protected _slideToChild: Record = {}; // A task to resolve target media if lazy loading is disabled. - protected _mediaResolutionTask = new Task<[BrowseMediaSource | undefined], void>( + protected _mediaResolutionTask = new Task< + [FrigateBrowseMediaSource | null | undefined], + void + >( this, - async ([target]: (BrowseMediaSource | undefined)[]): Promise => { + async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise => { for ( let i = 0; !this.viewerConfig?.lazy_load && @@ -227,12 +182,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { i < (target.children || []).length; ++i ) { - if (BrowseMediaUtil.isTrueMedia(target.children[i])) { - await ResolvedMediaUtil.resolveMedia( - this.hass, - target.children[i], - this.resolvedMediaCache, - ); + if (isTrueMedia(target.children[i])) { + await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache); } } }, @@ -244,22 +195,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - if (this._carousel && changedProperties.has('viewerConfig')) { - this._destroyCarousel(); - } + const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel(); - if (this._carousel && changedProperties.has('view')) { + if (frigateCardCarousel && changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; if (oldView) { - if (oldView.target != this.view?.target) { - // If the media target is different entirely, reset the carousel. - this._destroyCarousel(); - } else if (this.view?.childIndex != oldView.childIndex) { - const slide = this._getSlideForChild(this.view?.childIndex); - if (slide !== undefined && slide !== this.carouselSelected()) { + if ( + oldView.target === this.view?.target && + this.view.childIndex != oldView.childIndex + ) { + const slide = this._getSlideForChild(this.view.childIndex); + if ( + slide !== null && + slide !== frigateCardCarousel.getCarouselSelected()?.index + ) { // If the media target is the same as already loaded, but isn't of // the selected slide, scroll to that slide. - this.carouselScrollTo(slide); + frigateCardCarousel.carouselScrollTo(slide); } } } @@ -269,47 +221,29 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } /** - * Play the media on the selected slide. + * Get the slide number given a media child number. + * @param childIndex The child index (relative to `view.target`) + * @returns A number or null if the child is not found. */ - protected _autoPlayHandler(): void { - if (this.viewerConfig?.auto_play) { - super._autoPlayHandler(); - } - } - - /** - * Unmute the media on the selected slide. - */ - protected _autoUnmuteHandler(): void { - if (this.viewerConfig?.auto_unmute) { - super._autoUnmuteHandler(); - } - } - - protected _destroyCarousel(): void { - super._destroyCarousel(); - - // Notes on instance variables: - // * this._slideToChild: This is set as part of each render and does not - // need to be destroyed here. - } - - protected _getSlideForChild(childIndex: number | undefined): number | undefined { - if (childIndex === undefined) { - return undefined; + protected _getSlideForChild(childIndex: number | null | undefined): number | null { + if (childIndex === undefined || childIndex === null) { + return null; } const slideIndex = Object.keys(this._slideToChild).find( (key) => this._slideToChild[key] === childIndex, ); - return slideIndex !== undefined ? Number(slideIndex) : undefined; + return slideIndex !== undefined ? Number(slideIndex) : null; } /** * Get the transition effect to use. * @returns An TransitionEffect object. */ - protected _getTransitionEffect(): TransitionEffect | undefined { - return this.viewerConfig?.transition_effect; + protected _getTransitionEffect(): TransitionEffect { + return ( + this.viewerConfig?.transition_effect ?? + frigateCardConfigDefaults.media_viewer.transition_effect + ); } /** @@ -319,32 +253,67 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected _getOptions(): EmblaOptionsType { return { // Start the carousel on the selected child number. - startIndex: this._getSlideForChild(this.view?.childIndex), - draggable: this.viewerConfig?.draggable, + startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0, + draggable: this.viewerConfig?.draggable ?? true, }; } /** - * Get the Embla plugins to use. - * @returns An EmblaOptionsType object or undefined for no options. + * The the HLS player on a slide (or current slide if not provided.) + * @param slide An optional slide. + * @returns The FrigateCardMediaPlayer or null if not found. */ - protected _getPlugins(): EmblaPluginType[] | undefined { + protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null { + if (!slide) { + slide = this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.getCarouselSelected()?.element; + } + + return ( + (slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null + ); + } + + /** + * Get the Embla plugins to use. + * @returns A list of EmblaOptionsTypes. + */ + protected _getPlugins(): EmblaPluginType[] { return [ - Lazyload({ - lazyloadCallback: this.viewerConfig?.lazy_load - ? this._lazyloadSlide.bind(this) - : undefined, - }), - // Don't need autoplay/pause for snapshots. - ...(this.view?.is('clip') + // Only enable wheel plugin if there is more than one media item. + ...(this.view && + this.view.target && + this.view.target.children && + this.view.target.children.length > 1 ? [ - AutoMediaPlugin({ - playerSelector: 'frigate-card-ha-hls-player', - autoPlayWhenVisible: !!this.viewerConfig?.auto_play, - autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute, + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', }), ] : []), + Lazyload({ + ...(this.viewerConfig?.lazy_load && { + lazyLoadCallback: this._lazyloadSlide.bind(this), + }), + }), + AutoMediaPlugin({ + playerSelector: FRIGATE_CARD_HLS_SELECTOR, + ...(this.viewerConfig?.auto_play && { + autoPlayCondition: this.viewerConfig.auto_play, + }), + ...(this.viewerConfig?.auto_pause && { + autoPauseCondition: this.viewerConfig.auto_pause, + }), + ...(this.viewerConfig?.auto_mute && { + autoMuteCondition: this.viewerConfig.auto_mute, + }), + ...(this.viewerConfig?.auto_unmute && { + autoUnmuteCondition: this.viewerConfig.auto_unmute, + }), + }), ]; } @@ -358,7 +327,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { !this.view || !this.view.target || !this.view.target.children || - this.view.childIndex === undefined + this.view.childIndex === null ) { return null; } @@ -367,7 +336,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { let prevIndex: number | null = null; for (let i = this.view.childIndex - 1; i >= 0; i--) { const media = this.view.target.children[i]; - if (media && BrowseMediaUtil.isTrueMedia(media)) { + if (media && isTrueMedia(media)) { prevIndex = i; break; } @@ -377,7 +346,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { let nextIndex: number | null = null; for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) { const media = this.view.target.children[i]; - if (media && BrowseMediaUtil.isTrueMedia(media)) { + if (media && isTrueMedia(media)) { nextIndex = i; break; } @@ -398,7 +367,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * @returns The view that would show the matching clip. */ protected async _findRelatedClipView( - snapshot: BrowseMediaSource, + snapshot: FrigateBrowseMediaSource, ): Promise { if ( !this.hass || @@ -411,7 +380,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return null; } - const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot); + const snapshotStartTime = getEventStartTime(snapshot); if (!snapshotStartTime) { return null; } @@ -429,10 +398,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { let latest: number | null = null; for (let i = 0; i < this.view.target.children.length; i++) { const child = this.view.target.children[i]; - if (!BrowseMediaUtil.isTrueMedia(child)) { + if (!isTrueMedia(child)) { continue; } - const startTime = BrowseMediaUtil.extractEventStartTime(child); + const startTime = getEventStartTime(child); if (startTime && (earliest === null || startTime < earliest)) { earliest = startTime; @@ -445,15 +414,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return null; } - let clips: BrowseMediaSource | null; + let clips: FrigateBrowseMediaSource | null; - try { - clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { - ...this.browseMediaQueryParameters, + const params = overrideMultiBrowseMediaQueryParameters( + this.browseMediaQueryParameters, + { mediaType: 'clips', before: latest, after: earliest, - }); + }, + ); + + try { + clips = await multipleBrowseMediaQueryMerged(this.hass, params); } catch (e) { // This is best effort. return null; @@ -465,17 +438,15 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { for (let i = 0; i < clips.children.length; i++) { const child = clips.children[i]; - if (!BrowseMediaUtil.isTrueMedia(child)) { + if (!isTrueMedia(child)) { continue; } - const clipStartTime = BrowseMediaUtil.extractEventStartTime(child); + const clipStartTime = getEventStartTime(child); if (clipStartTime && clipStartTime === snapshotStartTime) { - return new View({ + return this.view.evolve({ view: 'clip', - camera: this.view.camera, target: clips, childIndex: i, - previous: this.view, }); } } @@ -485,26 +456,39 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { /** * Handle the user selecting a new slide in the carousel. */ - protected _selectSlideSetViewHandler(): void { - if (!this._carousel || !this.view) { + protected _setViewHandler(): void { + if (!this._refMediaCarousel.value || !this.view) { return; } // Update the childIndex in the view. - const slidesInView = this._carousel.slidesInView(true); - if (slidesInView.length) { - const childIndex = this._slideToChild[slidesInView[0]]; + const selected = this._refMediaCarousel.value + .frigateCardCarousel() + ?.getCarouselSelected()?.index; + if (selected !== undefined) { + const childIndex = this._slideToChild[selected]; if (childIndex !== undefined) { this.view .evolve({ childIndex: childIndex, - previous: this.view, }) .dispatchChangeEvent(this); } } } + /** + * Ensure media URLs use the correct HA URL (relevant for Chromecast where the + * default location will be the Chromecast receiver, not HA). + * @param url The media URL + */ + protected _canonicalizeHAURL(url?: string): string | undefined { + if (this.hass && url && url.startsWith('/')) { + return this.hass.hassUrl(url); + } + return url; + } + /** * Lazy load a slide. * @param index The index of the slide to lazy load. @@ -515,17 +499,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { const childIndex: number | undefined = this._slideToChild[index]; if ( - childIndex == undefined || + childIndex === undefined || !this.hass || !this.view || !this.view.target || !this.view.target.children || - !BrowseMediaUtil.isTrueMedia(this.view.target.children[childIndex]) + !isTrueMedia(this.view.target.children[childIndex]) ) { return; } - ResolvedMediaUtil.resolveMedia( + resolveMedia( this.hass, this.view.target.children[childIndex], this.resolvedMediaCache, @@ -538,43 +522,18 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { const img = slide.querySelector('img') as HTMLImageElement; // Frigate >= 0.9.0+ clips. - const hls_player = slide.querySelector( - 'frigate-card-ha-hls-player', - ) as HTMLElement & { url: string }; + const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & { + url: string; + }; if (img) { - img.src = resolvedMedia.url; + img.src = this._canonicalizeHAURL(resolvedMedia.url) || ''; } else if (hls_player) { - hls_player.url = resolvedMedia.url; + hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || ''; } }); } - /** - * Handle updating of the next/previous controls when the carousel is moved. - */ - protected _selectSlideNextPreviousHandler(): void { - const updateNextPreviousControl = ( - control: FrigateCardNextPreviousControl, - direction: 'previous' | 'next', - ): void => { - const neighbors = this._getMediaNeighbors(); - const [prev, next] = [neighbors?.previous, neighbors?.next]; - const target = direction == 'previous' ? prev : next; - - control.disabled = target == null; - control.title = target && target.title ? target.title : ''; - control.thumbnail = target && target.thumbnail ? target.thumbnail : undefined; - }; - - if (this._previousControlRef.value) { - updateNextPreviousControl(this._previousControlRef.value, 'previous'); - } - if (this._nextControlRef.value) { - updateNextPreviousControl(this._nextControlRef.value, 'next'); - } - } - /** * Get slides to include in the render. * @returns The slides to include in the render and an index keyed by slide @@ -615,6 +574,16 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return true; } + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('viewerConfig')) { + updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); + } + } + /** * Render the element, resolving the media first if necessary. */ @@ -624,12 +593,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // 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 html`${this._mediaResolutionTask.render({ - initial: () => renderProgressIndicator(), - pending: () => renderProgressIndicator(), - error: (e: unknown) => dispatchErrorMessageEvent(this, (e as Error).message), - complete: () => this._render(), - })}`; + return renderTask(this, this._mediaResolutionTask, this._render.bind(this)); } return this._render(); } @@ -641,62 +605,93 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { protected _render(): TemplateResult | void { const [slides, slideToChild] = this._getSlides(); this._slideToChild = slideToChild; - if (!slides) { + if (!slides.length || !this.view?.media) { return; } const neighbors = this._getMediaNeighbors(); const [prev, next] = [neighbors?.previous, neighbors?.next]; - return html`
- { - this._nextPreviousHandler('previous'); - stopEventFromActivatingCardWideActions(ev); - }} - > -
-
${slides}
-
- { - this._nextPreviousHandler('next'); - stopEventFromActivatingCardWideActions(ev); - }} - > -
- ${this.view?.media - ? html` - ` - : ``} `; + // Notes on the below: + // - guard() is used to avoid reseting the carousel unless the + // options/plugins actually change. + + return html` + { + this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious(); + stopEventFromActivatingCardWideActions(ev); + }} + > + ${slides} + { + this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); + stopEventFromActivatingCardWideActions(ev); + }} + > + `; } + /** + * Fire a media show event when a slide is selected. + */ + protected _recordingSeekHandler(): void { + // If this is a recording and play is desired to be started from a + // particular point, seek to that point. Use the media off the slide itself + // -- when the slide is changed, the media show event may be dispatched + // before this.view has been updated to reflect the new selection. + const player = this._getPlayer() as FrigateCardMediaPlayer & { + media?: FrigateBrowseMediaSource; + }; + if (player && player.media && player.media.frigate?.recording?.seek_seconds) { + player.seek(player.media.frigate.recording.seek_seconds); + } + } + + /** + * Render a single media item in the viewer carousel. + * @param mediaToRender The FrigateBrowseMediaSource to render. + * @param slideIndex The index of the slide to render. + * @returns A rendered template. + */ protected _renderMediaItem( - mediaToRender: BrowseMediaSource, + mediaToRender: FrigateBrowseMediaSource, slideIndex: number, ): TemplateResult | void { // Skip folders as they cannot be rendered by this viewer. if ( + !this.hass || !this.view || !this.viewerConfig || - !BrowseMediaUtil.isTrueMedia(mediaToRender) + !isTrueMedia(mediaToRender) || + !['video', 'image'].includes(mediaToRender.media_content_type) ) { return; } @@ -707,9 +702,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return; } + // The media is attached to the player as '.media' which is used in + // `_selectSlideMediaShowHandler` (and not used by the player itself). return html`
- ${this.view.isClipRelatedView() + ${mediaToRender.media_content_type === 'video' ? html`) => - this._mediaShowEventHandler(slideIndex, e)} + @frigate-card:media:loaded=${(e: CustomEvent) => { + wrapMediaLoadedEventForCarousel(slideIndex, e); + }} > ` : html` { - if (this._carousel?.clickAllowed()) { + if ( + this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.carouselClickAllowed() + ) { this._findRelatedClipView(mediaToRender).then((view) => { if (view) { view.dispatchChangeEvent(this); @@ -738,6 +745,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } }} @load="${(e: Event) => { + const lazyloadPlugin = this._refMediaCarousel.value + ?.frigateCardCarousel() + ?.getCarouselPlugins()?.lazyload; if ( // This handler will be called on the empty image (including // an updated empty image that is the same dimensions large as @@ -745,15 +755,27 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { // images in media-carousel.ts). Here we need to only call the // media load handler on a 'real' load. !lazyLoad || - (this._plugins['Lazyload'] as LazyloadType | undefined)?.hasLazyloaded( - slideIndex, - ) + lazyloadPlugin?.hasLazyloaded(slideIndex) ) { - this._mediaLoadedHandler(slideIndex, createMediaShowInfo(e)); + wrapRawMediaLoadedEventForCarousel(slideIndex, e); } }}" />`}
`; } + + /** + * Get element styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(viewerCarouselStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-viewer-carousel': FrigateCardViewerCarousel; + 'frigate-card-viewer': FrigateCardViewer; + } } diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index e2aec88b..f95ce8ce 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -1,28 +1,41 @@ -import { get, set } from 'lodash-es'; +import { cloneDeep, get, isEqual, set } from 'lodash-es'; import { CONF_CAMERAS, CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - CONF_CAMERAS_ARRAY_CAMERA_NAME, - CONF_CAMERAS_ARRAY_CLIENT_ID, - CONF_CAMERAS_ARRAY_LABEL, CONF_CAMERAS_ARRAY_LIVE_PROVIDER, - CONF_CAMERAS_ARRAY_URL, - CONF_CAMERAS_ARRAY_ZONE, - CONF_EVENT_VIEWER_AUTO_PLAY, - CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_IMAGE_URL, + CONF_LIVE_AUTO_UNMUTE, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, + CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + CONF_LIVE_LAZY_UNLOAD, CONF_LIVE_PRELOAD, CONF_LIVE_WEBRTC_CARD, + CONF_MEDIA_VIEWER, CONF_MENU, + CONF_MENU_BUTTONS_CAMERAS, + CONF_MENU_BUTTONS_CLIPS, + CONF_MENU_BUTTONS_DOWNLOAD, + CONF_MENU_BUTTONS_FRIGATE, + CONF_MENU_BUTTONS_FRIGATE_UI, + CONF_MENU_BUTTONS_FULLSCREEN, + CONF_MENU_BUTTONS_IMAGE, + CONF_MENU_BUTTONS_LIVE, + CONF_MENU_BUTTONS_SNAPSHOTS, CONF_MENU_BUTTON_SIZE, - CONF_MENU_MODE, + CONF_MENU_POSITION, + CONF_MENU_STYLE, CONF_OVERRIDES, CONF_VIEW_DEFAULT, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_ENTITIES, } from './const'; -import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './types'; +import { + BUTTON_SIZE_MIN, + RawFrigateCardConfig, + RawFrigateCardConfigArray, + THUMBNAIL_WIDTH_MAX, + THUMBNAIL_WIDTH_MIN, +} from './types'; /** * Set a configuration value. @@ -123,7 +136,7 @@ export const trimConfig = function (obj: RawFrigateCardConfig): boolean { * @returns A new deeply-copied configuration. */ export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig { - return JSON.parse(JSON.stringify(obj)); + return cloneDeep(obj); }; /** @@ -131,7 +144,7 @@ export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardCo * @param value The value. * @returns `true` is the value is not an object. */ -const isNotObject = function (value: unknown) { +const isNotObject = function (value: unknown): unknown | undefined { return typeof value !== 'object' ? value : undefined; }; @@ -140,10 +153,62 @@ const isNotObject = function (value: unknown) { * @param value The value. * @returns A number or undefined. */ -const toNumberOrIgnore = function (value: unknown) { +const toNumberOrIgnore = function (value: unknown): number | undefined { return isNaN(value as number) ? undefined : Number(value); }; +/** + * Create a transform that will cap a numeric value. + * @param value The value. + * @returns A number or null. + */ +const createRangedTransform = function ( + transform: (value: unknown) => unknown, + min?: number, + max?: number, +): (valueIn: unknown) => unknown { + return (value: unknown): unknown => { + let transformed = transform(value); + if (typeof transformed !== 'number') { + return transformed; + } + transformed = min ? Math.max(min, transformed as number) : transformed; + transformed = max ? Math.min(max, transformed as number) : transformed; + return transformed; + }; +}; + +/** + * Convert a value from 'XXpx' to XX (as a number). + * @param value Incoming value. + * @returns A number, null if the property should be deleted or undefined if it + * should be ignored. + */ +const toPixelsOrDelete = function (value: unknown): number | null | undefined { + // Ignore the value if it's a number. + if (typeof value === 'number') { + return undefined; + } + // Delete the value if it's not a string. + if (typeof value !== 'string') { + return null; + } + // Remove 'px' and return the number, unless it's an invalid number -- then + // delete it. + value = value.replace(/px$/i, ''); + return isNaN(value as number) ? null : Number(value); +}; + +/** + * Request a property be deleted. + * @param _value Inbound value (not required). + * @returns `null` to request the property be deleted. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const deleteProperty = function (_value: unknown): number | null | undefined { + return null; +}; + /** * Move a property from one location to another. * @param obj The configuration object in which the property resides. @@ -156,15 +221,31 @@ export const moveConfigValue = ( obj: RawFrigateCardConfig, oldPath: string, newPath: string, - transform?: (valueIn: unknown) => unknown, + options?: { + transform?: (valueIn: unknown) => unknown; + keepOriginal?: boolean; + }, ): boolean => { - let value = getConfigValue(obj, oldPath); - if (transform) { - value = transform(value); + const inValue = getConfigValue(obj, oldPath); + if (inValue === undefined) { + return false; } - if (typeof value !== 'undefined') { - deleteConfigValue(obj, oldPath); - setConfigValue(obj, newPath, value); + const outValue = options?.transform ? options.transform(inValue) : inValue; + if (oldPath === newPath && isEqual(inValue, outValue)) { + return false; + } + if (outValue === null) { + if (!options?.keepOriginal) { + deleteConfigValue(obj, oldPath); + return true; + } + return false; + } + if (outValue !== undefined) { + if (!options?.keepOriginal) { + deleteConfigValue(obj, oldPath); + } + setConfigValue(obj, newPath, outValue); return true; } return false; @@ -190,37 +271,13 @@ export const getArrayConfigPath = (path: string, index: number): string => { const upgradeMoveTo = function ( oldPath: string, newPath: string, - transform?: (valueIn: unknown) => unknown, + options?: { + transform?: (valueIn: unknown) => unknown; + keepOriginal?: boolean; + }, ): (obj: RawFrigateCardConfig) => boolean { return function (obj: RawFrigateCardConfig): boolean { - return moveConfigValue(obj, oldPath, newPath, transform); - }; -}; - -/** - * Upgrade a property by changing it if it is present. - * @param path The property path. - * @param transform A callback that transforms the old value to the new value, - * if undefined is returned the property is removed. - * @returns `true` if the configuration was modified. - */ -const upgradeChangeIfPresent = function ( - path: string, - transform: (valueIn: unknown) => unknown, -): (obj: RawFrigateCardConfig) => boolean { - return function (obj: RawFrigateCardConfig): boolean { - const oldValue = getConfigValue(obj, path); - if (oldValue !== undefined) { - const newValue = transform(oldValue); - if (newValue === undefined) { - deleteConfigValue(obj, path); - return true; - } else if (newValue !== oldValue) { - setConfigValue(obj, path, newValue); - return true; - } - } - return false; + return moveConfigValue(obj, oldPath, newPath, options); }; }; @@ -235,20 +292,49 @@ const upgradeChangeIfPresent = function ( const upgradeMoveToWithOverrides = function ( oldPath: string, newPath: string, - transform?: (valueIn: unknown) => unknown, + options?: { + transform?: (valueIn: unknown) => unknown; + keepOriginal?: boolean; + }, ): (obj: RawFrigateCardConfig) => boolean { return function (obj: RawFrigateCardConfig): boolean { - let modified = upgradeMoveTo(oldPath, newPath, transform)(obj); + let modified = upgradeMoveTo(oldPath, newPath, options)(obj); modified = upgradeArrayValue( CONF_OVERRIDES, - upgradeMoveTo(oldPath, newPath, transform), + upgradeMoveTo(oldPath, newPath, options), (obj) => obj.overrides as RawFrigateCardConfig | undefined, )(obj) || modified; return modified; }; }; +/** + * Upgrade a property in place with overrides. + * @param path The old property path. + * @param transform An optional transform for the value. + * @returns A function that returns `true` if the configuration was modified. + */ +const upgradeWithOverrides = function ( + path: string, + transform: (valueIn: unknown) => unknown, +): (obj: RawFrigateCardConfig) => boolean { + return upgradeMoveToWithOverrides(path, path, { transform: transform }); +}; + +/** + * Upgrade a property in place without overrides. + * @param path The old property path. + * @param transform An optional transform for the value. + * @returns A function that returns `true` if the configuration was modified. + */ +const upgrade = function ( + path: string, + transform: (valueIn: unknown) => unknown, +): (obj: RawFrigateCardConfig) => boolean { + return upgradeMoveTo(path, path, { transform: transform }); +}; + /** * Given a path to an array, apply an upgrade to each object in the array. * @param arrayPath The path to the array to upgrade. @@ -279,8 +365,7 @@ const upgradeArrayValue = function ( /** * Upgrade from a singular camera model to multiple. - * @param key A string key. - * @returns A safe key. + * @returns An upgrade function. */ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => { return function (obj: RawFrigateCardConfig): boolean { @@ -294,11 +379,11 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => const imports = { camera_entity: CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - 'frigate.camera_name': CONF_CAMERAS_ARRAY_CAMERA_NAME, - 'frigate.client_id': CONF_CAMERAS_ARRAY_CLIENT_ID, - 'frigate.label': CONF_CAMERAS_ARRAY_LABEL, - 'frigate.url': CONF_CAMERAS_ARRAY_URL, - 'frigate.zone': CONF_CAMERAS_ARRAY_ZONE, + 'frigate.camera_name': 'cameras.#.camera_name', + 'frigate.client_id': 'cameras.#.client_id', + 'frigate.label': 'cameras.#.label', + 'frigate.url': 'cameras.#.frigate_url', + 'frigate.zone': 'cameras.#.zone', 'live.webrtc.entity': `cameras.#.webrtc.entity`, 'live.webrtc.url': `cameras.#.webrtc.url`, 'live.provider': CONF_CAMERAS_ARRAY_LIVE_PROVIDER, @@ -311,6 +396,70 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => }; }; +/** + * Upgrade from a menu-mode to a style & position. + * @returns An upgrade function. + */ +const upgradeMenuModeToStyleAndPosition = (): (( + obj: RawFrigateCardConfig, +) => boolean) => { + return function (obj: RawFrigateCardConfig): boolean { + let modified = false; + + // Change the 'start' of the mode into a style. + modified = + upgradeMoveToWithOverrides('menu.mode', CONF_MENU_STYLE, { + transform: (mode: unknown): string | undefined => { + if (typeof mode === 'string') { + const result = mode.match(/^(hover|hidden|overlay|above|below|none)/); + if (result) { + switch (result[1]) { + case 'hover': + case 'hidden': + case 'overlay': + case 'none': + return result[1]; + case 'above': + case 'below': + return 'outside'; + } + } + } + return undefined; + }, + keepOriginal: true, + })(obj) || modified; + + // Change the 'end' of the mode into a position. + modified = + upgradeMoveToWithOverrides('menu.mode', CONF_MENU_POSITION, { + transform: (mode: unknown): string | undefined => { + if (typeof mode === 'string') { + const result = mode.match(/(above|below|left|right|top|bottom)$/); + if (result) { + switch (result[1]) { + case 'left': + case 'right': + case 'top': + case 'bottom': + return result[1]; + case 'above': + return 'top'; + case 'below': + return 'bottom'; + } + } + } + return undefined; + }, + keepOriginal: true, + })(obj) || modified; + + // Delete the old `menu.mode` . + return upgradeWithOverrides('menu.mode', deleteProperty)(obj) || modified; + }; +}; + /** * Upgrade from a condition on the menu (to allow rendering) to a menu mode * override instead. @@ -345,6 +494,56 @@ const upgradeMenuConditionToMenuOverride = (): (( }; }; +/** + * Transform a menu button from a boolean to a priority. + * @param value The boolean true/false for show/hide the switch. + * @returns A priority value. + */ +const menuButtonBooleanToObject = function ( + value: unknown, +): { enabled: boolean } | null | undefined { + if (typeof value === 'object') { + return undefined; + } + // If it's not a boolean remove it. + if (typeof value !== 'boolean') { + return null; + } + return { enabled: value }; +}; + +/** + * Upgrade from a show_controls key to individual favorite/timeline keys. + * @returns An upgrade function. + */ +const upgradeThumbnailShowControlsToIndividualControls = ( + thumbnailsBasePath: string, +): ((obj: RawFrigateCardConfig) => boolean) => { + const thumbnailsShowControlsPath = `${thumbnailsBasePath}.show_controls`; + + return function (obj: RawFrigateCardConfig): boolean { + let modified = false; + modified = + upgradeMoveToWithOverrides( + thumbnailsShowControlsPath, + `${thumbnailsBasePath}.show_favorite_control`, + { keepOriginal: true }, + )(obj) || modified; + + modified = + upgradeMoveToWithOverrides( + thumbnailsShowControlsPath, + `${thumbnailsBasePath}.show_timeline_control`, + { keepOriginal: true }, + )(obj) || modified; + + // Delete the old `show_controls`. + return ( + upgradeWithOverrides(thumbnailsShowControlsPath, deleteProperty)(obj) || modified + ); + }; +}; + const UPGRADES = [ // v1.2.1 -> v2.0.0 upgradeMoveTo('frigate_url', 'frigate.url'), @@ -358,12 +557,12 @@ const UPGRADES = [ upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD), upgradeMoveTo('webrtc', 'live.webrtc'), upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'), - upgradeMoveTo('controls.nextprev', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE), - upgradeMoveTo('controls.nextprev_size', CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE), - upgradeMoveTo('menu_mode', CONF_MENU_MODE), + upgradeMoveTo('controls.nextprev', 'event_viewer.controls.next_previous.style'), + upgradeMoveTo('controls.nextprev_size', 'event_viewer.controls.next_previous.size'), + upgradeMoveTo('menu_mode', 'menu.mode'), upgradeMoveTo('menu_buttons', 'menu.buttons'), upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE), - upgradeMoveTo('image', 'image.src', isNotObject), + upgradeMoveTo('image', 'image.src', { transform: isNotObject }), // v2.0.0 -> v2.1.0 upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES), @@ -371,17 +570,76 @@ const UPGRADES = [ // v2.1.0 -> v3.0.0-rc.1 upgradeToMultipleCameras(), upgradeMenuConditionToMenuOverride(), - upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore), - upgradeMoveTo('event_viewer.autoplay_clip', CONF_EVENT_VIEWER_AUTO_PLAY), + upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, { + transform: toNumberOrIgnore, + }), + upgradeMoveTo('event_viewer.autoplay_clip', 'event_viewer.auto_play'), // v3.0.0-rc.1 -> v3.0.0-rc.2 upgradeArrayValue( CONF_CAMERAS, - upgradeChangeIfPresent('live_provider', (val) => + upgradeWithOverrides('live_provider', (val) => val === 'frigate' ? 'ha' : val === 'webrtc' ? 'webrtc-card' : val, ), ), upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('webrtc', 'webrtc_card')), upgradeMoveToWithOverrides('live.webrtc', CONF_LIVE_WEBRTC_CARD), upgradeMoveToWithOverrides('image.src', CONF_IMAGE_URL), + + // v3.0.0 -> v4.0.0-rc.1 + upgradeWithOverrides( + CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX), + ), + upgradeWithOverrides( + 'event_viewer.controls.thumbnails.size', + createRangedTransform(toPixelsOrDelete, THUMBNAIL_WIDTH_MIN, THUMBNAIL_WIDTH_MAX), + ), + upgradeWithOverrides( + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, + createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN), + ), + upgradeWithOverrides( + 'event_viewer.controls.next_previous.size', + createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN), + ), + upgradeWithOverrides( + CONF_MENU_BUTTON_SIZE, + createRangedTransform(toPixelsOrDelete, BUTTON_SIZE_MIN), + ), + upgradeWithOverrides('event_gallery.min_columns', deleteProperty), + upgradeMenuModeToStyleAndPosition(), + upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_CAMERAS, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_LIVE, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_CLIPS, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_SNAPSHOTS, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_IMAGE, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE_UI, menuButtonBooleanToObject), + upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject), + upgrade(CONF_LIVE_LAZY_UNLOAD, (val) => + typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, + ), + upgrade(CONF_LIVE_AUTO_UNMUTE, (val) => + typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, + ), + upgrade('event_viewer.auto_play', (val) => + typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, + ), + upgrade('event_viewer.auto_unmute', (val) => + typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, + ), + upgradeMoveToWithOverrides('event_viewer', CONF_MEDIA_VIEWER), + upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('camera_name', 'frigate.camera_name')), + upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('client_id', 'frigate.client_id')), + upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('label', 'frigate.label')), + upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('frigate_url', 'frigate.url')), + upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('zone', 'frigate.zone')), + + // v4.0.0-rc.1 -> v4.0.0-rc.3 + upgradeThumbnailShowControlsToIndividualControls('event_gallery.controls.thumbnails'), + upgradeThumbnailShowControlsToIndividualControls('media_viewer.controls.thumbnails'), + upgradeThumbnailShowControlsToIndividualControls('live.controls.thumbnails'), + upgradeThumbnailShowControlsToIndividualControls('timeline.controls.thumbnails'), ]; diff --git a/src/const.ts b/src/const.ts index 48b571a4..ca56f40e 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,58 +1,103 @@ -export const CARD_VERSION = '3.0.0' as const; +export const CAMERA_BIRDSEYE = 'birdseye' as const; export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const; export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const; export const CONF_CAMERAS = 'cameras' as const; export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY = `${CONF_CAMERAS}.#.camera_entity` as const; -export const CONF_CAMERAS_ARRAY_CAMERA_NAME = `${CONF_CAMERAS}.#.camera_name` as const; -export const CONF_CAMERAS_ARRAY_CLIENT_ID = `${CONF_CAMERAS}.#.client_id` as const; -export const CONF_CAMERAS_ARRAY_LABEL = `${CONF_CAMERAS}.#.label` as const; -export const CONF_CAMERAS_ARRAY_URL = `${CONF_CAMERAS}.#.frigate_url` as const; -export const CONF_CAMERAS_ARRAY_ZONE = `${CONF_CAMERAS}.#.zone` as const; +export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME = + `${CONF_CAMERAS}.#.frigate.camera_name` as const; +export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID = + `${CONF_CAMERAS}.#.frigate.client_id` as const; +export const CONF_CAMERAS_ARRAY_FRIGATE_LABEL = + `${CONF_CAMERAS}.#.frigate.label` as const; +export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const; +export const CONF_CAMERAS_ARRAY_FRIGATE_ZONE = `${CONF_CAMERAS}.#.frigate.zone` as const; export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY = `${CONF_CAMERAS}.#.webrtc_card.entity` as const; -export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const; +export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = + `${CONF_CAMERAS}.#.webrtc_card.url` as const; export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = `${CONF_CAMERAS}.#.live_provider` as const; +export const CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS = + `${CONF_CAMERAS}.#.dependencies.cameras` as const; +export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS = + `${CONF_CAMERAS}.#.dependencies.all_cameras` as const; +export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION = + `${CONF_CAMERAS}.#.triggers.motion` as const; +export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY = + `${CONF_CAMERAS}.#.triggers.occupancy` as const; +export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = + `${CONF_CAMERAS}.#.triggers.entities` as const; export const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; +export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const; export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; +export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; +export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; +export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS = + `${CONF_VIEW_SCAN}.show_trigger_status` as const; +export const CONF_VIEW_SCAN_UNTRIGGER_RESET = + `${CONF_VIEW_SCAN}.untrigger_reset` as const; +export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS = + `${CONF_VIEW_SCAN}.untrigger_seconds` as const; export const CONF_EVENT_GALLERY = 'event_gallery' as const; -export const CONF_EVENT_GALLERY_MIN_COLUMNS = - `${CONF_EVENT_GALLERY}.min_columns` as const; +export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const; +export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_EVENT_GALLERY}.controls.thumbnails.show_favorite_control` as const; +export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = + `${CONF_EVENT_GALLERY}.controls.thumbnails.show_timeline_control` as const; +export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE = + `${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const; -export const CONF_EVENT_VIEWER = 'event_viewer' as const; -export const CONF_EVENT_VIEWER_AUTO_PLAY = `${CONF_EVENT_VIEWER}.auto_play` as const; -export const CONF_EVENT_VIEWER_AUTO_UNMUTE = `${CONF_EVENT_VIEWER}.auto_unmute` as const; -export const CONF_EVENT_VIEWER_DRAGGABLE = `${CONF_EVENT_VIEWER}.draggable` as const; -export const CONF_EVENT_VIEWER_LAZY_LOAD = `${CONF_EVENT_VIEWER}.lazy_load` as const; -export const CONF_EVENT_VIEWER_TRANSITION_EFFECT = - `${CONF_EVENT_VIEWER}.transition_effect` as const; -export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = - `${CONF_EVENT_VIEWER}.controls.next_previous.style` as const; -export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = - `${CONF_EVENT_VIEWER}.controls.next_previous.size` as const; -export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE = - `${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const; -export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE = - `${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const; -export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE = - `${CONF_EVENT_VIEWER}.controls.title.mode` as const; -export const CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS = - `${CONF_EVENT_VIEWER}.controls.title.duration_seconds` as const; +export const CONF_MEDIA_VIEWER = 'media_viewer' as const; +export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const; +export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const; +export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const; +export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const; +export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const; +export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const; +export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT = + `${CONF_MEDIA_VIEWER}.transition_effect` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = + `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = + `${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE = + `${CONF_MEDIA_VIEWER}.controls.title.mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS = + `${CONF_MEDIA_VIEWER}.controls.title.duration_seconds` as const; +export const CONF_MEDIA_VIEWER_LAYOUT_FIT = `${CONF_MEDIA_VIEWER}.layout.fit` as const; +export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X = + `${CONF_MEDIA_VIEWER}.layout.position.x` as const; +export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y = + `${CONF_MEDIA_VIEWER}.layout.position.y` as const; export const CONF_LIVE = 'live' as const; +export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; +export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const; +export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const; export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const; export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = `${CONF_LIVE}.controls.next_previous.style` as const; @@ -64,35 +109,69 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = `${CONF_LIVE}.controls.thumbnails.mode` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE = `${CONF_LIVE}.controls.thumbnails.size` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_LIVE}.controls.thumbnails.show_details` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const; +export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const; 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_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; export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const; export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const; export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const; export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const; export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const; +export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD = + `${CONF_LIVE}.show_image_during_load` as const; export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const; export const CONF_IMAGE = 'image' as const; +export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const; +export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const; +export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const; export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const; export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; +export const CONF_TIMELINE = 'timeline' as const; +export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const; +export const CONF_TIMELINE_CLUSTERING_THRESHOLD = + `${CONF_TIMELINE}.clustering_threshold` as const; +export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const; +export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = + `${CONF_TIMELINE}.controls.thumbnails.mode` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = + `${CONF_TIMELINE}.controls.thumbnails.size` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_TIMELINE}.controls.thumbnails.show_details` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const; + export const CONF_MENU = 'menu' as const; +export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; +export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const; +export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; +export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; +export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const; + +export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` as const; +export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; +export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const; export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const; export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const; -export const CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN = - `${CONF_MENU}.buttons.fullscreen` as const; -export const CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD = - `${CONF_MENU}.buttons.download` as const; -export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; -export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; -export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; +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_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; -export const CONF_MENU_MODE = `${CONF_MENU}.mode` as const; +export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; +export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; export const CONF_DIMENSIONS = 'dimensions' as const; export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; @@ -100,3 +179,6 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = `${CONF_DIMENSIONS}.aspect_ratio_mode` as const; export const CONF_OVERRIDES = 'overrides' as const; + +// 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/declarations.d.ts b/src/declarations.d.ts index 59c79d0c..cd69265b 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -1,2 +1,6 @@ declare module '*.scss'; declare module '*.jpg'; +declare module 'view' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ViewContext {} +} diff --git a/src/editor.ts b/src/editor.ts index 30afefa1..b5f92116 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,77 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { fireEvent, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; - -import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; -import { localize } from './localize/localize.js'; -import { - frigateCardConfigDefaults, - RawFrigateCardConfig, - RawFrigateCardConfigArray, -} from './types.js'; - -import { - CONF_CAMERAS, - CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - CONF_CAMERAS_ARRAY_CAMERA_NAME, - CONF_CAMERAS_ARRAY_CLIENT_ID, - CONF_CAMERAS_ARRAY_ICON, - CONF_CAMERAS_ARRAY_ID, - CONF_CAMERAS_ARRAY_LABEL, - CONF_CAMERAS_ARRAY_LIVE_PROVIDER, - CONF_CAMERAS_ARRAY_TITLE, - CONF_CAMERAS_ARRAY_URL, - CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, - CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, - CONF_CAMERAS_ARRAY_ZONE, - CONF_DIMENSIONS_ASPECT_RATIO, - CONF_DIMENSIONS_ASPECT_RATIO_MODE, - CONF_EVENT_GALLERY_MIN_COLUMNS, - CONF_EVENT_VIEWER_AUTO_PLAY, - CONF_EVENT_VIEWER_AUTO_UNMUTE, - CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE, - CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, - CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, - CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE, - CONF_EVENT_VIEWER_DRAGGABLE, - CONF_EVENT_VIEWER_LAZY_LOAD, - CONF_EVENT_VIEWER_TRANSITION_EFFECT, - CONF_IMAGE_MODE, - CONF_IMAGE_REFRESH_SECONDS, - CONF_IMAGE_URL, - CONF_LIVE_AUTO_UNMUTE, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, - CONF_LIVE_CONTROLS_THUMBNAILS_MODE, - CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, - CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, - CONF_LIVE_CONTROLS_TITLE_MODE, - CONF_LIVE_DRAGGABLE, - CONF_LIVE_LAZY_LOAD, - CONF_LIVE_LAZY_UNLOAD, - CONF_LIVE_PRELOAD, - CONF_LIVE_TRANSITION_EFFECT, - CONF_MENU_BUTTONS_CLIPS, - CONF_MENU_BUTTONS_FRIGATE, - CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD, - CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN, - CONF_MENU_BUTTONS_FRIGATE_UI, - CONF_MENU_BUTTONS_IMAGE, - CONF_MENU_BUTTONS_LIVE, - CONF_MENU_BUTTONS_SNAPSHOTS, - CONF_MENU_BUTTON_SIZE, - CONF_MENU_MODE, - CONF_VIEW_CAMERA_SELECT, - CONF_VIEW_DEFAULT, - CONF_VIEW_TIMEOUT_SECONDS, - CONF_VIEW_UPDATE_CYCLE_CAMERA, - CONF_VIEW_UPDATE_FORCE, - CONF_VIEW_UPDATE_SECONDS, -} from './const.js'; -import { arrayMove, getEntityTitle, prettifyFrigateName } from './common.js'; +import { classMap } from 'lit/directives/class-map.js'; import { copyConfig, deleteConfigValue, @@ -81,25 +11,155 @@ import { setConfigValue, upgradeConfig, } from './config-mgmt.js'; - +import { + CONF_CAMERAS, + CONF_CAMERAS_ARRAY_CAMERA_ENTITY, + CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, + CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, + CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, + CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, + CONF_CAMERAS_ARRAY_FRIGATE_LABEL, + CONF_CAMERAS_ARRAY_FRIGATE_URL, + CONF_CAMERAS_ARRAY_FRIGATE_ZONE, + CONF_CAMERAS_ARRAY_ICON, + CONF_CAMERAS_ARRAY_ID, + CONF_CAMERAS_ARRAY_LIVE_PROVIDER, + CONF_CAMERAS_ARRAY_TITLE, + CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, + CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, + CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, + CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, + CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, + CONF_DIMENSIONS_ASPECT_RATIO, + CONF_DIMENSIONS_ASPECT_RATIO_MODE, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, + CONF_IMAGE_LAYOUT_FIT, + CONF_IMAGE_LAYOUT_POSITION_X, + CONF_IMAGE_LAYOUT_POSITION_Y, + CONF_IMAGE_MODE, + CONF_IMAGE_REFRESH_SECONDS, + CONF_IMAGE_URL, + CONF_LIVE_AUTO_MUTE, + CONF_LIVE_AUTO_PAUSE, + CONF_LIVE_AUTO_PLAY, + CONF_LIVE_AUTO_UNMUTE, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, + CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, + 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_THUMBNAILS_SIZE, + CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, + CONF_LIVE_CONTROLS_TITLE_MODE, + CONF_LIVE_DRAGGABLE, + CONF_LIVE_LAYOUT_FIT, + CONF_LIVE_LAYOUT_POSITION_X, + CONF_LIVE_LAYOUT_POSITION_Y, + CONF_LIVE_LAZY_LOAD, + CONF_LIVE_LAZY_UNLOAD, + CONF_LIVE_PRELOAD, + 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_AUTO_UNMUTE, + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, + CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, + CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, + CONF_MEDIA_VIEWER_DRAGGABLE, + CONF_MEDIA_VIEWER_LAYOUT_FIT, + CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, + CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y, + CONF_MEDIA_VIEWER_LAZY_LOAD, + CONF_MEDIA_VIEWER_TRANSITION_EFFECT, + CONF_MENU_ALIGNMENT, + CONF_MENU_BUTTONS, + CONF_MENU_BUTTON_SIZE, + CONF_MENU_POSITION, + CONF_MENU_STYLE, + CONF_TIMELINE_CLUSTERING_THRESHOLD, + 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_CONTROLS_THUMBNAILS_SIZE, + CONF_TIMELINE_MEDIA, + CONF_TIMELINE_SHOW_RECORDINGS, + CONF_TIMELINE_WINDOW_SECONDS, + CONF_VIEW_CAMERA_SELECT, + CONF_VIEW_DARK_MODE, + CONF_VIEW_DEFAULT, + CONF_VIEW_SCAN, + CONF_VIEW_SCAN_ENABLED, + CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, + CONF_VIEW_SCAN_UNTRIGGER_RESET, + CONF_VIEW_SCAN_UNTRIGGER_SECONDS, + CONF_VIEW_TIMEOUT_SECONDS, + CONF_VIEW_UPDATE_CYCLE_CAMERA, + CONF_VIEW_UPDATE_FORCE, + CONF_VIEW_UPDATE_SECONDS, +} from './const.js'; +import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; +import { + BUTTON_SIZE_MIN, + frigateCardConfigDefaults, + FRIGATE_MENU_PRIORITY_MAX, + RawFrigateCardConfig, + RawFrigateCardConfigArray, + THUMBNAIL_WIDTH_MAX, + THUMBNAIL_WIDTH_MIN, +} from './types.js'; +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'; + +const MENU_BUTTONS = 'buttons'; +const MENU_CAMERAS = 'cameras'; +const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; +const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; +const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; +const MENU_CAMERAS_WEBRTC = 'cameras.webrtc'; +const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls'; +const MENU_IMAGE_LAYOUT = 'image.layout'; +const MENU_LIVE_CONTROLS = 'live.controls'; +const MENU_LIVE_LAYOUT = 'live.layout'; +const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls'; +const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; +const MENU_TIMELINE_CONTROLS = 'timeline.controls'; +const MENU_OPTIONS = 'options'; +const MENU_VIEW_SCAN = 'scan'; interface EditorOptionsSet { icon: string; name: string; secondary: string; - show: boolean; } interface EditorOptions { [setName: string]: EditorOptionsSet; } -interface EditorCameraTarget { - cameraIndex: number; +interface EditorSelectOption { + value: string; + label: string; } -interface EditorOptionSetTarget { - optionSetName: string; +interface EditorMenuTarget { + domain: string; + key: string | number; } const options: EditorOptions = { @@ -107,55 +167,51 @@ const options: EditorOptions = { icon: 'video', name: localize('editor.cameras'), secondary: localize('editor.cameras_secondary'), - show: true, }, view: { icon: 'eye', name: localize('editor.view'), secondary: localize('editor.view_secondary'), - show: false, }, menu: { icon: 'menu', name: localize('editor.menu'), secondary: localize('editor.menu_secondary'), - show: false, }, live: { icon: 'cctv', name: localize('editor.live'), secondary: localize('editor.live_secondary'), - show: false, }, - event_viewer: { + media_viewer: { icon: 'filmstrip', - name: localize('editor.event_viewer'), - secondary: localize('editor.event_viewer_secondary'), - show: false, + name: localize('editor.media_viewer'), + secondary: localize('editor.media_viewer_secondary'), }, event_gallery: { icon: 'grid', name: localize('editor.event_gallery'), secondary: localize('editor.event_gallery_secondary'), - show: false, }, image: { icon: 'image', name: localize('editor.image'), secondary: localize('editor.image_secondary'), - show: false, + }, + timeline: { + icon: 'chart-gantt', + name: localize('editor.timeline'), + secondary: localize('editor.timeline_secondary'), }, dimensions: { icon: 'aspect-ratio', name: localize('editor.dimensions'), secondary: localize('editor.dimensions_secondary'), - show: false, }, overrides: { icon: 'file-replace', name: localize('editor.overrides'), secondary: localize('editor.overrides_secondary'), - show: false, }, }; @@ -163,14 +219,13 @@ const options: EditorOptions = { export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; @state() protected _config?: RawFrigateCardConfig; - @state() protected _helpers?: any; protected _initialized = false; protected _configUpgradeable = false; - @property({ attribute: false }) - protected _expandedCameraIndex: number | null = null; + @state() + protected _expandedMenus: Record = {}; - protected _viewModes = [ + protected _viewModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'live', label: localize('config.view.views.live') }, { value: 'clips', label: localize('config.view.views.clips') }, @@ -178,49 +233,56 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'clip', label: localize('config.view.views.clip') }, { value: 'snapshot', label: localize('config.view.views.snapshot') }, { value: 'image', label: localize('config.view.views.image') }, + { value: 'timeline', label: localize('config.view.views.timeline') }, ]; - protected _cameraSelectViewModes = [ + protected _cameraSelectViewModes: EditorSelectOption[] = [ ...this._viewModes, { value: 'current', label: localize('config.view.views.current') }, ]; - protected _menuModes = [ + protected _menuStyles: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'none', label: localize('config.menu.modes.none') }, - { value: 'hidden-top', label: localize('config.menu.modes.hidden-top') }, - { value: 'hidden-left', label: localize('config.menu.modes.hidden-left') }, - { value: 'hidden-bottom', label: localize('config.menu.modes.hidden-bottom') }, - { value: 'hidden-right', label: localize('config.menu.modes.hidden-right') }, - { value: 'overlay-top', label: localize('config.menu.modes.overlay-top') }, - { value: 'overlay-left', label: localize('config.menu.modes.overlay-left') }, - { value: 'overlay-bottom', label: localize('config.menu.modes.overlay-bottom') }, - { value: 'overlay-right', label: localize('config.menu.modes.overlay-right') }, - { value: 'hover-top', label: localize('config.menu.modes.hover-top') }, - { value: 'hover-left', label: localize('config.menu.modes.hover-left') }, - { value: 'hover-bottom', label: localize('config.menu.modes.hover-bottom') }, - { value: 'hover-right', label: localize('config.menu.modes.hover-right') }, - { value: 'above', label: localize('config.menu.modes.above') }, - { value: 'below', label: localize('config.menu.modes.below') }, + { value: 'none', label: localize('config.menu.styles.none') }, + { value: 'hidden', label: localize('config.menu.styles.hidden') }, + { value: 'overlay', label: localize('config.menu.styles.overlay') }, + { value: 'hover', label: localize('config.menu.styles.hover') }, + { value: 'outside', label: localize('config.menu.styles.outside') }, ]; - protected _eventViewerNextPreviousControlStyles = [ + protected _menuPositions: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'left', label: localize('config.menu.positions.left') }, + { value: 'right', label: localize('config.menu.positions.right') }, + { value: 'top', label: localize('config.menu.positions.top') }, + { value: 'bottom', label: localize('config.menu.positions.bottom') }, + ]; + + protected _menuAlignments: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'left', label: localize('config.menu.alignments.left') }, + { value: 'right', label: localize('config.menu.alignments.right') }, + { value: 'top', label: localize('config.menu.alignments.top') }, + { value: 'bottom', label: localize('config.menu.alignments.bottom') }, + ]; + + protected _eventViewerNextPreviousControlStyles: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'thumbnails', - label: localize('config.event_viewer.controls.next_previous.styles.thumbnails'), + label: localize('config.media_viewer.controls.next_previous.styles.thumbnails'), }, { value: 'chevrons', - label: localize('config.event_viewer.controls.next_previous.styles.chevrons'), + label: localize('config.media_viewer.controls.next_previous.styles.chevrons'), }, { value: 'none', - label: localize('config.event_viewer.controls.next_previous.styles.none'), + label: localize('config.media_viewer.controls.next_previous.styles.none'), }, ]; - protected _liveNextPreviousControlStyles = [ + protected _liveNextPreviousControlStyles: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'chevrons', @@ -233,7 +295,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'none', label: localize('config.live.controls.next_previous.styles.none') }, ]; - protected _aspectRatioModes = [ + protected _aspectRatioModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'dynamic', @@ -246,23 +308,31 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _thumbnailModes = [ + protected _thumbnailModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'none', - label: localize('config.event_viewer.controls.thumbnails.modes.none'), + label: localize('config.media_viewer.controls.thumbnails.modes.none'), }, { value: 'above', - label: localize('config.event_viewer.controls.thumbnails.modes.above'), + label: localize('config.media_viewer.controls.thumbnails.modes.above'), }, { value: 'below', - label: localize('config.event_viewer.controls.thumbnails.modes.below'), + label: localize('config.media_viewer.controls.thumbnails.modes.below'), + }, + { + value: 'left', + label: localize('config.media_viewer.controls.thumbnails.modes.left'), + }, + { + value: 'right', + label: localize('config.media_viewer.controls.thumbnails.modes.right'), }, ]; - protected _thumbnailMedias = [ + protected _thumbnailMedias: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'clips', label: localize('config.live.controls.thumbnails.medias.clips') }, { @@ -271,40 +341,86 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; - protected _titleModes = [ + protected _titleModes: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'none', label: localize('config.event_viewer.controls.title.modes.none') }, + { value: 'none', label: localize('config.media_viewer.controls.title.modes.none') }, { value: 'popup-top-left', - label: localize('config.event_viewer.controls.title.modes.popup-top-left'), + label: localize('config.media_viewer.controls.title.modes.popup-top-left'), }, { value: 'popup-top-right', - label: localize('config.event_viewer.controls.title.modes.popup-top-right'), + label: localize('config.media_viewer.controls.title.modes.popup-top-right'), }, { value: 'popup-bottom-left', - label: localize('config.event_viewer.controls.title.modes.popup-bottom-left'), + label: localize('config.media_viewer.controls.title.modes.popup-bottom-left'), }, { value: 'popup-bottom-right', - label: localize('config.event_viewer.controls.title.modes.popup-bottom-right'), + label: localize('config.media_viewer.controls.title.modes.popup-bottom-right'), }, ]; - protected _transitionEffects = [ + protected _transitionEffects: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'none', label: localize('config.event_viewer.transition_effects.none') }, - { value: 'slide', label: localize('config.event_viewer.transition_effects.slide') }, + { value: 'none', label: localize('config.media_viewer.transition_effects.none') }, + { value: 'slide', label: localize('config.media_viewer.transition_effects.slide') }, ]; - protected _imageModes = [ + protected _imageModes: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'camera', label: localize('config.image.modes.camera') }, { value: 'screensaver', label: localize('config.image.modes.screensaver') }, { value: 'url', label: localize('config.image.modes.url') }, ]; + protected _timelineMediaTypes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'all', label: localize('config.timeline.medias.all') }, + { value: 'clips', label: localize('config.timeline.medias.clips') }, + { value: 'snapshots', label: localize('config.timeline.medias.snapshots') }, + ]; + + protected _darkModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'on', label: localize('config.view.dark_modes.on') }, + { value: 'off', label: localize('config.view.dark_modes.off') }, + { value: 'auto', label: localize('config.view.dark_modes.auto') }, + ]; + + protected _mediaActionNegativeConditions: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'all', label: localize('config.common.media_action_conditions.all') }, + { + value: 'unselected', + label: localize('config.common.media_action_conditions.unselected'), + }, + { value: 'hidden', label: localize('config.common.media_action_conditions.hidden') }, + { value: 'never', label: localize('config.common.media_action_conditions.never') }, + ]; + + protected _mediaActionPositiveConditions: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'all', label: localize('config.common.media_action_conditions.all') }, + { + value: 'selected', + label: localize('config.common.media_action_conditions.selected'), + }, + { + value: 'visible', + label: localize('config.common.media_action_conditions.visible'), + }, + { value: 'never', label: localize('config.common.media_action_conditions.never') }, + ]; + + protected _layoutFits: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'contain', label: localize('config.common.layout.fits.contain') }, + { value: 'cover', label: localize('config.common.layout.fits.cover') }, + { value: 'fill', label: localize('config.common.layout.fits.fill') }, + ]; + 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 @@ -312,29 +428,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor // such, RawFrigateCardConfig is used as the type. this._config = config; this._configUpgradeable = isConfigUpgradeable(config); - this.loadCardHelpers(); } - protected shouldUpdate(): boolean { + /** + * Called before each update. + */ + protected willUpdate(): void { if (!this._initialized) { - this._initialize(); + sideLoadHomeAssistantElements().then((success) => { + if (success) { + this._initialized = true; + } + }); } - - return true; - } - - protected _getEntities(domain: string): string[] { - if (!this.hass) { - return []; - } - const entities = Object.keys(this.hass.states).filter( - (eid) => eid.substr(0, eid.indexOf('.')) === domain, - ); - entities.sort(); - - // Add a blank entry to unset a selection. - entities.unshift(''); - return entities; } /** @@ -348,8 +454,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return html`
@@ -405,11 +512,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * Render an option/"select" selector. * @param configPath The configuration path to set/read. * @param options The options to show in the selector. + * @param params Option parameters to control the selector. * @returns A rendered template. */ protected _renderOptionSelector( configPath: string, options: string[] | { value: string; label: string }[], + params?: { + multiple?: boolean; + label?: string; + }, ): TemplateResult | void { if (!this._config) { return; @@ -418,8 +530,41 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return html` this._valueChangedHandler(configPath, ev)} + > + + `; + } + + /** + * Render an icon selector. + * @param configPath The configuration path to set/read. + * @param params Optional parameters to control the selector. + * @returns A rendered template. + */ + protected _renderIconSelector( + configPath: string, + params?: { + label?: string; + }, + ): TemplateResult | void { + if (!this._config) { + return; + } + + return html` + this._valueChangedHandler(configPath, ev)} @@ -431,29 +576,30 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor /** * Render a number slider. * @param configPath Configuration path of the variable. - * @param valueDefault The default value. - * @param icon The icon to use on the slider. - * @param min The minimum value. - * @param max The maximum value. + * @param params Optional parameters to control the selector. * @returns A rendered template. */ protected _renderNumberInput( configPath: string, - min?: number, - max?: number, + params?: { + min?: number; + max?: number; + label?: string; + default?: number; + }, ): TemplateResult | void { if (!this._config) { return; } const value = getConfigValue(this._config, configPath); - const mode = max === undefined ? 'box' : 'slider'; + const mode = params?.max === undefined ? 'box' : 'slider'; return html` this._valueChangedHandler(configPath, ev)} > @@ -471,59 +617,208 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } /** - * Render a camera header. - * @param cameraIndex The index of the camera to edit/add. - * @param cameraConfig The configuration of the camera in question. - * @param addNewCamera Whether or not this is a header to add a new camera. - * @returns A rendered template. + * Get an editor title for the camera. + * @param cameraIndex The index of the camera in the cameras array. + * @param cameraConfig The raw camera configuration object. + * @returns A string title. */ - protected _renderCameraHeader( + protected _getEditorCameraTitle( cameraIndex: number, - cameraConfig?: RawFrigateCardConfig, - addNewCamera?: boolean, - ): TemplateResult { + cameraConfig: RawFrigateCardConfig, + ): string { + return ( + getCameraTitle(this.hass, cameraConfig) || + localize('editor.camera') + ' #' + cameraIndex + ); + } + + protected _renderViewScanMenu(): TemplateResult { + const submenuClasses = { + submenu: true, + selected: !!this._expandedMenus[MENU_VIEW_SCAN], + }; return html` -
- - - ${addNewCamera - ? html` - [${localize('editor.add_new_camera')}...] - ` - : // Attempt to render a recognizable name for the camera, - // starting with the most likely to be useful and working our - // ways towards the least useful. - html` - ${cameraConfig?.title || - cameraConfig?.id || - [ - cameraConfig?.camera_entity - ? getEntityTitle(this.hass, String(cameraConfig.camera_entity)) - : '', - cameraConfig?.client_id, - cameraConfig?.camera_name - ? prettifyFrigateName(String(cameraConfig.camera_name)) - : '', - cameraConfig?.label - ? prettifyFrigateName(String(cameraConfig.label)) - : '', - cameraConfig?.zone - ? prettifyFrigateName(String(cameraConfig.zone)) - : '', - ] - .filter(Boolean) - .join(' / ') || - localize('editor.camera') + ' #' + cameraIndex} - `} - +
+ + ${this._expandedMenus[MENU_VIEW_SCAN] + ? html`
+ ${this._renderSwitch( + CONF_VIEW_SCAN_ENABLED, + frigateCardConfigDefaults.view.scan.enabled, + { + label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`), + }, + )} + ${this._renderSwitch( + CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, + frigateCardConfigDefaults.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._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, { + default: frigateCardConfigDefaults.view.scan.untrigger_seconds, + })} +
` + : ''}
`; } + /** + * Render an editor menu for the card menu buttons. + * @param button The name of the button. + * @returns A rendered template. + */ + protected _renderMenuButton(button: string): TemplateResult { + const menuButtonAlignments: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'matching', label: localize('config.menu.buttons.alignments.matching') }, + { value: 'opposing', label: localize('config.menu.buttons.alignments.opposing') }, + ]; + const submenuClasses = { + submenu: true, + selected: this._expandedMenus[MENU_BUTTONS] === button, + }; + + return html` +
+ + + ${this._expandedMenus[MENU_BUTTONS] === button + ? html`
+ ${this._renderSwitch( + `${CONF_MENU_BUTTONS}.${button}.enabled`, + frigateCardConfigDefaults.menu.buttons[button]?.enabled ?? true, + { + label: localize('config.menu.buttons.enabled'), + }, + )} + ${this._renderOptionSelector( + `${CONF_MENU_BUTTONS}.${button}.alignment`, + menuButtonAlignments, + { + label: localize('config.menu.buttons.alignment'), + }, + )} + ${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, { + max: FRIGATE_MENU_PRIORITY_MAX, + default: frigateCardConfigDefaults.menu.buttons[button]?.priority, + label: localize('config.menu.buttons.priority'), + })} + ${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, { + label: localize('config.menu.buttons.icon'), + })} +
` + : ''} +
+ `; + } + + /** + * Put a given rendered template into a submenu. + * @param domain The submenu domain. + * @param key The submenu key. + * @param icon The icon for the submenu. + * @param labelPath The path to the label to localize. + * @param template The template to put in the submenu. + * @returns + */ + protected _putInSubmenu( + domain: string, + key: unknown, + labelPath: string, + icon: { + name?: string; + path?: string; + }, + template: TemplateResult, + ): TemplateResult { + const selected = this._expandedMenus[domain] === key; + const submenuClasses = { + submenu: true, + selected: selected, + }; + + return html`
+ + ${selected ? html`
${template}
` : ''} +
`; + } + + /** + * Render a media layout section. + * @param domain The submenu domain. + * @param labelPath The path to the label. + * @param configPathFit The path to the fit config. + * @param configPathPositionX The path to the position.x config. + * @param configPathPositionY The path to the position.y config. + * @returns A rendered template. + */ + protected _renderMediaLayout( + domain: string, + labelPath: string, + configPathFit: string, + configPathPositionX: string, + configPathPositionY: string, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + labelPath, + { name: 'mdi:page-layout-body' }, + html` + ${this._renderOptionSelector(configPathFit, this._layoutFits)} + ${this._renderNumberInput(configPathPositionX, { + min: 0, + max: 100, + label: localize('config.common.layout.position.x'), + })} + ${this._renderNumberInput(configPathPositionY, { + min: 0, + max: 100, + label: localize('config.common.layout.position.y'), + })} + `, + ); + } + /** * Render a camera section. * @param cameras The full array of cameras. @@ -534,9 +829,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _renderCamera( cameras: RawFrigateCardConfigArray, cameraIndex: number, + entities: string[], addNewCamera?: boolean, ): TemplateResult | void { - const liveProviders = [ + const liveProviders: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'auto', label: localize('config.cameras.live_providers.auto') }, { value: 'ha', label: localize('config.cameras.live_providers.ha') }, @@ -550,6 +846,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, ]; + const dependentCameras: EditorSelectOption[] = []; + cameras.forEach((camera, index) => { + if (index !== cameraIndex) { + dependentCameras.push({ + value: getCameraID(camera), + label: this._getEditorCameraTitle(index, camera), + }); + } + }); + // Make a new config and update the editor with changes on it, const modifyConfig = (func: (config: RawFrigateCardConfig) => boolean): void => { if (this._config) { @@ -560,113 +866,206 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } }; + const submenuClasses = { + submenu: true, + selected: this._expandedMenus[MENU_CAMERAS] === cameraIndex, + }; + return html` - ${this._renderCameraHeader(cameraIndex, cameras[cameraIndex], addNewCamera)} - ${this._expandedCameraIndex === cameraIndex - ? html`
-
- - !addNewCamera && - modifyConfig((config: RawFrigateCardConfig): boolean => { - if (Array.isArray(config.cameras) && cameraIndex > 0) { - arrayMove(config.cameras, cameraIndex, cameraIndex - 1); - this._expandedCameraIndex = cameraIndex - 1; - return true; - } - return false; - })} - > - - - = this._config.cameras.length - 1} - @click=${() => - !addNewCamera && - modifyConfig((config: RawFrigateCardConfig): boolean => { - if ( - Array.isArray(config.cameras) && - cameraIndex < config.cameras.length - 1 - ) { - arrayMove(config.cameras, cameraIndex, cameraIndex + 1); - this._expandedCameraIndex = cameraIndex + 1; - return true; - } - return false; - })} - > - - - { - modifyConfig((config: RawFrigateCardConfig): boolean => { - if (Array.isArray(config.cameras)) { - config.cameras.splice(cameraIndex, 1); - this._expandedCameraIndex = null; - return true; - } - return false; - }); - }} - > - - -
- ${this._renderEntitySelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_ENTITY, cameraIndex), - 'camera', - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_NAME, cameraIndex), - )} - ${this._renderOptionSelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_LIVE_PROVIDER, cameraIndex), - liveProviders, - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_URL, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_LABEL, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_ZONE, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_CLIENT_ID, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_TITLE, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_ICON, cameraIndex), - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), - )} - ${this._renderEntitySelector( - getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, cameraIndex), - 'camera', - )} - ${this._renderStringInput( - getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), - )} -
` - : ``} +
+ + ${this._expandedMenus[MENU_CAMERAS] === cameraIndex + ? html`
+
+ + !addNewCamera && + modifyConfig((config: RawFrigateCardConfig): boolean => { + if (Array.isArray(config.cameras) && cameraIndex > 0) { + arrayMove(config.cameras, cameraIndex, cameraIndex - 1); + this._openMenu(MENU_CAMERAS, cameraIndex - 1); + return true; + } + return false; + })} + > + + + = this._config.cameras.length - 1} + @click=${() => + !addNewCamera && + modifyConfig((config: RawFrigateCardConfig): boolean => { + if ( + Array.isArray(config.cameras) && + cameraIndex < config.cameras.length - 1 + ) { + arrayMove(config.cameras, cameraIndex, cameraIndex + 1); + this._openMenu(MENU_CAMERAS, cameraIndex + 1); + return true; + } + return false; + })} + > + + + { + modifyConfig((config: RawFrigateCardConfig): boolean => { + if (Array.isArray(config.cameras)) { + config.cameras.splice(cameraIndex, 1); + this._closeMenu(MENU_CAMERAS); + return true; + } + return false; + }); + }} + > + + +
+ ${this._renderEntitySelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_CAMERA_ENTITY, cameraIndex), + 'camera', + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_LIVE_PROVIDER, cameraIndex), + liveProviders, + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TITLE, cameraIndex), + )} + ${this._renderIconSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_ICON, cameraIndex), + { + label: localize('config.cameras.icon'), + }, + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), + )} + ${this._putInSubmenu( + MENU_CAMERAS_FRIGATE, + cameraIndex, + 'config.cameras.frigate.options', + { path: FRIGATE_ICON_SVG_PATH }, + html` + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_URL, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_LABEL, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_ZONE, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, + cameraIndex, + ), + )} + `, + )} + ${this._putInSubmenu( + MENU_CAMERAS_DEPENDENCIES, + cameraIndex, + 'config.cameras.dependencies.options', + { name: 'mdi:graph' }, + html` ${this._renderSwitch( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, + cameraIndex, + ), + frigateCardConfigDefaults.cameras.dependencies.all_cameras, + )} + ${this._renderOptionSelector( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, + cameraIndex, + ), + dependentCameras, + { + multiple: true, + }, + )}`, + )} + ${this._putInSubmenu( + MENU_CAMERAS_TRIGGERS, + cameraIndex, + 'config.cameras.triggers.options', + { name: 'mdi:magnify-scan' }, + html` ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, cameraIndex), + frigateCardConfigDefaults.cameras.triggers.occupancy, + )} + ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex), + frigateCardConfigDefaults.cameras.triggers.motion, + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, cameraIndex), + entities, + { + multiple: true, + }, + )}`, + )} + ${this._putInSubmenu( + MENU_CAMERAS_WEBRTC, + cameraIndex, + 'config.cameras.webrtc_card.options', + { name: 'mdi:webrtc' }, + html`${this._renderEntitySelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, cameraIndex), + 'camera', + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), + )}`, + )} +
` + : ``} +
`; } @@ -714,13 +1113,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * Render a boolean selector. * @param configPath The configuration path to set/read. * @param valueDefault The default switch value if unset. - * @param label An optional switch label. + * @param params Optional parameters to control the selector. * @returns A rendered template. */ protected _renderSwitch( configPath: string, valueDefault: boolean, - label?: string, + params?: { + label?: string; + }, ): TemplateResult | void { if (!this._config) { return; @@ -730,7 +1131,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._valueChangedHandler(configPath, ev)} @@ -745,15 +1146,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } protected render(): TemplateResult | void { - if (!this.hass || !this._helpers || !this._config) { + if (!this.hass || !this._config) { return html``; } const defaults = frigateCardConfigDefaults; - - const getShowButtonLabel = (configPath: string) => - localize('editor.show_button') + ': ' + localize(`config.${configPath}`); - + const entities = getEntitiesFromHASS(this.hass); const cameras = (getConfigValue(this._config, CONF_CAMERAS) || []) as RawFrigateCardConfigArray; @@ -780,14 +1178,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor : html``}
${this._renderOptionSetHeader('cameras')} - ${options.cameras.show - ? html`
- ${cameras.map((_, index) => this._renderCamera(cameras, index))} - ${this._renderCamera(cameras, cameras.length, true)} -
` + ${this._expandedMenus[MENU_OPTIONS] === 'cameras' + ? html` + ${cameras.map((_, index) => this._renderCamera(cameras, index, entities))} + ${this._renderCamera(cameras, cameras.length, entities, true)} + ` : ''} ${this._renderOptionSetHeader('view')} - ${options.view.show + ${this._expandedMenus[MENU_OPTIONS] === 'view' ? html`
${this._renderOptionSelector(CONF_VIEW_DEFAULT, this._viewModes)} @@ -795,6 +1193,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_CAMERA_SELECT, this._cameraSelectViewModes, )} + ${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)} @@ -802,157 +1201,303 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_UPDATE_CYCLE_CAMERA, defaults.view.update_cycle_camera, )} + ${this._renderViewScanMenu()}
` : ''} ${this._renderOptionSetHeader('menu')} - ${options.menu.show + ${this._expandedMenus[MENU_OPTIONS] === 'menu' ? html`
- ${this._renderOptionSelector(CONF_MENU_MODE, this._menuModes)} - ${this._renderStringInput(CONF_MENU_BUTTON_SIZE)} - ${this._renderSwitch( - CONF_MENU_BUTTONS_FRIGATE, - defaults.menu.buttons.frigate, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_LIVE, - defaults.menu.buttons.live, - getShowButtonLabel('view.views.live'), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_CLIPS, - defaults.menu.buttons.clips, - getShowButtonLabel('view.views.clips'), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_SNAPSHOTS, - defaults.menu.buttons.snapshots, - getShowButtonLabel('view.views.snapshots'), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_IMAGE, - defaults.menu.buttons.image, - getShowButtonLabel('view.views.image'), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD, - defaults.menu.buttons.download, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_FRIGATE_UI, - defaults.menu.buttons.frigate_ui, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE_UI), - )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN, - defaults.menu.buttons.fullscreen, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN), - )} + ${this._renderOptionSelector(CONF_MENU_STYLE, this._menuStyles)} + ${this._renderOptionSelector(CONF_MENU_POSITION, this._menuPositions)} + ${this._renderOptionSelector(CONF_MENU_ALIGNMENT, this._menuAlignments)} + ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, { + min: BUTTON_SIZE_MIN, + })}
+ ${this._renderMenuButton('frigate') /* */} + ${this._renderMenuButton('cameras') /* */} + ${this._renderMenuButton('live') /* */} + ${this._renderMenuButton('clips') /* */} + ${this._renderMenuButton('snapshots')} + ${this._renderMenuButton('image') /* */} + ${this._renderMenuButton('download')} + ${this._renderMenuButton('frigate_ui')} + ${this._renderMenuButton('fullscreen')} + ${this._renderMenuButton('timeline')} + ${this._renderMenuButton('media_player')} ` : ''} ${this._renderOptionSetHeader('live')} - ${options.live.show + ${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_LAZY_UNLOAD, defaults.live.lazy_unload)} - ${this._renderSwitch(CONF_LIVE_AUTO_UNMUTE, defaults.live.auto_unmute)} ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, - this._liveNextPreviousControlStyles, - )} - ${this._renderStringInput(CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE)} - ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_THUMBNAILS_MODE, - this._thumbnailModes, + CONF_LIVE_LAZY_UNLOAD, + this._mediaActionNegativeConditions, )} ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, - this._thumbnailMedias, + CONF_LIVE_AUTO_PLAY, + this._mediaActionPositiveConditions, )} - ${this._renderStringInput(CONF_LIVE_CONTROLS_THUMBNAILS_SIZE)} ${this._renderOptionSelector( - CONF_LIVE_CONTROLS_TITLE_MODE, - this._titleModes, + CONF_LIVE_AUTO_PAUSE, + this._mediaActionNegativeConditions, )} - ${this._renderNumberInput( - CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, - 0, - 60, + ${this._renderOptionSelector( + CONF_LIVE_AUTO_MUTE, + this._mediaActionNegativeConditions, + )} + ${this._renderOptionSelector( + CONF_LIVE_AUTO_UNMUTE, + this._mediaActionPositiveConditions, )} ${this._renderOptionSelector( CONF_LIVE_TRANSITION_EFFECT, this._transitionEffects, )} + ${this._renderSwitch( + CONF_LIVE_SHOW_IMAGE_DURING_LOAD, + defaults.live.show_image_during_load, + )} + ${this._putInSubmenu( + MENU_LIVE_CONTROLS, + true, + 'config.live.controls.options', + { name: 'mdi:gamepad' }, + html` + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, + this._liveNextPreviousControlStyles, + )} + ${this._renderNumberInput(CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, { + min: BUTTON_SIZE_MIN, + })} + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_THUMBNAILS_MODE, + this._thumbnailModes, + )} + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, + this._thumbnailMedias, + )} + ${this._renderNumberInput(CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, { + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + })} + ${this._renderOptionSelector( + CONF_LIVE_CONTROLS_TITLE_MODE, + this._titleModes, + )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + defaults.live.controls.thumbnails.show_details, + )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + defaults.live.controls.thumbnails.show_favorite_control, + )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + defaults.live.controls.thumbnails.show_timeline_control, + )} + ${this._renderNumberInput( + CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, + { + min: 0, + max: 60, + }, + )} + `, + )} + ${this._renderMediaLayout( + MENU_LIVE_LAYOUT, + 'config.live.layout', + CONF_LIVE_LAYOUT_FIT, + CONF_LIVE_LAYOUT_POSITION_X, + CONF_LIVE_LAYOUT_POSITION_Y, + )}
` : ''} ${this._renderOptionSetHeader('event_gallery')} - ${options.event_gallery.show + ${this._expandedMenus[MENU_OPTIONS] === 'event_gallery' ? html`
- ${this._renderNumberInput(CONF_EVENT_GALLERY_MIN_COLUMNS, 1, 10)} + ${this._putInSubmenu( + MENU_EVENT_GALLERY_CONTROLS, + true, + 'config.event_gallery.controls.options', + { name: 'mdi:gamepad' }, + html` ${this._renderNumberInput( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE, + { + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + }, + )} + ${this._renderSwitch( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + defaults.media_viewer.controls.thumbnails.show_details, + )} + ${this._renderSwitch( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + defaults.media_viewer.controls.thumbnails.show_favorite_control, + )} + ${this._renderSwitch( + CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + defaults.media_viewer.controls.thumbnails.show_timeline_control, + )}`, + )}
` : ''} - ${this._renderOptionSetHeader('event_viewer')} - ${options.event_viewer.show + ${this._renderOptionSetHeader('media_viewer')} + ${this._expandedMenus[MENU_OPTIONS] === 'media_viewer' ? html`
- ${this._renderSwitch( - CONF_EVENT_VIEWER_AUTO_PLAY, - defaults.event_viewer.auto_play, - )} - ${this._renderSwitch( - CONF_EVENT_VIEWER_AUTO_UNMUTE, - defaults.event_viewer.auto_unmute, - )} - ${this._renderSwitch( - CONF_EVENT_VIEWER_DRAGGABLE, - defaults.event_viewer.draggable, - )} - ${this._renderSwitch( - CONF_EVENT_VIEWER_LAZY_LOAD, - defaults.event_viewer.lazy_load, + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_AUTO_PLAY, + this._mediaActionPositiveConditions, )} ${this._renderOptionSelector( - CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - this._eventViewerNextPreviousControlStyles, - )} - ${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE)} - ${this._renderOptionSelector( - CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE, - this._thumbnailModes, - )} - ${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE)} - ${this._renderOptionSelector( - CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE, - this._titleModes, - )} - ${this._renderNumberInput( - CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, - 0, - 60, + CONF_MEDIA_VIEWER_AUTO_PAUSE, + this._mediaActionNegativeConditions, )} ${this._renderOptionSelector( - CONF_EVENT_VIEWER_TRANSITION_EFFECT, + CONF_MEDIA_VIEWER_AUTO_MUTE, + this._mediaActionNegativeConditions, + )} + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_AUTO_UNMUTE, + this._mediaActionPositiveConditions, + )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_DRAGGABLE, + defaults.media_viewer.draggable, + )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_LAZY_LOAD, + defaults.media_viewer.lazy_load, + )} + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_TRANSITION_EFFECT, this._transitionEffects, )} + ${this._putInSubmenu( + MENU_MEDIA_VIEWER_CONTROLS, + true, + 'config.media_viewer.controls.options', + { name: 'mdi:gamepad' }, + html` + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, + this._eventViewerNextPreviousControlStyles, + )} + ${this._renderNumberInput( + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, + { + min: BUTTON_SIZE_MIN, + }, + )} + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, + this._thumbnailModes, + )} + ${this._renderNumberInput(CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, { + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + })} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS, + defaults.media_viewer.controls.thumbnails.show_details, + )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + defaults.media_viewer.controls.thumbnails.show_favorite_control, + )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + defaults.media_viewer.controls.thumbnails.show_timeline_control, + )} + ${this._renderOptionSelector( + CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, + this._titleModes, + )} + ${this._renderNumberInput( + CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, + { min: 0, max: 60 }, + )} + `, + )} + ${this._renderMediaLayout( + MENU_MEDIA_VIEWER_LAYOUT, + 'config.media_viewer.layout', + CONF_MEDIA_VIEWER_LAYOUT_FIT, + CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, + CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y, + )}
` : ''} ${this._renderOptionSetHeader('image')} - ${options.image.show + ${this._expandedMenus[MENU_OPTIONS] === 'image' ? html`
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)} ${this._renderStringInput(CONF_IMAGE_URL)} ${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)} + ${this._renderMediaLayout( + MENU_IMAGE_LAYOUT, + 'config.image.layout', + CONF_IMAGE_LAYOUT_FIT, + CONF_IMAGE_LAYOUT_POSITION_X, + CONF_IMAGE_LAYOUT_POSITION_Y, + )} +
` + : ''} + ${this._renderOptionSetHeader('timeline')} + ${this._expandedMenus[MENU_OPTIONS] === 'timeline' + ? html`
+ ${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)} + ${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)} + ${this._renderOptionSelector( + CONF_TIMELINE_MEDIA, + this._timelineMediaTypes, + )} + ${this._renderSwitch( + CONF_TIMELINE_SHOW_RECORDINGS, + defaults.timeline.show_recordings, + )} + ${this._putInSubmenu( + MENU_TIMELINE_CONTROLS, + true, + 'config.timeline.controls.options', + { name: 'mdi:gamepad' }, + html` ${this._renderOptionSelector( + CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, + this._thumbnailModes, + )} + ${this._renderNumberInput(CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, { + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + })} + ${this._renderSwitch( + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + defaults.timeline.controls.thumbnails.show_details, + )} + ${this._renderSwitch( + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + defaults.timeline.controls.thumbnails.show_favorite_control, + )} + ${this._renderSwitch( + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + defaults.timeline.controls.thumbnails.show_timeline_control, + )}`, + )}
` : ''} ${this._renderOptionSetHeader('dimensions')} - ${options.dimensions.show + ${this._expandedMenus[MENU_OPTIONS] === 'dimensions' ? html`
${this._renderOptionSelector( CONF_DIMENSIONS_ASPECT_RATIO_MODE, @@ -963,7 +1508,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor : ''} ${this._config['overrides'] !== undefined ? html` ${this._renderOptionSetHeader('overrides')} - ${options.overrides.show + ${this._expandedMenus[MENU_OPTIONS] === 'overrides' ? html`
${this._renderInfo(localize('config.overrides.info'))}
` @@ -974,72 +1519,38 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } /** - * Verify editor is initialized. + * Close the editor menu with the given domain. + * @param targetDomain The menu domain to close. */ - protected _initialize(): void { - if (this.hass === undefined) return; - if (this._config === undefined) return; - if (this._helpers === undefined) return; - - (async (): Promise => { - // The picture-glance editor loads the ha-selectors. - // See: https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements - const pictureGlance = await this._helpers.createCardElement({ - type: 'picture-glance', - entities: [], - camera_image: 'dummy-to-load-editor-components', - }); - if (pictureGlance.constructor.getConfigElement) { - await pictureGlance.constructor.getConfigElement(); - this._initialized = true; - } - })(); + protected _closeMenu(targetDomain: string) { + delete this._expandedMenus[targetDomain]; + this.requestUpdate(); } /** - * Load card helpers. + * Open an editor menu. + * @param targetDomain The menu domain to open. + * @param key The menu object key to open. */ - protected async loadCardHelpers(): Promise { - this._helpers = await (window as any).loadCardHelpers(); + protected _openMenu(targetDomain: string, key: number | string) { + this._expandedMenus[targetDomain] = key; + this.requestUpdate(); } /** - * Display/hide a camera section. - * @param ev The event triggering the change. + * Toggle an editor menu. + * @param ev An event. */ - protected _toggleCameraHandler(ev: { target: EditorCameraTarget | null }): void { + protected _toggleMenu(ev: { target: EditorMenuTarget | null }): void { if (ev && ev.target) { - this._expandedCameraIndex = - this._expandedCameraIndex == ev.target.cameraIndex - ? null - : ev.target.cameraIndex; - } - } + const domain = ev.target.domain; + const key = ev.target.key; - /** - * Handle a toggled set of options. - * @param ev The event triggering the change. - */ - protected _toggleOptionHandler(ev: { target: EditorOptionSetTarget | null }): void { - this._toggleOptionSet(ev, options); - } - - /** - * Toggle display of a set of options (e.g. 'Live') - * @param ev The event triggering the change. - * @param options The EditorOptions object. - */ - protected _toggleOptionSet( - ev: { target: EditorOptionSetTarget | null }, - options: EditorOptions, - ): void { - if (ev && ev.target) { - const show = !options[ev.target.optionSetName].show; - for (const [key] of Object.entries(options)) { - options[key].show = false; + if (this._expandedMenus[domain] === key) { + this._closeMenu(domain); + } else { + this._openMenu(domain, key); } - options[ev.target.optionSetName].show = show; - this.requestUpdate(); } } @@ -1082,3 +1593,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return unsafeCSS(frigate_card_editor_style); } } + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-editor': FrigateCardEditor; + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index fee462b2..fb13f480 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -1,235 +1,387 @@ { "common": { - "version": "Version", "frigate_card": "Frigate card", "frigate_card_description": "A Lovelace card for use with Frigate", - "no_snapshots": "No snapshots", + "live": "Live", + "no_clip": "No recent clip", "no_clips": "No clips", "no_snapshot": "No recent snapshot", - "no_clip": "No recent clip", - "live": "Live" + "no_snapshots": "No snapshots", + "recordings": "Recordings", + "version": "Version" }, "config": { "cameras": { "camera_entity": "Camera Entity", - "camera_name": "Frigate camera name (Autodetected from entity)", - "client_id": "Frigate client id (For >1 Frigate server)", - "id": "Unique id for this camera in this card", - "label": "Frigate label/object filter", - "frigate_url": "Frigate server URL", - "title": "Title for this camera (Autodetected from entity)", + "dependencies": { + "all_cameras": "Show events for all cameras with this camera", + "cameras": "Show events for specific cameras with this camera", + "options": "Dependency Options" + }, + "frigate": { + "camera_name": "Frigate camera name (Autodetected from entity)", + "client_id": "Frigate client id (For >1 Frigate server)", + "label": "Frigate label/object filter", + "options": "Frigate Options", + "url": "Frigate server URL", + "zone": "Frigate zone" + }, "icon": "Icon for this camera (Autodetected from entity)", + "id": "Unique id for this camera in this card", "live_provider": "Live view provider for this camera", "live_providers": { "auto": "Automatic", - "ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)", "frigate-jsmpeg": "Frigate JSMpeg", + "ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, + "title": "Title for this camera (Autodetected from entity)", + "triggers": { + "entities": "Trigger from other entities", + "motion": "Trigger by auto-detecting the motion sensor", + "occupancy": "Trigger by auto-detecting the occupancy sensor", + "options": "Trigger Options" + }, "webrtc_card": { "entity": "WebRTC Card Camera Entity (Not a Frigate camera)", + "options": "WebRTC Card Options", "url": "WebRTC Card Camera URL" - }, - "zone": "Frigate zone" + } }, - "view": { - "camera_select": "View for newly selected cameras", - "default": "Default view", - "views": { - "live": "Live view", - "clip": "Most recent clip", - "clips": "Clips gallery", - "snapshot": "Most recent snapshot", - "snapshots": "Snapshots gallery", - "image": "Static image", - "current": "Current view" + "common": { + "media_action_conditions": { + "all": "All opportunities", + "hidden": "On browser/tab hiding", + "never": "Never", + "selected": "On selection", + "unselected": "On unselection", + "visible": "On browser/tab visibility" }, - "timeout_seconds": "Reset to default view X seconds after user action (0=never)", - "update_cycle_camera": "Cycle through cameras when default view updates", - "update_force": "Force card updates (ignore user interaction)", - "update_seconds": "Refresh default view every X seconds (0=never)" - }, - "event_gallery": { - "min_columns": "Minimum number of columns" - }, - "event_viewer": { - "auto_play": "Automatically play media", - "auto_unmute": "Automatically unmute media", - "draggable": "Event Viewer can be dragged/swiped", - "lazy_load": "Event Viewer media is lazily loaded in carousel", - "transition_effect": "Event Viewer transition effect", - "transition_effects": { - "none": "No transition", - "slide": "Slide transition" - }, - "controls": { - "next_previous": { - "style": "Event Viewer next & previous control style", - "size": "Event Viewer next & previous control size (e.g. '48px')", - "styles": { - "thumbnails": "Thumbnails", - "chevrons": "Chevrons", - "none": "None" - } + "layout": { + "fit": "Layout fit", + "fits": { + "cover": "Media expands proportionally to cover the card", + "contain": "Media is contained/letterboxed", + "fill": "Media is stretched to fill the card" }, - "thumbnails": { - "mode": "Event Viewer thumbnails mode", - "size": "Event Viewer thumbnails size (e.g. '100px')", - "modes": { - "below": "Thumbnails below the media", - "above": "Thumbnails above the media", - "none": "No thumbnails" - } - }, - "title": { - "mode": "Event Viewer media title display mode", - "modes": { - "none": "No title display", - "popup-top-left": "Popup on the top left", - "popup-top-right": "Popup on the top right", - "popup-bottom-left": "Popup on the bottom left", - "popup-bottom-right": "Popup on the bottom right" - }, - "duration_seconds": "Seconds to display popup title (0=forever)" + "position": { + "x": "Horizontal placement percentage", + "y": "Vertical placement percentage" } } }, + "dimensions": { + "aspect_ratio": "Default aspect ratio (e.g. '16:9')", + "aspect_ratio_mode": "Aspect ratio mode", + "aspect_ratio_modes": { + "dynamic": "Aspect ratio adjusts to media", + "static": "Static aspect ratio", + "unconstrained": "Unconstrained aspect ratio" + } + }, + "event_gallery": { + "controls": { + "options": "Event Gallery Controls", + "thumbnails": { + "show_details": "Show event details with thumbnails", + "show_favorite_control": "Show favorite control on thumbnails", + "show_timeline_control": "Show timeline control on thumbnails", + "size": "Event Gallery thumbnails size in pixels" + } + } + }, + "image": { + "layout": "Image Layout", + "mode": "Image view mode", + "modes": { + "camera": "Home Assistant camera snapshot of camera entity", + "screensaver": "Embedded Frigate logo", + "url": "Arbitrary image specified by URL" + }, + "refresh_seconds": "Number of seconds after which to refresh (0=never)", + "url": "Static image URL for image view" + }, "live": { - "preload": "Preload live view in the background", - "draggable": "Live cameras view can be dragged/swiped", - "lazy_load": "Live cameras are lazily loaded", - "lazy_unload": "Live cameras are lazily unloaded", + "auto_mute": "Automatically mute live cameras", + "auto_pause": "Automatically pause live cameras", + "auto_play": "Automatically play live cameras", "auto_unmute": "Automatically unmute live cameras", - "transition_effect": "Live camera transition effect", "controls": { "next_previous": { + "size": "Live view next & previous control size in pixels", "style": "Live view next & previous control style", - "size": "Live view next & previous control size (e.g. '48px')", "styles": { "chevrons": "Chevrons", "icons": "Icons", "none": "None" } }, + "options": "Live Controls", "thumbnails": { - "mode": "Live thumbnails mode", - "size": "Live thumbnails size (e.g. '100px')", "media": "Whether to show thumbnails of clips or snapshots", "medias": { "clips": "Clip thumbnails", "snapshots": "Snapshot thumbnails" - } + }, + "mode": "Live thumbnails mode", + "show_details": "Show event details with thumbnails", + "show_favorite_control": "Show favorite control on thumbnails", + "show_timeline_control": "Show timeline control on thumbnails", + "size": "Live thumbnails size in pixels" }, "title": { - "mode": "Live media title display mode", - "duration_seconds": "Seconds to display popup title (0=forever)" + "duration_seconds": "Seconds to display popup title (0=forever)", + "mode": "Live media title display mode" } + }, + "draggable": "Live cameras view can be dragged/swiped", + "layout": "Live Layout", + "lazy_load": "Live cameras are lazily loaded", + "lazy_unload": "Live cameras are lazily unloaded", + "preload": "Preload live view in the background", + "show_image_during_load": "Show still image while the live stream is loading", + "transition_effect": "Live camera transition effect" + }, + "media_viewer": { + "auto_mute": "Automatically mute media", + "auto_pause": "Automatically pause media", + "auto_play": "Automatically play media", + "auto_unmute": "Automatically unmute media", + "controls": { + "next_previous": { + "size": "Media Viewer next & previous control size in pixels", + "style": "Media Viewer next & previous control style", + "styles": { + "chevrons": "Chevrons", + "none": "None", + "thumbnails": "Thumbnails" + } + }, + "options": "Media Viewer Controls", + "thumbnails": { + "mode": "Media Viewer thumbnails mode", + "modes": { + "above": "Thumbnails above the media", + "below": "Thumbnails below the media", + "left": "Thumbnails in a drawer left of the media", + "none": "No thumbnails", + "right": "Thumbnails in a drawer right of the media" + }, + "show_details": "Show details with thumbnails", + "show_favorite_control": "Show favorite control on thumbnails", + "show_timeline_control": "Show timeline control on thumbnails", + "size": "Media Viewer thumbnails size in pixels" + }, + "title": { + "duration_seconds": "Seconds to display popup title (0=forever)", + "mode": "Media Viewer media title display mode", + "modes": { + "none": "No title display", + "popup-bottom-left": "Popup on the bottom left", + "popup-bottom-right": "Popup on the bottom right", + "popup-top-left": "Popup on the top left", + "popup-top-right": "Popup on the top right" + } + } + }, + "draggable": "Media Viewer can be dragged/swiped", + "lazy_load": "Media Viewer media is lazily loaded in carousel", + "layout": "Media Viewer Layout", + "transition_effect": "Media Viewer transition effect", + "transition_effects": { + "none": "No transition", + "slide": "Slide transition" } }, - "image": { - "mode": "Image view mode", - "modes": { - "screensaver": "Embedded Frigate logo", - "url": "Arbitrary image specified by URL", - "camera": "Home Assistant camera snapshot of camera entity" - }, - "url": "Static image URL for image view", - "refresh_seconds": "Number of seconds after which to refresh (0=never)" - }, "menu": { + "alignment": "Menu alignment", + "alignments": { + "bottom": "Aligned to the bottom", + "left": "Aligned to the left", + "right": "Aligned to the right", + "top": "Aligned to the top" + }, + "button_size": "Menu button size in pixels", "buttons": { + "alignment": "Button alignment", + "alignments": { + "matching": "Matching the menu alignment", + "opposing": "Opposing the menu alignment" + }, + "cameras": "Cameras", + "clips": "Clips", + "download": "Download", + "enabled": "Button enabled", "frigate": "Frigate menu / Default view", - "frigate_ui": "Frigate user Interface", + "frigate_ui": "Frigate user interface", "fullscreen": "Fullscreen", - "download": "Download event media", - "cameras": "Select camera" + "icon": "Icon", + "image": "Image", + "live": "Live", + "media_player": "Send to media player", + "priority": "Priority", + "snapshots": "Snapshots", + "timeline": "Timeline" }, - "mode": "Menu mode", - "modes": { + "position": "Menu position", + "positions": { + "bottom": "Positioned on the bottom", + "left": "Positioned on the left", + "right": "Positioned on the right", + "top": "Positioned on the top" + }, + "style": "Menu style", + "styles": { + "hidden": "Hidden menu", + "hover": "Hover menu", "none": "No menu", - "hidden-top": "Hidden top", - "hidden-left": "Hidden left", - "hidden-bottom": "Hidden bottom", - "hidden-right": "Hidden right", - "overlay-top": "Overlay top", - "overlay-left": "Overlay left", - "overlay-bottom": "Overlay bottom", - "overlay-right": "Overlay right", - "hover-top": "Hover top", - "hover-left": "Hover left", - "hover-bottom": "Hover bottom", - "hover-right": "Hover right", - "above": "Above", - "below": "Below" - }, - "button_size": "Menu button size (e.g. '40px')" - }, - "dimensions": { - "aspect_ratio": "Default aspect ratio (e.g. '16:9')", - "aspect_ratio_mode": "Aspect ratio mode", - "aspect_ratio_modes": { - "unconstrained": "Unconstrained aspect ratio", - "dynamic": "Aspect ratio adjusts to media", - "static": "Static aspect ratio" + "outside": "Outside menu", + "overlay": "Overlay menu" } }, "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" + }, + "timeline": { + "clustering_threshold": "The count of events at which they are clustered (0=no clustering)", + "controls": { + "options": "Timeline Controls", + "thumbnails": { + "mode": "Timeline thumbnails mode", + "show_details": "Show event details with thumbnails", + "show_favorite_control": "Show favorite control on thumbnails", + "show_timeline_control": "Show timeline control on thumbnails", + "size": "Timeline thumbnails size in pixels" + } + }, + "media": "The media the timeline displays", + "medias": { + "all": "All media types", + "clips": "Clips", + "snapshots": "Snapshots" + }, + "show_recordings": "Show recordings", + "window_seconds": "The default length of the timeline view in seconds" + }, + "view": { + "camera_select": "View for newly selected cameras", + "dark_mode": "Dark mode", + "dark_modes": { + "auto": "Auto", + "off": "Off", + "on": "On" + }, + "default": "Default view", + "scan": { + "enabled": "Scan mode enabled", + "scan_mode": "Scan mode", + "show_trigger_status": "Show pulsing border when triggered", + "untrigger_reset": "Reset the view to default after untrigger", + "untrigger_seconds": "Seconds after inactive state change to untrigger" + }, + "timeout_seconds": "Reset to default view X seconds after user action (0=never)", + "update_cycle_camera": "Cycle through cameras when default view updates", + "update_force": "Force card updates (ignore user interaction)", + "update_seconds": "Refresh default view every X seconds (0=never)", + "views": { + "clip": "Most recent clip", + "clips": "Clips gallery", + "current": "Current view", + "image": "Static image", + "live": "Live view", + "snapshot": "Most recent snapshot", + "snapshots": "Snapshots gallery", + "timeline": "Timeline view" + } } }, "editor": { + "add_new_camera": "Add new camera", + "button": "Button", + "camera": "Camera", "cameras": "Cameras", "cameras_secondary": "What cameras to render on this card", - "view": "View", - "view_secondary": "What the card should show and how to show it", - "menu": "Menu", - "menu_secondary": "Menu look & feel options", - "live": "Live", - "live_secondary": "Live camera view options", - "event_gallery": "Event gallery", - "event_gallery_secondary": "Snapshots & clips gallery options", - "event_viewer": "Event viewer", - "event_viewer_secondary": "Snapshots & clips viewer options", - "image": "Image", - "image_secondary": "Static image view options", + "delete": "Delete", "dimensions": "Dimensions", "dimensions_secondary": "Dimensions & shape options", - "show_button": "Show button", + "event_gallery": "Event gallery", + "event_gallery_secondary": "Snapshots & clips gallery options", + "image": "Image", + "image_secondary": "Static image view options", + "live": "Live", + "live_secondary": "Live camera view options", + "media_viewer": "Media viewer", + "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)", + "menu": "Menu", + "menu_secondary": "Menu look & feel options", + "move_down": "Move down", + "move_up": "Move up", + "overrides": "Overrides are active", + "overrides_secondary": "Dynamic configuration overrides detected", + "timeline": "Timeline", + "timeline_secondary": "Event timeline options", "upgrade": "Upgrade", "upgrade_available": "An automatic card configuration upgrade is available", - "delete": "Delete", - "move_up": "Move up", - "move_down": "Move down", - "add_new_camera": "Add new camera", - "camera": "Camera", - "overrides": "Overrides are active", - "overrides_secondary": "Dynamic configuration overrides detected" + "view": "View", + "view_secondary": "What the card should show and how to show it" }, "error": { - "empty_response": "Received empty response from Home Assistant for request", - "invalid_response": "Received invalid response from Home Assistant for request", - "invalid_keys": "Invalid keys", - "unknown": "Unknown error", - "troubleshooting": "Check troubleshooting", + "could_not_render_elements": "Could not render picture elements", "could_not_resolve": "Could not resolve media URL", - "no_live_camera": "The camera_entity parameter must be set and valid for this live provider", - "live_camera_unavailable": "The configured camera_entity is unavailable", + "diagnostics": "Card diagnostics. Please review for confidential information prior to sharing", + "download_no_event_id": "Could not extract Frigate event id from media", + "download_no_media": "No media to download", + "download_sign_failed": "Could not sign media URL for download", + "duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", + "empty_response": "Received empty response from Home Assistant for request", + "failed_response": "Failed to receive response from Home Assistant for request", + "failed_retain": "Could not retain event", + "failed_sign": "Could not sign Home Assistant URL", "image_load_error": "The image could not be loaded", "invalid_configuration": "Invalid configuration", "invalid_configuration_no_hint": "No location hint available (bad or missing type?)", + "invalid_elements_config": "Invalid picture elements configuration", + "invalid_response": "Received invalid response from Home Assistant for request", + "jsmpeg_no_player": "Could not start JSMPEG player", + "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", + "live_camera_not_found": "The configured camera_entity was not found", + "live_camera_unavailable": "Camera unavailable", + "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", + "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", + "no_cameras": "No valid cameras found, you must configure at least one camera entry", + "no_live_camera": "The camera_entity parameter must be set and valid for this live provider", + "reconnecting": "Reconnecting", + "timeline_no_cameras": "No Frigate cameras to show in timeline", + "troubleshooting": "Check troubleshooting", + "unknown": "Unknown error", "upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor", "webrtc_card_reported_error": "WebRTC Card reported an error", - "webrtc_card_waiting": "Waiting for WebRTC Card to load ...", - "no_cameras": "No valid cameras found, you must configure at least one camera entry", - "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", - "duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras", - "could_not_render_elements": "Could not render picture elements", - "invalid_elements_config": "Invalid picture elements configuration", - "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", - "jsmpeg_no_player": "Could not start JSMPEG player", - "no_camera_name": "Could not determine Frigate camera name for camera, please specify either 'camera_entity' or 'camera_name' for the following camera", - "download_no_media": "No media to download", - "download_no_event_id": "Could not extract Frigate event id from media", - "download_sign_failed": "Could not sign media URL for download" + "webrtc_card_waiting": "Waiting for WebRTC Card to load ..." + }, + "event": { + "duration": "Duration", + "in_progress": "In Progress", + "score": "Score", + "start": "Start" + }, + "recording": { + "events": "Events", + "seek": "Seek" + }, + "thumbnail": { + "no_thumbnail": "No thumbnail available", + "retain_indefinitely": "Event will be indefinitely retained", + "timeline": "See event in timeline" + }, + "elements": { + "ptz": { + "up": "Up", + "down": "Down", + "left": "Left", + "right": "Right", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out", + "home": "Home" + } } } diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json new file mode 100644 index 00000000..bbca08af --- /dev/null +++ b/src/localize/languages/it.json @@ -0,0 +1,361 @@ +{ + "common": { + "frigate_card": "Frigate card", + "frigate_card_description": "Una scheda Lovelace per l'uso con Frigate", + "live": "Live", + "no_clip": "Nessuna clip recente", + "no_clips": "Nessun clip", + "no_snapshot": "Nessuna istantanea recente", + "no_snapshots": "Nessuna istantanea", + "recordings": "Registrazioni", + "version": "Versione" + }, + "config": { + "cameras": { + "camera_entity": "Entità della telecamera", + "dependencies": { + "all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera", + "cameras": "Mostra eventi per telecamere specifiche con questa telecamera", + "options": "Opzioni di dipendenza" + }, + "frigate": { + "camera_name": "Nome della telecamera frigate (autodificato dall'entità)", + "client_id": "ID client Frigate (per > 1 Frigate server)", + "label": "Filtro etichetta/oggetto Frigate", + "options": "Frigate Opzione", + "url": "Frigate URL del server", + "zone": "Frigate zona" + }, + "icon": "Icona per questa telecamera (Autoidentificato dall'entità)", + "id": "ID univoco per questa telecamera in questa carta", + "live_provider": "Provider di visualizzazione dal vivo per questa telecamera", + "live_providers": { + "auto": "Automatica", + "frigate-jsmpeg": "Frigate JSMpeg", + "ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)", + "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" + }, + "title": "Titolo per questa telecamera (Autoidentificato dall'entità)", + "triggers": { + "entities": "Trigger da altre entità", + "motion": "Trigger rilevando automaticamente dal sensore di movimento", + "occupancy": "Attivare rilevando automatico tramite il sensore di presenza", + "options": "Trigger Opzioni" + }, + "webrtc_card": { + "entity": "Entità della telecamera della scheda WebRTC (non una telecamera Frigate)", + "options": "Opzioni della scheda WebRTC", + "url": "URL della telecamera della scheda WebRTC" + } + }, + "common": { + "media_action_conditions": { + "all": "Tutte le opportunità", + "hidden": "Sul browser/nascondere le schede", + "never": "Mai", + "selected": "Sulla selezione", + "unselected": "Sulla non selezione", + "visible": "Sul browser/visibilità della scheda" + } + }, + "dimensions": { + "aspect_ratio": "Proporzioni predefinite (ad es. '16:9')", + "aspect_ratio_mode": "Modalità proporzioni", + "aspect_ratio_modes": { + "dynamic": "Le proporzioni si adattano ai media", + "static": "Proporzioni statiche", + "unconstrained": "Proporzioni non vincolate" + } + }, + "event_gallery": { + "controls": { + "options": "Controlli della galleria degli eventi", + "thumbnails": { + "show_details": "Mostra i dettagli dell'evento con le miniature", + "show_favorite_control": "Mostra il controllo preferito sulle miniature", + "show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature", + "size": "Dimensione delle miniature della galleria di eventi nei pixel" + } + } + }, + "image": { + "mode": "Modalità Visualizza immagine", + "modes": { + "camera": "Istantanea della telecamera di Home Assistant dell'entità telecamera", + "screensaver": "Logo Frigate incorporato", + "url": "Immagine arbitraria specificata dall'URL" + }, + "refresh_seconds": "Numero di secondi dopo i quali aggiornare (0 = mai)", + "url": "URL di immagine statica per la vista dell'immagine" + }, + "live": { + "auto_mute": "Muta automaticamente le telecamere in diretta", + "auto_pause": "Metti in pausa automaticamente le telecamere in diretta", + "auto_play": "Gioca automaticamente le telecamere dal vivo", + "auto_unmute": "Riattiva automaticamente l'audio delle telecamere live", + "controls": { + "next_previous": { + "size": "Vista live Successiva e Precedenti dimensioni di controllo nei pixel", + "style": "Stile di controllo successivo e precedente della vista dal vivo", + "styles": { + "chevrons": "Chevrons", + "icons": "Icone", + "none": "Icone" + } + }, + "options": "Controlli dal vivo", + "thumbnails": { + "media": "Se mostrare miniature di clip o istantanee", + "medias": { + "clips": "Miniature di clip", + "snapshots": "Miniature istantanee" + }, + "mode": "Modalità di miniatura dal vivo", + "show_details": "Mostra i dettagli dell'evento con le miniature", + "show_favorite_control": "Mostra il controllo preferito sulle miniature", + "show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature", + "size": "Dimensione delle miniature dal vivo nei pixel" + }, + "title": { + "duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)", + "mode": "Modalità di visualizzazione del titolo multimediale dal vivo" + } + }, + "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", + "lazy_load": "Le telecamere dal vivo sono pigramente cariche", + "lazy_unload": "Le telecamere dal vivo sono pigramente non caricate", + "preload": "Precarica Live View in background", + "show_image_during_load": "", + "transition_effect": "Effetto di transizione della telecamera dal vivo" + }, + "media_viewer": { + "auto_mute": "Muta automaticamente i media", + "auto_pause": "Metti in Pausa automaticamente i media", + "auto_play": "Riproduci automaticamente i contenuti multimediali", + "auto_unmute": "Riattiva automaticamente i contenuti multimediali", + "controls": { + "next_previous": { + "size": "Media Viewer successivo e precedente controllo dimensione in pixel", + "style": "Visualizzatore multimediale successivo e stile di controllo precedente", + "styles": { + "chevrons": "chevrons", + "none": "Nessuno", + "thumbnails": "Miniature" + } + }, + "options": "Controlli di visualizzatore multimediale", + "thumbnails": { + "mode": "Modalità miniature del visualizzatore multimediale", + "modes": { + "above": "Miniature sopra i media", + "below": "Miniature sotto i media", + "left": "Miniature in un cassetto a sinistra del supporto", + "none": "Nessuna miniatura", + "right": "Miniature in un cassetto a destra dei media" + }, + "show_details": "Mostra i dettagli con le miniature", + "show_favorite_control": "Mostra il controllo preferito sulle miniature", + "show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature", + "size": "Dimensioni delle miniature di Media Viewer in pixel" + }, + "title": { + "duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)", + "mode": "Media Viewer modalità di visualizzazione del titolo multimediale", + "modes": { + "none": "Nessuna visualizzazione del titolo", + "popup-bottom-left": "Popup in basso a sinistra", + "popup-bottom-right": "Popup in basso a destra", + "popup-top-left": "Popup in alto a sinistra", + "popup-top-right": "Popup in alto a destra" + } + } + }, + "draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere", + "lazy_load": "Il media Viewer viene caricato pigramente nel carosello", + "transition_effect": "Effetto di transizione del visualizzatore multimediale", + "transition_effects": { + "none": "Nessuna transizione", + "slide": "Transizione diapositiva" + } + }, + "menu": { + "alignment": "Allineamento dei menu", + "alignments": { + "bottom": "Allineato al fondo", + "left": "Allineato a sinistra", + "right": "Allineato a destra", + "top": "Allineato in cima" + }, + "button_size": "Dimensione del pulsante menu in pixel", + "buttons": { + "alignment": "Allineamento dei pulsanti", + "alignments": { + "matching": "Corrispondenza con l'allineamento del menu", + "opposing": "Contrastare l'allineamento del menu" + }, + "cameras": "Telecamere", + "clips": "Clip", + "download": "Download", + "enabled": "Pulsante abilitato", + "frigate": "Frigate menu / Visualizzazione predefinita", + "frigate_ui": "Frigate interfaccia utente", + "fullscreen": "A schermo intero", + "icon": "Icona", + "image": "Immagine", + "live": "Abitare", + "media_player": "Invia a Media Player", + "priority": "Priorità", + "snapshots": "Istantanee", + "timeline": "Timeline" + }, + "position": "Posizione del menu", + "positions": { + "bottom": "Posizionato sul fondo", + "left": "Posizionato a sinistra", + "right": "Posizionato a destra", + "top": "Posizionato in alto" + }, + "style": "Stile menu", + "styles": { + "hidden": "Menu nascosto", + "hover": "Menu al passaggio del mouse", + "none": "Nessun menu", + "outside": "Menu esterno", + "overlay": "Menu di overlay" + } + }, + "overrides": { + "info": "Questa configurazione della scheda ha specificato manualmente le sostituzioni configurate che possono sostituire i valori mostrati nell'editor visivo, consultare l'editor di codice per visualizzare/modificare queste sostituzioni" + }, + "timeline": { + "clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)", + "controls": { + "options": "Controlli della sequenza temporale", + "thumbnails": { + "mode": "Modalità miniatura della sequenza temporale", + "show_details": "Mostra i dettagli dell'evento con le miniature", + "show_favorite_control": "Mostra il controllo preferito sulle miniature", + "show_timeline_control": "Mostra il controllo della sequenza temporale sulle miniature", + "size": "Dimensione delle miniature della sequenza temporale in pixel" + } + }, + "media": "I media vengono visualizzati la sequenza temporale", + "medias": { + "all": "Tutti i tipi di media", + "clips": "Clip", + "snapshots": "Istantanee" + }, + "show_recordings": "Mostra registrazioni", + "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" + }, + "view": { + "camera_select": "Visualizza per le telecamere appena selezionate", + "dark_mode": "Tema scuro", + "dark_modes": { + "auto": "auto", + "off": "Off", + "on": "On" + }, + "default": "Visualizzazione predefinita", + "scan": { + "enabled": "Modalità di scansione abilitata", + "scan_mode": "Modalità di scansione", + "show_trigger_status": "Mostra bordo pulsante quando attivato", + "untrigger_reset": "Reset the view to default after untrigger", + "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" + }, + "timeout_seconds": "Ripristina la vista predefinita x secondi dopo l'azione dell'utente (0 = mai)", + "update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", + "update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)", + "update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)", + "views": { + "clip": "Clip più recente", + "clips": "Galleria delle clip", + "current": "Vista corrente", + "image": "Immagine statica", + "live": "Dal vivo", + "snapshot": "Snapshot più recente", + "snapshots": "Galleria delle istantanee", + "timeline": "Vista della timeline" + } + } + }, + "editor": { + "add_new_camera": "Aggiungi nuova telecamera", + "button": "Pulsante", + "camera": "Telecamera", + "cameras": "Telecamere", + "cameras_secondary": "Quali telecamere visualizzare su questa card", + "delete": "Elimina", + "dimensions": "Dimensioni", + "dimensions_secondary": "Dimensioni e opzioni di forma", + "event_gallery": "Galleria degli eventi", + "event_gallery_secondary": "Opzioni della galleria di istantanee e clips", + "image": "Immagine", + "image_secondary": "Opzioni di visualizzazione dell'immagine statica", + "live": "Live", + "live_secondary": "Opzioni di visualizzazione della telecamera live", + "media_viewer": "Visualizzatore dei media", + "media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)", + "menu": "Menu", + "menu_secondary": "Opzioni di aspetto e funzionalità del menu", + "move_down": "Sposta verso il basso", + "move_up": "Sposta verso l'alto", + "overrides": "La sovrascrittura è attiva", + "overrides_secondary": "Rilevate sovrascritture della configurazione dinamica", + "timeline": "Timeline", + "timeline_secondary": "Opzioni della timeline degli eventi", + "upgrade": "Aggiornamento", + "upgrade_available": "È disponibile un aggiornamento della configurazione della scheda automatica", + "view": "Visualizzazione", + "view_secondary": "Cosa dovrebbe mostrare la carta e come mostrarla" + }, + "error": { + "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine", + "could_not_resolve": "Impossibile risolvere l'URL dei media", + "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere", + "download_no_event_id": "Impossibile estrarre l'evento ID tramite media", + "download_no_media": "Nessun media da scaricare", + "download_sign_failed": "Impossibile firmare URL multimediale per il download", + "duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere", + "empty_response": "Ricevuto risposta vuota da Home Assistant per la richiesta", + "failed_response": "Impossibile ricevere risposta da Home Assistant per la richiesta", + "failed_retain": "Impossibile conservare l'evento", + "failed_sign": "Impossibile firmare l'URL ad Home Assistant", + "image_load_error": "L'immagine non può essere caricata", + "invalid_configuration": "Configurazione non valida", + "invalid_configuration_no_hint": "Nessun suggerimento di posizione disponibile (tipo difettoso o mancante?)", + "invalid_elements_config": "Configurazione degli elementi di immagine non valida", + "invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta", + "jsmpeg_no_player": "Impossibile avviare JSMPEG Player", + "jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG", + "live_camera_not_found": "La telecamera configurata non è stata trovata", + "live_camera_unavailable": "Telecamera non disponibile", + "no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", + "no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'", + "no_cameras": "Nessuna telecamera valida trovata, è necessario configurare almeno una voce della telecamera", + "no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live", + "reconnecting": "Riconnessione", + "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", + "troubleshooting": "Controllare la risoluzione dei problemi", + "unknown": "Errore sconosciuto", + "upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive", + "webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore", + "webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..." + }, + "event": { + "duration": "Durata", + "in_progress": "In corso", + "score": "Punteggio", + "start": "Avvia" + }, + "recording": { + "events": "Eventi", + "seek": "Cercare" + }, + "thumbnail": { + "no_thumbnail": "Nessuna miniatura disponibile", + "retain_indefinitely": "L'evento sarà mantenuto indefinitamente", + "timeline": "Vedi evento nella timeline" + } +} diff --git a/src/localize/languages/pt_br.json b/src/localize/languages/pt-BR.json similarity index 54% rename from src/localize/languages/pt_br.json rename to src/localize/languages/pt-BR.json index 98b3b497..79aa2999 100644 --- a/src/localize/languages/pt_br.json +++ b/src/localize/languages/pt-BR.json @@ -1,234 +1,361 @@ { "common": { - "version": "Versão", "frigate_card": "Cartão Frigate", "frigate_card_description": "Um cartão da Lovelace para usar com Frigate", - "no_snapshots": "Sem snapshots", + "live": "Ao Vivo", + "no_clip": "Sem clip recente", "no_clips": "Sem clips", "no_snapshot": "Sem snapshot recente", - "no_clip": "Sem clip recente", - "live": "Ao Vivo" + "no_snapshots": "Sem snapshots", + "recordings": "Gravações", + "version": "Versão" }, "config": { "cameras": { "camera_entity": "Entidade da Câmera", - "camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)", - "client_id": "ID de cliente do Frigate (para >1 servidor da Frigate)", - "id": "ID exclusivo para esta câmera nesse cartão", - "label": "Filtro de rótulo/objeto do Frigate", - "frigate_url": "URL do servidor Frigate", - "title": "Título para esta câmera (detectado automaticamente pela entidade)", + "dependencies": { + "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera", + "cameras": "Mostrar eventos para câmeras específicas nesta câmera", + "options": "Opções de dependência" + }, + "frigate": { + "camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)", + "client_id": "ID do cliente do Frigate (para >1 servidor Frigate)", + "label": "Filtro de rótulo/objeto do Frigate", + "options": "Opções do Frigate", + "url": "URL do servidor Frigate", + "zone": "Zona do Frigate" + }, "icon": "Ícone para esta câmera (detectado automaticamente pela entidade)", + "id": "ID exclusivo para esta câmera nesse cartão", "live_provider": "Provedor de visualização ao vivo para esta câmera", "live_providers": { "auto": "Automatico", - "ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", "frigate-jsmpeg": "Frigate JSMpeg", + "ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", "webrtc-card": "Cartão WebRTC (de @AlexxIT)" }, + "title": "Título para esta câmera (detectado automaticamente pela entidade)", + "triggers": { + "entities": "Acionar a partir de outras entidades", + "motion": "Acionar detectando automaticamente o sensor de movimento", + "occupancy": "Acionar detectando automaticamente o sensor de ocupação", + "options": "Opções de acionamento" + }, "webrtc_card": { "entity": "Entidade de câmera de cartão WebRTC (não é uma câmera Frigate)", + "options": "Opções do cartão WebRTC", "url": "URL da câmera do cartão WebRTC" - }, - "zone": "Zona Frigate" - }, - "view": { - "camera_select": "Visualização de câmeras recém-selecionadas", - "default": "Visualização padrão", - "views": { - "live": "Visualização ao vivo", - "clip": "Clipe mais recente", - "clips": "Galeria de clipes", - "snapshot": "Snapshot mais recente", - "snapshots": "Galeria de Snapshots", - "image": "Imagem estática", - "current": "Visualização atual" - }, - "timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)", - "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", - "update_force": "Forçar atualizações do cartão (ignore a interação do usuário)", - "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)" - }, - "event_gallery": { - "min_columns": "Número mínimo de colunas" - }, - "event_viewer": { - "auto_play": "Reproduzir mídia automaticamente", - "auto_unmute": "Ativar mídia automaticamente", - "draggable": "Visualizador de eventos pode ser arrastado/deslizado", - "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", - "transition_effect": "Efeito de transição do Visualizador de eventos", - "transition_effects": { - "none": "Sem transição", - "slide": "Transição de slides" - }, - "controls": { - "next_previous": { - "style": "Estilo do controle próximo e anterior do Visualizador de eventos", - "size": "Tamanho do controle próximo e anterior do Visualizador de eventos (por exemplo, '48px')", - "styles": { - "thumbnails": "Miniaturas", - "chevrons": "Chevrons", - "none": "Nenhum" - } - }, - "thumbnails": { - "mode": "Modo de miniaturas do Visualizador de eventos", - "size": "Tamanho das miniaturas do Visualizador de eventos (por exemplo, '100px')", - "modes": { - "below": "Miniaturas abaixo da mídia", - "above": "Miniaturas acima da mídia", - "none": "Sem miniaturas" - } - }, - "title": { - "mode": "Modo de exibição de título de mídia do Visualizador de eventos", - "modes": { - "none": "Sem exibição de título", - "popup-top-left": "Pop-up no canto superior esquerdo", - "popup-top-right": "Pop-up no canto superior direito", - "popup-bottom-left": "Pop-up no canto inferior esquerdo", - "popup-bottom-right": "Pop-up no canto inferior direito" - }, - "duration_seconds": "Segundos de exibição do pop-up no Visualizador de Eventos (0 = para sempre)" - } } }, - "live": { - "preload": "Pré-carregar a visualização ao vivo em segundo plano", - "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", - "lazy_load": "As câmeras ao vivo são carregadas lentamente", - "lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente", - "auto_unmute": "Ativar automaticamente o som das câmeras ao vivo", - "transition_effect": "Efeito de transição de câmera ao vivo", + "common": { + "media_action_conditions": { + "all": "Todas as oportunidades", + "hidden": "Ao ocultar o navegador/aba", + "never": "Nunca", + "selected": "Ao selecionar", + "unselected": "Ao desselecionar", + "visible": "Ao mostrar o navegador/aba" + } + }, + "dimensions": { + "aspect_ratio": "Proporção padrão (e.g. '16:9')", + "aspect_ratio_mode": "Modo de proporção", + "aspect_ratio_modes": { + "dynamic": "A proporção se ajusta à mídia", + "static": "Proporção estática", + "unconstrained": "Proporção irrestrita" + } + }, + "event_gallery": { "controls": { - "next_previous": { - "style": "Estilo do controle próximo e anterior na visualização ao vivo", - "size": "Tamanho de controle próximo e anterior na visualização ao vivo (por exemplo, '48px')", - "styles": { - "chevrons": "Chevrons", - "icons": "Ícones", - "none": "Nenhum" - } - }, + "options": "Controles da Galeria de Eventos", "thumbnails": { - "mode": "Miniaturas do modo ao vivo", - "size": "Tamanho das miniaturas ao vivo (e.g. '100px')", - "media": "Se deve mostrar miniaturas de clipes ou snapshots", - "medias": { - "clips": "Miniaturas de clipes", - "snapshots": "Miniaturas de Snapshots" - } - }, - "title": { - "mode": "Modo de exibição de título de mídia ao vivo", - "duration_seconds": "Segundos para exibir o pop-up na visualização ao vivo (0 = para sempre)" + "show_details": "Mostrar detalhes do evento com miniaturas", + "show_favorite_control": "Mostrar controle de favorito nas miniaturas", + "show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas", + "size": "Tamanho das miniaturas da Galeria de eventos em pixels" } } }, "image": { "mode": "Modo de visualização de imagem", "modes": { + "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera", "screensaver": "Logo Frigate embutido", - "url": "Imagem arbitrária especificada por URL", - "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera" + "url": "Imagem arbitrária especificada por URL" }, - "url": "Imagem arbitrária especificada por URL", - "refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)" + "refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)", + "url": "Imagem arbitrária especificada por URL" + }, + "live": { + "auto_mute": "Silenciar câmeras ao vivo automaticamente", + "auto_pause": "Pausar câmeras ao vivo automaticamente", + "auto_play": "Reproduzir câmeras ao vivo automaticamente", + "auto_unmute": "Ativar automaticamente o som das câmeras ao vivo", + "controls": { + "next_previous": { + "size": "Tamanho de controle próximo e anterior na visualização ao vivo (por exemplo, '48px')", + "style": "Estilo do controle próximo e anterior na visualização ao vivo", + "styles": { + "chevrons": "Setas", + "icons": "Ícones", + "none": "Nenhum" + } + }, + "options": "Controles da visualização ao vivo", + "thumbnails": { + "media": "Se deve mostrar miniaturas de clipes ou snapshots", + "medias": { + "clips": "Miniaturas de clipes", + "snapshots": "Miniaturas de Snapshots" + }, + "mode": "Miniaturas do modo ao vivo", + "show_details": "Mostrar detalhes do evento com miniaturas", + "show_favorite_control": "Mostrar controle de favorito nas miniaturas", + "show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas", + "size": "Tamanho das miniaturas ao vivo (e.g. '100px')" + }, + "title": { + "duration_seconds": "Segundos para exibir o pop-up na visualização ao vivo (0 = para sempre)", + "mode": "Modo de exibição de título de mídia ao vivo" + } + }, + "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", + "lazy_load": "As câmeras ao vivo são carregadas lentamente", + "lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente", + "preload": "Pré-carregar a visualização ao vivo em segundo plano", + "show_image_during_load": "", + "transition_effect": "Efeito de transição de câmera ao vivo" + }, + "media_viewer": { + "auto_mute": "Silenciar mídia automaticamente", + "auto_pause": "Pausar mídia automaticamente", + "auto_play": "Reproduzir mídia automaticamente", + "auto_unmute": "Ativar mídia automaticamente", + "controls": { + "next_previous": { + "size": "Tamanho do controle próximo e anterior do Visualizador de eventos (por exemplo, '48px')", + "style": "Estilo do controle próximo e anterior do Visualizador de eventos", + "styles": { + "chevrons": "Setas", + "none": "Nenhum", + "thumbnails": "Miniaturas" + } + }, + "options": "Controles do visualizador de mídia", + "thumbnails": { + "mode": "Modo de miniaturas do Visualizador de eventos", + "modes": { + "above": "Miniaturas acima da mídia", + "below": "Miniaturas abaixo da mídia", + "left": "Miniaturas em uma gaveta à esquerda da mídia", + "none": "Sem miniaturas", + "right": "Miniaturas em uma gaveta à direita da mídia" + }, + "show_details": "Mostrar detalhes com miniaturas", + "show_favorite_control": "Mostrar controle de favorito nas miniaturas", + "show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas", + "size": "Tamanho das miniaturas do Visualizador de eventos (por exemplo, '100px')" + }, + "title": { + "duration_seconds": "Segundos de exibição do pop-up no Visualizador de Eventos (0 = para sempre)", + "mode": "Modo de exibição de título de mídia do Visualizador de eventos", + "modes": { + "none": "Sem exibição de título", + "popup-bottom-left": "Pop-up no canto inferior esquerdo", + "popup-bottom-right": "Pop-up no canto inferior direito", + "popup-top-left": "Pop-up no canto superior esquerdo", + "popup-top-right": "Pop-up no canto superior direito" + } + } + }, + "draggable": "Visualizador de eventos pode ser arrastado/deslizado", + "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", + "transition_effect": "Efeito de transição do Visualizador de eventos", + "transition_effects": { + "none": "Sem transição", + "slide": "Transição de slides" + } }, "menu": { + "alignment": "Alinhamento do menu", + "alignments": { + "bottom": "Alinhado à parte inferior", + "left": "Alinhado à esquerda", + "right": "Alinhado à direita", + "top": "Alinhado ao topo" + }, + "button_size": "Tamanho do botão de menu (e.g. '40px')", "buttons": { + "alignment": "Alinhamento do botão", + "alignments": { + "matching": "Mesmo alinhamento do menu", + "opposing": "Opor-se ao alinhamento do menu" + }, + "cameras": "Selecionar câmera", + "clips": "Clipes", + "download": "Baixe a mídia do evento", + "enabled": "Botão ativado", "frigate": "Frigate menu / Visualização padrão", "frigate_ui": "Frigate Interface de usuário", "fullscreen": "Tela cheia", - "download": "Baixe a mídia do evento", - "cameras": "Selecionar câmera" + "icon": "Ícone", + "image": "Imagem", + "live": "Ao vivo", + "media_player": "Enviar para o reprodutor de mídia", + "priority": "Prioridade", + "snapshots": "Instantâneos", + "timeline": "Linha do tempo" }, - "mode": "Modo do menu", - "modes": { + "position": "Posição do menu", + "positions": { + "bottom": "Posicionado na parte inferior", + "left": "Posicionado à esquerda", + "right": "Posicionado à direita", + "top": "Posicionado no topo" + }, + "style": "Estilo do menu", + "styles": { + "hidden": "Menu oculto", + "hover": "Menu suspenso", "none": "Sem menu", - "hidden-top": "Ocultar parte superior", - "hidden-left": "Ocultar esquerda", - "hidden-bottom": "Ocultar parte inferior", - "hidden-right": "Ocultar direita", - "overlay-top": "Sobreposição da parte superior", - "overlay-left": "Sobreposição esquerda", - "overlay-bottom": "Sobreposição da parte inferior", - "overlay-right": "Sobreposição direita", - "hover-top": "Passar para cima", - "hover-left": "Passar para esquerda", - "hover-bottom": "Passar para baixo", - "hover-right": "Passar para direita", - "above": "Acima", - "below": "Abaixo" - }, - "button_size": "Tamanho do botão de menu (e.g. '40px')" - }, - "dimensions": { - "aspect_ratio": "Proporção padrão (e.g. '16:9')", - "aspect_ratio_mode": "Modo de proporção", - "aspect_ratio_modes": { - "unconstrained": "Proporção irrestrita", - "dynamic": "A proporção se ajusta à mídia", - "static": "Proporção estática" + "outside": "Menu externo", + "overlay": "Menu sobreposto" } }, "overrides": { "info": "Esta configuração do cartão especificou manualmente as substituições configuradas que podem substituir os valores mostrados no editor visual, consulte o editor de código para visualizar/modificar essas substituições" + }, + "timeline": { + "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", + "controls": { + "options": "Controles de linha do tempo", + "thumbnails": { + "mode": "Modo de miniaturas da linha do tempo", + "show_details": "Mostrar detalhes do evento com miniaturas", + "show_favorite_control": "Mostrar controle de favorito nas miniaturas", + "show_timeline_control": "Mostrar controle da linha do tempo nas miniaturas", + "size": "Tamanho das miniaturas da linha do tempo em pixels" + } + }, + "media": "A mídia que a linha do tempo exibe", + "medias": { + "all": "Todos os tipos de mídia", + "clips": "Clipes", + "snapshots": "Instantâneos" + }, + "show_recordings": "Mostrar gravações", + "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" + }, + "view": { + "camera_select": "Visualização de câmeras recém-selecionadas", + "dark_mode": "Modo escuro", + "dark_modes": { + "auto": "Automático", + "off": "Desligado", + "on": "Ligado" + }, + "default": "Visualização padrão", + "scan": { + "enabled": "Modo scan ativado", + "scan_mode": "Modo scan", + "show_trigger_status": "Pulsar borda quando acionado", + "untrigger_reset": "Redefinir a visualização para o padrão após desacionar", + "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" + }, + "timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)", + "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", + "update_force": "Forçar atualizações do cartão (ignore a interação do usuário)", + "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", + "views": { + "clip": "Clipe mais recente", + "clips": "Galeria de clipes", + "current": "Visualização atual", + "image": "Imagem estática", + "live": "Visualização ao vivo", + "snapshot": "Snapshot mais recente", + "snapshots": "Galeria de Snapshots", + "timeline": "Visualização da linha do tempo" + } } }, "editor": { + "add_new_camera": "Adicionar nova câmera", + "button": "Botão", + "camera": "Câmera", "cameras": "Câmeras", "cameras_secondary": "Quais câmeras renderizar neste cartão", - "view": "Visualizar", - "view_secondary": "O que o cartão deve mostrar e como mostrá-lo", - "menu": "Menu", - "menu_secondary": "Opções de aparência do menu", - "live": "Ao vivo", - "live_secondary": "Opções de visualização da câmera ao vivo", - "event_gallery": "Galeria de eventos", - "event_gallery_secondary": "Opções da galeria de Snapshots e clipes", - "event_viewer": "Visualizador de eventos", - "event_viewer_secondary": "Opções do visualizador de Snapshots e clipes", - "image": "Imagem", - "image_secondary": "Opções de visualização de imagem estática", + "delete": "Excluir", "dimensions": "Dimensões", "dimensions_secondary": "Dimensões e opções de forma", - "show_button": "Mostrar botão", + "event_gallery": "Galeria de eventos", + "event_gallery_secondary": "Opções da galeria de Snapshots e clipes", + "image": "Imagem", + "image_secondary": "Opções de visualização de imagem estática", + "live": "Ao vivo", + "live_secondary": "Opções de visualização da câmera ao vivo", + "media_viewer": "Visualizador de eventos", + "media_viewer_secondary": "Opções do visualizador de Snapshots e clipes", + "menu": "Menu", + "menu_secondary": "Opções de aparência do menu", + "move_down": "Descer", + "move_up": "Subir", + "overrides": "As substituições estão ativas", + "overrides_secondary": "Substituições de configuração dinâmica detectadas", + "timeline": "Linha do tempo", + "timeline_secondary": "Opções do evento da linha do tempo", "upgrade": "Upgrade", "upgrade_available": "Um upgrade automático da configuração de cartão está disponível", - "delete": "Excluir", - "move_up": "Subir", - "move_down": "Descer", - "add_new_camera": "Adicionar nova câmera", - "camera": "Câmera", - "overrides": "As substituições estão ativas", - "overrides_secondary": "Substituições de configuração dinâmica detectadas" + "view": "Visualizar", + "view_secondary": "O que o cartão deve mostrar e como mostrá-lo" }, "error": { - "empty_response": "Sem resposta do Home Assistant para a solicitação", - "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", - "invalid_keys": "Chaves inválidas", - "unknown": "Erro desconhecido", - "troubleshooting": "Verifique a solução de problemas", + "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_resolve": "Não foi possível resolver o URL de mídia", - "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", - "live_camera_unavailable": "camera_entity configurada não está disponível", + "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar", + "download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia", + "download_no_media": "Nenhuma mídia para download", + "download_sign_failed": "Não foi possível assinar o URL de mídia para download", + "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras", + "empty_response": "Sem resposta do Home Assistant para a solicitação", + "failed_response": "Falha ao receber resposta do Home Assistant para solicitação", + "failed_retain": "Não foi possível reter o evento", + "failed_sign": "Não foi possível assinar a URL do Home Assistant", + "image_load_error": "A imagem não pôde ser carregada", "invalid_configuration": "Configuração inválida", "invalid_configuration_no_hint": "Nenhuma dica de local disponível (tipo incorreto ou ausente?)", + "invalid_elements_config": "Configuração de elementos de imagem inválida", + "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", + "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", + "jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", + "live_camera_not_found": "", + "live_camera_unavailable": "", + "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", + "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", + "no_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera", + "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", + "reconnecting": "Reconectando", + "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", + "troubleshooting": "Verifique a solução de problemas", + "unknown": "Erro desconhecido", "upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão", "webrtc_card_reported_error": "O cartão WebRTC relatou um erro", - "webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ...", - "no_cameras": "Nenhuma câmera válida encontrada, você deve configurar pelo menos uma câmera", - "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", - "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras", - "could_not_render_elements": "Não foi possível renderizar os elementos da imagem", - "invalid_elements_config": "Configuração de elementos de imagem inválida", - "jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", - "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", - "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", - "download_no_media": "Nenhuma mídia para download", - "download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia", - "download_sign_failed": "Não foi possível assinar o URL de mídia para download" + "webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..." + }, + "event": { + "duration": "Duração", + "in_progress": "Em andamento", + "score": "Pontuação", + "start": "Início" + }, + "recording": { + "events": "Eventos", + "seek": "Procurar" + }, + "thumbnail": { + "no_thumbnail": "Nenhuma miniatura disponível", + "retain_indefinitely": "Evento será retido por tempo indeterminado", + "timeline": "Ver evento na linha do tempo" } } diff --git a/src/localize/localize.ts b/src/localize/localize.ts index 6ed6b131..de9e9640 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -1,28 +1,46 @@ import * as en from './languages/en.json'; -import * as pt_BR from './languages/pt_br.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: any = { en: en, pt_BR: pt_BR, + it: it, }; -export function localize(string: string, search = '', replace = ''): string { - // Get the browser language. - let lang = localStorage - .getItem('selectedLanguage') - ?.replace(/['"]+/g, '') - .replace('-', '_'); +export function getLanguage(): string { + const canonicalizeLanguage = (language?: string | null): string | null => { + if (!language) { + return null; + } + return language.replace('-', '_'); + }; - // If that's not specified, try to find the Home Assistant language. - if (!lang) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hass = (document.querySelector('home-assistant') as any)?.hass; - lang = hass.selectedLanguage || hass.language; + // Try the HA language first... + let lang: string | null = null; + const HALanguage = localStorage.getItem('selectedLanguage'); + if (HALanguage) { + const selectedLanguage = canonicalizeLanguage(JSON.parse(HALanguage)); + if (selectedLanguage) { + lang = selectedLanguage; + } } - // Default to English is there's still no language setting. - lang = lang || 'en'; + // Then fall back to the browser language. + if (!lang) { + for (const language of navigator.languages) { + const canonicalLanguage = canonicalizeLanguage(language); + if (canonicalLanguage && canonicalLanguage in languages) { + lang = language; + } + } + } + return lang || 'en'; +} + +export function localize(string: string, search = '', replace = ''): string { + const lang = getLanguage(); let translated: string; try { @@ -31,7 +49,7 @@ export function localize(string: string, search = '', replace = ''): string { translated = string.split('.').reduce((o, i) => o[i], languages['en']); } - if (translated === undefined) { + if (!translated) { translated = string.split('.').reduce((o, i) => o[i], languages['en']); } diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 00c26155..2081dca5 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -9,10 +9,14 @@ // available as compilation time. // ==================================================================== -import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import { TemplateResult, css, html } from 'lit'; +import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { dispatchMediaShowEvent } from '../common.js'; +import { query } from 'lit/decorators/query.js'; +import { FrigateCardMediaPlayer } from '../types.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import './ha-hls-player'; +import './ha-web-rtc-player'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-camera-stream').then(() => { // ======================================================================================== @@ -38,7 +42,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { @customElement('frigate-card-ha-camera-stream') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') { - protected _playerRef: Ref = createRef(); + // Due to an obscure behavior when this card is casted, this element needs + // to use query rather than the ref directive to find the player. + @query('#player') + protected _player: FrigateCardMediaPlayer; // ======================================================================================== // Minor modifications from: @@ -49,28 +56,35 @@ customElements.whenDefined('ha-camera-stream').then(() => { * Play the video. */ public play(): void { - this._playerRef.value?.play(); + this._player?.play(); } /** * Pause the video. */ public pause(): void { - this._playerRef.value?.pause(); + this._player?.pause(); } /** * Mute the video. */ public mute(): void { - this.muted = true; + this._player?.mute(); } /** * Unmute the video. */ public unmute(): void { - this.muted = false; + this._player?.unmute(); + } + + /** + * Seek the video (unsupported). + */ + public seek(seconds: number): void { + this._player?.seek(seconds); } /** @@ -85,8 +99,8 @@ customElements.whenDefined('ha-camera-stream').then(() => { if (this._shouldRenderMJPEG) { return html` { - dispatchMediaShowEvent(this, e); + @load=${(ev: Event) => { + dispatchMediaLoadedEvent(this, ev); }} .src=${typeof this._connected == 'undefined' || this._connected ? computeMJPEGStreamUrl(this.stateObj) @@ -98,7 +112,7 @@ customElements.whenDefined('ha-camera-stream').then(() => { if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS) { return this._url ? html` { } if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_WEB_RTC) { return html` { static get styles(): CSSResultGroup { return [ super.styles, + unsafeCSS(liveHAComponentsStyle), css` :host { width: 100%; height: 100%; } + img { + width: 100%; + height: 100%; + } `, ]; } } }); + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-ha-camera-stream': FrigateCardHaCameraStream; + } +} diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 95fdc1f7..fa910c91 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -9,43 +9,64 @@ // available as compilation time. // ==================================================================== -import { TemplateResult, css, html } from 'lit'; -import { Ref, createRef, ref } from 'lit/directives/ref.js'; +import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { dispatchErrorMessageEvent, dispatchMediaShowEvent } from '../common.js'; +import { query } from 'lit/decorators/query.js'; +import { dispatchErrorMessageEvent } from '../components/message.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-hls-player').then(() => { @customElement('frigate-card-ha-hls-player') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaHlsPlayer extends customElements.get('ha-hls-player') { - protected _videoRef: Ref = createRef(); + // Due to an obscure behavior when this card is casted, this element needs + // to use query rather than the ref directive to find the player. + @query('#video') + protected _video: HTMLVideoElement; /** * Play the video. */ public play(): void { - this._videoRef.value?.play(); + this._video?.play(); } /** * Pause the video. */ public pause(): void { - this._videoRef.value?.pause(); + this._video?.pause(); } /** * Mute the video. */ public mute(): void { - this.muted = true; + // The muted property is only for the initial muted state. Must explicitly + // set the muted on the video player to make the change dynamic. + if (this._video) { + this._video.muted = true; + } } /** * Unmute the video. */ public unmute(): void { - this.muted = false; + // See note in mute(). + if (this._video) { + this._video.muted = false; + } + } + + /** + * Seek the video. + */ + public seek(seconds: number): void { + if (this._video) { + this._video.currentTime = seconds; + } } // ===================================================================================== @@ -54,19 +75,18 @@ customElements.whenDefined('ha-hls-player').then(() => { // ===================================================================================== protected render(): TemplateResult { if (this._error) { - // Use native Frigate card error handling, and attach the entityid to - // clarify which camera the error refers to. + // Use native Frigate card error handling. return dispatchErrorMessageEvent(this, this._error); } return html` `; @@ -75,18 +95,24 @@ customElements.whenDefined('ha-hls-player').then(() => { static get styles(): CSSResultGroup { return [ super.styles, + unsafeCSS(liveHAComponentsStyle), css` :host { width: 100%; height: 100%; } video { - object-fit: contain; - height: 100%; width: 100%; + height: 100%; } - ` - ] + `, + ]; } } }); + +declare global { + interface HTMLElementTagNameMap { + "frigate-card-ha-hls-player": FrigateCardHaHlsPlayer + } +} diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index c1c34a47..5c4a1d44 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -9,43 +9,64 @@ // available as compilation time. // ==================================================================== -import { Ref, createRef, ref } from 'lit/directives/ref.js'; -import { TemplateResult, html } from 'lit'; +import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { dispatchErrorMessageEvent, dispatchMediaShowEvent } from '../common.js'; +import { query } from 'lit/decorators/query.js'; +import { dispatchErrorMessageEvent } from '../components/message.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import liveHAComponentsStyle from '../scss/live-ha-components.scss'; customElements.whenDefined('ha-web-rtc-player').then(() => { @customElement('frigate-card-ha-web-rtc-player') // eslint-disable-next-line @typescript-eslint/no-unused-vars class FrigateCardHaWebRtcPlayer extends customElements.get('ha-web-rtc-player') { - protected _videoRef: Ref = createRef(); + // Due to an obscure behavior when this card is casted, this element needs + // to use query rather than the ref directive to find the player. + @query('#remote-stream') + protected _video: HTMLVideoElement; /** * Play the video. */ public play(): void { - this._videoRef.value?.play(); + this._video?.play(); } /** * Pause the video. */ public pause(): void { - this._videoRef.value?.pause(); + this._video?.pause(); } /** * Mute the video. */ public mute(): void { - this.muted = true; + // The muted property is only for the initial muted state. Must explicitly + // set the muted on the video player to make the change dynamic. + if (this._video) { + this._video.muted = true; + } } /** * Unmute the video. */ public unmute(): void { - this.muted = false; + // See note in mute(). + if (this._video) { + this._video.muted = false; + } + } + + /** + * Seek the video. + */ + public seek(seconds: number): void { + if (this._video) { + this._video.currentTime = seconds; + } } // ===================================================================================== @@ -56,23 +77,43 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { if (this._error) { // Use native Frigate card error handling, and attach the entityid to // clarify which camera the error refers to. - return dispatchErrorMessageEvent( - this, - `${this._error} (${this.entityid})`); + return dispatchErrorMessageEvent(this, `${this._error} (${this.entityid})`); } return html` `; } + + static get styles(): CSSResultGroup { + return [ + super.styles, + unsafeCSS(liveHAComponentsStyle), + css` + :host { + width: 100%; + height: 100%; + } + video { + width: 100%; + height: 100%; + } + `, + ]; + } } }); + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-ha-web-rtc-player': FrigateCardHaWebRtcPlayer; + } +} diff --git a/src/resolved-media.ts b/src/resolved-media.ts deleted file mode 100644 index 4268719e..00000000 --- a/src/resolved-media.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { HomeAssistant } from 'custom-card-helpers'; -import { homeAssistantWSRequest } from './common.js'; -import { - BrowseMediaSource, - ExtendedHomeAssistant, - ResolvedMedia, - resolvedMediaSchema, -} from './types.js'; -import QuickLRU from 'quick-lru'; - -// It's important the cache size be at least as large as the largest likely -// media query or media items will from a given query will be evicted for other -// items in the same query (which would result in only partial results being -// returned to the user). -// Note: Each entry is about 400 bytes. - -const RESOLVED_MEDIA_CACHE_SIZE = 1000; - -export class ResolvedMediaCache { - protected _cache: QuickLRU; - - constructor() { - this._cache = new QuickLRU({maxSize: RESOLVED_MEDIA_CACHE_SIZE}); - } - - public has(id: string): boolean { - return this._cache.has(id); - } - - public get(id: string): ResolvedMedia | undefined { - return this._cache.get(id); - } - - public set(id: string, resolvedMedia: ResolvedMedia): void { - this._cache.set(id, resolvedMedia); - } -} - -export class ResolvedMediaUtil { - static async resolveMedia( - hass: HomeAssistant & ExtendedHomeAssistant, - mediaSource?: BrowseMediaSource, - cache?: ResolvedMediaCache, - ): Promise { - if (!mediaSource) { - return null; - } - const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined; - if (cachedValue) { - return cachedValue; - } - const request = { - type: 'media_source/resolve_media', - media_content_id: mediaSource.media_content_id, - }; - const resolvedMedia = await homeAssistantWSRequest( - hass, - resolvedMediaSchema, - request, - ); - if (cache && resolvedMedia) { - cache.set(mediaSource.media_content_id, resolvedMedia); - } - return resolvedMedia; - } -} diff --git a/src/scss/card.scss b/src/scss/card.scss index 34f29ce7..85786508 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,11 +1,23 @@ :host { display: block; - // Anchor absolutes to the outer part of the card. + // Anchor absolutes to the outermost part of the card. position: relative; + + // Different browsers use different colors as their fullscreen background, + // this ensures the same experience across all browsers. + background-color: var(--card-background-color); + + // The primary border-radius used is the div.main. This is only useful for + // keeping the background-color within the radius. + border-radius: var(--ha-card-border-radius, 4px); } -.container { +:host([dark]) { + filter: brightness(75%); +} + +div.main { position: relative; overflow: auto; width: 100%; @@ -14,49 +26,49 @@ display: flex; justify-content: center; - // Need to apply the border radius on the container level, as the ha-card has - // overflow visible in order to allow a submenu to extend beyond the card - // boundary. - border-radius: var(--ha-card-border-radius, 4px); -} + // Necessary to get Safari to show border-radius correctly. + transform: translateZ(0); -.frigate-card-contents { - width: inherit; - height: inherit; - margin: auto; - overflow: auto; - -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ - scrollbar-width: none; /* Hide scrollbar: Firefox */ - display: flex; - align-items: center; - justify-content: center; + // Include the borders in the sizing (to keep the triggered warning pulse + // visible in fullscreen). + box-sizing: border-box; + + // Hide scrollbar: Firefox + scrollbar-width: none; + // Hide scrollbar: IE and Edge + -ms-overflow-style: none; } /* Hide scrollbar for Chrome, Safari and Opera */ -.frigate-card-contents::-webkit-scrollbar { +div.main::-webkit-scrollbar { display: none; } -/* When forcing aspect ratio absolute positioning is required */ -.frigate-card-contents.absolute { - position: absolute; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; +// Need to apply the border radius on the container level, as the ha-card has +// overflow visible in order to allow a submenu to extend beyond the card +// boundary. Need to be able to selectively curve top or bottom depending on +// whether the outside menu is being shown. There's no way to select 'preceding +// element' in CSS, so this must be implemented in JS. +div.main.curve-top { + border-top-left-radius: var(--ha-card-border-radius, 4px); + border-top-right-radius: var(--ha-card-border-radius, 4px); +} +div.main.curve-bottom { + border-bottom-left-radius: var(--ha-card-border-radius, 4px); + border-bottom-right-radius: var(--ha-card-border-radius, 4px); } /* The 'hover' menu mode is styling applied outside of the menu itself */ -frigate-card-menu[data-mode^=hover-] { +frigate-card-menu[data-style='hover'] { z-index: 1; transition: opacity 0.5s ease; } -.outer + frigate-card-menu[data-mode^=hover-] { - opacity: 0.0; +.main + frigate-card-menu[data-style='hover'] { + opacity: 0; } -.outer:hover + frigate-card-menu[data-mode^=hover-], frigate-card-menu[data-mode^=hover-]:hover { - opacity: 1.0; +frigate-card-menu[data-style='hover']:hover { + opacity: 1; } ha-card { @@ -70,34 +82,64 @@ ha-card { height: 100%; position: static; color: var(--secondary-text-color, white); - background-color: var(--secondary-background-color, black); - border-radius: var(--ha-card-border-radius, 4px); +} +ha-card.triggered { + @keyframes warning-pulse { + 0% { + border: solid 2px rgba(0, 0, 0, 0); + } + 50% { + border: solid 2px var(--warning-color); + } + 100% { + border: solid 2px rgba(0, 0, 0, 0); + } + } + animation: warning-pulse 5s infinite; } -frigate-card-live.hidden { + +frigate-card-live.hidden { // Live view will be rendered but hidden for live preloading. display: none; } +/************ + * Fullscreen + *************/ + // Browsers will reject invalid whole CSS selectors if one selector is bad, so // need to use mixin here instead of just comma-separated selectors. // - Related: https://stackoverflow.com/questions/16982449/why-isnt-it-possible-to-combine-vendor-specific-pseudo-elements-classes-into-on @mixin fullscreen-ha-card { - // Make the background black, to give it the expected fullscreen feel. - background-color: black; + // Hide corners on Safari fullscreen. + border-radius: 0px; + // Theme styles may set these which are not helpful in fullscreen mode, reset + // them. + box-shadow: none; + margin: 0; +} + +// Need to use an id selector here to overcome theme styling that may +// incorrectly apply to ha-card style. +:host(:fullscreen) #ha-card { + @include fullscreen-ha-card; +} +:host(:-webkit-full-screen) #ha-card { + @include fullscreen-ha-card; +} + +@mixin fullscreen-no-rounded-corners { // Hide corners on Safari fullscreen. border-radius: 0px; } -:host(:fullscreen) ha-card { - @include fullscreen-ha-card; + +:host(:fullscreen) div.main, +:host(:fullscreen) frigate-card-menu { + @include fullscreen-no-rounded-corners; } -:host(:-webkit-full-screen) ha-card { - @include fullscreen-ha-card; -} -:host(:-moz-full-screen) ha-card { - @include fullscreen-ha-card; -} -:host(:-ms-fullscreen) ha-card { - @include fullscreen-ha-card; +:host(:-webkit-full-screen) div.main, +:host(:-webkit-full-screen) frigate-card-menu { + @include fullscreen-no-rounded-corners; } diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index 68a2132e..167146a8 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -4,16 +4,9 @@ width: 100%; } -img,video { - width: 100%; - height: 100%; - display: block; -} - .embla { width: 100%; height: 100%; - position: relative; margin-left: auto; margin-right: auto; } @@ -28,6 +21,12 @@ img,video { -khtml-user-select: none; -webkit-tap-highlight-color: transparent; } +:host([direction='vertical']) .embla__container { + flex-direction: column; +} +:host([direction='horizontal']) .embla__container { + flex-direction: row; +} .embla__viewport { width: 100%; @@ -47,14 +46,10 @@ img,video { cursor: grabbing; } -.embla__slide { - position: relative; - height: 100%; +:host([direction='vertical']) ::slotted(.embla__slide) { + margin-bottom: 5px; +} + +:host([direction='horizontal']) ::slotted(.embla__slide) { margin-right: 5px; - overflow: visible; } -.embla__slide img,video { - // Letterbox media. has similar added directly in - // its element. - object-fit: contain; -} \ No newline at end of file diff --git a/src/scss/const.scss b/src/scss/const.scss new file mode 100644 index 00000000..4d531702 --- /dev/null +++ b/src/scss/const.scss @@ -0,0 +1,6 @@ +:host { + --frigate-card-thumbnail-size-max: 175px; + --frigate-card-thumbnail-details-width: calc( + var(--frigate-card-thumbnail-size) + 200px + ); +} \ No newline at end of file diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss new file mode 100644 index 00000000..17eaeeef --- /dev/null +++ b/src/scss/drawer-inject.scss @@ -0,0 +1,45 @@ +:host { + // Drawer width sizes to contents. + width: unset; +} + +#fs { + // Hide the freespace screen. + display: none; + width: 100%; + inset: 0; +} + +#d, +#fs { + // Override width/height to be 100% instead of 100v[wh]. + height: 100%; + + // Position absolutely. + position: absolute; +} + +#d { + // Need to allow drawer controls to be visible. + overflow: visible; + + // Don't let the drawer exceed 90% of the full width of the relative parent. + max-width: 90%; +} + +:host([location=right]) #d { + // Position to the right. + left: unset; + right: 0; + transform: translateX(100%); +} + +:host([location=right][open]) #d { + transform: none; + box-shadow: 0px 0px 25px 0px black; +} + +#ifs { + // Override width/height to be 100% instead of 100v[wh]. + height: 100%; +} diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss new file mode 100644 index 00000000..6c5288cf --- /dev/null +++ b/src/scss/drawer.scss @@ -0,0 +1,65 @@ +$drawer-icon-size: 20px; +$drawer-padding-extend: 20px; + +side-drawer { + background-color: var(--card-background-color); +} + +div.control-surround { + position: absolute; + bottom: 50%; + transform: translateY(50%); + z-index: 0; + padding-top: $drawer-padding-extend; + padding-bottom: $drawer-padding-extend; +} +:host([location=left]) div.control-surround { + @if $drawer-icon-size < 32 { + // Ensure the clickable area is at least 32px wide. + padding-right: calc(32px - $drawer-icon-size); + } + left: 100%; +} +:host([location=right]) div.control-surround { + @if $drawer-icon-size < 32 { + // See note above. + padding-left: calc(32px - $drawer-icon-size); + } + right: 100%; +} +:host([empty]), :host([empty]) > * { + visibility: hidden; +} +:host(:not([empty])), :host(:not([empty])) > * { + visibility: visible; +} + +ha-icon.control { + color: var(--secondary-color, white); + background-color: rgba(0, 0, 0, 0.7); + opacity: 0.7; + pointer-events: all; + + --mdc-icon-size: #{$drawer-icon-size}; + padding-top: $drawer-padding-extend; + padding-bottom: $drawer-padding-extend; + + transition: opacity 0.5s ease; +} + +:host([open]) ha-icon.control, ha-icon.control:hover { + // When the drawer is open or hovered make the button to close it more + // prominent. + opacity: 1; + background-color: black; +} + +:host([location=left]) ha-icon.control { + border-top-right-radius: $drawer-icon-size; + border-bottom-right-radius: $drawer-icon-size; +} + +:host([location=right]) ha-icon.control { + border-top-left-radius: $drawer-icon-size; + border-bottom-left-radius: $drawer-icon-size; +} diff --git a/src/scss/editor.scss b/src/scss/editor.scss index 7dfc161e..0c251615 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -23,15 +23,14 @@ pointer-events: none; } .values { - padding: 10px; background: var(--secondary-background-color); display: grid; - margin-bottom: 10px; } -ha-formfield { - padding-bottom: 8px; +// Space submenus from the next top-level options. +.values + .option, +.submenu + .option { + margin-top: 10px; } - div.upgrade { width: auto; border: 1px dotted var(--primary-color); @@ -44,38 +43,68 @@ div.upgrade span { padding: 10px; } -.camera-header { +.submenu-header { display: flex; - margin-top: 4px; + padding: 10px; cursor: pointer; } -.camera-header * { +.submenu.selected > .submenu-header { + background-color: var(--primary-color); + color: var(--primary-text-color); +} + +.submenu-header * { flex-basis: auto; - // Only allow clicks on the outermost header. + // Only allow clicks on the header. pointer-events: none; } -.camera-header ha-icon { - padding-right: 10px; -} -.camera-header .new-camera { +.submenu-header .new-camera { font-style: italic; +} +.submenu:not(.selected) > .submenu-header .new-camera { color: var(--secondary-text-color, 'black'); } -.cameras { - margin: 5px 5px 10px 20px; + +.submenu-header ha-icon, +.submenu-header ha-svg-icon { + padding-right: 15px; } -.cameras .controls { + +.submenu { + border: 1px solid var(--divider-color); +} +.submenu.selected { + border: 1px solid var(--primary-color); +} + +// Nested menus should shrink width. +.submenu .submenu { + width: calc(100% - 10px * 2); + margin-left: auto; + margin-right: auto; + margin-bottom: 10px; +} +.submenu :not(.submenu.selected) { + border: none; +} + +.submenu .controls { display: inline-block; margin-left: auto; margin-right: 0px; margin-bottom: 5px; } -.cameras .controls ha-icon-button.button { +.submenu .controls ha-icon-button.button { --mdc-icon-button-size: 32px; --mdc-icon-size: calc(var(--mdc-icon-button-size) / 2); } span.info { padding: 4px; -} \ No newline at end of file +} + +ha-selector { + padding: 10px; + border: 1px solid var(--divider-color); +} diff --git a/src/scss/elements-ptz.scss b/src/scss/elements-ptz.scss new file mode 100644 index 00000000..34bfbb4a --- /dev/null +++ b/src/scss/elements-ptz.scss @@ -0,0 +1,135 @@ +// Modified from / inspired by: +// https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js +:host { + position: relative; + + width: fit-content; + height: fit-content; + + --frigate-card-ptz-icon-size: 24px; +} + +/***************** + * Main Containers + *****************/ +.ptz { + display: flex; + gap: 10px; + + color: var(--light-primary-color); + opacity: 0.4; + + transition: opacity 0.3s ease-in-out; +} + +:host([data-orientation='vertical']) .ptz { + flex-direction: column; +} +:host([data-orientation='horizontal']) .ptz { + flex-direction: row; +} + +.ptz:hover { + opacity: 1; +} + +:host([data-orientation='vertical']) .ptz div { + width: calc(var(--frigate-card-ptz-icon-size) * 3); +} +:host([data-orientation='horizontal']) .ptz div { + height: calc(var(--frigate-card-ptz-icon-size) * 3); +} + +.ptz-move, +.ptz-zoom, +.ptz-home { + position: relative; + background-color: rgba(0, 0, 0, 0.3); +} + +.ptz-move { + height: calc(var(--frigate-card-ptz-icon-size) * 3); + width: calc(var(--frigate-card-ptz-icon-size) * 3); + + border-radius: 50%; +} + +:host([data-orientation='horizontal']) .ptz .ptz-zoom, +:host([data-orientation='horizontal']) .ptz .ptz-home { + width: calc(var(--frigate-card-ptz-icon-size) * 1.5); +} +:host([data-orientation='vertical']) .ptz .ptz-zoom, +:host([data-orientation='vertical']) .ptz .ptz-home { + height: calc(var(--frigate-card-ptz-icon-size) * 1.5); +} + +.ptz-zoom, +.ptz-home { + border-radius: var(--ha-card-border-radius, 4px); +} + +/*********** + * PTZ Icons + ***********/ +ha-icon { + position: absolute; + --mdc-icon-size: var(--frigate-card-ptz-icon-size); +} +ha-icon:not(.disabled) { + cursor: pointer; +} +.disabled { + color: var(--disabled-text-color); +} +.up { + top: 5px; + left: 50%; + transform: translateX(-50%); +} +.down { + bottom: 5px; + left: 50%; + transform: translateX(-50%); +} +.left { + left: 5px; + top: 50%; + transform: translateY(-50%); +} +.right { + right: 5px; + top: 50%; + transform: translateY(-50%); +} + +:host([data-orientation='vertical']) .zoom_in { + right: 5px; + top: 50%; +} +:host([data-orientation='vertical']) .zoom_out { + left: 5px; + top: 50%; +} +:host([data-orientation='horizontal']) .zoom_in { + left: 50%; + top: 5px; +} +:host([data-orientation='horizontal']) .zoom_out { + left: 50%; + bottom: 5px; +} + +:host([data-orientation='vertical']) .zoom_in, +:host([data-orientation='vertical']) .zoom_out { + transform: translateY(-50%); +} +:host([data-orientation='horizontal']) .zoom_in, +:host([data-orientation='horizontal']) .zoom_out { + transform: translateX(-50%); +} + +.home { + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); +} diff --git a/src/scss/elements.scss b/src/scss/elements.scss index 239c854c..2621cf83 100644 --- a/src/scss/elements.scss +++ b/src/scss/elements.scss @@ -1,12 +1,21 @@ +:host { + position: absolute; + inset: 0; + + // Don't let elements overflow. + overflow: hidden; + pointer-events: none; +} + .element { position: absolute; transform: translate(-50%, -50%); + pointer-events: auto; } // Errors encountered by HA (not the card) during parsing of the configuration // (e.g. custom picture element that does not exist). hui-error-card.element { inset: 0px; - background-color: var(--secondary-background-color, black); transform: unset; } \ No newline at end of file diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 5f367850..662c0a78 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,46 +1,62 @@ -@use "@material/image-list/mdc-image-list"; -@use "@material/image-list"; - :host { - display: block; width: 100%; - height: 100%; + height: auto; overflow: auto; - -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ - scrollbar-width: none; /* Hide scrollbar: Firefox */ + + // Hide scrollbar: IE and Edge + -ms-overflow-style: none; + + // Hide scrollbar: Firefox + scrollbar-width: none; + + --frigate-card-gallery-gap: 3px; + --frigate-card-gallery-columns: 4; + + display: grid; + grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: var(--frigate-card-gallery-gap); } -/* Hide scrollbar for Chrome, Safari and Opera */ +// Hide scrollbar for Chrome, Safari and Opera :host::-webkit-scrollbar { display: none; } -.frigate-card-gallery { - // Note: In fullscreen, number of columns is overwritten in Javascript based - // on dimensions. - @include image-list.standard-columns(4, 5px); - @include image-list.shape-radius(5px); -} - -.frigate-card-gallery .mdc-image-list__image { - transition: transform 0.2s linear; -} -.frigate-card-gallery .mdc-image-list__image:hover { - transform: scale(1.04); -} - -ha-card.frigate-card-gallery-folder { +:host ha-card { display: flex; justify-content: center; align-items: center; box-sizing: border-box; text-align: center; - opacity: 0.7; - color: var(--secondary-text-color, white); - border: 2px ridge var(--secondary-text-color, black); - border-radius: 5px; + color: var(--primary-text-color, white); + border: 1px solid var(--primary-color); + border-radius: var(--ha-card-border-radius, 4px); + + // Folder background color should match the thumbnail element background + // color. background-color: var(--primary-background-color, black); padding: 10px; - height: 100%; line-height: 1; -} \ No newline at end of file + overflow: hidden; + + // Some themes override these values (with high CSS specificity) which is + // intended for full cards rather than internal ones. Since we use multiple of + // these for folders, we cannot use an ID and need to resort to !important. + margin: 0px !important; + box-shadow: none !important; + + transition: transform 0.2s linear; +} +ha-card:hover { + transform: scale(1.04); +} + +ha-card, +frigate-card-thumbnail { + height: 100%; + max-height: var(--frigate-card-thumbnail-size); +} +frigate-card-thumbnail:not([details]) { + width: 100%; +} diff --git a/src/scss/image.scss b/src/scss/image.scss index c8d401d5..954b500b 100644 --- a/src/scss/image.scss +++ b/src/scss/image.scss @@ -1,6 +1,9 @@ +@use 'media-layout.scss'; + img { width: 100%; height: 100%; display: block; - object-fit: scale-down; + + @include media-layout.media-layout(); } \ No newline at end of file diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss new file mode 100644 index 00000000..aab21ed3 --- /dev/null +++ b/src/scss/live-carousel.scss @@ -0,0 +1,4 @@ +.embla__slide { + height: 100%; + flex: 0 0 100%; +} diff --git a/src/scss/live-ha-components.scss b/src/scss/live-ha-components.scss new file mode 100644 index 00000000..2815cb6a --- /dev/null +++ b/src/scss/live-ha-components.scss @@ -0,0 +1,5 @@ +@use 'media-layout.scss'; + +img, video { + @include media-layout.media-layout(); +} \ No newline at end of file diff --git a/src/scss/live-jsmpeg.scss b/src/scss/live-jsmpeg.scss index 9f7cbf2c..0cb0dfb6 100644 --- a/src/scss/live-jsmpeg.scss +++ b/src/scss/live-jsmpeg.scss @@ -1,12 +1,15 @@ +@use 'media-layout.scss'; + :host { width: 100%; height: 100%; - display: block; + display: flex; } canvas { display: block; width: 100%; height: 100%; - object-fit: contain; -} \ No newline at end of file + + @include media-layout.media-layout(); +} diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss new file mode 100644 index 00000000..f4d336fd --- /dev/null +++ b/src/scss/live-provider.scss @@ -0,0 +1,3 @@ +.hidden { + display: none; +} diff --git a/src/scss/live-webrtc.scss b/src/scss/live-webrtc.scss index 0f4e1c18..6482aed1 100644 --- a/src/scss/live-webrtc.scss +++ b/src/scss/live-webrtc.scss @@ -1,3 +1,5 @@ +@use 'media-layout.scss'; + :host { width: 100%; height: 100%; @@ -5,11 +7,15 @@ } /* Don't drop shadow or have radius for nested webrtc card */ -webrtc-camera ha-card { - box-shadow: none; +#webrtc ha-card { border-radius: 0px; - background-color: black; + background-color: unset; - // To get the WebRTC player to line up correctly in Safari. + // To get the WebRTC player to line up correctly with some themes. margin: 0px; -} \ No newline at end of file + box-shadow: none; +} + +#webrtc #video { + @include media-layout.media-layout(); +} diff --git a/src/scss/live.scss b/src/scss/live.scss index b12a21a2..624620b1 100644 --- a/src/scss/live.scss +++ b/src/scss/live.scss @@ -10,7 +10,3 @@ frigate-card-live-carousel { flex: 1; min-height: 0; } - -frigate-card-thumbnail-carousel { - flex: 0 0 var(--frigate-card-carousel-thumbnail-size); -} \ No newline at end of file diff --git a/src/scss/media-carousel.scss b/src/scss/media-carousel.scss index 5611628f..45964ad6 100644 --- a/src/scss/media-carousel.scss +++ b/src/scss/media-carousel.scss @@ -1,7 +1,9 @@ :host { + display: block; + width: 100%; + height: 100%; --video-max-height: none; -} -.embla__slide { - flex: 0 0 100%; -} \ No newline at end of file + // Keep the controls relative to the media carousel itself. + position: relative; +} diff --git a/src/scss/media-layout.scss b/src/scss/media-layout.scss new file mode 100644 index 00000000..cde335ec --- /dev/null +++ b/src/scss/media-layout.scss @@ -0,0 +1,5 @@ +@mixin media-layout { + object-fit: var(--frigate-card-media-layout-fit, contain); + object-position: var(--frigate-card-media-layout-position-x, 50%) + var(--frigate-card-media-layout-position-y, 50%); +} diff --git a/src/scss/menu.scss b/src/scss/menu.scss index 45063aa3..fc3dc84d 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -11,26 +11,91 @@ display: flex; flex-direction: row; - flex-wrap: wrap; + justify-content: space-between; } -:host([data-mode="above"]),:host([data-mode="below"]) { +/*********************************** + * Aligned divs: matching & opposing + ***********************************/ + +div.matching, +div.opposing { + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-items: flex-start; + + // Allow the divs to be resized. + min-width: 0px; + min-height: 0px; +} +div.matching { + justify-content: flex-start; +} +div.opposing { + justify-content: flex-end; +} + +/******************** + * Outside menu style + ********************/ + +:host([data-style='outside']) { width: 100%; background: var(--secondary-background-color); } -:host([data-mode="above"]) { + +/************************************ + * Match menu rounded corners to card + ************************************/ + +:host([data-position='top']), +:host([data-position='left']) { border-top-left-radius: var(--ha-card-border-radius, 4px); +} +:host([data-position='top']), +:host([data-position='right']) { border-top-right-radius: var(--ha-card-border-radius, 4px); } -:host([data-mode="below"]) { +:host([data-position='bottom']), +:host([data-position='left']) { border-bottom-left-radius: var(--ha-card-border-radius, 4px); +} +:host([data-position='bottom']), +:host([data-position='right']) { border-bottom-right-radius: var(--ha-card-border-radius, 4px); } -:host([data-mode$="-left"]) { - left: 0px; - top: 0px; +/************************************** + * Positioning for absolute menu styles + **************************************/ +:host(:not([data-style='outside'])[data-position='top']), +:host(:not([data-style='outside'])[data-position='left'][data-alignment='top']), +:host(:not([data-style='outside'])[data-position='right'][data-alignment='top']) { + top: 0px; +} +:host(:not([data-style='outside'])[data-position='bottom']), +:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']), +:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) { + bottom: 0px; +} +:host(:not([data-style='outside'])[data-position='left']), +:host(:not([data-style='outside'])[data-position='top'][data-alignment='left']), +:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='left']) { + left: 0px; +} +:host(:not([data-style='outside'])[data-position='right']), +:host(:not([data-style='outside'])[data-position='top'][data-alignment='right']), +:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='right']) { + right: 0px; +} + +/******************************************************** + * Hack: Ensure host & div expand for column flex layouts + ********************************************************/ + +:host(:not([data-style='outside'])[data-position='left']) { // Awful hack: Flexbox column wrapping doesn't work properly in most major // browsers -- the element boundary does not expand to cover the full wrapped // content as it should. This results in the wrapped 'content' appearing @@ -43,66 +108,80 @@ // - https://bugs.chromium.org/p/chromium/issues/detail?id=507397 writing-mode: vertical-lr; } - -:host([data-mode$="-right"]) { - right: 0px; - top: 0px; - +:host(:not([data-style='outside'])[data-position='right']) { // See "Awful hack" above. writing-mode: vertical-rl; } - -:host([data-mode$="-top"]) { - left: 0px; - top: 0px; +:host(:not([data-style='outside'])[data-style='overlay'][data-position='left']) div > *, +:host(:not([data-style='outside'])[data-style='overlay'][data-position='right']) div > *, +:host(:not([data-style='outside'])[data-style='hover'][data-position='left']) div > *, +:host(:not([data-style='outside'])[data-style='hover'][data-position='right']) div > *, +:host(:not([data-style='outside'])[data-style='hidden'][data-position='left']) div > *, +:host(:not([data-style='outside'])[data-style='hidden'][data-position='right']) div > * { + // See "Awful hack" above. Note that this "cancelation" of writing mode only + // affects from beyond the divs, i.e. the writing mode hack applies to host + // and divs both. + writing-mode: horizontal-tb; } -:host([data-mode$="-bottom"]) { - left: 0px; - bottom: 0px; +/********************** + * "Reverse" alignments + **********************/ +:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']), +:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']), +:host([data-position='top'][data-alignment='right']), +:host([data-position='bottom'][data-alignment='right']), +:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']) div, +:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) div, +:host([data-position='top'][data-alignment='right']) div, +:host([data-position='bottom'][data-alignment='right']) div { + flex-direction: row-reverse; +} + +/**************************** + * Wrap upwards on the bottom + ****************************/ + +:host(:not([data-style='outside'])[data-position='bottom']) div { // If the menu has more content that allows, "wrap upwards" to keep the // Frigate button in the same place. flex-wrap: wrap-reverse; } -:host([data-mode^="overlay-"]),:host([data-mode^="hover-"]),:host([data-mode^="hidden-"]) { +/******************************************** + * Positioning for absolute based menu styles + ********************************************/ + +:host([data-style='overlay']), +:host([data-style='hover']), +:host([data-style='hidden']) { position: absolute; overflow: hidden; width: calc(var(--frigate-card-menu-button-size) + 6px); height: calc(var(--frigate-card-menu-button-size) + 6px); } -:host([data-mode="overlay-top"]), -:host([data-mode="overlay-bottom"]), -:host([data-mode="hover-top"]), -:host([data-mode="hover-bottom"]), -:host([data-mode="hidden-top"][expanded]), -:host([data-mode="hidden-bottom"][expanded]) { +:host([data-style='overlay'][data-position='top']), +:host([data-style='overlay'][data-position='bottom']), +:host([data-style='hover'][data-position='top']), +:host([data-style='hover'][data-position='bottom']), +:host([data-style='hidden'][data-position='top'][expanded]), +:host([data-style='hidden'][data-position='bottom'][expanded]) { width: 100%; height: auto; overflow: visible; - background: linear-gradient(90deg, rgba(0,0,0,0.3), rgba(0,0,0,0)); + background: linear-gradient(90deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)); } -:host([data-mode="overlay-left"]), -:host([data-mode="overlay-right"]), -:host([data-mode="hover-left"]), -:host([data-mode="hover-right"]), -:host([data-mode="hidden-left"][expanded]), -:host([data-mode="hidden-right"][expanded]) { +:host([data-style='overlay'][data-position='left']), +:host([data-style='overlay'][data-position='right']), +:host([data-style='hover'][data-position='left']), +:host([data-style='hover'][data-position='right']), +:host([data-style='hidden'][data-position='left'][expanded]), +:host([data-style='hidden'][data-position='right'][expanded]) { height: 100%; width: auto; overflow: visible; - background: linear-gradient(180deg, rgba(0,0,0,0.3), rgba(0,0,0,0)); -} - -:host([data-mode="overlay-left"]) > *, -:host([data-mode="hover-left"]) > *, -:host([data-mode="hidden-left"]) > *, -:host([data-mode="overlay-right"]) > *, -:host([data-mode="hover-right"]) > *, -:host([data-mode="hidden-right"]) > * { - // See "Awful hack" above. - writing-mode: horizontal-tb; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)); } diff --git a/src/scss/message.scss b/src/scss/message.scss index 384525e5..50746fb2 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -1,7 +1,16 @@ :host { - height: 100%; + min-height: 100%; width: 100%; - display: block; + display: flex; + flex-direction: column; + justify-content: center; + + // Ensure error messages are selectable. + user-select: text; + // Safari only has prefixed support. + -webkit-user-select: text; + + color: var(--primary-text-color); } div.wrapper { @@ -13,13 +22,17 @@ div.message { justify-content: center; align-items: center; box-sizing: border-box; - padding: 10%; + padding: 20px; height: 100%; } div.message div.contents { + display: flex; + flex-direction: column; padding: 10px; - height: 100%; + margin-top: auto; + margin-bottom: auto; + min-width: 0; } div.message div.icon { @@ -37,4 +50,30 @@ div.message div.icon { .message pre { margin-top: 20px; + white-space: pre-wrap; + word-break: break-all; +} + +.dotdotdot:after { + @keyframes dots { + 0%, + 20% { + content: '.'; + } + 40% { + content: '..'; + } + 60% { + content: '...'; + } + 90%, + 100% { + content: ''; + } + } + + animation: dots 2s linear infinite; + content: ''; + display: inline-block; + width: 3em; } diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss index c031ffbb..2d9e97b0 100644 --- a/src/scss/next-previous-control.scss +++ b/src/scss/next-previous-control.scss @@ -22,7 +22,7 @@ } .controls.icons { - top: calc(50% - (40px / 2)); + top: calc(50% - (var(--frigate-card-next-prev-size) / 2)); } .controls.thumbnails { @@ -32,6 +32,7 @@ box-shadow: 0px 0px 20px 5px black; transition: all 0.2s ease-out; opacity: 0.8; + aspect-ratio: 1 / 1; } .controls.thumbnails:hover { opacity: 1 !important; diff --git a/src/scss/surround-thumbnails.scss b/src/scss/surround-thumbnails.scss new file mode 100644 index 00000000..f0f64c07 --- /dev/null +++ b/src/scss/surround-thumbnails.scss @@ -0,0 +1,5 @@ +:host { + width: 100%; + height: 100%; + display: block; +} \ No newline at end of file diff --git a/src/scss/surround.scss b/src/scss/surround.scss new file mode 100644 index 00000000..d6ff998a --- /dev/null +++ b/src/scss/surround.scss @@ -0,0 +1,21 @@ +:host { + width: 100%; + height: 100%; + + // Share the screen space with thumbnails that may be above/below. + display: flex; + flex-direction: column; + + // Set the drawer relative to this host. + position: relative; + + // Hide any content outside the main pane (e.g. side drawers) to ensure the + // user cannot scroll across to the drawers without opening them. + overflow: hidden; +} + +::slotted:not([name]) { + // Expand the main body to fill available content not otherwise used by the + // surround. + flex: 1; +} diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 22055ca5..c0774cd5 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,28 +1,34 @@ +@use 'const.scss'; + :host { - --frigate-card-carousel-thumbnail-size: 100px; - --frigate-card-carousel-thumbnail-opacity: 0.6; + display: block; + width: 100%; + height: 100%; + + --frigate-card-carousel-thumbnail-opacity: 1; +} + +:host([direction='vertical']) { + height: 100%; +} +:host([direction='horizontal']) { + // In fullscreen mode, without explicitly setting the height to auto Chrome + // will construct a stylesheet with 100% height. + height: auto; } .embla__slide { - flex: 0 0 var(--frigate-card-carousel-thumbnail-size); + flex: 0 0 auto; opacity: var(--frigate-card-carousel-thumbnail-opacity); - transition: opacity 0.6s ease, transform 0.2s linear; } .embla__slide.slide-selected { - opacity: 1.0; + opacity: 1; } -.embla__slide:hover { - transform: scale(1.04); + +frigate-card-thumbnail { + width: var(--frigate-card-thumbnail-size); + height: var(--frigate-card-thumbnail-size); +} +frigate-card-thumbnail[details] { + width: var(--frigate-card-thumbnail-details-width); } -.embla__slide img { - border-radius: 5px; - - // Not 'contain' as some thumbnails may vary in aspect-ratio slightly and - // should be clipped to fill the thumbnail div whilst maintaining - // aspect-ratio. - object-fit: cover; - - // Restrict images to a maximum of thumbnail size. - max-width: var(--frigate-card-carousel-thumbnail-size); - max-height: var(--frigate-card-carousel-thumbnail-size); -} \ No newline at end of file diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss new file mode 100644 index 00000000..f8ea64e7 --- /dev/null +++ b/src/scss/thumbnail-details.scss @@ -0,0 +1,46 @@ +:host { + display: flex; + width: 100%; + margin-left: 5px; + padding: 8px; + color: var(--primary-text-color); + overflow: hidden; + column-gap: 5%; +} + +div.right, +div.left { + display: flex; + flex-direction: column; + justify-content: center; +} +div.right { + align-items: center; +} + +div.left { + flex: 1; + + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: flex-start; + justify-content: center; + + overflow: hidden; + + column-gap: 5%; +} + +div.left div { + width: 100%; +} + +span.heading { + font-weight: bold; +} + +div.larger, +span.larger { + font-size: 1.5rem; +} diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss new file mode 100644 index 00000000..04df7733 --- /dev/null +++ b/src/scss/thumbnail-feature-event.scss @@ -0,0 +1,33 @@ +:host { + display: block; + overflow: hidden; +} + +img { + display: block; +} + +img, +ha-icon, +frigate-card-progress-indicator { + border-radius: var(--ha-card-border-radius, 4px); + + max-width: var(--frigate-card-thumbnail-size); + max-height: 100%; + aspect-ratio: 1 / 1; + + // Not 'contain' as some thumbnails may vary in aspect-ratio slightly and + // should be clipped to fill the thumbnail div whilst maintaining + // aspect-ratio. + object-fit: cover; +} + +ha-icon { + --mdc-icon-size: 50%; + color: var(--primary-text-color); + display: flex; + justify-content: center; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.3); + box-sizing: border-box; +} diff --git a/src/scss/thumbnail-feature-recording.scss b/src/scss/thumbnail-feature-recording.scss new file mode 100644 index 00000000..62907313 --- /dev/null +++ b/src/scss/thumbnail-feature-recording.scss @@ -0,0 +1,27 @@ +:host { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + aspect-ratio: 1 / 1; + overflow: hidden; + max-width: var(--frigate-card-thumbnail-size); + max-height: var(--frigate-card-thumbnail-size); + padding: 10px; + + border: 1px solid var(--secondary-color); + background-color: var(--secondary-background-color); + border-radius: var(--ha-card-border-radius, 4px); + box-sizing: border-box; + + color: var(--primary-text-color); +} + +div { + text-align: center; +} + +div.title { + font-size: 1.5rem; +} diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss new file mode 100644 index 00000000..caa64791 --- /dev/null +++ b/src/scss/thumbnail.scss @@ -0,0 +1,59 @@ +:host { + display: flex; + flex-direction: row; + box-sizing: border-box; + + // Ensure control icons are relative to the thumbnail. + position: relative; + overflow: hidden; + + transition: transform 0.2s linear; +} + +:host(:not([details])) { + aspect-ratio: 1 / 1; +} + +:host([details]) { + border: 1px solid var(--primary-color); + border-radius: var(--ha-card-border-radius, 4px); + padding: 2px; + + // When details are enabled, use a background color so that the details have + // contrast with the background. + background-color: var(--primary-background-color, black); +} + +:host(:hover) { + transform: scale(1.04); +} + +ha-icon { + position: absolute; + border-radius: 50%; + opacity: 0.5; + color: var(--primary-color); + top: 3px; + + cursor: pointer; + + transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; +} +ha-icon:hover { + opacity: 1.0; +} + +ha-icon.star { + left: 3px; +} +ha-icon.star.starred { + color: gold; +} + +ha-icon.timeline { + right: 3px; +} + +frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording { + flex: 1; +} \ No newline at end of file diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss new file mode 100644 index 00000000..f83a47e1 --- /dev/null +++ b/src/scss/timeline-core.scss @@ -0,0 +1,129 @@ +@use 'vis-timeline/dist/vis-timeline-graph2d.css'; +@use 'drawer'; +@use 'const.scss'; + +:host { + width: 100%; + height: 100%; + background-color: var(--card-background-color); + padding-bottom: 5px; + + // Share the screen space with thumbnails that may be above/below. + display: flex; + flex-direction: column; + + // So that absolute sidedrawer is relative to this host. + position: relative; +} + +frigate-card-thumbnail { + height: var(--frigate-card-thumbnail-size); + width: var(--frigate-card-thumbnail-size); +} +frigate-card-thumbnail[details] { + width: var(--frigate-card-thumbnail-details-width); +} + +div.timeline { + flex: 1; +} +div.timeline.left-margin { + // Clearance for the drawer button. + margin-left: calc(drawer.$drawer-icon-size + 1px); +} +div.timeline.right-margin { + // Clearance for the drawer button. + margin-right: calc(drawer.$drawer-icon-size + 1px); +} + +.vis-text { + color: var(--primary-text-color) !important; +} + +.vis-timeline { + border: none; +} + +.vis-labelset .vis-label { + // Group labels. + color: var(--primary-text-color); +} + +.vis-item { + border-color: var(--primary-color); + background: none; + color: var(--primary-text-color); + background-color: var(--primary-color); + + transition: background-color ease-in-out 1s, border-color ease-in-out 1s, + box-shadow ease-in-out 1s; +} +.vis-item.vis-selected { + border-color: var(--accent-color); + background-color: var(--accent-color); + box-shadow: 0px 0px 5px 1px var(--primary-color); +} +.vis-item.vis-background { + background-color: var(--primary-color); + opacity: 0.1; +} + +.vis-item:not(.vis-background) { + cursor: pointer; +} +:host([recordings]) .vis-item.vis-background, +:host([recordings]) .vis-labelset, +:host([recordings]) .vis-time-axis { + cursor: crosshair; +} +.vis-item:active { + cursor: unset; +} + +.vis-item.vis-box { + border: none; +} + +.vis-item .vis-item-content { + padding: 0px; +} + +.vis-item.vis-cluster { + border-style: dotted; + color: var(--primary-text-color); + background-color: var(--primary-background-color); + box-shadow: 0px 0px 5px 1px var(--primary-color); +} + +.vis-time-axis .vis-grid.vis-minor { + border-color: var(--secondary-color); +} + +// Give an indication that the user can interact with the axes. +.vis-text.vis-minor, +.vis-label { + transition: background-color 0.5s ease-out; +} +:host([recordings]) .vis-text.vis-minor:hover, +:host([recordings]) .vis-label:hover { + background-color: var(--primary-color); +} + +.vis-time-axis .vis-grid.vis-major { + border-color: var(--secondary-color); +} + +.vis-label { + display: flex; + justify-content: center; + align-items: center; +} + +div.vis-tooltip { + padding: 0px; + background-color: unset; + border: none; + + // Use browser default font-family for tooltips. + font-family: unset; +} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss new file mode 100644 index 00000000..f0f64c07 --- /dev/null +++ b/src/scss/timeline.scss @@ -0,0 +1,5 @@ +:host { + width: 100%; + height: 100%; + display: block; +} \ No newline at end of file diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss new file mode 100644 index 00000000..fdf6042c --- /dev/null +++ b/src/scss/viewer-carousel.scss @@ -0,0 +1,14 @@ +@use 'media-layout.scss'; + +.embla__slide { + height: 100%; + flex: 0 0 100%; +} + +.embla__slide img { + display: block; + width: 100%; + height: 100%; + + @include media-layout.media-layout(); +} diff --git a/src/scss/viewer-core.scss b/src/scss/viewer-core.scss deleted file mode 100644 index f8822b5a..00000000 --- a/src/scss/viewer-core.scss +++ /dev/null @@ -1,17 +0,0 @@ -:host { - display: flex; - flex-direction: column; - gap: 5px; - - height: 100%; - width: 100%; -} - -frigate-card-viewer-carousel { - flex: 1; - min-height: 0; -} - -frigate-card-thumbnail-carousel { - flex: 0 0 var(--frigate-card-carousel-thumbnail-size); -} \ No newline at end of file diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss index e792d174..19bc1a0b 100644 --- a/src/scss/viewer.scss +++ b/src/scss/viewer.scss @@ -1,5 +1,12 @@ :host { - height: 100%; width: 100%; - display: block; + height: 100%; + display: flex; + flex-direction: column; + gap: 5px; } + +frigate-card-viewer-carousel { + flex: 1; + min-height: 0; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 04f3fb72..9749f550 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,67 +1,94 @@ -import { StyleInfo } from 'lit/directives/style-map.js'; import { CallServiceActionConfig, + ConfirmationRestrictionConfig, CustomActionConfig, - LovelaceCard, - LovelaceCardEditor, + HomeAssistant, + LovelaceCardConfig, MoreInfoActionConfig, NavigateActionConfig, NoActionConfig, + Themes, ToggleActionConfig, UrlActionConfig, } from 'custom-card-helpers'; +import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; +import { deepRemoveDefaults } from './utils/zod.js'; -import { deepRemoveDefaults } from './zod-util'; +// The min allowed size of buttons. +export const BUTTON_SIZE_MIN = 20; -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-editor': LovelaceCardEditor; - 'hui-error-card': LovelaceCard; - } -} +// The min/max width thumbnail (Frigate returns a maximum of 175px). +export const THUMBNAIL_WIDTH_MAX = 175; +export const THUMBNAIL_WIDTH_MIN = 75; /** * Internal types. */ -const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ - 'live', // Live view. - 'clip', // Most recent clip. - 'clips', // Clips gallery. - 'snapshot', // Most recent snapshot. - 'snapshots', // Snapshots gallery. - 'image', // Static image. +export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ + 'live', + 'clip', + 'clips', + 'snapshot', + 'snapshots', + 'image', + 'timeline', ] as const; -export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; +const FRIGATE_CARD_VIEWS = [ + ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, -const FRIGATE_MENU_MODES = [ - 'none', - 'hidden-top', - 'hidden-left', - 'hidden-bottom', - 'hidden-right', - 'overlay-top', - 'overlay-left', - 'overlay-bottom', - 'overlay-right', - 'hover-top', - 'hover-left', - 'hover-bottom', - 'hover-right', - 'above', - 'below', + // Media: A generic piece of media (could be clip, snapshot, recording). + 'media', ] as const; + +export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number]; +export type FrigateCardUserSpecifiedView = + typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; +export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; + +const FRIGATE_MENU_STYLES = ['none', 'hidden', 'overlay', 'hover', 'outside'] as const; +const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; +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; export type LiveProvider = typeof LIVE_PROVIDERS[number]; -export class FrigateCardError extends Error {} +const MEDIA_ACTION_NEGATIVE_CONDITIONS = [ + 'all', + 'unselected', + 'hidden', + 'never', +] as const; +export type LazyUnloadCondition = typeof MEDIA_ACTION_NEGATIVE_CONDITIONS[number]; +export type AutoMuteCondition = typeof MEDIA_ACTION_NEGATIVE_CONDITIONS[number]; +export type AutoPauseCondition = typeof MEDIA_ACTION_NEGATIVE_CONDITIONS[number]; + +const MEDIA_ACTION_POSITIVE_CONDITIONS = [ + 'all', + 'selected', + 'visible', + 'never', +] as const; +export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; +export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; + +export class FrigateCardError extends Error { + context?: unknown; + + constructor(message: string, context?: unknown) { + super(message); + this.context = context; + } +} /** * Action Types (for "Picture Elements" / Menu) */ - // Declare schemas to existing types: // - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 const schemaForType = @@ -70,50 +97,92 @@ const schemaForType = >(arg: S) => { return arg; }; -const toggleActionSchema = schemaForType()( - z.object({ + +// https://www.home-assistant.io/dashboards/actions/#options-for-confirmation +const actionBaseSchema = z.object({ + confirmation: z + .boolean() + .or( + z.object({ + text: z.string().optional(), + exemptions: z + .object({ + user: z.string(), + }) + .array() + .optional(), + }), + ) + .optional(), +}); + +// HA accepts either a boolean or a ConfirmationRestrictionConfig object. +// `custom-card-helpers` currently only supports the latter. For maximum +// compatibility, this card supports what HA supports. +export interface ExtendedConfirmationRestrictionConfig { + confirmation?: boolean | ConfirmationRestrictionConfig; +} + +const toggleActionSchema = schemaForType< + ToggleActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ action: z.literal('toggle'), }), ); -const callServiceActionSchema = schemaForType()( - z.object({ +const callServiceActionSchema = schemaForType< + CallServiceActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ action: z.literal('call-service'), service: z.string(), service_data: z.object({}).passthrough().optional(), }), ); -const navigateActionSchema = schemaForType()( - z.object({ +const navigateActionSchema = schemaForType< + NavigateActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ action: z.literal('navigate'), navigation_path: z.string(), }), ); -const urlActionSchema = schemaForType()( - z.object({ +const urlActionSchema = schemaForType< + UrlActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ action: z.literal('url'), url_path: z.string(), }), ); -const moreInfoActionSchema = schemaForType()( - z.object({ +const moreInfoActionSchema = schemaForType< + MoreInfoActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ action: z.literal('more-info'), }), ); -const customActionSchema = schemaForType()( - z.object({ - action: z.literal('fire-dom-event'), - }), +const customActionSchema = schemaForType< + CustomActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema + .extend({ + action: z.literal('fire-dom-event'), + }) + .passthrough(), ); -const noActionSchema = schemaForType()( - z.object({ +const noActionSchema = schemaForType< + NoActionConfig & ExtendedConfirmationRestrictionConfig +>()( + actionBaseSchema.extend({ action: z.literal('none'), }), ); -const frigateCardCustomActionBaseSchema = customActionSchema.extend({ - // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. +const frigateCardCustomactionsBaseSchema = customActionSchema.extend({ action: z .literal('custom:frigate-card-action') + // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. .transform((): 'fire-dom-event' => 'fire-dom-event') .or(z.literal('fire-dom-event')), }); @@ -126,24 +195,37 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'live', 'snapshot', 'snapshots', + 'timeline', 'download', 'frigate_ui', 'fullscreen', 'menu_toggle', + 'diagnostics', +] as const; +const FRIGATE_CARD_ACTIONS = [ + ...FRIGATE_CARD_GENERAL_ACTIONS, + 'camera_select', + 'media_player', ] as const; -const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const; export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number]; -const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.extend({ +const frigateCardGeneralActionSchema = frigateCardCustomactionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), }); -const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.extend({ +const frigateCardCameraSelectActionSchema = frigateCardCustomactionsBaseSchema.extend({ frigate_card_action: z.literal('camera_select'), camera: z.string(), }); +const frigateCarMediaPlayerActionSchema = frigateCardCustomactionsBaseSchema.extend({ + frigate_card_action: z.literal('media_player'), + media_player: z.string(), + media_player_action: z.enum(['play', 'stop']), +}); + export const frigateCardCustomActionSchema = z.union([ frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, + frigateCarMediaPlayerActionSchema, ]); export type FrigateCardCustomAction = z.infer; @@ -156,11 +238,12 @@ const actionSchema = z.union([ urlActionSchema, moreInfoActionSchema, noActionSchema, + customActionSchema, frigateCardCustomActionSchema, ]); export type ActionType = z.infer; -const actionBaseSchema = z +const actionsBaseSchema = z .object({ tap_action: actionSchema.or(actionSchema.array()).optional(), hold_action: actionSchema.or(actionSchema.array()).optional(), @@ -172,17 +255,17 @@ const actionBaseSchema = z // card doesn't need these attributes, but handleAction() in // custom_card_helpers may depending on how the action is configured. .passthrough(); -export type Actions = z.infer; +export type Actions = z.infer; export type ActionsConfig = Actions & { camera_image?: string; entity?: string; }; const actionsSchema = z.object({ - actions: actionBaseSchema.optional(), + actions: actionsBaseSchema.optional(), }); -const elementsBaseSchema = actionBaseSchema.extend({ +const elementsBaseSchema = actionsBaseSchema.extend({ style: z.object({}).passthrough().optional(), title: z.string().nullable().optional(), }); @@ -247,16 +330,20 @@ const imageSchema = elementsBaseSchema.extend({ aspect_ratio: z.string().optional(), }); +// This state condition is used both for the Picture elements conditional +// schema, and also in frigateCardConditionSchema. +const stateConditions = z + .object({ + entity: z.string(), + state: z.string().optional(), + state_not: z.string().optional(), + }) + .array(); + // https://www.home-assistant.io/lovelace/picture-elements/#image-element const conditionalSchema = z.object({ type: z.literal('conditional'), - conditions: z - .object({ - entity: z.string(), - state: z.string().optional(), - state_not: z.string().optional(), - }) - .array(), + conditions: stateConditions, elements: z.lazy(() => pictureElementsSchema), }); @@ -280,8 +367,19 @@ const customSchema = z * Camera configuration section */ export const cameraConfigDefault = { - client_id: 'frigate' as const, live_provider: 'auto' as const, + frigate: { + client_id: 'frigate' as const, + }, + dependencies: { + all_cameras: false, + cameras: [], + }, + triggers: { + motion: false, + occupancy: true, + entities: [], + }, }; const webrtcCardCameraConfigSchema = z.object({ entity: z.string().optional(), @@ -289,12 +387,6 @@ const webrtcCardCameraConfigSchema = z.object({ }); const cameraConfigSchema = z .object({ - // No URL validation to allow relative URLs within HA (e.g. Frigate addon). - frigate_url: z.string().optional(), - client_id: z.string().default(cameraConfigDefault.client_id), - camera_name: z.string().optional(), - label: z.string().optional(), - zone: z.string().optional(), camera_entity: z.string().optional(), live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), @@ -307,8 +399,34 @@ const cameraConfigSchema = z // this card. id: z.string().optional(), + frigate: z + .object({ + // No URL validation to allow relative URLs within HA (e.g. Frigate addon). + url: z.string().optional(), + client_id: z.string().default(cameraConfigDefault.frigate.client_id), + camera_name: z.string().optional(), + label: z.string().optional(), + zone: z.string().optional(), + }) + .default(cameraConfigDefault.frigate), + // Camera identifiers for WebRTC. webrtc_card: webrtcCardCameraConfigSchema.optional(), + + dependencies: z + .object({ + all_cameras: z.boolean().default(cameraConfigDefault.dependencies.all_cameras), + cameras: z.string().array().default(cameraConfigDefault.dependencies.cameras), + }) + .default(cameraConfigDefault.dependencies), + + triggers: z + .object({ + motion: z.boolean().default(cameraConfigDefault.triggers.motion), + occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy), + entities: z.string().array().default(cameraConfigDefault.triggers.entities), + }) + .default(cameraConfigDefault.triggers), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -316,15 +434,29 @@ export type CameraConfig = z.infer; /** * Custom Element Types. */ +const menuBaseSchema = z.object({ + enabled: z.boolean().default(true).optional(), + priority: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(FRIGATE_MENU_PRIORITY_DEFAULT) + .optional(), + alignment: z.enum(['matching', 'opposing']).default('matching').optional(), + icon: z.string().optional(), +}); -export const menuIconSchema = iconSchema.extend({ +export const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ type: z.literal('custom:frigate-card-menu-icon'), }); export type MenuIcon = z.infer; -export const menuStateIconSchema = stateIconSchema.extend({ - type: z.literal('custom:frigate-card-menu-state-icon'), -}); +export const menuStateIconSchema = menuBaseSchema + .merge(stateIconSchema) + .extend({ + type: z.literal('custom:frigate-card-menu-state-icon'), + }) + .merge(menuBaseSchema); export type MenuStateIcon = z.infer; const menuSubmenuItemSchema = elementsBaseSchema.extend({ @@ -332,19 +464,31 @@ const menuSubmenuItemSchema = elementsBaseSchema.extend({ icon: z.string().optional(), state_color: z.boolean().default(true), selected: z.boolean().default(false), + subtitle: z.string().optional(), + enabled: z.boolean().default(true), }); export type MenuSubmenuItem = z.infer; -export const menuSubmenuSchema = iconSchema.extend({ +const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({ type: z.literal('custom:frigate-card-menu-submenu'), items: menuSubmenuItemSchema.array(), }); export type MenuSubmenu = z.infer; +const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({ + type: z.literal('custom:frigate-card-menu-submenu-select'), + options: z.record(menuSubmenuItemSchema.deepPartial()).optional(), +}); +export type MenuSubmenuSelect = z.infer; + +export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect; + const frigateCardConditionSchema = z.object({ view: z.string().array().optional(), fullscreen: z.boolean().optional(), camera: z.string().array().optional(), + mediaLoaded: z.boolean().optional(), + state: stateConditions.optional(), }); export type FrigateCardCondition = z.infer; @@ -355,13 +499,56 @@ const frigateConditionalSchema = z.object({ }); export type FrigateConditional = z.infer; +const frigateCardPTZSchema = z.preprocess( + // To avoid lots of YAML duplication, provide an easy way to just specify the + // service data as actions for each PTZ icon, and it will be preprocessed into + // the full form. This also provides compatability with the AlexIT/WebRTC PTZ + // configuration. + (data) => { + if (!data || typeof data !== 'object' || !data['service']) { + return data; + } + const out = { ...data }; + ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => { + if (`data_${name}` in data && !(`actions_${name}` in data)) { + out[`actions_${name}`] = { + tap_action: { + action: 'call-service', + service: data['service'], + service_data: data[`data_${name}`], + }, + }; + delete out[`data_${name}`]; + } + }); + return out; + }, + z.object({ + type: z.literal('custom:frigate-card-ptz'), + style: z.object({}).passthrough().optional(), + orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(), + service: z.string().optional(), + actions_left: actionsBaseSchema.optional(), + actions_right: actionsBaseSchema.optional(), + actions_up: actionsBaseSchema.optional(), + actions_down: actionsBaseSchema.optional(), + actions_zoom_in: actionsBaseSchema.optional(), + actions_zoom_out: actionsBaseSchema.optional(), + actions_home: actionsBaseSchema.optional(), + }), +); + +export type FrigateCardPTZConfig = z.infer; + // Cannot use discriminatedUnion since customSchema uses a superRefine, which // causes false rejections. const pictureElementSchema = z.union([ menuStateIconSchema, menuIconSchema, menuSubmenuSchema, + menuSubmenuSelectSchema, frigateConditionalSchema, + frigateCardPTZSchema, stateBadgeIconSchema, stateIconSchema, stateLabelSchema, @@ -376,16 +563,35 @@ export type PictureElement = z.infer; const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; +/** + * Media layout configuration section. + */ +const mediaLayoutConfigSchema = z.object({ + fit: z.enum(['contain', 'cover', 'fill']).optional(), + position: z.object({ + x: z.number().min(0).max(100).optional(), + y: z.number().min(0).max(100).optional(), + }).optional(), +}); +export type MediaLayoutConfig = z.infer; + /** * View configuration section. */ const viewConfigDefault = { - default: 'live' as const, + default: FRIGATE_CARD_VIEW_DEFAULT, camera_select: 'current' as const, timeout_seconds: 300, update_seconds: 0, update_force: false, update_cycle_camera: false, + dark_mode: 'off' as const, + scan: { + enabled: false, + show_trigger_status: true, + untrigger_seconds: 0, + untrigger_reset: true, + }, }; const viewConfigSchema = z .object({ @@ -401,6 +607,17 @@ const viewConfigSchema = z update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera), update_entities: z.string().array().optional(), render_entities: z.string().array().optional(), + dark_mode: z.enum(['on', 'off', 'auto']).optional(), + scan: z + .object({ + enabled: z.boolean().default(viewConfigDefault.scan.enabled), + show_trigger_status: z + .boolean() + .default(viewConfigDefault.scan.show_trigger_status), + untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds), + untrigger_reset: z.boolean().default(viewConfigDefault.scan.untrigger_reset), + }) + .default(viewConfigDefault.scan), }) .merge(actionsSchema) .default(viewConfigDefault); @@ -409,11 +626,7 @@ const viewConfigSchema = z * Image view configuration section. */ -export const IMAGE_MODES = [ - 'screensaver', - 'camera', - 'url', -] as const; +export const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; const imageConfigDefault = { mode: 'url' as const, refresh_seconds: 0, @@ -423,6 +636,7 @@ const imageConfigSchema = z mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), url: z.string().optional(), refresh_seconds: z.number().min(0).default(imageConfigDefault.refresh_seconds), + layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema) .default(imageConfigDefault); @@ -433,8 +647,11 @@ export type ImageViewConfig = z.infer; */ const thumbnailsControlSchema = z.object({ - mode: z.enum(['none', 'above', 'below']), - size: z.string().optional(), + mode: z.enum(['none', 'above', 'below', 'left', 'right']), + size: z.number().min(THUMBNAIL_WIDTH_MIN).max(THUMBNAIL_WIDTH_MAX).optional(), + show_details: z.boolean().optional(), + show_favorite_control: z.boolean().optional(), + show_timeline_control: z.boolean().optional(), }); export type ThumbnailsControlConfig = z.infer; @@ -444,7 +661,7 @@ export type ThumbnailsControlConfig = z.infer; const nextPreviousControlConfigSchema = z.object({ style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), - size: z.string(), + size: z.number().min(BUTTON_SIZE_MIN), }); export type NextPreviousControlConfig = z.infer; @@ -473,21 +690,28 @@ export type TitleControlConfig = z.infer; * Live view configuration section. */ const liveConfigDefault = { - auto_unmute: false, + auto_play: 'all' as const, + auto_pause: 'never' as const, + auto_mute: 'all' as const, + auto_unmute: 'never' as const, preload: false, lazy_load: true, - lazy_unload: false, + lazy_unload: 'never' as const, draggable: true, transition_effect: 'slide' as const, + show_image_during_load: true, controls: { next_previous: { - size: '48px', + size: 48, style: 'chevrons' as const, }, thumbnails: { media: 'clips' as const, - size: '100px', - mode: 'none' as const, + size: 100, + show_details: true, + show_favorite_control: true, + show_timeline_control: true, + mode: 'left' as const, }, title: { mode: 'popup-bottom-right' as const, @@ -547,6 +771,17 @@ const liveOverridableConfigSchema = z size: thumbnailsControlSchema.shape.size.default( liveConfigDefault.controls.thumbnails.size, ), + show_details: thumbnailsControlSchema.shape.show_details.default( + liveConfigDefault.controls.thumbnails.show_details, + ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + liveConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + liveConfigDefault.controls.thumbnails.show_timeline_control, + ), media: z .enum(['clips', 'snapshots']) .default(liveConfigDefault.controls.thumbnails.media), @@ -564,18 +799,37 @@ const liveOverridableConfigSchema = z .default(liveConfigDefault.controls.title), }) .default(liveConfigDefault.controls), + show_image_during_load: z + .boolean() + .default(liveConfigDefault.show_image_during_load), + layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema); const liveConfigSchema = liveOverridableConfigSchema .extend({ // Non-overrideable parameters. - auto_unmute: z.boolean().default(liveConfigDefault.auto_unmute), + auto_play: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(liveConfigDefault.auto_play), + auto_pause: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(liveConfigDefault.auto_pause), + auto_mute: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(liveConfigDefault.auto_mute), + auto_unmute: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(liveConfigDefault.auto_unmute), preload: z.boolean().default(liveConfigDefault.preload), lazy_load: z.boolean().default(liveConfigDefault.lazy_load), - lazy_unload: z.boolean().default(liveConfigDefault.lazy_unload), + lazy_unload: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(liveConfigDefault.lazy_unload), draggable: z.boolean().default(liveConfigDefault.draggable), - transition_effect: transitionEffectConfigSchema.default(liveConfigDefault.transition_effect), + transition_effect: transitionEffectConfigSchema.default( + liveConfigDefault.transition_effect, + ), }) .default(liveConfigDefault); export type LiveConfig = z.infer; @@ -583,39 +837,68 @@ export type LiveConfig = z.infer; /** * Menu configuration section. */ -const menuConfigDefault = { - mode: 'hidden-top' as const, - buttons: { - frigate: true, - cameras: true, - live: true, - clips: true, - snapshots: true, - image: false, - download: true, - frigate_ui: true, - fullscreen: true, - }, - button_size: '40px', + +const visibleButtonDefault = { + priority: FRIGATE_MENU_PRIORITY_DEFAULT, + enabled: true, }; +const hiddenButtonDefault = { + priority: FRIGATE_MENU_PRIORITY_DEFAULT, + enabled: false, +}; + +const menuConfigDefault = { + style: 'hidden' as const, + position: 'top' as const, + alignment: 'left' as const, + buttons: { + frigate: visibleButtonDefault, + cameras: visibleButtonDefault, + live: visibleButtonDefault, + clips: visibleButtonDefault, + snapshots: visibleButtonDefault, + image: hiddenButtonDefault, + timeline: visibleButtonDefault, + download: visibleButtonDefault, + frigate_ui: visibleButtonDefault, + fullscreen: visibleButtonDefault, + media_player: visibleButtonDefault, + }, + button_size: 40, +}; + +const visibleButtonSchema = menuBaseSchema.extend({ + enabled: menuBaseSchema.shape.enabled.default(visibleButtonDefault.enabled), + priority: menuBaseSchema.shape.priority.default(visibleButtonDefault.priority), +}); +const hiddenButtonSchema = menuBaseSchema.extend({ + enabled: menuBaseSchema.shape.enabled.default(hiddenButtonDefault.enabled), + priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority), +}); const menuConfigSchema = z .object({ - mode: z.enum(FRIGATE_MENU_MODES).default(menuConfigDefault.mode), + style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style), + position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position), + alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment), buttons: z .object({ - frigate: z.boolean().default(menuConfigDefault.buttons.frigate), - cameras: z.boolean().default(menuConfigDefault.buttons.cameras), - live: z.boolean().default(menuConfigDefault.buttons.live), - clips: z.boolean().default(menuConfigDefault.buttons.clips), - snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots), - image: z.boolean().default(menuConfigDefault.buttons.image), - download: z.boolean().default(menuConfigDefault.buttons.download), - frigate_ui: z.boolean().default(menuConfigDefault.buttons.frigate_ui), - fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen), + frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate), + cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), + live: visibleButtonSchema.default(menuConfigDefault.buttons.live), + clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips), + snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots), + image: hiddenButtonSchema.default(menuConfigDefault.buttons.image), + timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline), + download: visibleButtonSchema.default(menuConfigDefault.buttons.download), + frigate_ui: visibleButtonSchema.default(menuConfigDefault.buttons.frigate_ui), + fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), + media_player: visibleButtonSchema.default( + menuConfigDefault.buttons.media_player, + ), }) .default(menuConfigDefault.buttons), - button_size: z.string().default(menuConfigDefault.button_size), + button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), }) .default(menuConfigDefault); export type MenuConfig = z.infer; @@ -624,19 +907,24 @@ export type MenuConfig = z.infer; * Event viewer configuration section (clip, snapshot). */ const viewerConfigDefault = { - auto_play: true, - auto_unmute: false, + auto_play: 'all' as const, + auto_pause: 'all' as const, + auto_mute: 'all' as const, + auto_unmute: 'never' as const, lazy_load: true, draggable: true, transition_effect: 'slide' as const, controls: { next_previous: { - size: '48px', + size: 48, style: 'thumbnails' as const, }, thumbnails: { - size: '100px', - mode: 'none' as const, + size: 100, + show_details: true, + show_favorite_control: true, + show_timeline_control: true, + mode: 'left' as const, }, title: { mode: 'popup-bottom-right' as const, @@ -648,7 +936,9 @@ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.ex style: z .enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style), - size: z.string().default(viewerConfigDefault.controls.next_previous.size), + size: nextPreviousControlConfigSchema.shape.size.default( + viewerConfigDefault.controls.next_previous.size, + ), }); export type ViewerNextPreviousControlConfig = z.infer< typeof viewerNextPreviousControlConfigSchema @@ -656,11 +946,23 @@ export type ViewerNextPreviousControlConfig = z.infer< const viewerConfigSchema = z .object({ - auto_play: z.boolean().default(viewerConfigDefault.auto_play), - auto_unmute: z.boolean().default(viewerConfigDefault.auto_unmute), + auto_play: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(viewerConfigDefault.auto_play), + auto_pause: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(viewerConfigDefault.auto_pause), + auto_mute: z + .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) + .default(viewerConfigDefault.auto_mute), + auto_unmute: z + .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) + .default(viewerConfigDefault.auto_unmute), lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), draggable: z.boolean().default(viewerConfigDefault.draggable), - transition_effect: transitionEffectConfigSchema.default(viewerConfigDefault.transition_effect), + transition_effect: transitionEffectConfigSchema.default( + viewerConfigDefault.transition_effect, + ), controls: z .object({ next_previous: viewerNextPreviousControlConfigSchema.default( @@ -674,6 +976,17 @@ const viewerConfigSchema = z size: thumbnailsControlSchema.shape.size.default( viewerConfigDefault.controls.thumbnails.size, ), + show_details: thumbnailsControlSchema.shape.show_details.default( + viewerConfigDefault.controls.thumbnails.show_details, + ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + viewerConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + viewerConfigDefault.controls.thumbnails.show_timeline_control, + ), }) .default(viewerConfigDefault.controls.thumbnails), title: titleControlConfigSchema @@ -688,6 +1001,7 @@ const viewerConfigSchema = z .default(viewerConfigDefault.controls.title), }) .default(viewerConfigDefault.controls), + layout: mediaLayoutConfigSchema.optional(), }) .merge(actionsSchema) .default(viewerConfigDefault); @@ -697,12 +1011,42 @@ export type ViewerConfig = z.infer; * Event gallery configuration section (clips, snapshots). */ const galleryConfigDefault = { - min_columns: 5, + controls: { + thumbnails: { + size: 100, + show_details: false, + show_favorite_control: true, + show_timeline_control: true, + }, + }, }; const galleryConfigSchema = z .object({ - min_columns: z.number().min(1).max(10).default(galleryConfigDefault.min_columns), + controls: z + .object({ + thumbnails: thumbnailsControlSchema + // Gallery shows thumbnails "centrally" so no need for the mode. + .omit({ mode: true }) + .extend({ + size: thumbnailsControlSchema.shape.size.default( + galleryConfigDefault.controls.thumbnails.size, + ), + show_details: thumbnailsControlSchema.shape.show_details.default( + galleryConfigDefault.controls.thumbnails.show_details, + ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + galleryConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + galleryConfigDefault.controls.thumbnails.show_timeline_control, + ), + }) + .default(galleryConfigDefault.controls.thumbnails), + }) + .default(galleryConfigDefault.controls), }) .merge(actionsSchema) .default(galleryConfigDefault); @@ -734,6 +1078,73 @@ const dimensionsConfigSchema = z }) .default(dimensionsConfigDefault); +/** + * Timeline configuration section. + */ +const timelineConfigDefault = { + clustering_threshold: 3, + media: 'all' as const, + window_seconds: 60 * 60, + show_recordings: true, + controls: { + thumbnails: { + mode: 'left' as const, + size: 100, + show_details: true, + show_favorite_control: true, + show_timeline_control: true, + }, + }, +}; +const timelineConfigSchema = z + .object({ + clustering_threshold: z + .number() + .optional() + .default(timelineConfigDefault.clustering_threshold), + media: z + .enum(['all', 'clips', 'snapshots']) + .optional() + .default(timelineConfigDefault.media), + window_seconds: z + .number() + .min(1 * 60) + .max(24 * 60 * 60) + .optional() + .default(timelineConfigDefault.window_seconds), + show_recordings: z + .boolean() + .optional() + .default(timelineConfigDefault.show_recordings), + controls: z + .object({ + thumbnails: thumbnailsControlSchema + .extend({ + mode: thumbnailsControlSchema.shape.mode.default( + timelineConfigDefault.controls.thumbnails.mode, + ), + size: thumbnailsControlSchema.shape.size.default( + timelineConfigDefault.controls.thumbnails.size, + ), + show_details: thumbnailsControlSchema.shape.show_details.default( + timelineConfigDefault.controls.thumbnails.show_details, + ), + show_favorite_control: + thumbnailsControlSchema.shape.show_favorite_control.default( + timelineConfigDefault.controls.thumbnails.show_favorite_control, + ), + show_timeline_control: + thumbnailsControlSchema.shape.show_timeline_control.default( + timelineConfigDefault.controls.thumbnails.show_timeline_control, + ), + }) + .default(timelineConfigDefault.controls.thumbnails), + }) + .default(timelineConfigDefault.controls), + }) + .default(timelineConfigDefault); +export type TimelineConfig = z.infer; + /** * Configuration overrides */ @@ -743,6 +1154,8 @@ const overrideConfigurationSchema = z.object({ live: deepRemoveDefaults(liveOverridableConfigSchema).optional(), menu: deepRemoveDefaults(menuConfigSchema).optional(), image: deepRemoveDefaults(imageConfigSchema).optional(), + view: deepRemoveDefaults(viewConfigSchema).optional(), + dimensions: deepRemoveDefaults(dimensionsConfigSchema).optional(), }); export type OverrideConfigurationKey = keyof z.infer; @@ -772,11 +1185,12 @@ export const frigateCardConfigSchema = z.object({ view: viewConfigSchema, menu: menuConfigSchema, live: liveConfigSchema, - event_viewer: viewerConfigSchema, + media_viewer: viewerConfigSchema, event_gallery: galleryConfigSchema, image: imageConfigSchema, elements: pictureElementsSchema, dimensions: dimensionsConfigSchema, + timeline: timelineConfigSchema, // Configuration overrides. overrides: overridesSchema, @@ -797,49 +1211,88 @@ export const frigateCardConfigDefaults = { view: viewConfigDefault, menu: menuConfigDefault, live: liveConfigDefault, - event_viewer: viewerConfigDefault, + media_viewer: viewerConfigDefault, event_gallery: galleryConfigDefault, image: imageConfigDefault, + timeline: timelineConfigDefault, }; -const menuButtonSchema = z.discriminatedUnion("type", [ +const menuButtonSchema = z.discriminatedUnion('type', [ menuIconSchema, menuStateIconSchema, menuSubmenuSchema, + menuSubmenuSelectSchema, ]); export type MenuButton = z.infer; -export interface ExtendedHomeAssistant { +export interface ExtendedHomeAssistant extends HomeAssistant { hassUrl(path?): string; + themes: Themes & { + darkMode?: boolean; + }; } export interface BrowseMediaQueryParameters { - mediaType: 'clips' | 'snapshots'; + // ======================================== + // Parameters used to construct media query + // ======================================== + mediaType?: 'clips' | 'snapshots'; clientId: string; cameraName: string; label?: string; zone?: string; before?: number; after?: number; + unlimited?: boolean; + + // ======================================== + // Parameters used to differentiate results + // ======================================== + // Optional title to be used for separating results when merging multiple + // sets of results. See `mergeFrigateBrowseMediaSources()` . + title?: string; + + // Optional camera-id to which this query is associated. May be used to map + // results to a particular camera within the card. + cameraID?: string; +} + +export interface BrowseRecordingQueryParameters { + clientId: string; + cameraName: string; + year: number; + month: number; + day: number; + hour: number; } export interface BrowseMediaNeighbors { - previous: BrowseMediaSource | null; + previous: FrigateBrowseMediaSource | null; previousIndex: number | null; - next: BrowseMediaSource | null; + next: FrigateBrowseMediaSource | null; nextIndex: number | null; } -export interface MediaShowInfo { +export interface MediaLoadedInfo { width: number; height: number; } +export const MESSAGE_TYPE_PRIORITIES = { + info: 10, + error: 20, + connection: 30, + diagnostics: 40, +}; + +export type MessageType = 'info' | 'error' | 'connection' | 'diagnostics'; + export interface Message { message: string; - type: 'error' | 'info'; + type: MessageType; icon?: string; context?: unknown; + dotdotdot?: boolean; } export interface StateParameters { @@ -857,17 +1310,32 @@ export interface FrigateCardMediaPlayer { pause(): void; mute(): void; unmute(): void; + seek(seconds: number): void; +} + +export interface CardHelpers { + createCardElement(config: LovelaceCardConfig): Promise<{ + constructor: { + getConfigElement(): HTMLElement; + }; + }>; } /** * Home Assistant API types. */ +export const MEDIA_CLASS_PLAYLIST = 'playlist' as const; +export const MEDIA_CLASS_VIDEO = 'video' as const; +export const MEDIA_TYPE_PLAYLIST = 'playlist' as const; +export const MEDIA_TYPE_IMAGE = 'image' as const; +export const MEDIA_TYPE_VIDEO = 'video' as const; + // Recursive type, cannot use type interference: // See: https://github.com/colinhacks/zod#recursive-types // // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L46 -export interface BrowseMediaSource { +interface BrowseMediaSource { title: string; media_class: string; media_content_type: string; @@ -879,18 +1347,71 @@ export interface BrowseMediaSource { children?: BrowseMediaSource[] | null; } -export const browseMediaSourceSchema: z.ZodSchema = z.lazy(() => - z.object({ - title: z.string(), - media_class: z.string(), - media_content_type: z.string(), - media_content_id: z.string(), - can_play: z.boolean(), - can_expand: z.boolean(), - children_media_class: z.string().nullable().optional(), - thumbnail: z.string().nullable(), - children: z.array(browseMediaSourceSchema).nullable().optional(), - }), +export interface FrigateEvent { + camera: string; + end_time?: number; + false_positive: boolean; + has_clip: boolean; + has_snapshot: boolean; + id: string; + label: string; + start_time: number; + top_score: number; + zones: string[]; + retain_indefinitely?: boolean; +} + +export interface FrigateRecording { + camera: string; + start_time: number; + end_time: number; + events: number; + + // Specifies the point at which this recording should be played, the + // seek_time is the date of the desired play point, and seek_seconds is the + // number of seconds to seek to reach that point. + seek_time?: number; + seek_seconds?: number; +} + +export interface FrigateBrowseMediaSource extends BrowseMediaSource { + children?: FrigateBrowseMediaSource[] | null; + frigate?: { + event?: FrigateEvent; + recording?: FrigateRecording; + }; +} + +export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy( + () => + z.object({ + title: z.string(), + media_class: z.string(), + media_content_type: z.string(), + media_content_id: z.string(), + can_play: z.boolean(), + can_expand: z.boolean(), + children_media_class: z.string().nullable().optional(), + thumbnail: z.string().nullable(), + children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), + frigate: z + .object({ + event: z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean().nullable(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + retain_indefinitely: z.boolean().optional(), + }), + }) + .optional(), + }), ); // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py @@ -906,8 +1427,18 @@ export const signedPathSchema = z.object({ export type SignedPath = z.infer; export const entitySchema = z.object({ + config_entry_id: z.string().nullable(), + disabled_by: z.string().nullable(), entity_id: z.string(), - unique_id: z.string(), platform: z.string(), }); export type Entity = z.infer; + +export const extendedEntitySchema = entitySchema.extend({ + // Extended entity results. + unique_id: z.string().optional(), +}); +export type ExtendedEntity = z.infer; + +export const entityListSchema = entitySchema.array(); +export type EntityList = z.infer; diff --git a/src/utils/action.ts b/src/utils/action.ts new file mode 100644 index 00000000..e7b6d5f5 --- /dev/null +++ b/src/utils/action.ts @@ -0,0 +1,185 @@ +import { + ActionConfig, + handleActionConfig, + hasAction, + HomeAssistant +} from 'custom-card-helpers'; +import { + Actions, + ActionsConfig, + ActionType, + FrigateCardAction, + FrigateCardCustomAction, + frigateCardCustomActionSchema +} from '../types.js'; + +/** + * Convert a generic Action to a FrigateCardCustomAction if it parses correctly. + * @param action The generic action configuration. + * @returns A FrigateCardCustomAction or null if it cannot be converted. + */ +export function convertActionToFrigateCardCustomAction( + action: ActionType | null, +): FrigateCardCustomAction | null { + if (!action) { + return null; + } + // Parse a custom event as other things could generate ll-custom events that + // are not related to Frigate Card. + const parseResult = frigateCardCustomActionSchema.safeParse(action); + return parseResult.success ? parseResult.data : null; +} + +/** + * Create a Frigate card custom action. + * @param action The Frigate card action string (e.g. 'fullscreen') + * @returns A FrigateCardCustomAction for that action string or null. + */ +export function createFrigateCardCustomAction( + action: FrigateCardAction, + args?: { + camera?: string; + media_player?: string; + media_player_action?: 'play' | 'stop'; + }, +): FrigateCardCustomAction | null { + if (action === 'camera_select') { + if (!args?.camera) { + return null; + } + return { + action: 'fire-dom-event', + frigate_card_action: action, + camera: args.camera as string, + }; + } + if (action === 'media_player') { + if (!args?.media_player || !args.media_player_action) { + return null; + } + return { + action: 'fire-dom-event', + frigate_card_action: action, + media_player: args.media_player, + media_player_action: args.media_player_action, + }; + } + return { + action: 'fire-dom-event', + frigate_card_action: action, + }; +} + +/** + * Get an action configuration given a config and an interaction (e.g. 'tap'). + * @param interaction The interaction: `tap`, `hold` or `double_tap` + * @param config The configuration containing multiple actions. + * @returns The relevant action configuration or null if none found. + */ +export function getActionConfigGivenAction( + interaction?: string, + config?: Actions, +): ActionType | ActionType[] | undefined { + if (!interaction || !config) { + return undefined; + } + if (interaction == 'tap' && config.tap_action) { + return config.tap_action; + } else if (interaction == 'hold' && config.hold_action) { + return config.hold_action; + } else if (interaction == 'double_tap' && config.double_tap_action) { + return config.double_tap_action; + } else if (interaction == 'end_tap' && config.end_tap_action) { + return config.end_tap_action; + } else if (interaction == 'start_tap' && config.start_tap_action) { + return config.start_tap_action; + } + return undefined; +} + +/** + * Frigate card custom version of handleAction + * (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts) + * that handles the custom action events the card supports. + * @param node The node that fired the event. + * @param hass The Home Assistant object. + * @param config The multi-action configuration. + * @param action The action string (e.g. 'hold') + * @returns Whether or not an action was executed. + */ +export const frigateCardHandleAction = ( + node: HTMLElement, + hass: HomeAssistant, + config: ActionsConfig, + action: string, +): boolean => { + return frigateCardHandleActionConfig( + node, + hass, + config, + action, + getActionConfigGivenAction(action, config), + ); +}; + +/** + * Handle an ActionConfig or array of ActionConfigs. + * @param node The node that fired the event. + * @param hass The Home Assistant object. + * @param actionConfig A single action config, array of action configs or + * undefined for the default action config for 'tap'. + * @param action The action string (e.g. 'hold') + * @returns Whether or not an action was executed. + */ +export const frigateCardHandleActionConfig = ( + node: HTMLElement, + hass: HomeAssistant, + config: { + camera_image?: string; + entity?: string; + }, + action: string, + actionConfig: ActionType | ActionType[] | undefined, +): boolean => { + // Only allow a tap action to use a default non-config (the more-info config). + if (actionConfig || action == 'tap') { + // ActionConfig vs ActionType: + // There is a slight typing (but not functional) difference between + // ActionType in this card and ActionConfig in `custom-card-helpers`. See + // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and + // reason behind this difference. + if (Array.isArray(actionConfig)) { + actionConfig.forEach((action) => + handleActionConfig(node, hass, config, action as ActionConfig | undefined), + ); + } else { + handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined); + } + return true; + } + return false; +}; + +/** + * Determine if an action config has a real action. A modified version of + * custom-card-helpers hasAction to also work with arrays of action configs. + * @param config The action config in question. + * @returns `true` if there's a real action defined, `false` otherwise. + */ +export const frigateCardHasAction = ( + config?: ActionType | ActionType[] | undefined, +): boolean => { + // See note above on 'ActionConfig vs ActionType' for why this cast is + // necessary and harmless. + if (Array.isArray(config)) { + return !!config.find((item) => hasAction(item as ActionConfig | undefined)); + } + return hasAction(config as ActionConfig | undefined); +}; + +/** + * Stop an event from activating card wide actions. + */ +export const stopEventFromActivatingCardWideActions = (ev: Event): void => { + ev.stopPropagation(); +}; diff --git a/src/utils/basic.ts b/src/utils/basic.ts new file mode 100644 index 00000000..31504bbc --- /dev/null +++ b/src/utils/basic.ts @@ -0,0 +1,87 @@ +import { isEqual } from 'lodash-es'; +import { FrigateCardError } from '../types'; + +/** + * Dispatch a Frigate Card event. + * @param element The element to send the event. + * @param name The name of the Frigate card event to send. + * @param detail An optional detail object to attach. + */ +export function dispatchFrigateCardEvent( + target: EventTarget, + name: string, + detail?: T, +): void { + target.dispatchEvent( + new CustomEvent(`frigate-card:${name}`, { + bubbles: true, + composed: true, + detail: detail, + }), + ); +} + +/** + * Prettify a title by converting '_' to spaces and capitalizing words. + * @param input The input Frigate (camera/label/zone) name. + * @returns A prettified name. + */ +export function prettifyTitle(input?: string): string | undefined { + if (!input) { + return undefined; + } + const words = input.split(/[_\s]+/); + return words + .map((word) => { + return word[0].toUpperCase() + word.substring(1); + }) + .join(' '); +} + +/** + * Move an element within an array. + * @param target Target array. + * @param from From index. + * @param to To index. + */ +export function arrayMove(target: unknown[], from: number, to: number): void { + const element = target[from]; + target.splice(from, 1); + target.splice(to, 0, element); +} + +/** + * Determine if the contents of the n(ew) and o(ld) values have changed. For use + * in lit web components that may have a value that changes address but not + * contents -- and for which a re-render is expensive/jarring. + * @param n The new value. + * @param o The old value. + * @returns `true` is the contents have changed. + */ +export function contentsChanged(n: unknown, o: unknown): boolean { + return !isEqual(n, o); +} + +/** + * Log an error as a warning to the console. + * @param e The Error object. + * @param func The Console func to call. + */ +export function errorToConsole(e: Error, func?: CallableFunction): void { + if (!func) { + func = console.warn; + } + if (e instanceof FrigateCardError && e.context) { + func(e, e.context); + } else { + func(e); + } +} + +/** + * Determine if the device supports hovering. + * @returns `true` if the device supports hovering, `false` otherwise. + */ +export const isHoverableDevice = (): boolean => window.matchMedia( + '(hover: hover) and (pointer: fine)', +).matches; diff --git a/src/utils/camera.ts b/src/utils/camera.ts new file mode 100644 index 00000000..81c5d1ec --- /dev/null +++ b/src/utils/camera.ts @@ -0,0 +1,74 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig, RawFrigateCardConfig } from '../types.js'; +import { prettifyTitle } from './basic.js'; +import { getEntityIcon, getEntityTitle } from './ha'; + +/** + * Get a camera id. + * @param config The camera config (either parsed or raw). + * @returns A camera id. + */ +export function getCameraID( + config?: CameraConfig | RawFrigateCardConfig | null, +): string { + return ( + (typeof config?.id === 'string' && config.id) || + (typeof config?.camera_entity === 'string' && config.camera_entity) || + (typeof config?.webrtc_card === 'object' && + config.webrtc_card && + typeof config.webrtc_card['entity'] === 'string' && + config.webrtc_card['entity']) || + (typeof config?.frigate === 'object' && + config.frigate && + typeof config?.frigate['camera_name'] === 'string' && + config.frigate['camera_name']) || + '' + ); +} + +/** + * Get a camera text title. + * @param hass The Home Assistant object. + * @param config The camera config (either parsed or raw). + * @returns A title string. + */ +export function getCameraTitle( + hass?: HomeAssistant, + config?: CameraConfig | RawFrigateCardConfig | null, +): string { + // Attempt to render a recognizable name for the camera, + // starting with the most likely to be useful and working our + // ways towards the least useful. Extra type checking here since this is also + // used on raw configuration in the editor. + return ( + (typeof config?.title === 'string' && config.title) || + (typeof config?.camera_entity === 'string' + ? getEntityTitle(hass, config.camera_entity) + : '') || + (typeof config?.webrtc_card === 'object' && + config.webrtc_card && + typeof config.webrtc_card['entity'] === 'string' && + config.webrtc_card['entity']) || + (typeof config?.frigate === 'object' && + config.frigate && + typeof config?.frigate['camera_name'] === 'string' && + config.frigate['camera_name'] + ? prettifyTitle(config.frigate['camera_name']) + : '') || + (typeof config?.id === 'string' && config.id) || + '' + ); +} + +/** + * Get a camera icon. + * @param hass The Home Assistant object. + * @param config The camera config. + * @returns An icon string. + */ +export function getCameraIcon( + hass?: HomeAssistant, + config?: CameraConfig | null, +): string { + return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video'; +} diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts new file mode 100644 index 00000000..02953681 --- /dev/null +++ b/src/utils/frigate.ts @@ -0,0 +1,170 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { z } from 'zod'; +import { localize } from '../localize/localize'; +import { CameraConfig, ExtendedHomeAssistant, FrigateCardError } from '../types'; +import { homeAssistantWSRequest } from './ha'; + +export const FRIGATE_ICON_SVG_PATH = + 'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' + + '-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' + + '-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' + + '5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' + + '1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' + + '-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' + + '-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' + + '-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' + + '-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' + + '-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' + + '0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' + + '0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' + + '0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' + + '0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' + + '-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' + + 'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' + + '-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' + + '-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' + + '-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z'; + +const recordingSummaryHourSchema = z.object({ + hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), + duration: z.number().min(0), + events: z.number().min(0), +}); + +const recordingSummarySchema = z + .object({ + day: z.preprocess((arg) => { + // Must provide the hour:minute:second on parsing or Javascript will + // assume UTC midnight. + return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg; + }, z.date()), + events: z.number(), + hours: recordingSummaryHourSchema.array(), + }) + .array(); +export type RecordingSummary = z.infer; + +const recordingSegmentSchema = z.object({ + start_time: z.number(), + end_time: z.number(), + id: z.string(), +}); +const recordingSegmentsSchema = recordingSegmentSchema.array(); +export type RecordingSegments = z.infer; + +const retainResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type RetainResult = z.infer; + +/** + * Get the recordings summary. May throw. + * @param hass The Home Assistant object. + * @param client_id The Frigate client_id. + * @param camera_name The Frigate camera name. + * @returns A RecordingSummary object. + */ +export const getRecordingsSummary = async ( + hass: ExtendedHomeAssistant, + client_id: string, + camera_name: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSummarySchema, + { + type: "frigate/recordings/summary", + instance_id: client_id, + camera: camera_name + }, + true, + ); +}; + +/** + * Get the recording segments. May throw. + * @param hass The Home Assistant object. + * @param client_id The Frigate client_id. + * @param camera_name The Frigate camera name. + * @param before The segment low watermark. + * @param after The segment high watermark. + * @returns A RecordingSegments object. + */ +export const getRecordingSegments = async ( + hass: ExtendedHomeAssistant, + client_id: string, + camera_name: string, + before: Date, + after: Date, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSegmentsSchema, + { + type: "frigate/recordings/get", + instance_id: client_id, + camera: camera_name, + before: Math.floor(before.getTime() / 1000), + after: Math.ceil(after.getTime() / 1000), + }, + true, + ); +}; + +/** + * Request that Frigate retain an event. May throw. + * @param hass The HomeAssistant object. + * @param client_id The Frigate client_id. + * @param eventID The event ID to retain. + * @param retain `true` to retain or `false` to unretain. + */ +export async function retainEvent( + hass: HomeAssistant, + client_id: string, + eventID: string, + retain: boolean, +): Promise { + const retainRequest = { + type: 'frigate/event/retain', + instance_id: client_id, + event_id: eventID, + retain: retain, + }; + const response = await homeAssistantWSRequest( + hass, + retainResultSchema, + retainRequest, + true, + ); + if (!response.success) { + throw new FrigateCardError(localize('error.failed_retain'), { + request: retainRequest, + response: response, + }); + } +} + +/** + * Get an id that unique identifies a particular camera (not zone, object, etc) + * within a particular Frigate instance. ID will not (necessarily) be unique + * within the card. + * @param cameraConfig The camera config. + */ +export const getUniqueFrigateCameraID = (config: CameraConfig): string => { + return [config.frigate.client_id, config.frigate.camera_name].join('/'); +}; + +/** + * Get an id that unique identifies a source of Frigate events. ID will not + * (necessarily) be unique within the card. + * @param cameraConfig The camera config. + */ +export const getUniqueFrigateCameraEventsID = (config: CameraConfig): string => { + return [ + config.frigate.client_id, + config.frigate.camera_name, + config.frigate.label, + config.frigate.zone, + ].join('/'); +}; diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts new file mode 100644 index 00000000..dc411df7 --- /dev/null +++ b/src/utils/ha/browse-media.ts @@ -0,0 +1,503 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { + differenceInHours, + differenceInMinutes, + differenceInSeconds, + fromUnixTime, +} from 'date-fns'; +import { homeAssistantWSRequest } from '.'; +import { + dispatchErrorMessageEvent, + dispatchFrigateCardErrorEvent, + dispatchMessageEvent, +} from '../../components/message.js'; +import { localize } from '../../localize/localize.js'; +import { + BrowseMediaQueryParameters, + BrowseRecordingQueryParameters, + CameraConfig, + FrigateBrowseMediaSource, + frigateBrowseMediaSourceSchema, + FrigateCardError, + FrigateEvent, + FrigateRecording, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_VIDEO, +} from '../../types.js'; +import { View } from '../../view.js'; +import { getCameraTitle } from '../camera.js'; + +/** + * Return the Frigate event_id given a FrigateBrowseMediaSource object. + * @param media The event to get the id from. + * @returns The `event_id` or `null` if not successfully parsed. + */ +export const getEventID = (media: FrigateBrowseMediaSource): string | null => { + return media.frigate?.event?.id ?? null; +}; + +/** + * Return the event start time given a FrigateBrowseMediaSource object. + * @param browseMedia The media object to get the start time from. + * @returns The start time in unix/epoch time, or null if it cannot be determined. + */ +export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => { + return media.frigate?.event?.start_time ?? null; +}; + +/** + * Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder). + * @param media The media object. + * @returns `true` if it's truly a media item, `false` otherwise. + */ +export const isTrueMedia = (media?: FrigateBrowseMediaSource): boolean => { + return !!media && !media.can_expand; +}; + +/** + * From a FrigateBrowseMediaSource item extract the first true media item from the + * children (i.e. a clip/snapshot, not a folder). + * @param media The media object with children. + * @returns The first true media item found. + */ +export const getFirstTrueMediaChildIndex = ( + media: FrigateBrowseMediaSource | null, +): number | null => { + if (!media || !media.children) { + return null; + } + const index = media.children.findIndex((child) => isTrueMedia(child)); + return index >= 0 ? index : null; +}; + +/** + * Browse Frigate media with a media content id. May throw. + * @param hass The HomeAssistant object. + * @param media_content_id The media content id to browse. + * @returns A FrigateBrowseMediaSource object or null on malformed. + */ +export const browseMedia = async ( + hass: HomeAssistant, + media_content_id: string, +): Promise => { + const request = { + type: 'media_source/browse_media', + media_content_id: media_content_id, + }; + return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); +}; + +/** + * Browse Frigate media with a media query. May throw. + * @param hass The HomeAssistant object. + * @param params The search parameters to use to search for media. + * @returns A FrigateBrowseMediaSource object or null on malformed. + */ +export const browseMediaQuery = async ( + hass: HomeAssistant, + params: BrowseMediaQueryParameters, +): Promise => { + return browseMedia( + hass, + // Defined in: + // https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py + [ + 'media-source://frigate', + params.clientId, + 'event-search', + params.mediaType, + + // If the name field ends in '.all' the integration will return up to 10K events. + params.unlimited ? '.all' : '', + params.after ? String(Math.floor(params.after)) : '', + params.before ? String(Math.ceil(params.before)) : '', + params.cameraName, + params.label, + params.zone, + ].join('/'), + ); +}; + +/** + * Browse multiple Frigate media queries. May throw. + * @param hass The HomeAssistant object. + * @param params An array of search parameters to use to search for media. + * @returns A map of FrigateBrowseMediaSource object or null on malformed. + */ +export const multipleBrowseMediaQuery = async ( + hass: HomeAssistant, + params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], +): Promise> => { + params = Array.isArray(params) ? params : [params]; + const output: Map = new Map(); + await Promise.all( + params.map(async (param: BrowseMediaQueryParameters): Promise => { + output.set(param, await browseMediaQuery(hass, param)); + }), + ); + return output; +}; + +/** + * Browse multiple Frigate media queries, then merged them. May throw. + * @param hass The HomeAssistant object. + * @param params An array of search parameters to use to search for media. + * @returns A single FrigateBrowseMediaSource object or null on malformed. + */ +export const multipleBrowseMediaQueryMerged = async ( + hass: HomeAssistant, + params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], +): Promise => { + return mergeFrigateBrowseMediaSources(await multipleBrowseMediaQuery(hass, params)); +}; + +/** + * Merge multiple FrigateBrowseMediaSource into a single. Note that this may + * use information from the query to differentiate results that may otherwise + * be identical. + * @param input A map of query -> result. + * @returns A single FrigateBrowseMediaSource object. + */ +export const mergeFrigateBrowseMediaSources = async ( + input: Map, +): Promise => { + const children: FrigateBrowseMediaSource[] = []; + + for (const [query, result] of input.entries()) { + for (const child of result.children || []) { + if (isTrueMedia(child)) { + children.push(child); + } else { + // If there are multiple inputs, separate the folder names with the + // query title (if available). + if (query.title && input.size > 1) { + children.push({ ...child, title: `[${query.title}] ${child.title}` }); + } else { + children.push(child); + } + } + } + } + + const eventSort = ( + a: FrigateBrowseMediaSource, + b: FrigateBrowseMediaSource, + ): number => { + if ( + !a.frigate?.event || + (b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time) + ) { + return 1; + } + + if ( + !b.frigate?.event || + (a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time) + ) { + return -1; + } + return 0; + }; + + return createEventParentForChildren('Merged events', children.sort(eventSort)); +}; + +/** + * Get the parameters to search for media. + * @returns A BrowseMediaQueryParameters object. + */ +export const getBrowseMediaQueryParameters = ( + hass: HomeAssistant, + cameraID: string, + cameraConfig?: CameraConfig, + overrides?: Partial, +): BrowseMediaQueryParameters | null => { + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return null; + } + return { + clientId: cameraConfig.frigate.client_id, + cameraName: cameraConfig.frigate.camera_name, + label: cameraConfig.frigate.label, + zone: cameraConfig.frigate.zone, + title: getCameraTitle(hass, cameraConfig), + cameraID: cameraID, + ...overrides, + }; +}; + +/** + * Apply overrides to multiple query parameters. + * @param parameters An array of query parameters. + * @param overrides The overrides to apply. + * @returns The override query parameters. + */ +export const overrideMultiBrowseMediaQueryParameters = ( + parameters: BrowseMediaQueryParameters[], + overrides: Partial, +): BrowseMediaQueryParameters[] => { + const output: BrowseMediaQueryParameters[] = []; + parameters.forEach((param) => { + output.push({ ...param, ...overrides }); + }); + return output; +}; + +/** + * Get BrowseMediaQueryParameters for a camera (including its dependencies). + * @param hass Home Assistant object. + * @param cameras Cameras map. + * @param camera Name of the current camera. + * @param mediaType Optional media type to include in the parameters. + * @returns An array of query parameters. + */ +export const getFullDependentBrowseMediaQueryParameters = ( + hass: HomeAssistant, + cameras: Map, + camera: string, + mediaType?: 'clips' | 'snapshots', +): BrowseMediaQueryParameters[] | null => { + const cameraIDs: Set = new Set(); + const getDependentCameras = (camera: string): void => { + const cameraConfig = cameras.get(camera); + if (cameraConfig) { + cameraIDs.add(camera); + const dependentCameras: Set = new Set(); + (cameraConfig.dependencies.cameras || []).forEach((item) => + dependentCameras.add(item), + ); + if (cameraConfig.dependencies.all_cameras) { + cameras.forEach((_, key) => dependentCameras.add(key)); + } + for (const eventCameraID of dependentCameras) { + if (!cameraIDs.has(eventCameraID)) { + getDependentCameras(eventCameraID); + } + } + } + }; + getDependentCameras(camera); + + const params: BrowseMediaQueryParameters[] = []; + for (const cameraID of cameraIDs) { + const param = getBrowseMediaQueryParameters( + hass, + cameraID, + cameras.get(cameraID), + mediaType ? { mediaType: mediaType } : {}, + ); + if (param) { + params.push(param); + } + } + return params.length ? params : null; +}; + +/** + * Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error. + * @param element The element from which to dispatch the error. + * @param hass Home Assistant object. + * @param cameras Cameras map. + * @param camera Name of the current camera. + * @param mediaType Optional media type to include in the parameters. + * @returns An array of query parameters. + */ +export const getFullDependentBrowseMediaQueryParametersOrDispatchError = ( + element: HTMLElement, + hass: HomeAssistant, + cameras: Map, + camera: string, + mediaType?: 'clips' | 'snapshots', +): BrowseMediaQueryParameters[] | null => { + const params = getFullDependentBrowseMediaQueryParameters( + hass, + cameras, + camera, + mediaType, + ); + if (!params) { + dispatchErrorMessageEvent(element, localize('error.no_camera_name'), { + context: cameras.get(camera), + }); + return null; + } + return params; +}; + +/** + * Fetch the latest media and dispatch a change view event to reflect the + * results. If no media is found a suitable message event will be triggered + * instead. + * @param element The HTMLElement to dispatch events from. + * @param hass The Home Assistant object. + * @param view The current view to evolve. + * @param browseMediaQueryParameters The media parameters to query with. + * @returns + */ +export const fetchLatestMediaAndDispatchViewChange = async ( + element: HTMLElement, + hass: HomeAssistant, + view: Readonly, + browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[], +): Promise => { + let parent: FrigateBrowseMediaSource | null; + try { + parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters); + } catch (e) { + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); + } + const childIndex = getFirstTrueMediaChildIndex(parent); + if (!parent || !parent.children || childIndex == null) { + return dispatchMessageEvent( + element, + view.isClipRelatedView() + ? localize('common.no_clip') + : localize('common.no_snapshot'), + 'info', + { + icon: view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off', + }, + ); + } + + view + .evolve({ + target: parent, + childIndex: childIndex, + }) + .dispatchChangeEvent(element); +}; + +/** + * Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change + * view event to reflect the results. + * @param node The HTMLElement to dispatch events from. + * @param hass The Home Assistant object. + * @param view The current view to evolve. + * @param child The FrigateBrowseMediaSource child to query for. + * @returns + */ +export const fetchChildMediaAndDispatchViewChange = async ( + element: HTMLElement, + hass: HomeAssistant, + view: Readonly, + child: Readonly, +): Promise => { + let parent: FrigateBrowseMediaSource; + try { + parent = await browseMedia(hass, child.media_content_id); + } catch (e) { + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); + } + + view + .evolve({ + target: parent, + }) + .dispatchChangeEvent(element); +}; + +/** + * Given an array of media children, create a parent for them. + * @param title The title to use for the parent. + * @param children The children media items. + * @returns A single parent containing the children. + */ +export const createEventParentForChildren = ( + title: string, + children: FrigateBrowseMediaSource[], +): FrigateBrowseMediaSource => { + return { + title: title, + media_class: MEDIA_CLASS_PLAYLIST, + media_content_type: MEDIA_TYPE_PLAYLIST, + media_content_id: '', + can_play: false, + can_expand: true, + children_media_class: MEDIA_CLASS_PLAYLIST, + thumbnail: null, + children: children, + }; +}; + +/** + * Given a media video child with a given media_content_id. + * @param title The title to use for the child. + * @param media_con + * @param children The children media items. + * @returns A single parent containing the children. + */ +export const createVideoChild = ( + title: string, + mediaContentID: string, + options?: { + thumbnail?: string; + recording?: FrigateRecording; + }, +): FrigateBrowseMediaSource => { + return { + title: title, + media_class: MEDIA_CLASS_VIDEO, + media_content_type: MEDIA_TYPE_VIDEO, + media_content_id: mediaContentID, + can_play: true, + can_expand: false, + thumbnail: options?.thumbnail ?? null, + children: null, + ...(options?.recording && { + frigate: { + recording: options.recording, + }, + }), + }; +}; + +/** + * Convenience function to convert a timestamp to hours, minutes and seconds + * string. Heavily inspired by, and returning the same format as, the Frigate + * UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97 + * @param event The Frigate event. + * @returns A duration string. + */ +export function getEventDurationString(event: FrigateEvent): string { + if (!event.end_time) { + return localize('event.in_progress'); + } + const start = fromUnixTime(event.start_time); + const end = fromUnixTime(event.end_time); + const hours = differenceInHours(end, start); + const minutes = differenceInMinutes(end, start) - hours * 60; + const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; + let duration = ''; + + if (hours) { + duration += `${hours}h `; + } + if (minutes) { + duration += `${minutes}m `; + } + duration += `${seconds}s`; + return duration; +} + +/** + * Generate a recording identifier. + * @param hass The HomeAssistant object. + * @param params The recording parameters to use in the identifer. + * @returns A recording identifier. + */ +export const generateRecordingIdentifier = ( + params: BrowseRecordingQueryParameters, +): string => { + return [ + 'media-source://frigate', + params.clientId, + 'recordings', + `${params.year}-${String(params.month).padStart(2, '0')}`, + String(params.day).padStart(2, '0'), + String(params.hour).padStart(2, '0'), + params.cameraName, + ].join('/'); +}; diff --git a/src/utils/ha/device-registry.ts b/src/utils/ha/device-registry.ts new file mode 100644 index 00000000..84278eda --- /dev/null +++ b/src/utils/ha/device-registry.ts @@ -0,0 +1,22 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { z } from 'zod'; +import { homeAssistantWSRequest } from '.'; + +const deviceSchema = z.object({ + model: z.string().nullable(), + config_entries: z.string().array(), + manufacturer: z.string().nullable(), +}) +export const deviceListSchema = deviceSchema.array(); +export type DeviceList = z.infer; + +/** + * Get a list of all entities from the entity registry. May throw. + * @param hass The Home Assistant object. + * @returns An entity list object. + */ +export const getAllDevices = async (hass: HomeAssistant): Promise => { + return await homeAssistantWSRequest(hass, deviceListSchema, { + type: 'config/device_registry/list', + }); +}; diff --git a/src/utils/ha/entity-registry.ts b/src/utils/ha/entity-registry.ts new file mode 100644 index 00000000..e77f5edf --- /dev/null +++ b/src/utils/ha/entity-registry.ts @@ -0,0 +1,109 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { homeAssistantWSRequest } from '.'; +import { + Entity, + EntityList, + entityListSchema, + ExtendedEntity, + extendedEntitySchema, +} from '../../types.js'; + +export class ExtendedEntityCache { + protected _cache: Map = new Map(); + + /** + * Determine if the cache has a given entity_id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ + public has(id: string): boolean { + return this._cache.has(id); + } + + /** + * Get the first value that returns true for the given predicate. + * @param func A callback function that returns a boolean. + * @returns The first matching value. + */ + public getMatch(func: (arg: ExtendedEntity) => boolean): ExtendedEntity | null { + return [...this._cache.values()].find(func) ?? null; + } + + /** + * Get entity information given an id. + * @param id The entity id. + * @returns The `ExtendedEntity` for this id. + */ + public get(id: string): ExtendedEntity | undefined { + return this._cache.get(id); + } + + /** + * Add a given ExtendedEntity to the cache. + * @param extendedEntity + */ + public set(extendedEntity: ExtendedEntity): void { + this._cache.set(extendedEntity.entity_id, extendedEntity); + } +} + +/** + * Get the extended entity information for an entity. May throw. + * @param hass The Home Assistant object. + * @param entity The entity id. + * @param cache An optional ExtendedEntityCache. + * @returns The ExtendedEntity information. + */ +export const getExtendedEntity = async ( + hass: HomeAssistant, + entity: string, + cache?: ExtendedEntityCache, +): Promise => { + const cachedValue = cache ? cache.get(entity) : undefined; + if (cachedValue) { + return cachedValue; + } + const result = await homeAssistantWSRequest( + hass, + extendedEntitySchema, + { + type: 'config/entity_registry/get', + entity_id: entity, + }, + ); + if (cache) { + cache.set(result); + } + return result; +}; + +/** + * Get the extended entity information for an array of entities. May throw. + * @param hass The Home Assistant object. + * @param entities An array of entity ids. + * @param cache An optional ExtendedEntityCache. + * @returns A map of entity id to ExtendedEntity objects. + */ +export const getExtendedEntities = async ( + hass: HomeAssistant, + entities: string[], + cache?: ExtendedEntityCache, +): Promise> => { + const output: Map = new Map(); + const _storeExtendedEntity = async (entity: string): Promise => { + output.set(entity, await getExtendedEntity(hass, entity, cache)); + }; + await Promise.all(entities.map(_storeExtendedEntity)); + return output; +}; + +/** + * Get a list of all entities from the entity registry. May throw. + * @param hass The Home Assistant object. + * @returns An entity list object. + */ +export const getAllEntities = async (hass: HomeAssistant): Promise => { + return await homeAssistantWSRequest(hass, entityListSchema, { + type: 'config/entity_registry/list', + }); +}; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts new file mode 100644 index 00000000..74fd0f6a --- /dev/null +++ b/src/utils/ha/index.ts @@ -0,0 +1,338 @@ +import { computeStateDomain, HomeAssistant } from 'custom-card-helpers'; +import { HassEntity, MessageBase } from 'home-assistant-js-websocket'; +import { StyleInfo } from 'lit/directives/style-map.js'; +import { ZodSchema } from 'zod'; +import { localize } from '../../localize/localize.js'; +import { + CardHelpers, + ExtendedHomeAssistant, + FrigateCardError, + SignedPath, + signedPathSchema, + StateParameters, +} from '../../types.js'; +import { stateIcon } from '../icons/state-icon.js'; +import { getParseErrorKeys } from '../zod.js'; + +/** + * Make a HomeAssistant websocket request. May throw. + * @param hass The HomeAssistant object to send the request with. + * @param schema The expected Zod schema of the response. + * @param request The request to make. + * @returns The parsed valid response or null on malformed. + */ +export async function homeAssistantWSRequest( + hass: HomeAssistant, + schema: ZodSchema, + request: MessageBase, + passthrough = false, +): Promise { + let response; + try { + response = await hass.callWS(request); + } catch (e) { + if (!(e instanceof Error)) { + throw new FrigateCardError(localize('error.failed_response'), { + request: request, + response: e, + }); + } + throw e; + } + + if (!response) { + throw new FrigateCardError(localize('error.empty_response'), { + request: request, + }); + } + // Some endpoints on the integration pass through JSON directly from Frigate + // These end up wrapped in a string and must be unwrapped first + const parseResult = passthrough + ? schema.safeParse(JSON.parse(response)) + : schema.safeParse(response); + if (!parseResult.success) { + throw new FrigateCardError(localize('error.invalid_response'), { + request: request, + response: response, + invalid_keys: getParseErrorKeys(parseResult.error), + }); + } + return parseResult.data; +} + +/** + * Request that HA sign a path. May throw. + * @param hass The HomeAssistant object used to request the signature. + * @param path The path to sign. + * @param expires An optional number of seconds to sign the path for. + * @returns The signed URL, or null if the response was malformed. + */ +export async function homeAssistantSignPath( + hass: ExtendedHomeAssistant, + path: string, + expires?: number, +): Promise { + const request = { + type: 'auth/sign_path', + path: path, + expires: expires, + }; + const response = await homeAssistantWSRequest( + hass, + signedPathSchema, + request, + ); + if (!response) { + return null; + } + return hass.hassUrl(response.path); +} + +interface HassStateDifference { + entity: string; + oldState?: HassEntity; + newState: HassEntity; +} + +/** + * Get the difference between two hass objects. + * @param newHass The new HA object. + * @param oldHass The old HA object. + * @param entities The entities to examine for changes. + * @param options An options object. stateOnly: whether or not to compare state + * strings only, firstOnly: whether or not to get the first difference only. + * @returns An array of HassStateDifference objects. + */ +export function getHassDifferences( + newHass: HomeAssistant | undefined | null, + oldHass: HomeAssistant | undefined | null, + entities: string[] | null, + options?: { + firstOnly?: boolean; + stateOnly?: boolean; + }, +): HassStateDifference[] { + if (!newHass || !entities || !entities.length) { + return []; + } + + const differences: HassStateDifference[] = []; + for (const entity of entities) { + const oldState: HassEntity | undefined = oldHass?.states[entity]; + const newState: HassEntity | undefined = newHass.states[entity]; + if ( + (options?.stateOnly && oldState?.state !== newState?.state) || + (!options?.stateOnly && oldState !== newState) + ) { + differences.push({ + entity: entity, + oldState: oldState, + newState: newState, + }); + if (options?.firstOnly) { + break; + } + } + } + return differences; +} + +/** + * Determine if two hass objects are different for a list of entities. + * @param newHass The new HA object. + * @param oldHass The old HA object. + * @param entities The entities to examine for changes. + * @param options An options object. stateOnly: whether or not to compare state strings only. + * @returns An array of HassStateDifference objects. + */ +export function isHassDifferent( + newHass: HomeAssistant | undefined | null, + oldHass: HomeAssistant | undefined | null, + entities: string[] | null, + options?: { + stateOnly?: boolean; + }, +): boolean { + return !!getHassDifferences(newHass, oldHass, entities, { + ...options, + firstOnly: true, + }).length; +} + +/** + * Calculate a style brightness from a hass state. + * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296 + * @param state The hass state object. + * @returns A CSS brightness string. + */ +function computeBrightnessFromState(state: HassEntity): string { + if (state.state === 'off' || !state.attributes.brightness) { + return ''; + } + const brightness = state.attributes.brightness; + return `brightness(${(brightness + 245) / 5}%)`; +} + +/** + * Calculate a style color from a hass state. + * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304 + * @param state The hass state object. + * @returns A CSS color string. + */ +function computeColorFromState(state: HassEntity): string { + if (state.state === 'off') { + return ''; + } + return state.attributes.rgb_color + ? `rgb(${state.attributes.rgb_color.join(',')})` + : ''; +} + +/** + * Get the style of emphasized menu items. + * @returns A StyleInfo. + */ +function computeStyle(state: HassEntity): StyleInfo { + return { + color: computeColorFromState(state), + filter: computeBrightnessFromState(state), + }; +} + +/** + * Determine the string state of a given stateObj. + * From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_active_state.ts + * @param stateObj The HassEntity object from `hass.states`. + * @returns A string state, e.g. 'on'. + */ +export const computeActiveState = (stateObj: HassEntity): string => { + const domain = stateObj.entity_id.split('.')[0]; + let state = stateObj.state; + + if (domain === 'climate') { + state = stateObj.attributes.hvac_action; + } + + return state; +}; + +/** + * Use Home Assistant state to refresh state parameters for an item to be rendered. + * @param hass Home Assistant object. + * @param params A StateParameters object to modify in place. + * @returns A StateParameters object updated based on HASS state. + */ +export function refreshDynamicStateParameters( + hass: HomeAssistant, + params: StateParameters, +): StateParameters { + if (!params.entity) { + return params; + } + const state = hass.states[params.entity]; + if (!!state && !!params.state_color) { + params.style = { ...computeStyle(state), ...params.style }; + } + params.title = params.title ?? (state?.attributes?.friendly_name || params.entity); + params.icon = params.icon ?? stateIcon(state); + + const domain = state ? computeStateDomain(state) : undefined; + params.data_domain = + params.state_color || (domain === 'light' && params.state_color !== false) + ? domain + : undefined; + if (state) { + params.data_state = computeActiveState(state); + } + return params; +} + +/** + * Get the title of an entity. + * @param entity The entity id. + * @param hass The Home Assistant object. + * @returns The title or undefined. + */ +export function getEntityTitle( + hass?: HomeAssistant, + entity?: string, +): string | undefined { + return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined; +} + +/** + * Get the icon of an entity. + * @param entity The entity id. + * @param hass The Home Assistant object. + * @returns The icon or undefined. + */ +export function getEntityIcon(hass?: HomeAssistant, entity?: string): string { + return stateIcon(entity ? hass?.states[entity] : null); +} + +/** + * Side loads the HA elements this card needs. This trickery is unfortunate + * necessary, see: + * - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements + * @returns `true` if the load is successful, `false` otherwise. + */ +export const sideLoadHomeAssistantElements = async (): Promise => { + const neededElements = [ + 'ha-selector', + 'ha-menu-button', + 'ha-camera-stream', + 'ha-hls-player', + 'ha-web-rtc-player', + 'ha-icon', + 'ha-circular-progress', + 'ha-icon-button', + 'ha-card', + 'ha-svg-icon', + 'ha-button-menu', + ]; + + if (neededElements.every((element) => customElements.get(element))) { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const helpers: CardHelpers = await (window as any).loadCardHelpers(); + + // The picture-glance editor loads everything this card needs. + const pictureGlance = await helpers.createCardElement({ + type: 'picture-glance', + entities: [], + camera_image: 'dummy-to-load-editor-components', + }); + if (pictureGlance.constructor.getConfigElement) { + await pictureGlance.constructor.getConfigElement(); + return true; + } + return false; +}; + +/** + * Determine if a given state qualifies as 'triggered'. + * @param state The HASSEntity. + * @returns `true` if triggered, `false` otherwise. + */ +export const isTriggeredState = (state?: HassEntity): boolean => { + return !!state && ['on', 'open'].includes(state.state); +}; + +/** + * Get entities from the HASS object. + * @param hass + * @param domain + * @returns A list of entities ids. + */ +export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => { + if (!hass) { + return []; + } + const entities = Object.keys(hass.states).filter( + (eid) => !domain || eid.substr(0, eid.indexOf('.')) === domain, + ); + entities.sort(); + return entities; +}; diff --git a/src/utils/ha/resolved-media.ts b/src/utils/ha/resolved-media.ts new file mode 100644 index 00000000..b9e6abfd --- /dev/null +++ b/src/utils/ha/resolved-media.ts @@ -0,0 +1,86 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import QuickLRU from 'quick-lru'; +import { homeAssistantWSRequest } from '.'; +import { + FrigateBrowseMediaSource, + ResolvedMedia, + resolvedMediaSchema, +} from '../../types.js'; +import { errorToConsole } from '../basic'; + +// It's important the cache size be at least as large as the largest likely +// media query or media items will from a given query will be evicted for other +// items in the same query (which would result in only partial results being +// returned to the user). +// Note: Each entry is about 400 bytes. +const RESOLVED_MEDIA_CACHE_SIZE = 1000; + +export class ResolvedMediaCache { + protected _cache: QuickLRU; + + constructor() { + this._cache = new QuickLRU({ maxSize: RESOLVED_MEDIA_CACHE_SIZE }); + } + + /** + * Determine if the cache has a given id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ + public has(id: string): boolean { + return this._cache.has(id); + } + + /** + * Get resolved media information given an id. + * @param id The id. + * @returns The `ResolvedMedia` for this id. + */ + public get(id: string): ResolvedMedia | undefined { + return this._cache.get(id); + } + + /** + * Add a given ResolvedMedia to the cache. + * @param id The id for the object. + * @param resolvedMedia The `ResolvedMedia` object. + */ + public set(id: string, resolvedMedia: ResolvedMedia): void { + this._cache.set(id, resolvedMedia); + } +} + +/** + * Resolve a given media source item. + * @param hass The Home Assistant object. + * @param mediaSource The media source object. + * @param cache An optional ResolvedMediaCache object. + * @returns The resolved media or `null`. + */ +export const resolveMedia = async ( + hass: HomeAssistant, + mediaSource?: FrigateBrowseMediaSource, + cache?: ResolvedMediaCache, +): Promise => { + if (!mediaSource) { + return null; + } + const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined; + if (cachedValue) { + return cachedValue; + } + const request = { + type: 'media_source/resolve_media', + media_content_id: mediaSource.media_content_id, + }; + let resolvedMedia: ResolvedMedia | null = null; + try { + resolvedMedia = await homeAssistantWSRequest(hass, resolvedMediaSchema, request); + } catch (e) { + errorToConsole(e as Error); + } + if (cache && resolvedMedia) { + cache.set(mediaSource.media_content_id, resolvedMedia); + } + return resolvedMedia; +}; diff --git a/src/utils/ha/update.ts b/src/utils/ha/update.ts new file mode 100644 index 00000000..9499b89d --- /dev/null +++ b/src/utils/ha/update.ts @@ -0,0 +1,36 @@ +import { + HassEntity, + HassEntityAttributeBase, + HassEntityBase +} from 'home-assistant-js-websocket'; + +export const UPDATE_SUPPORT_INSTALL = 1; +export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2; +export const UPDATE_SUPPORT_PROGRESS = 4; +export const UPDATE_SUPPORT_BACKUP = 8; +export const UPDATE_SUPPORT_RELEASE_NOTES = 16; + +interface UpdateEntityAttributes extends HassEntityAttributeBase { + auto_update: boolean | null; + installed_version: string | null; + in_progress: boolean | number; + latest_version: string | null; + release_summary: string | null; + release_url: string | null; + skipped_version: string | null; + title: string | null; +} + +export interface UpdateEntity extends HassEntityBase { + attributes: UpdateEntityAttributes; +} + +export const supportsFeature = (stateObj: HassEntity, feature: number): boolean => + ((stateObj.attributes.supported_features ?? 0) & feature) !== 0; + +export const updateUsesProgress = (entity: UpdateEntity): boolean => + supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && + typeof entity.attributes.in_progress === 'number'; + +export const updateIsInstalling = (entity: UpdateEntity): boolean => + updateUsesProgress(entity) || !!entity.attributes.in_progress; diff --git a/src/utils/icons/LICENSE b/src/utils/icons/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/src/utils/icons/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/utils/icons/README.md b/src/utils/icons/README.md new file mode 100644 index 00000000..89ca8032 --- /dev/null +++ b/src/utils/icons/README.md @@ -0,0 +1,7 @@ +## Icons + +These entity icon mappings are copied (with slight tweaks to work with this +card), from the [Lovelace Mushroom +Card](https://github.com/piitaya/lovelace-mushroom/tree/main/src/utils/icons), which are in turn copied from [the Home Assistant frontend](https://github.com/home-assistant/frontend/tree/dev/src/common/entity) in order to +replace the out of date entity icons in +[custom-card-helpers](https://github.com/custom-cards/custom-card-helpers). \ No newline at end of file diff --git a/src/utils/icons/alarm-panel-icon.ts b/src/utils/icons/alarm-panel-icon.ts new file mode 100644 index 00000000..fd9915fa --- /dev/null +++ b/src/utils/icons/alarm-panel-icon.ts @@ -0,0 +1,42 @@ +export const alarmPanelIcon = (state?: string) => { + switch (state) { + case 'armed_away': + return 'mdi:shield-lock'; + case 'armed_vacation': + return 'mdi:shield-airplane'; + case 'armed_home': + return 'mdi:shield-home'; + case 'armed_night': + return 'mdi:shield-moon'; + case 'armed_custom_bypass': + return 'mdi:security'; + case 'pending': + case 'arming': + return 'mdi:shield-sync'; + case 'triggered': + return 'mdi:bell-ring'; + case 'disarmed': + return 'mdi:shield-off'; + default: + return 'mdi:shield'; + } +}; + +export const alarmPanelIconAction = (state?: string) => { + switch (state) { + case 'armed_away': + return 'mdi:shield-lock-outline'; + case 'armed_vacation': + return 'mdi:shield-airplane-outline'; + case 'armed_home': + return 'mdi:shield-home-outline'; + case 'armed_night': + return 'mdi:shield-moon-outline'; + case 'armed_custom_bypass': + return 'mdi:shield-half-full'; + case 'disarmed': + return 'mdi:shield-off-outline'; + default: + return 'mdi:shield-outline'; + } +}; diff --git a/src/utils/icons/binary-sensor-icon.ts b/src/utils/icons/binary-sensor-icon.ts new file mode 100644 index 00000000..ec6a74b2 --- /dev/null +++ b/src/utils/icons/binary-sensor-icon.ts @@ -0,0 +1,58 @@ +import { HassEntity } from 'home-assistant-js-websocket'; + +export const binarySensorIcon = (state?: string, entity?: HassEntity) => { + const isOff = state === 'off'; + switch (entity?.attributes.device_class) { + case 'battery': + return isOff ? 'mdi:battery' : 'mdi:battery-outline'; + case 'battery_charging': + return isOff ? 'mdi:battery' : 'mdi:battery-charging'; + case 'cold': + return isOff ? 'mdi:thermometer' : 'mdi:snowflake'; + case 'connectivity': + return isOff ? 'mdi:close-network-outline' : 'mdi:check-network-outline'; + case 'door': + return isOff ? 'mdi:door-closed' : 'mdi:door-open'; + case 'garage_door': + return isOff ? 'mdi:garage' : 'mdi:garage-open'; + case 'power': + return isOff ? 'mdi:power-plug-off' : 'mdi:power-plug'; + case 'gas': + case 'problem': + case 'safety': + case 'tamper': + return isOff ? 'mdi:check-circle' : 'mdi:alert-circle'; + case 'smoke': + return isOff ? 'mdi:check-circle' : 'mdi:smoke'; + case 'heat': + return isOff ? 'mdi:thermometer' : 'mdi:fire'; + case 'light': + return isOff ? 'mdi:brightness5' : 'mdi:brightness-7'; + case 'lock': + return isOff ? 'mdi:lock' : 'mdi:lock-open'; + case 'moisture': + return isOff ? 'mdi:water-off' : 'mdi:water'; + case 'motion': + return isOff ? 'mdi:motion-sensor-off' : 'mdi:motion-sensor'; + case 'occupancy': + return isOff ? 'mdi:home-outline' : 'mdi:home'; + case 'opening': + return isOff ? 'mdi:square' : 'mdi:square-outline'; + case 'plug': + return isOff ? 'mdi:power-plug-off' : 'mdi:power-plug'; + case 'presence': + return isOff ? 'mdi:home-outline' : 'mdi:home'; + case 'running': + return isOff ? 'mdi:stop' : 'mdi:play'; + case 'sound': + return isOff ? 'mdi:music-note-off' : 'mdi:music-note'; + case 'update': + return isOff ? 'mdi:package' : 'mdi:package-up'; + case 'vibration': + return isOff ? 'mdi:crop-portrait' : 'mdi:vibrate'; + case 'window': + return isOff ? 'mdi:window-closed' : 'mdi:window-open'; + default: + return isOff ? 'mdi:radiobox-blank' : 'mdi:checkbox-marked-circle'; + } +}; diff --git a/src/utils/icons/cover-icon.ts b/src/utils/icons/cover-icon.ts new file mode 100644 index 00000000..37cd15d6 --- /dev/null +++ b/src/utils/icons/cover-icon.ts @@ -0,0 +1,113 @@ +import { HassEntity } from 'home-assistant-js-websocket'; + +export const coverIcon = (state?: string, entity?: HassEntity): string => { + const open = state !== 'closed'; + + switch (entity?.attributes.device_class) { + case 'garage': + switch (state) { + case 'opening': + return 'mdi:arrow-up-box'; + case 'closing': + return 'mdi:arrow-down-box'; + case 'closed': + return 'mdi:garage'; + default: + return 'mdi:garage-open'; + } + case 'gate': + switch (state) { + case 'opening': + case 'closing': + return 'mdi:gate-arrow-right'; + case 'closed': + return 'mdi:gate'; + default: + return 'mdi:gate-open'; + } + case 'door': + return open ? 'mdi:door-open' : 'mdi:door-closed'; + case 'damper': + return open ? 'md:circle' : 'mdi:circle-slice-8'; + case 'shutter': + switch (state) { + case 'opening': + return 'mdi:arrow-up-box'; + case 'closing': + return 'mdi:arrow-down-box'; + case 'closed': + return 'mdi:window-shutter'; + default: + return 'mdi:window-shutter-open'; + } + case 'curtain': + switch (state) { + case 'opening': + return 'mdi:arrow-split-vertical'; + case 'closing': + return 'mdi:arrow-collapse-horizontal'; + case 'closed': + return 'mdi:curtains-closed'; + default: + return 'mdi:curtains'; + } + case 'blind': + case 'shade': + switch (state) { + case 'opening': + return 'mdi:arrow-up-box'; + case 'closing': + return 'mdi:arrow-down-box'; + case 'closed': + return 'mdi:blinds'; + default: + return 'mdi:blinds-open'; + } + case 'window': + switch (state) { + case 'opening': + return 'mdi:arrow-up-box'; + case 'closing': + return 'mdi:arrow-down-box'; + case 'closed': + return 'mdi:window-closed'; + default: + return 'mdi:window-open'; + } + } + + switch (state) { + case 'opening': + return 'mdi:arrow-up-box'; + case 'closing': + return 'mdi:arrow-down-box'; + case 'closed': + return 'mdi:window-closed'; + default: + return 'mdi:window-open'; + } +}; + +export const computeOpenIcon = (stateObj: HassEntity): string => { + switch (stateObj.attributes.device_class) { + case 'awning': + case 'curtain': + case 'door': + case 'gate': + return 'mdi:arrow-expand-horizontal'; + default: + return 'mdi:arrow-up'; + } +}; + +export const computeCloseIcon = (stateObj: HassEntity): string => { + switch (stateObj.attributes.device_class) { + case 'awning': + case 'curtain': + case 'door': + case 'gate': + return 'mdi:arrow-collapse-horizontal'; + default: + return 'mdi:arrow-down'; + } +}; diff --git a/src/utils/icons/domain-icon.ts b/src/utils/icons/domain-icon.ts new file mode 100644 index 00000000..3c01bd76 --- /dev/null +++ b/src/utils/icons/domain-icon.ts @@ -0,0 +1,166 @@ +import { HassEntity } from 'home-assistant-js-websocket'; +import { UpdateEntity, updateIsInstalling } from '../ha/update.js'; +import { alarmPanelIcon } from './alarm-panel-icon'; +import { binarySensorIcon } from './binary-sensor-icon'; +import { coverIcon } from './cover-icon'; +import { sensorIcon } from './sensor-icon'; + +export const DEFAULT_DOMAIN_ICON = 'mdi:bookmark'; + +const FIXED_DOMAIN_ICONS = { + alert: 'mdi:alert', + air_quality: 'mdi:air-filter', + automation: 'mdi:robot', + calendar: 'mdi:calendar', + camera: 'mdi:video', + climate: 'mdi:thermostat', + configurator: 'mdi:cog', + conversation: 'mdi:text-to-speech', + counter: 'mdi:counter', + fan: 'mdi:fan', + google_assistant: 'mdi:google-assistant', + group: 'mdi:google-circles-communities', + homeassistant: 'mdi:home-assistant', + homekit: 'mdi:home-automation', + image_processing: 'mdi:image-filter-frames', + input_button: 'mdi:gesture-tap-button', + input_datetime: 'mdi:calendar-clock', + input_number: 'mdi:ray-vertex', + input_select: 'mdi:format-list-bulleted', + input_text: 'mdi:form-textbox', + light: 'mdi:lightbulb', + mailbox: 'mdi:mailbox', + notify: 'mdi:comment-alert', + number: 'mdi:ray-vertex', + persistent_notification: 'mdi:bell', + person: 'mdi:account', + plant: 'mdi:flower', + proximity: 'mdi:apple-safari', + remote: 'mdi:remote', + scene: 'mdi:palette', + script: 'mdi:script-text', + select: 'mdi:format-list-bulleted', + sensor: 'mdi:eye', + siren: 'mdi:bullhorn', + simple_alarm: 'mdi:bell', + sun: 'mdi:white-balance-sunny', + timer: 'mdi:timer-outline', + updater: 'mdi:cloud-upload', + vacuum: 'mdi:robot-vacuum', + water_heater: 'mdi:thermometer', + weather: 'mdi:weather-cloudy', + zone: 'mdi:map-marker-radius', +}; + +export function domainIcon(domain: string, entity?: HassEntity, state?: string): string { + switch (domain) { + case 'alarm_control_panel': + return alarmPanelIcon(state); + + case 'binary_sensor': + return binarySensorIcon(state, entity); + + case 'button': + switch (entity?.attributes.device_class) { + case 'restart': + return 'mdi:restart'; + case 'update': + return 'mdi:package-up'; + default: + return 'mdi:gesture-tap-button'; + } + + case 'cover': + return coverIcon(state, entity); + + case 'device_tracker': + if (entity?.attributes.source_type === 'router') { + return state === 'home' ? 'mdi:lan-connect' : 'mdi:lan-disconnect'; + } + if (['bluetooth', 'bluetooth_le'].includes(entity?.attributes.source_type)) { + return state === 'home' ? 'mdi:bluetooth-connect' : 'mdi:bluetooth'; + } + return state === 'not_home' ? 'mdi:account-arrow-right' : 'mdi:account'; + + case 'humidifier': + return state && state === 'off' ? 'mdi:air-humidifier-off' : 'mdi:air-humidifier'; + + case 'input_boolean': + return state === 'on' ? 'mdi:check-circle-outline' : 'mdi:close-circle-outline'; + + case 'lock': + switch (state) { + case 'unlocked': + return 'mdi:lock-open'; + case 'jammed': + return 'mdi:lock-alert'; + case 'locking': + case 'unlocking': + return 'mdi:lock-clock'; + default: + return 'mdi:lock'; + } + + case 'media_player': + return state === 'playing' ? 'mdi:cast-connected' : 'mdi:cast'; + + case 'switch': + switch (entity?.attributes.device_class) { + case 'outlet': + return state === 'on' ? 'mdi:power-plug' : 'mdi:power-plug-off'; + case 'switch': + return state === 'on' ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off'; + default: + return 'mdi:flash'; + } + + case 'zwave': + switch (state) { + case 'dead': + return 'mdi:emoticon-dead'; + case 'sleeping': + return 'mdi:sleep'; + case 'initializing': + return 'mdi:timer-sand'; + default: + return 'mdi:z-wave'; + } + + case 'sensor': { + const icon = sensorIcon(entity); + if (icon) { + return icon; + } + + break; + } + + case 'input_datetime': + if (!entity?.attributes.has_date) { + return 'mdi:clock'; + } + if (!entity.attributes.has_time) { + return 'mdi:calendar'; + } + break; + + case 'sun': + return entity?.state === 'above_horizon' + ? FIXED_DOMAIN_ICONS[domain] + : 'mdi:weather-night'; + + case 'update': + return entity?.state === 'on' + ? updateIsInstalling(entity as UpdateEntity) + ? 'mdi:package-down' + : 'mdi:package-up' + : 'mdi:package'; + } + + if (domain in FIXED_DOMAIN_ICONS) { + return FIXED_DOMAIN_ICONS[domain]; + } + + console.warn(`Unable to find icon for domain ${domain}`); + return DEFAULT_DOMAIN_ICON; +} diff --git a/src/utils/icons/sensor-icon.ts b/src/utils/icons/sensor-icon.ts new file mode 100644 index 00000000..885641ef --- /dev/null +++ b/src/utils/icons/sensor-icon.ts @@ -0,0 +1,118 @@ +import { UNIT_C, UNIT_F } from 'custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; + +const FIXED_DEVICE_CLASS_ICONS = { + apparent_power: 'mdi:flash', + aqi: 'mdi:air-filter', + carbon_dioxide: 'mdi:molecule-co2', + carbon_monoxide: 'mdi:molecule-co', + current: 'mdi:current-ac', + date: 'mdi:calendar', + energy: 'mdi:lightning-bolt', + frequency: 'mdi:sine-wave', + gas: 'mdi:gas-cylinder', + humidity: 'mdi:water-percent', + illuminance: 'mdi:brightness-5', + monetary: 'mdi:cash', + nitrogen_dioxide: 'mdi:molecule', + nitrogen_monoxide: 'mdi:molecule', + nitrous_oxide: 'mdi:molecule', + ozone: 'mdi:molecule', + pm1: 'mdi:molecule', + pm10: 'mdi:molecule', + pm25: 'mdi:molecule', + power: 'mdi:flash', + power_factor: 'mdi:angle-acute', + pressure: 'mdi:gauge', + reactive_power: 'mdi:flash', + signal_strength: 'mdi:wifi', + sulphur_dioxide: 'mdi:molecule', + temperature: 'mdi:thermometer', + timestamp: 'mdi:clock', + volatile_organic_compounds: 'mdi:molecule', + voltage: 'mdi:sine-wave', +}; + +const SENSOR_DEVICE_CLASS_BATTERY = 'battery'; + +const BATTERY_ICONS = { + 10: 'mdi:battery-10', + 20: 'mdi:battery-20', + 30: 'mdi:battery-30', + 40: 'mdi:battery-40', + 50: 'mdi:battery-50', + 60: 'mdi:battery-60', + 70: 'mdi:battery-70', + 80: 'mdi:battery-80', + 90: 'mdi:battery-90', + 100: 'mdi:battery', +}; +const BATTERY_CHARGING_ICONS = { + 10: 'mdi:battery-charging-10', + 20: 'mdi:battery-charging-20', + 30: 'mdi:battery-charging-30', + 40: 'mdi:battery-charging-40', + 50: 'mdi:battery-charging-50', + 60: 'mdi:battery-charging-60', + 70: 'mdi:battery-charging-70', + 80: 'mdi:battery-charging-80', + 90: 'mdi:battery-charging-90', + 100: 'mdi:battery-charging', +}; + +const batteryStateIcon = ( + batteryEntity: HassEntity, + batteryChargingEntity?: HassEntity, +) => { + const battery = batteryEntity.state; + const batteryCharging = batteryChargingEntity?.state === 'on'; + + return batteryIcon(battery, batteryCharging); +}; + +export const batteryIcon = ( + batteryState: number | string, + batteryCharging?: boolean, +) => { + const batteryValue = Number(batteryState); + if (isNaN(batteryValue)) { + if (batteryState === 'off') { + return 'mdi:battery'; + } + if (batteryState === 'on') { + return 'mdi:battery-alert'; + } + return 'mdi:battery-unknown'; + } + + const batteryRound = Math.round(batteryValue / 10) * 10; + if (batteryCharging && batteryValue >= 10) { + return BATTERY_CHARGING_ICONS[batteryRound]; + } + if (batteryCharging) { + return 'mdi:battery-charging-outline'; + } + if (batteryValue <= 5) { + return 'mdi:battery-alert-variant-outline'; + } + return BATTERY_ICONS[batteryRound]; +}; + +export const sensorIcon = (entity?: HassEntity): string | undefined => { + const dclass = entity?.attributes.device_class; + + if (dclass && dclass in FIXED_DEVICE_CLASS_ICONS) { + return FIXED_DEVICE_CLASS_ICONS[dclass]; + } + + if (dclass === SENSOR_DEVICE_CLASS_BATTERY) { + return entity ? batteryStateIcon(entity) : 'mdi:battery'; + } + + const unit = entity?.attributes.unit_of_measurement; + if (unit === UNIT_C || unit === UNIT_F) { + return 'mdi:thermometer'; + } + + return undefined; +}; diff --git a/src/utils/icons/state-icon.ts b/src/utils/icons/state-icon.ts new file mode 100644 index 00000000..0e45a66d --- /dev/null +++ b/src/utils/icons/state-icon.ts @@ -0,0 +1,18 @@ +import { computeDomain } from 'custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; +import { DEFAULT_DOMAIN_ICON, domainIcon } from './domain-icon'; + +export function stateIcon(entity?: HassEntity | null): string { + if (!entity) { + return DEFAULT_DOMAIN_ICON; + } + if (entity.attributes.icon) { + return entity.attributes.icon; + } + + const domain = computeDomain(entity.entity_id); + + const state = entity.state; + + return domainIcon(domain, entity, state); +} diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts new file mode 100644 index 00000000..e642bece --- /dev/null +++ b/src/utils/media-info.ts @@ -0,0 +1,85 @@ +import { MediaLoadedInfo } from '../types.js'; +import { dispatchFrigateCardEvent } from './basic.js'; + +const MEDIA_INFO_HEIGHT_CUTOFF = 50; +const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF; + +/** + * Create a MediaLoadedInfo object. + * @param source An event or HTMLElement that should be used as a source. + * @returns A new MediaLoadedInfo object or null if one could not be created. + */ +export function createMediaLoadedInfo( + source: Event | HTMLElement, +): MediaLoadedInfo | null { + let target: HTMLElement | EventTarget; + if (source instanceof Event) { + target = source.composedPath()[0]; + } else { + target = source; + } + + if (target instanceof HTMLImageElement) { + return { + width: (target as HTMLImageElement).naturalWidth, + height: (target as HTMLImageElement).naturalHeight, + }; + } else if (target instanceof HTMLVideoElement) { + return { + width: (target as HTMLVideoElement).videoWidth, + height: (target as HTMLVideoElement).videoHeight, + }; + } else if (target instanceof HTMLCanvasElement) { + return { + width: (target as HTMLCanvasElement).width, + height: (target as HTMLCanvasElement).height, + }; + } + return null; +} + +/** + * Dispatch a Frigate card media loaded event. + * @param element The element to send the event. + * @param source An event or HTMLElement that should be used as a source. + */ +export function dispatchMediaLoadedEvent( + element: HTMLElement, + source: Event | HTMLElement, +): void { + const mediaLoadedInfo = createMediaLoadedInfo(source); + if (mediaLoadedInfo) { + dispatchExistingMediaLoadedInfoAsEvent(element, mediaLoadedInfo); + } +} + +/** + * Dispatch a pre-existing MediaLoadedInfo object as an event. + * @param element The element to send the event. + * @param MediaLoadedInfo The MediaLoadedInfo object to send. + */ +export function dispatchExistingMediaLoadedInfoAsEvent( + element: HTMLElement, + MediaLoadedInfo: MediaLoadedInfo, +): void { + dispatchFrigateCardEvent(element, 'media:loaded', MediaLoadedInfo); +} + +/** + * Determine if a MediaLoadedInfo object is valid/acceptable. + * @param info The MediaLoadedInfo object. + * @returns True if the object is valid, false otherwise. + */ +export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean { + return ( + info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF + ); +} + +/** + * Dispatch a media unloaded event. + * @param element The element to send the event. + */ +export function dispatchMediaUnloadedEvent(element: HTMLElement): void { + dispatchFrigateCardEvent(element, 'media:unloaded'); +} diff --git a/src/utils/media-layout.ts b/src/utils/media-layout.ts new file mode 100644 index 00000000..bf5ff24f --- /dev/null +++ b/src/utils/media-layout.ts @@ -0,0 +1,27 @@ +import { MediaLayoutConfig } from '../types'; + +/** + * Update element style from a media configuration. + * @param element The element to update the style for. + * @param mediaLayoutConfig The media config object. + */ +export const updateElementStyleFromMediaLayoutConfig = ( + element: HTMLElement, + mediaLayoutConfig?: MediaLayoutConfig, +) => { + if (mediaLayoutConfig?.fit !== undefined) { + element.style.setProperty('--frigate-card-media-layout-fit', mediaLayoutConfig.fit); + } else { + element.style.removeProperty('--frigate-card-media-layout-fit'); + } + for (const dimension of ['x', 'y']) { + if (mediaLayoutConfig?.position?.[dimension] !== undefined) { + element.style.setProperty( + `--frigate-card-media-layout-position-${dimension}`, + `${mediaLayoutConfig.position[dimension]}%`, + ); + } else { + element.style.removeProperty(`--frigate-card-media-layout-position-${dimension}`); + } + } +}; diff --git a/src/utils/task.ts b/src/utils/task.ts new file mode 100644 index 00000000..afb91518 --- /dev/null +++ b/src/utils/task.ts @@ -0,0 +1,32 @@ +import { Task } from '@lit-labs/task'; +import { html, TemplateResult } from 'lit'; +import { + dispatchFrigateCardErrorEvent, + renderProgressIndicator, +} from '../components/message'; +import { errorToConsole } from './basic'; + +/** + * Render the result of a Lit task. + * @param host The host object. + * @param task The Lit task. + * @param completeFunc The function to call with the result. + * @param inProgressFunc The function to call whilst in progress. + * @returns A template. + */ +export const renderTask = ( + host: EventTarget, + task: Task, + completeFunc: (result: R) => TemplateResult | void, + inProgressFunc?: () => TemplateResult | void, +): TemplateResult => { + return html` ${task.render({ + initial: () => inProgressFunc?.() ?? renderProgressIndicator(), + pending: () => inProgressFunc?.() ?? renderProgressIndicator(), + error: (e: unknown) => { + errorToConsole(e as Error); + dispatchFrigateCardErrorEvent(host, e as Error); + }, + complete: completeFunc, + })}`; +}; diff --git a/src/utils/thumbnail.ts b/src/utils/thumbnail.ts new file mode 100644 index 00000000..2a002adb --- /dev/null +++ b/src/utils/thumbnail.ts @@ -0,0 +1,73 @@ +import { Task } from '@lit-labs/task'; +import { ReactiveControllerHost } from '@lit/reactive-element'; +import { HomeAssistant } from 'custom-card-helpers'; + +/** + * Fetch a thumbnail URL and return a data URL. + * @param hass Home Assistant object. + * @param thumbnailURL The thumbnail URL. + * @returns A base64 encoded data URL for the thumbnail. + */ +export const fetchThumbnail = async ( + hass: HomeAssistant, + thumbnailURL: string, +): Promise => { + if (!hass) { + return null; + } + if (thumbnailURL?.startsWith('data:')) { + return thumbnailURL; + } + return new Promise((resolve, reject) => { + if (!hass) { + reject(); + return; + } + hass + .fetchWithAuth(thumbnailURL) + // Since we are fetching with an authorization header, we cannot just put the + // URL directly into the document; we need to embed the image. We could do this + // using blob URLs, but then we would need to keep track of them in order to + // release them properly. Instead, we embed the thumbnail using base64. + .then((response) => response.blob()) + .then((blob) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + resolve(typeof result === 'string' ? result : null); + }; + reader.onerror = (e) => reject(e); + reader.readAsDataURL(blob); + }); + }); +}; + +type FetchThumbnailTaskArgs = [boolean, string | undefined]; + +/** + * Create a Lit task to fetch a thumbnail. + * @param host The Lit Element. + * @param getHASS A function to get the Home Assistant object. + * @param getThumbnail A function to get the Thumbnail URL. + * @returns A new Lit Task. + */ +export const createFetchThumbnailTask = ( + host: ReactiveControllerHost, + getHASS: () => HomeAssistant | undefined, + getThumbnailURL: () => string | undefined, +): Task => { + return new Task( + host, + async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise< + string | null + > => { + const hass = getHASS(); + if (!haveHASS || !hass || !thumbnailURL) { + return null; + } + return fetchThumbnail(hass, thumbnailURL); + }, + // Do not re-run the task if hass changes, unless it was previously undefined. + (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()], + ); +}; diff --git a/src/zod-util.ts b/src/utils/zod.ts similarity index 80% rename from src/zod-util.ts rename to src/utils/zod.ts index 1d7f6b3b..2298797c 100644 --- a/src/zod-util.ts +++ b/src/utils/zod.ts @@ -46,3 +46,13 @@ export function deepRemoveDefaults(schema: T): any { } return schema; } + +/** + * Get the keys that didn't parse from a ZodError. + * @param error The zoderror to extract the keys from. + * @returns An array of error keys. + */ +export function getParseErrorKeys(error: z.ZodError): string[] { + const errors = error.format(); + return Object.keys(errors).filter((v) => !v.startsWith('_')); +} diff --git a/src/view.ts b/src/view.ts index cd5a6bab..9c75b90f 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,12 +1,20 @@ -import type { BrowseMediaSource, FrigateCardView } from './types.js'; -import { dispatchFrigateCardEvent } from './common.js'; +import { ViewContext } from 'view'; +import { + FrigateBrowseMediaSource, + FrigateCardUserSpecifiedView, + FrigateCardView, + FRIGATE_CARD_VIEWS_USER_SPECIFIED, + FRIGATE_CARD_VIEW_DEFAULT, +} from './types.js'; +import { dispatchFrigateCardEvent } from './utils/basic.js'; export interface ViewEvolveParameters { view?: FrigateCardView; camera?: string; - target?: BrowseMediaSource; - childIndex?: number; - previous?: View; + target?: FrigateBrowseMediaSource | null; + childIndex?: number | null; + previous?: View | null; + context?: ViewContext | null; } export interface ViewParameters extends ViewEvolveParameters { @@ -17,16 +25,60 @@ export interface ViewParameters extends ViewEvolveParameters { export class View { view: FrigateCardView; camera: string; - target?: BrowseMediaSource; - childIndex?: number; - previous?: View; + target: FrigateBrowseMediaSource | null; + childIndex: number | null; + previous: View | null; + context: ViewContext | null; constructor(params: ViewParameters) { - this.view = params?.view; - this.camera = params?.camera; - this.target = params?.target; - this.childIndex = params?.childIndex; - this.previous = params?.previous; + this.view = params.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; + } + + /** + * Selects the best view for a non-Frigate camera. + * @param view The wanted view. + * @returns The closest view supported by the non-Frigate camera. + */ + public static selectBestViewForNonFrigateCameras(view: FrigateCardView) { + return ['timeline', 'image'].includes(view) ? view : FRIGATE_CARD_VIEW_DEFAULT; + } + + /** + * Selects the best view for a user specified view. + * @param view The wanted view. + * @returns The closest view supported that is user changeable. + */ + public static selectBestViewForUserSpecified(view: FrigateCardView) { + return FRIGATE_CARD_VIEWS_USER_SPECIFIED.includes( + view as FrigateCardUserSpecifiedView, + ) + ? view + : FRIGATE_CARD_VIEW_DEFAULT; + } + + /** + * Detect if a view change represents a major "media change" for the given + * view. + * @param prev The previous view. + * @param curr The current view. + * @returns True if the view change is a real media change. + */ + public static isMediaChange(prev?: View, curr?: View): boolean { + return ( + !prev || + !curr || + prev.view !== curr.view || + prev.camera !== curr.camera || + // When in the live view, the target/childIndex are the events that + // happened in the past -- not reflective of the actual live media viewer. + (curr.view !== 'live' && + (prev.target !== curr.target || prev.childIndex !== curr.childIndex)) + ); } /** @@ -38,7 +90,8 @@ export class View { camera: this.camera, target: this.target, childIndex: this.childIndex, - previous: this.previous + previous: this.previous, + context: this.context, }); } @@ -49,12 +102,38 @@ export class View { */ public evolve(params: ViewEvolveParameters): View { return new View({ - view: params.view ?? this.view, - camera: params.camera ?? this.camera, - target: params.target ?? this.target, - childIndex: params.childIndex ?? this.childIndex, - previous: params.previous ?? this.previous, - }) + view: params.view !== undefined ? params.view : this.view, + camera: params.camera !== undefined ? params.camera : this.camera, + 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, + }); + } + + /** + * Merge view contexts. + * @param context The context to merge in. + * @returns This view. + */ + public mergeInContext(context: ViewContext): View { + this.context = { ...this.context, ...context }; + return this; + } + + /** + * Remove a context key. + * @param key The key to remove. + * @returns This view. + */ + public removeContext(key: keyof ViewContext): View { + if (this.context) { + delete(this.context[key]); + } + return this; } /** @@ -72,19 +151,18 @@ export class View { } /** - * Determine if a view is of a piece of media (i.e. not the gallery). + * Determine if a view is of a piece of media (including the media viewer, + * live view, image view -- anything that can create a MediaLoadedInfo event). */ - public isMediaView(): boolean { - return !this.isGalleryView(); + public isAnyMediaView(): boolean { + return this.isViewerView() || this.is('live') || this.is('image'); } /** * Determine if a view is for the media viewer. */ public isViewerView(): boolean { - return ['clip', 'snapshot'].includes( - this.view, - ); + return ['clip', 'snapshot', 'media'].includes(this.view); } /** @@ -101,24 +179,47 @@ export class View { return ['snapshot', 'snapshots'].includes(this.view); } + /** + * Get the media type for this view if available. + * @returns Whether the media is `clips` or `snapshots` or unknown (`null`) + */ + public getMediaType(): 'clips' | 'snapshots' | null { + return this.isClipRelatedView() + ? 'clips' + : this.isSnapshotRelatedView() + ? 'snapshots' + : null; + } + /** * Get the media item that should be played. **/ - get media(): BrowseMediaSource | undefined { + get media(): FrigateBrowseMediaSource | null { if (this.target) { - if (this.target.children && this.childIndex !== undefined) { - return this.target.children[this.childIndex]; + if (this.target.children && this.childIndex !== null) { + return this.target.children[this.childIndex] ?? null; } - return this.target; } - return undefined; + return null; } /** * Dispatch an event to request a view change. - * @param node The element dispatching the event. + * @param target The target dispatching the event. */ - public dispatchChangeEvent(node: HTMLElement): void { - dispatchFrigateCardEvent(node, 'change-view', this); + public dispatchChangeEvent(target: EventTarget): void { + dispatchFrigateCardEvent(target, 'view:change', this); } } + +/** + * Dispatch an event to change the view context. + * @param target The EventTarget to send the event from. + * @param context The context to change. + */ +export const dispatchViewContextChangeEvent = ( + target: EventTarget, + context: ViewContext, +): void => { + dispatchFrigateCardEvent(target, 'view:change-context', context); +}; diff --git a/tsconfig.json b/tsconfig.json index 0e8ff671..0b7df973 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,11 +3,7 @@ "target": "es2017", "module": "es2020", "moduleResolution": "node", - "lib": [ - "es2017", - "dom", - "dom.iterable" - ], + "lib": ["es2017", "dom", "dom.iterable"], "noEmit": true, "noUnusedParameters": true, "noImplicitReturns": true, @@ -18,5 +14,35 @@ "resolveJsonModule": true, "experimentalDecorators": true, "esModuleInterop": true, + "sourceMap": true, + "plugins": [ + { + "name": "ts-lit-plugin", + "strict": false, + // These tags are provided by the HA frontend and [almost all] cannot be + // imported by a custom card directly. + "globalTags": [ + "ha-card", + "ha-icon", + "ha-icon-button", + "ha-button-menu", + "ha-circular-progress", + "ha-selector", + "ha-svg-icon", + "mwc-button", + "mwc-list-item", + "paper-toast" + ], + "rules": { + "no-unknown-tag-name": "error", + "no-missing-import": "error", + "no-unclosed-tag": "error", + "no-incompatible-type-binding": "warning", + "no-invalid-css": "warning", + "no-missing-element-type-definition": "warning", + "no-property-visibility-mismatch": "error" + } + } + ] } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index af4376ae..3816386d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,310 +3,301 @@ "@ampproject/remapping@^2.1.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.1.2.tgz#4edca94973ded9630d20101cd8559cedb8d8bd34" - integrity sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg== + version "2.2.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" + integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== dependencies: - "@jridgewell/trace-mapping" "^0.3.0" + "@jridgewell/gen-mapping" "^0.1.0" + "@jridgewell/trace-mapping" "^0.3.9" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789" - integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg== +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" + integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== dependencies: - "@babel/highlight" "^7.16.7" + "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.17.7.tgz#078d8b833fbbcc95286613be8c716cef2b519fa2" - integrity sha512-p8pdE6j0a29TNGebNm7NzYZWB3xVZJBZ7XGs42uAKzQo8VQ3F0By/cQCtUEABwIqw5zo6WA4NbmxsfzADzMKnQ== +"@babel/compat-data@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.0.tgz#2a592fd89bacb1fcde68de31bee4f2f2dacb0e86" + integrity sha512-y5rqgTTPTmaF5e2nVhOxw+Ur9HDJLsWb6U/KpgUzRZEdPfE6VOubXBKLdbcUTijzRptednSBDQbYZBOSqJxpJw== -"@babel/core@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.17.7.tgz#f7c28228c83cdf2dbd1b9baa06eaf9df07f0c2f9" - integrity sha512-djHlEfFHnSnTAcPb7dATbiM5HxGOP98+3JLBZtjRb5I7RXrw7kFRoG2dXM8cm3H+o11A8IFH/uprmJpwFynRNQ== +"@babel/core@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.0.tgz#d2f5f4f2033c00de8096be3c9f45772563e150c3" + integrity sha512-reM4+U7B9ss148rh2n1Qs9ASS+w94irYXga7c2jaQv9RVzpS7Mv1a9rnYYwuDa45G+DkORt9g6An2k/V4d9LbQ== dependencies: "@ampproject/remapping" "^2.1.0" - "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.17.7" - "@babel/helper-compilation-targets" "^7.17.7" - "@babel/helper-module-transforms" "^7.17.7" - "@babel/helpers" "^7.17.7" - "@babel/parser" "^7.17.7" - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.3" - "@babel/types" "^7.17.0" + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.19.0" + "@babel/helper-compilation-targets" "^7.19.0" + "@babel/helper-module-transforms" "^7.19.0" + "@babel/helpers" "^7.19.0" + "@babel/parser" "^7.19.0" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" - json5 "^2.1.2" + json5 "^2.2.1" semver "^6.3.0" -"@babel/generator@^7.17.3", "@babel/generator@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.17.7.tgz#8da2599beb4a86194a3b24df6c085931d9ee45ad" - integrity sha512-oLcVCTeIFadUoArDTwpluncplrYBmTCCZZgXCbgNGvOBBiSDDK3eWO4b/+eOTli5tKv1lg+a5/NAXg+nTcei1w== +"@babel/generator@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.0.tgz#785596c06425e59334df2ccee63ab166b738419a" + integrity sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg== dependencies: - "@babel/types" "^7.17.0" + "@babel/types" "^7.19.0" + "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" - source-map "^0.5.0" -"@babel/helper-annotate-as-pure@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz#bb2339a7534a9c128e3102024c60760a3a7f3862" - integrity sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw== +"@babel/helper-annotate-as-pure@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" + integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== dependencies: - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.6" -"@babel/helper-compilation-targets@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.7.tgz#a3c2924f5e5f0379b356d4cfb313d1414dc30e46" - integrity sha512-UFzlz2jjd8kroj0hmCFV5zr+tQPi1dpC2cRsDV/3IEW8bJfCPrPpmcSN6ZS8RqIq4LXcmpipCQFPddyFA5Yc7w== +"@babel/helper-compilation-targets@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.0.tgz#537ec8339d53e806ed422f1e06c8f17d55b96bb0" + integrity sha512-Ai5bNWXIvwDvWM7njqsG3feMlL9hCVQsPYXodsZyLwshYkZVJt59Gftau4VrE8S9IT9asd2uSP1hG6wCNw+sXA== dependencies: - "@babel/compat-data" "^7.17.7" - "@babel/helper-validator-option" "^7.16.7" - browserslist "^4.17.5" + "@babel/compat-data" "^7.19.0" + "@babel/helper-validator-option" "^7.18.6" + browserslist "^4.20.2" semver "^6.3.0" -"@babel/helper-create-class-features-plugin@^7.16.7", "@babel/helper-create-class-features-plugin@^7.17.1": - version "7.17.6" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.17.6.tgz#3778c1ed09a7f3e65e6d6e0f6fbfcc53809d92c9" - integrity sha512-SogLLSxXm2OkBbSsHZMM4tUi8fUzjs63AT/d0YQIzr6GSd8Hxsbk2KYDX0k0DweAzGMj/YWeiCsorIdtdcW8Eg== +"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b" + integrity sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw== dependencies: - "@babel/helper-annotate-as-pure" "^7.16.7" - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-function-name" "^7.16.7" - "@babel/helper-member-expression-to-functions" "^7.16.7" - "@babel/helper-optimise-call-expression" "^7.16.7" - "@babel/helper-replace-supers" "^7.16.7" - "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/helper-member-expression-to-functions" "^7.18.9" + "@babel/helper-optimise-call-expression" "^7.18.6" + "@babel/helper-replace-supers" "^7.18.9" + "@babel/helper-split-export-declaration" "^7.18.6" -"@babel/helper-environment-visitor@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz#ff484094a839bde9d89cd63cba017d7aae80ecd7" - integrity sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag== +"@babel/helper-environment-visitor@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" + integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== + +"@babel/helper-function-name@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" + integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== dependencies: - "@babel/types" "^7.16.7" + "@babel/template" "^7.18.10" + "@babel/types" "^7.19.0" -"@babel/helper-function-name@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz#f1ec51551fb1c8956bc8dd95f38523b6cf375f8f" - integrity sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA== +"@babel/helper-hoist-variables@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" + integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== dependencies: - "@babel/helper-get-function-arity" "^7.16.7" - "@babel/template" "^7.16.7" - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.6" -"@babel/helper-get-function-arity@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz#ea08ac753117a669f1508ba06ebcc49156387419" - integrity sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw== +"@babel/helper-member-expression-to-functions@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815" + integrity sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg== dependencies: - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.9" -"@babel/helper-hoist-variables@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz#86bcb19a77a509c7b77d0e22323ef588fa58c246" - integrity sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg== +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" + integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== dependencies: - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.6" -"@babel/helper-member-expression-to-functions@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.7.tgz#42b9ca4b2b200123c3b7e726b0ae5153924905b0" - integrity sha512-VtJ/65tYiU/6AbMTDwyoXGPKHgTsfRarivm+YbB5uAzKUyuPjgZSgAFeG87FCigc7KNHu2Pegh1XIT3lXjvz3Q== +"@babel/helper-module-transforms@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" + integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ== dependencies: - "@babel/types" "^7.16.7" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-module-imports" "^7.18.6" + "@babel/helper-simple-access" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/helper-validator-identifier" "^7.18.6" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz#25612a8091a999704461c8a222d0efec5d091437" - integrity sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg== +"@babel/helper-optimise-call-expression@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" + integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA== dependencies: - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz#3943c7f777139e7954a5355c815263741a9c1cbd" - integrity sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw== +"@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" + integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw== + +"@babel/helper-replace-supers@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6" + integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ== dependencies: - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-module-imports" "^7.16.7" - "@babel/helper-simple-access" "^7.17.7" - "@babel/helper-split-export-declaration" "^7.16.7" - "@babel/helper-validator-identifier" "^7.16.7" - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.3" - "@babel/types" "^7.17.0" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-member-expression-to-functions" "^7.18.9" + "@babel/helper-optimise-call-expression" "^7.18.6" + "@babel/traverse" "^7.18.9" + "@babel/types" "^7.18.9" -"@babel/helper-optimise-call-expression@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.7.tgz#a34e3560605abbd31a18546bd2aad3e6d9a174f2" - integrity sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w== +"@babel/helper-simple-access@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" + integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g== dependencies: - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.6" -"@babel/helper-plugin-utils@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" - integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== - -"@babel/helper-replace-supers@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.16.7.tgz#e9f5f5f32ac90429c1a4bdec0f231ef0c2838ab1" - integrity sha512-y9vsWilTNaVnVh6xiJfABzsNpgDPKev9HnAgz6Gb1p6UUwf9NepdlsV7VXGCftJM+jqD5f7JIEubcpLjZj5dBw== +"@babel/helper-split-export-declaration@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" + integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== dependencies: - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-member-expression-to-functions" "^7.16.7" - "@babel/helper-optimise-call-expression" "^7.16.7" - "@babel/traverse" "^7.16.7" - "@babel/types" "^7.16.7" + "@babel/types" "^7.18.6" -"@babel/helper-simple-access@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz#aaa473de92b7987c6dfa7ce9a7d9674724823367" - integrity sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA== +"@babel/helper-string-parser@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" + integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== + +"@babel/helper-validator-identifier@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" + integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== + +"@babel/helper-validator-option@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" + integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== + +"@babel/helpers@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18" + integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg== dependencies: - "@babel/types" "^7.17.0" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" -"@babel/helper-split-export-declaration@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz#0b648c0c42da9d3920d85ad585f2778620b8726b" - integrity sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw== +"@babel/highlight@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" + integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== dependencies: - "@babel/types" "^7.16.7" - -"@babel/helper-validator-identifier@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" - integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== - -"@babel/helper-validator-option@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz#b203ce62ce5fe153899b617c08957de860de4d23" - integrity sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ== - -"@babel/helpers@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.17.7.tgz#6fc0a24280fd00026e85424bbfed4650e76d7127" - integrity sha512-TKsj9NkjJfTBxM7Phfy7kv6yYc4ZcOo+AaWGqQOKTPDOmcGkIFb5xNA746eKisQkm4yavUYh4InYM9S+VnO01w== - dependencies: - "@babel/template" "^7.16.7" - "@babel/traverse" "^7.17.3" - "@babel/types" "^7.17.0" - -"@babel/highlight@^7.16.7": - version "7.16.10" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.10.tgz#744f2eb81579d6eea753c227b0f570ad785aba88" - integrity sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw== - dependencies: - "@babel/helper-validator-identifier" "^7.16.7" + "@babel/helper-validator-identifier" "^7.18.6" chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.16.7", "@babel/parser@^7.17.3", "@babel/parser@^7.17.7": - version "7.17.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.7.tgz#fc19b645a5456c8d6fdb6cecd3c66c0173902800" - integrity sha512-bm3AQf45vR4gKggRfvJdYJ0gFLoCbsPxiFLSH6hTVYABptNHY6l9NrhnucVjQ/X+SPtLANT9lc0fFhikj+VBRA== +"@babel/parser@^7.18.10", "@babel/parser@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.0.tgz#497fcafb1d5b61376959c1c338745ef0577aa02c" + integrity sha512-74bEXKX2h+8rrfQUfsBfuZZHzsEs6Eql4pqy/T4Nn6Y9wNPggQOqD6z6pn5Bl8ZfysKouFZT/UXEH94ummEeQw== -"@babel/plugin-proposal-class-properties@^7.14.5": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz#925cad7b3b1a2fcea7e59ecc8eb5954f961f91b0" - integrity sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww== +"@babel/plugin-proposal-class-properties@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" + integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== dependencies: - "@babel/helper-create-class-features-plugin" "^7.16.7" - "@babel/helper-plugin-utils" "^7.16.7" + "@babel/helper-create-class-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-proposal-decorators@^7.15.8": - version "7.17.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.17.2.tgz#c36372ddfe0360cac1ee331a238310bddca11493" - integrity sha512-WH8Z95CwTq/W8rFbMqb9p3hicpt4RX4f0K659ax2VHxgOyT6qQmUaEVEjIh4WR9Eh9NymkVn5vwsrE68fAQNUw== +"@babel/plugin-proposal-decorators@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.0.tgz#5a3bc0699ee34117c73c960a5396ffce104c4eaa" + integrity sha512-Bo5nOSjiJccjv00+BrDkmfeBLBi2B0qe8ygj24KdL8VdwtZz+710NCwehF+x/Ng+0mkHx5za2eAofmvVFLF4Fg== dependencies: - "@babel/helper-create-class-features-plugin" "^7.17.1" - "@babel/helper-plugin-utils" "^7.16.7" - "@babel/helper-replace-supers" "^7.16.7" - "@babel/plugin-syntax-decorators" "^7.17.0" - charcodes "^0.2.0" + "@babel/helper-create-class-features-plugin" "^7.19.0" + "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-replace-supers" "^7.18.9" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/plugin-syntax-decorators" "^7.19.0" -"@babel/plugin-syntax-decorators@^7.17.0": - version "7.17.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.17.0.tgz#a2be3b2c9fe7d78bd4994e790896bc411e2f166d" - integrity sha512-qWe85yCXsvDEluNP0OyeQjH63DlhAR3W7K9BxxU1MvbDb48tgBG+Ao6IJJ6smPDrrVzSQZrbF6donpkFBMcs3A== +"@babel/plugin-syntax-decorators@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz#5f13d1d8fce96951bea01a10424463c9a5b3a599" + integrity sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ== dependencies: - "@babel/helper-plugin-utils" "^7.16.7" + "@babel/helper-plugin-utils" "^7.19.0" -"@babel/template@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" - integrity sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w== +"@babel/template@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" + integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== dependencies: - "@babel/code-frame" "^7.16.7" - "@babel/parser" "^7.16.7" - "@babel/types" "^7.16.7" + "@babel/code-frame" "^7.18.6" + "@babel/parser" "^7.18.10" + "@babel/types" "^7.18.10" -"@babel/traverse@^7.16.7", "@babel/traverse@^7.17.3": - version "7.17.3" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.17.3.tgz#0ae0f15b27d9a92ba1f2263358ea7c4e7db47b57" - integrity sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw== +"@babel/traverse@^7.18.9", "@babel/traverse@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.0.tgz#eb9c561c7360005c592cc645abafe0c3c4548eed" + integrity sha512-4pKpFRDh+utd2mbRC8JLnlsMUii3PMHjpL6a0SZ4NMZy7YFP9aXORxEhdMVOc9CpWtDF09IkciQLEhK7Ml7gRA== dependencies: - "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.17.3" - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-function-name" "^7.16.7" - "@babel/helper-hoist-variables" "^7.16.7" - "@babel/helper-split-export-declaration" "^7.16.7" - "@babel/parser" "^7.17.3" - "@babel/types" "^7.17.0" + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.19.0" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.19.0" + "@babel/types" "^7.19.0" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.16.7", "@babel/types@^7.17.0": - version "7.17.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.0.tgz#a826e368bccb6b3d84acd76acad5c0d87342390b" - integrity sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw== +"@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600" + integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA== dependencies: - "@babel/helper-validator-identifier" "^7.16.7" + "@babel/helper-string-parser" "^7.18.10" + "@babel/helper-validator-identifier" "^7.18.6" to-fast-properties "^2.0.0" -"@cycjimmy/awesome-js-funcs@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@cycjimmy/awesome-js-funcs/-/awesome-js-funcs-3.1.0.tgz#1cc2e55101aea4a0e752c7da2cf027884473676c" - integrity sha512-FT+V5fSZoPAdEAo34FJFy5+DfhdoQ+s6cOmghjAUQzluM7NlUkPKUSFy5fzJHVJD2dofy8v2k3IIfgUXNiG2yw== +"@cycjimmy/jsmpeg-player@^6.0.4": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.4.tgz#2ad00118a48bef30db0115ba873b335fa1559ae7" + integrity sha512-w3E1nwSDKPJaBD3bq/aMvR3hgUJpyCrqX2jJbQAjE9g4xk9bRkeAIH1h1mBtdRFMpTzUlE3GX8r44lXEH0dVVQ== -"@cycjimmy/jsmpeg-player@^5.1.1": - version "5.1.1" - resolved "https://registry.yarnpkg.com/@cycjimmy/jsmpeg-player/-/jsmpeg-player-5.1.1.tgz#21ac4cc9b36527a17ba27271bd3610eebe20999b" - integrity sha512-laMBuYW4GI/GPySSnhHo59fjvWMeawvj6Y3lC29gPYDNhTrfNKA0Llp5fGBXHYbiUBptglcjxgZApEsD7PfMqQ== +"@egjs/hammerjs@^2.0.17": + version "2.0.17" + resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" + integrity sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A== dependencies: - "@cycjimmy/awesome-js-funcs" "^3.1.0" - "@cycjimmy/sass-lib" "^1.1.0" + "@types/hammerjs" "^2.0.36" -"@cycjimmy/sass-lib@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@cycjimmy/sass-lib/-/sass-lib-1.1.0.tgz#94c5d454d7ca67f5c09a924f7f26a9f180461072" - integrity sha512-eMN9QMtiPfiDqdJvE8agBOR53lGJXkz2pOy8lhttsuOBzfmf3gs10Uf0ogX/rjt3U7//ZSqKckRT/br1S2uZ6A== - -"@eslint/eslintrc@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.2.1.tgz#8b5e1c49f4077235516bc9ec7d41378c0f69b8c6" - integrity sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ== +"@eslint/eslintrc@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.3.1" - globals "^13.9.0" + espree "^9.4.0" + globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.0" - minimatch "^3.0.4" + minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@formatjs/ecma402-abstract@1.11.3": - version "1.11.3" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.3.tgz#f25276dfd4ef3dac90da667c3961d8aa9732e384" - integrity sha512-kP/Buv5vVFMAYLHNvvUzr0lwRTU0u2WTy44Tqwku1X3C3lJ5dKqDCYVqA8wL+Y19Bq+MwHgxqd5FZJRCIsLRyQ== +"@formatjs/ecma402-abstract@1.11.4": + version "1.11.4" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz#b962dfc4ae84361f9f08fbce411b4e4340930eda" + integrity sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw== dependencies: - "@formatjs/intl-localematcher" "0.2.24" + "@formatjs/intl-localematcher" "0.2.25" tslib "^2.1.0" "@formatjs/fast-memoize@1.2.1": @@ -316,27 +307,27 @@ dependencies: tslib "^2.1.0" -"@formatjs/icu-messageformat-parser@2.0.18": - version "2.0.18" - resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.18.tgz#b09e8f16b88e988fd125e7c5810300e8a6dd2c42" - integrity sha512-vquIzsAJJmZ5jWVH8dEgUKcbG4yu3KqtyPet+q35SW5reLOvblkfeCXTRW2TpIwNXzdVqsJBwjbTiRiSU9JxwQ== +"@formatjs/icu-messageformat-parser@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz#a54293dd7f098d6a6f6a084ab08b6d54a3e8c12d" + integrity sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw== dependencies: - "@formatjs/ecma402-abstract" "1.11.3" - "@formatjs/icu-skeleton-parser" "1.3.5" + "@formatjs/ecma402-abstract" "1.11.4" + "@formatjs/icu-skeleton-parser" "1.3.6" tslib "^2.1.0" -"@formatjs/icu-skeleton-parser@1.3.5": - version "1.3.5" - resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.5.tgz#babc93a1c36383cf87cbb3d2f2145d26c2f7cb40" - integrity sha512-Nhyo2/6kG7ZfgeEfo02sxviOuBcvtzH6SYUharj3DLCDJH3A/4OxkKcmx/2PWGX4bc6iSieh+FA94CsKDxnZBQ== +"@formatjs/icu-skeleton-parser@1.3.6": + version "1.3.6" + resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz#4ce8c0737d6f07b735288177049e97acbf2e8964" + integrity sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg== dependencies: - "@formatjs/ecma402-abstract" "1.11.3" + "@formatjs/ecma402-abstract" "1.11.4" tslib "^2.1.0" -"@formatjs/intl-localematcher@0.2.24": - version "0.2.24" - resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.24.tgz#b49fd753c0f54421f26a3c1d0e9cf98a3966e78f" - integrity sha512-K/HRGo6EMnCbhpth/y3u4rW4aXkmQNqRe1L2G+Y5jNr3v0gYhvaucV8WixNju/INAMbPBlbsRBRo/nfjnoOnxQ== +"@formatjs/intl-localematcher@0.2.25": + version "0.2.25" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz#60892fe1b271ec35ba07a2eb018a2dd7bca6ea3a" + integrity sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA== dependencies: tslib "^2.1.0" @@ -347,351 +338,89 @@ dependencies: emojis-list "^3.0.0" -"@gar/promisify@^1.0.1": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" - integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== - -"@humanwhocodes/config-array@^0.9.2": - version "0.9.5" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" - integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw== +"@humanwhocodes/config-array@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c" + integrity sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" minimatch "^3.0.4" +"@humanwhocodes/gitignore-to-minimatch@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz#316b0a63b91c10e53f242efb4ace5c3b34e8728d" + integrity sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + "@humanwhocodes/object-schema@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@jridgewell/gen-mapping@^0.1.0": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" + integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== + dependencies: + "@jridgewell/set-array" "^1.0.0" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" + integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + "@jridgewell/resolve-uri@^3.0.3": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz#68eb521368db76d040a6315cdb24bf2483037b9c" - integrity sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew== + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/source-map@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" + integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" "@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.11" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz#771a1d8d744eeb71b6adb35808e1a6c7b9b8c8ec" - integrity sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg== + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/trace-mapping@^0.3.0": - version "0.3.4" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.4.tgz#f6a0832dffd5b8a6aaa633b7d9f8e8e94c83a0c3" - integrity sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ== +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.15" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" + integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== dependencies: "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@lit-labs/task@^1.0.0": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@lit-labs/task/-/task-1.1.1.tgz#f476807b4842f46c2bb5a109437c07f37c212df6" - integrity sha512-ihsNEAdqxsiHiMq+R6y65i8wr3ApWr7k3TN7QVzmVHg2j6a2fcP2RSDD6EckqfFWaudP0dZWftogmmBVvRhA6A== +"@lit-labs/task@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@lit-labs/task/-/task-1.1.3.tgz#1215ae8ca746ecc895813463b81b2ce4b823098a" + integrity sha512-Ta5rdbmuDaYAZwlkjezCoqcnn+J54XBAGI0X4PsCjycY1nJ/Ntbd+YY/eMdfnu777qrvznuqanTa4r1ptPXXxA== dependencies: "@lit/reactive-element" "^1.1.0" -"@lit/reactive-element@1.0.0-rc.4": - version "1.0.0-rc.4" - resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.0-rc.4.tgz#3473f6a687e9925fc2e9ff1b716a86b5f4b576ad" - integrity sha512-dJMha+4NFYdpnUJzRrWTFV5Hdp9QHWFuPnaoqonrKl4lGJVnYez9mu8ev9F/5KM47tjAjh22DuRHrdFDHfOijA== - -"@lit/reactive-element@^1.1.0", "@lit/reactive-element@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.3.0.tgz#c85c5a5fea41d7230e52fb9017307f473ab947ea" - integrity sha512-0TKSIuJHXNLM0k98fi0AdMIdUoHIYlDHTP+0Vruc2SOs4T6vU1FinXgSvYd8mSrkt+8R+qdRAXvjpqrMXMyBgw== - -"@material/animation@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/animation/-/animation-14.0.0-canary.261f2db59.0.tgz#1e3897a25dc4264afd5beb40f8040686276903fc" - integrity sha512-OjxWJYSRNs4vnPe8NclaNn+TsNc8TR/wHusGtezF5F+wl+5mh+K69BMXAmURtq3idoRg4XaOSC/Ohk1ovD1fMQ== - dependencies: - tslib "^2.1.0" - -"@material/base@14.0.0-canary.261f2db59.0", "@material/base@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/base/-/base-14.0.0-canary.261f2db59.0.tgz#199d0dd5ee64150a96b1392c207e45f7e9df9b80" - integrity sha512-vy5SQt+jcwwdRFfBvtpVdpULUBujecVUKOXcopaQoi2XIzI5EBHuR4gPN0cd1yfmVEucD6p2fvVv2FJ3Ngr61w== - dependencies: - tslib "^2.1.0" - -"@material/density@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/density/-/density-14.0.0-canary.261f2db59.0.tgz#e34683ef5d68c661b0e2adaed1cd68668f35c18d" - integrity sha512-zOR5wISqPVr8KS/ERNC1jdRV9O832lzclyS9Ea20rDrWfuOiYsQ9bbIk12xWlxpgsn7r9fxQJyd1O2SURoHdRA== - dependencies: - tslib "^2.1.0" - -"@material/dom@14.0.0-canary.261f2db59.0", "@material/dom@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/dom/-/dom-14.0.0-canary.261f2db59.0.tgz#7ee09608c9742f4cba06d2867ce6e211e84b2d50" - integrity sha512-iUpZG6Bb2l/PfNV2Fb/pXfG1p4Bz4PC9A7ATPlKfcU5HioObcnYVc/+Hrtaw8eu28BNIc+VVROtbfpqG/YgKSQ== - dependencies: - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/elevation@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/elevation/-/elevation-14.0.0-canary.261f2db59.0.tgz#c49a90b1040589ac21b9fc3a9dda216e67cc140f" - integrity sha512-AqN/tsTGGyBzZ7CtoSMBY9bDYvCuUt98EUfiGjZGXcf4HgoHV3Cn/JSLrhru5Cq8Nx6HF6AmHh3dQCfNCQduew== - dependencies: - "@material/animation" "14.0.0-canary.261f2db59.0" - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/feature-targeting@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/feature-targeting/-/feature-targeting-14.0.0-canary.261f2db59.0.tgz#ef9a02e87c1fdc91c290f1e54d17e141be940b91" - integrity sha512-CrVoGNu0ym52OPEKy3kgeNL2oSWOCBYbYxSH3GhERxCq5FwGBN+XmK/ZDLFVQlHYy3v8x4TqVEwXviCeumNTxQ== - dependencies: - tslib "^2.1.0" - -"@material/feature-targeting@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@material/feature-targeting/-/feature-targeting-13.0.0.tgz#7bc7840b40930b6831101a22882acdd0329591c0" - integrity sha512-QJClfeaA4EMyAxKJy9WR0Nx+/VwSZCkhGLUVBG9NhxqYGfl/LtaeaidrNm32vYEoNZAofN92VD2RwQTRwp/dMQ== - dependencies: - tslib "^2.1.0" - -"@material/image-list@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@material/image-list/-/image-list-13.0.0.tgz#8a1da68ebd53ff0123f071c49691ffde4c369fca" - integrity sha512-D78QKpK5JmO6zrbsSYt1YfRlkqzzduDTe6BstS0efUFS1CA11hrqwQFoMaR1L1dw2U3CQ/CP22bBMSZVV9aU6A== - dependencies: - "@material/feature-targeting" "^13.0.0" - "@material/shape" "^13.0.0" - "@material/theme" "^13.0.0" - "@material/typography" "^13.0.0" - tslib "^2.1.0" - -"@material/list@14.0.0-canary.261f2db59.0", "@material/list@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/list/-/list-14.0.0-canary.261f2db59.0.tgz#f2b413c0da1cab9f49615587520eff00be99969a" - integrity sha512-bjHXXk2ZeWxAFs4cJxy5J5A5ClUd3FGjRv/LwCYpsh7Dm7e8kSe8Lw2MWb6FXyF3mDJM6xqN3xXQWOh6UEu5wA== - dependencies: - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/density" "14.0.0-canary.261f2db59.0" - "@material/dom" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/ripple" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - "@material/shape" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - "@material/typography" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/menu-surface@14.0.0-canary.261f2db59.0", "@material/menu-surface@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/menu-surface/-/menu-surface-14.0.0-canary.261f2db59.0.tgz#5332e93bdd4724745825396a0f336b0114cfd96e" - integrity sha512-zv/fv/W3zdSb+c/p6GNcOqA3+wAc/r8MOtV53WJPLlvZZSpGoTwHUp+GPiNeovfbsTSxN95XOXuVQBEfKEb8vA== - dependencies: - "@material/animation" "14.0.0-canary.261f2db59.0" - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/elevation" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - "@material/shape" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/menu@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/menu/-/menu-14.0.0-canary.261f2db59.0.tgz#7bd1e8eaa5a4933d2f4bb868085b6e4895ec15e9" - integrity sha512-x59UHoTLvEsPKjFdffrKTgEyc0T4W3m58RsizAmapXr59Uthq8+PTFOkAv9R1PV/ZCzxay7Vx+QcekC4qOr40A== - dependencies: - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/dom" "14.0.0-canary.261f2db59.0" - "@material/elevation" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/list" "14.0.0-canary.261f2db59.0" - "@material/menu-surface" "14.0.0-canary.261f2db59.0" - "@material/ripple" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/mwc-base@^0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@material/mwc-base/-/mwc-base-0.25.3.tgz#b3e5f5409ce8250a40856f0d35cf9476244227dc" - integrity sha512-4wvxZ9dhPr0O4jjOHPmFyn77pafe+h1gHPlT9sbQ+ly8NY/fSn/TXn7/PbxgL8g4ZHxMvD3o7PJopg+6cbHp8Q== - dependencies: - "@lit/reactive-element" "1.0.0-rc.4" - "@material/base" "=14.0.0-canary.261f2db59.0" - "@material/dom" "=14.0.0-canary.261f2db59.0" - lit "^2.0.0" - tslib "^2.0.1" - -"@material/mwc-checkbox@^0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@material/mwc-checkbox/-/mwc-checkbox-0.25.3.tgz#93fd75b301186b4775456b8fe5155c9417e8fcfd" - integrity sha512-PSh9IAgQK4XiDzBwgclheejkA4cbZ3K9V1JTTl/YVRDD/OLLM+Bh8tbnAg/1kGVlPWOUfDrYCcZ0gg472ca7gw== - dependencies: - "@material/mwc-base" "^0.25.3" - "@material/mwc-ripple" "^0.25.3" - lit "^2.0.0" - tslib "^2.0.1" - -"@material/mwc-list@^0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@material/mwc-list/-/mwc-list-0.25.3.tgz#9cbcb4e535f718e1029d0ae9de637cbe0049d589" - integrity sha512-2T297qVaQsKv+QDNP2ag9g04RLKO1tm2F6BwwqvdbXTsY+LKYOJe2/aSe0kX2tQLayX4ydy2RnTevo9Ld+c+4g== - dependencies: - "@material/base" "=14.0.0-canary.261f2db59.0" - "@material/dom" "=14.0.0-canary.261f2db59.0" - "@material/list" "=14.0.0-canary.261f2db59.0" - "@material/mwc-base" "^0.25.3" - "@material/mwc-checkbox" "^0.25.3" - "@material/mwc-radio" "^0.25.3" - "@material/mwc-ripple" "^0.25.3" - lit "^2.0.0" - tslib "^2.0.1" - -"@material/mwc-menu@^0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@material/mwc-menu/-/mwc-menu-0.25.3.tgz#f3dec5f520726715249bbe7301bfad83f7e9273d" - integrity sha512-jr5R61BfqrJC0lsAI63y4BsEM2eY3n6kiCy2ZnwinmxrfFrS709T/zuSUUW/xG9b9inSku4WjjSkDhPzQrmS3g== - dependencies: - "@material/menu" "=14.0.0-canary.261f2db59.0" - "@material/menu-surface" "=14.0.0-canary.261f2db59.0" - "@material/mwc-base" "^0.25.3" - "@material/mwc-list" "^0.25.3" - "@material/shape" "=14.0.0-canary.261f2db59.0" - "@material/theme" "=14.0.0-canary.261f2db59.0" - lit "^2.0.0" - tslib "^2.0.1" - -"@material/mwc-radio@^0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@material/mwc-radio/-/mwc-radio-0.25.3.tgz#f57aeadba2fc2fcfa2a7eec9ee4484249031e661" - integrity sha512-SXpVDrsQnz7+2w/kfBxcOJ4P+uJ0RxBd9mCLE7wVyN53gDLkNHqA0npdl2PNpRaaMavVrt27L8wWo5QIT+7zWA== - dependencies: - "@material/mwc-base" "^0.25.3" - "@material/mwc-ripple" "^0.25.3" - "@material/radio" "=14.0.0-canary.261f2db59.0" - lit "^2.0.0" - tslib "^2.0.1" - -"@material/mwc-ripple@^0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@material/mwc-ripple/-/mwc-ripple-0.25.3.tgz#3b8f08b9d4195ff42989d08a22cc07a432fd753d" - integrity sha512-G/gt/csxgME6/sAku3GiuB0O2LLvoPWsRTLq/9iABpaGLJjqaKHvNg/IVzNDdF3YZT7EORgR9cBWWl7umA4i4Q== - dependencies: - "@material/dom" "=14.0.0-canary.261f2db59.0" - "@material/mwc-base" "^0.25.3" - "@material/ripple" "=14.0.0-canary.261f2db59.0" - lit "^2.0.0" - tslib "^2.0.1" - -"@material/radio@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/radio/-/radio-14.0.0-canary.261f2db59.0.tgz#d3e663e39b300c9cd19e84e00ff5d5ee3360d99b" - integrity sha512-AvrsOqhP8UZ5d58RWgaTmQVlWQRULwk2BXhsEhtxz56CmTsyVM49thNbaNnc/TzuY9Ssxv/L2wYVbR2B3BX9Yw== - dependencies: - "@material/animation" "14.0.0-canary.261f2db59.0" - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/density" "14.0.0-canary.261f2db59.0" - "@material/dom" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/ripple" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - "@material/touch-target" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/ripple@14.0.0-canary.261f2db59.0", "@material/ripple@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/ripple/-/ripple-14.0.0-canary.261f2db59.0.tgz#32a0dbdac1e65bdbc181ef36d59ee3f0d5afd037" - integrity sha512-3FLCLj8X7KrFfuYBHJg1b7Odb3V/AW7fxk3m1i1zhDnygKmlQ/abVucH1s2qbX3Y+JIiq+5/C5407h9BFtOf+A== - dependencies: - "@material/animation" "14.0.0-canary.261f2db59.0" - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/dom" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/rtl@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/rtl/-/rtl-14.0.0-canary.261f2db59.0.tgz#01ef76b6c084acfc507999581ff4be46fe2c7703" - integrity sha512-bVnXBbUsHs57+EXdeFbcwaKy3lT/itI/qTLmJ88ar0qaGEujO1GmESHm3ioqkeo4kQpTfDhBwQGeEi1aDaTdFg== - dependencies: - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/rtl@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@material/rtl/-/rtl-13.0.0.tgz#c8dbdc2b63d122450ff456572d08a9f5639704ab" - integrity sha512-nFGy3iQg7k+xLs67eb86mRFVLwa0yi7MusqRK4OM8DXcLO5yoVfUTPKpdSykcbRryp9imVHsxutox2tZawR4og== - dependencies: - "@material/theme" "^13.0.0" - tslib "^2.1.0" - -"@material/shape@14.0.0-canary.261f2db59.0", "@material/shape@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/shape/-/shape-14.0.0-canary.261f2db59.0.tgz#256be16b1b6e6e0a604198f3c3d29abed1880950" - integrity sha512-VjcQltd1uF9ugvLExMy00SMISjy/370o8lsZlb1T+xHyhXHL3UxeuWYLW5Amq6mbx65+c9Df9WmlXXOdebpEkw== - dependencies: - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/shape@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@material/shape/-/shape-13.0.0.tgz#42751ceb4c709c87026f14340aed1831106fdfda" - integrity sha512-exk96+iCjzCujk3aSrvIMhmW773s1Tc0h+MbQKbt6Iv3nHJCyLSiRbxclCHXWHrVwG/9KZRkrt/g2qk7P3VRBg== - dependencies: - "@material/feature-targeting" "^13.0.0" - "@material/rtl" "^13.0.0" - "@material/theme" "^13.0.0" - tslib "^2.1.0" - -"@material/theme@14.0.0-canary.261f2db59.0", "@material/theme@=14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/theme/-/theme-14.0.0-canary.261f2db59.0.tgz#acc25e9841a0072a7ce8f59588eaa2c9849bdb1f" - integrity sha512-bUqyFT0QF8Nxx02fekt3CXIfC9DEPOPdo2hjgdtvhrNP+vftbkI2tKZ5/uRUnVA+zqQAOyIl5z6FOMg4fyemCA== - dependencies: - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/theme@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@material/theme/-/theme-13.0.0.tgz#dad25ca7c3427d5d35f074343c7f7957cbc1eb8c" - integrity sha512-KAe1s0MvvfCGAwJliDVTvgAKuD3ESwhl7F7br4Iam4IPdqME2rWl8NPhKHFfaWqTG7PyCgMMngYEYuA8cLNTsA== - dependencies: - "@material/feature-targeting" "^13.0.0" - tslib "^2.1.0" - -"@material/touch-target@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/touch-target/-/touch-target-14.0.0-canary.261f2db59.0.tgz#fe6be5287a415112b3f29a95d939b17f1c1de7a1" - integrity sha512-xA6TTHN7aOTXg/+c6mQJlogzTD+Sp8WPC5TK8RBXbQxEykGXGW15p+H9pG+rX/gzD5iehnHRBrDUFmAGoskhcQ== - dependencies: - "@material/base" "14.0.0-canary.261f2db59.0" - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/rtl" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/typography@14.0.0-canary.261f2db59.0": - version "14.0.0-canary.261f2db59.0" - resolved "https://registry.yarnpkg.com/@material/typography/-/typography-14.0.0-canary.261f2db59.0.tgz#dd89dcff311b5398f22de4746a9d64bbf3d0a62e" - integrity sha512-WOCdcNkD5KBRAwICcRqWBRG3cDkyrwK5USTNmG0oxnwnZAN7daOpPTdLppVAhadE7faj8d67ON+V9pH7+T62FQ== - dependencies: - "@material/feature-targeting" "14.0.0-canary.261f2db59.0" - "@material/theme" "14.0.0-canary.261f2db59.0" - tslib "^2.1.0" - -"@material/typography@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@material/typography/-/typography-13.0.0.tgz#da40615bddea8c56496a89b3a6f8f71c1fccce96" - integrity sha512-UfaK4vT3LmGiiySf2RVIrf7fJZa6EJadFwo4YUMJx9bvUMRlBm1oI8Vo9fYpKdLfuSTeA+2BlgbwYVObj3laFw== - dependencies: - "@material/feature-targeting" "^13.0.0" - "@material/theme" "^13.0.0" - tslib "^2.1.0" +"@lit/reactive-element@^1.1.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.4.0": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3" + integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -714,67 +443,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@npmcli/fs@^1.0.0": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257" - integrity sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ== - dependencies: - "@gar/promisify" "^1.0.1" - semver "^7.3.5" - -"@npmcli/git@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-3.0.0.tgz#466a18980da6b646a8112a7676688ae5347deba3" - integrity sha512-xfSBJ+KBMZWWqRHFbEgIaXG/LtELHrQZMJ72Gkb3yWdHysu/7+VGOs8ME0c3td7QNQX57Ggo3kYL6ylcd70/kA== - dependencies: - "@npmcli/promise-spawn" "^1.3.2" - lru-cache "^7.3.1" - mkdirp "^1.0.4" - npm-pick-manifest "^7.0.0" - proc-log "^2.0.0" - promise-inflight "^1.0.1" - promise-retry "^2.0.1" - semver "^7.3.5" - which "^2.0.2" - -"@npmcli/installed-package-contents@^1.0.7": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-1.0.7.tgz#ab7408c6147911b970a8abe261ce512232a3f4fa" - integrity sha512-9rufe0wnJusCQoLpV9ZPKIVP55itrM5BxOXs10DmdbRfgWtHy1LDyskbwRnBghuB0PrF7pNPOqREVtpz4HqzKw== - dependencies: - npm-bundled "^1.1.1" - npm-normalize-package-bin "^1.0.1" - -"@npmcli/move-file@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.1.2.tgz#1a82c3e372f7cae9253eb66d72543d6b8685c674" - integrity sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg== - dependencies: - mkdirp "^1.0.4" - rimraf "^3.0.2" - -"@npmcli/node-gyp@^1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-1.0.3.tgz#a912e637418ffc5f2db375e93b85837691a43a33" - integrity sha512-fnkhw+fmX65kiLqk6E3BFLXNC26rUhK90zVwe2yncPliVT/Qos3xjhTLE59Df8KnPlcwIERXKVlU1bXoUQ+liA== - -"@npmcli/promise-spawn@^1.2.0", "@npmcli/promise-spawn@^1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-1.3.2.tgz#42d4e56a8e9274fba180dabc0aea6e38f29274f5" - integrity sha512-QyAGYo/Fbj4MXeGdJcFzZ+FkDkomfRBrPM+9QYJSg+PxgAUL+LU3FneQk37rKR2/zjqkCV1BLHccX98wRXG3Sg== - dependencies: - infer-owner "^1.0.4" - -"@npmcli/run-script@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-3.0.1.tgz#9d10b46586300074cc9e53ef320130a69567e1ce" - integrity sha512-o2fkld5hYwu9sKYzoXTpqEocMnDLaigobaPzLaGB63k/ExmLBTaB+KpfKlpcIePPnuP8RFR+0GDI4KopJCM6Xg== - dependencies: - "@npmcli/node-gyp" "^1.0.3" - "@npmcli/promise-spawn" "^1.3.2" - node-gyp "^9.0.0" - read-package-json-fast "^2.0.3" - "@rollup/plugin-babel@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -783,10 +451,10 @@ "@babel/helper-module-imports" "^7.10.4" "@rollup/pluginutils" "^3.1.0" -"@rollup/plugin-commonjs@^21.0.2": - version "21.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz#0b9c539aa1837c94abfaf87945838b0fc8564891" - integrity sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg== +"@rollup/plugin-commonjs@^22.0.2": + version "22.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz#ee8ca8415cda30d383b4096aad5222435b4b69b6" + integrity sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" @@ -811,18 +479,26 @@ dependencies: "@rollup/pluginutils" "^3.0.8" -"@rollup/plugin-node-resolve@^13.1.3": - version "13.1.3" - resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.3.tgz#2ed277fb3ad98745424c1d2ba152484508a92d79" - integrity sha512-BdxNk+LtmElRo5d06MGY4zoepyrXX1tkzX2hrnPEZ53k78GuOMWLqmJDGIIOPwVRIFZrLQOo+Yr6KtCuLIA0AQ== +"@rollup/plugin-node-resolve@^13.3.0": + version "13.3.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c" + integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw== dependencies: "@rollup/pluginutils" "^3.1.0" "@types/resolve" "1.17.1" - builtin-modules "^3.1.0" deepmerge "^4.2.2" + is-builtin-module "^3.1.0" is-module "^1.0.0" resolve "^1.19.0" +"@rollup/plugin-replace@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz#e34c457d6a285f0213359740b43f39d969b38a67" + integrity sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g== + dependencies: + "@rollup/pluginutils" "^3.1.0" + magic-string "^0.25.7" + "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" @@ -833,35 +509,23 @@ picomatch "^2.2.2" "@rollup/pluginutils@^4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.2.tgz#ed5821c15e5e05e32816f5fb9ec607cdf5a75751" - integrity sha512-ROn4qvkxP9SyPeHaf7uQC/GPFY6L/OWy9+bd9AwcjOAWQwxRscoEyAUD8qCY5o5iL4jqQwoLk2kaTKJPb/HwzQ== + version "4.2.1" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" + integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== dependencies: estree-walker "^2.0.1" picomatch "^2.2.2" -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - dependencies: - defer-to-connect "^1.0.1" - -"@tootallnate/once@2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" - integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== - "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@types/bluebird@^3.5.36": + version "3.5.36" + resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652" + integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q== + "@types/cssnano@^5.0.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@types/cssnano/-/cssnano-5.1.0.tgz#a83529c148dccc984bccbe33563941f0fa6bd1e9" @@ -870,24 +534,29 @@ cssnano "*" "@types/estree@*": - version "0.0.51" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" - integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" + integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/hammerjs@^2.0.36": + version "2.0.41" + resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.41.tgz#f6ecf57d1b12d2befcce00e928a6a097c22980aa" + integrity sha512-ewXv/ceBaJprikMcxCmWU1FKyMAQ2X7a9Gtmzw8fcg2kIePI1crERDM818W+XYrxqdBBOdlf2rm137bU+BltCA== + "@types/json-schema@^7.0.9": - version "7.0.9" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" - integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== + version "7.0.11" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" + integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" - integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/lodash-es@^4.17.5": version "4.17.6" @@ -897,14 +566,14 @@ "@types/lodash" "*" "@types/lodash@*": - version "4.14.179" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.179.tgz#490ec3288088c91295780237d2497a3aa9dfb5c5" - integrity sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w== + version "4.14.184" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.184.tgz#23f96cd2a21a28e106dc24d825d4aa966de7a9fe" + integrity sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q== "@types/node@*": - version "17.0.21" - resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644" - integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ== + version "18.7.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.16.tgz#0eb3cce1e37c79619943d2fd903919fc30850601" + integrity sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg== "@types/parse-json@^4.0.0": version "4.0.0" @@ -923,133 +592,96 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== -"@typescript-eslint/eslint-plugin@^5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.15.0.tgz#c28ef7f2e688066db0b6a9d95fb74185c114fb9a" - integrity sha512-u6Db5JfF0Esn3tiAKELvoU5TpXVSkOpZ78cEGn/wXtT2RVqs2vkt4ge6N8cRCyw7YVKhmmLDbwI2pg92mlv7cA== +"@typescript-eslint/eslint-plugin@^5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.2.tgz#6df092a20e0f9ec748b27f293a12cb39d0c1fe4d" + integrity sha512-OwwR8LRwSnI98tdc2z7mJYgY60gf7I9ZfGjN5EjCwwns9bdTuQfAXcsjSB2wSQ/TVNYSGKf4kzVXbNGaZvwiXw== dependencies: - "@typescript-eslint/scope-manager" "5.15.0" - "@typescript-eslint/type-utils" "5.15.0" - "@typescript-eslint/utils" "5.15.0" - debug "^4.3.2" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/type-utils" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" functional-red-black-tree "^1.0.1" - ignore "^5.1.8" + ignore "^5.2.0" regexpp "^3.2.0" - semver "^7.3.5" + semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.15.0.tgz#95f603f8fe6eca7952a99bfeef9b85992972e728" - integrity sha512-NGAYP/+RDM2sVfmKiKOCgJYPstAO40vPAgACoWPO/+yoYKSgAXIFaBKsV8P0Cc7fwKgvj27SjRNX4L7f4/jCKQ== +"@typescript-eslint/parser@^5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.2.tgz#3ddf323d3ac85a25295a55fcb9c7a49ab4680ddd" + integrity sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA== dependencies: - "@typescript-eslint/scope-manager" "5.15.0" - "@typescript-eslint/types" "5.15.0" - "@typescript-eslint/typescript-estree" "5.15.0" - debug "^4.3.2" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + debug "^4.3.4" -"@typescript-eslint/scope-manager@5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.15.0.tgz#d97afab5e0abf4018d1289bd711be21676cdd0ee" - integrity sha512-EFiZcSKrHh4kWk0pZaa+YNJosvKE50EnmN4IfgjkA3bTHElPtYcd2U37QQkNTqwMCS7LXeDeZzEqnsOH8chjSg== +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== dependencies: - "@typescript-eslint/types" "5.15.0" - "@typescript-eslint/visitor-keys" "5.15.0" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" -"@typescript-eslint/type-utils@5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.15.0.tgz#d2c02eb2bdf54d0a645ba3a173ceda78346cf248" - integrity sha512-KGeDoEQ7gHieLydujGEFLyLofipe9PIzfvA/41urz4hv+xVxPEbmMQonKSynZ0Ks2xDhJQ4VYjB3DnRiywvKDA== +"@typescript-eslint/type-utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.2.tgz#752373f4babf05e993adf2cd543a763632826391" + integrity sha512-rPQtS5rfijUWLouhy6UmyNquKDPhQjKsaKH0WnY6hl/07lasj8gPaH2UD8xWkePn6SC+jW2i9c2DZVDnL+Dokw== dependencies: - "@typescript-eslint/utils" "5.15.0" - debug "^4.3.2" + "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/utils" "5.36.2" + debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.15.0.tgz#c7bdd103843b1abae97b5518219d3e2a0d79a501" - integrity sha512-yEiTN4MDy23vvsIksrShjNwQl2vl6kJeG9YkVJXjXZnkJElzVK8nfPsWKYxcsGWG8GhurYXP4/KGj3aZAxbeOA== +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== -"@typescript-eslint/typescript-estree@5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.15.0.tgz#81513a742a9c657587ad1ddbca88e76c6efb0aac" - integrity sha512-Hb0e3dGc35b75xLzixM3cSbG1sSbrTBQDfIScqdyvrfJZVEi4XWAT+UL/HMxEdrJNB8Yk28SKxPLtAhfCbBInA== +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== dependencies: - "@typescript-eslint/types" "5.15.0" - "@typescript-eslint/visitor-keys" "5.15.0" - debug "^4.3.2" - globby "^11.0.4" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" is-glob "^4.0.3" - semver "^7.3.5" + semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.15.0.tgz#468510a0974d3ced8342f37e6c662778c277f136" - integrity sha512-081rWu2IPKOgTOhHUk/QfxuFog8m4wxW43sXNOMSCdh578tGJ1PAaWPsj42LOa7pguh173tNlMigsbrHvh/mtA== +"@typescript-eslint/utils@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.2.tgz#b01a76f0ab244404c7aefc340c5015d5ce6da74c" + integrity sha512-uNcopWonEITX96v9pefk9DC1bWMdkweeSsewJ6GeC7L6j2t0SJywisgkr9wUTtXk90fi2Eljj90HSHm3OGdGRg== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.15.0" - "@typescript-eslint/types" "5.15.0" - "@typescript-eslint/typescript-estree" "5.15.0" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.15.0": - version "5.15.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.15.0.tgz#5669739fbf516df060f978be6a6dce75855a8027" - integrity sha512-+vX5FKtgvyHbmIJdxMJ2jKm9z2BIlXJiuewI8dsDYMp5LzPUcuTT78Ya5iwvQg3VqSVdmxyM8Anj1Jeq7733ZQ== +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== dependencies: - "@typescript-eslint/types" "5.15.0" - eslint-visitor-keys "^3.0.0" + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" -"@yarn-tool/resolve-package@^1.0.40": - version "1.0.45" - resolved "https://registry.yarnpkg.com/@yarn-tool/resolve-package/-/resolve-package-1.0.45.tgz#4d9716a67903f46a76c8691eff546dafe55bf66f" - integrity sha512-xnfY8JceApkSTliZtr7X6yl1wZYhGbRp0beBMi1OtmvTVTm/ZSt3881Fw1M3ZwhHqr7OEfl8828LJK2q62BvoQ== - dependencies: - pkg-dir "< 6 >= 5" - tslib "^2.3.1" - upath2 "^3.1.12" - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -acorn-jsx@^5.3.1: +acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.5.0, acorn@^8.7.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" - integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== - -agent-base@6, agent-base@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -agentkeepalive@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.2.1.tgz#a7975cbb9f83b367f06c90cc51ff28fe7d499717" - integrity sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA== - dependencies: - debug "^4.1.0" - depd "^1.1.2" - humanize-ms "^1.2.1" - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" +acorn@^8.5.0, acorn@^8.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" + integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== ajv@^6.10.0, ajv@^6.12.4: version "6.12.6" @@ -1061,13 +693,6 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-align@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" - integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== - dependencies: - string-width "^4.1.0" - ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -1080,7 +705,7 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0, ansi-styles@^4.1.0: +ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -1095,32 +720,19 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -"aproba@^1.0.3 || ^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" - integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== - -are-we-there-yet@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.0.tgz#ba20bd6b553e31d62fc8c31bd23d22b95734390d" - integrity sha512-0GWpv50YSOcLXaN6/FAKY3vfRbllXWV2xvfA/oKJF8pzFhWXPV+yjhJXDBbjscDYowv7Yw1A3uigpzn5iEGTyw== - dependencies: - delegates "^1.0.0" - readable-stream "^3.6.0" - argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== array-includes@^3.1.4: - version "3.1.4" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.4.tgz#f5b493162c760f3539631f005ba2bb46acb45ba9" - integrity sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw== + version "3.1.5" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.5.tgz#2c320010db8d31031fd2a5f6b3bbd4b1aad31bdb" + integrity sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ== dependencies: call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.1" + define-properties "^1.1.4" + es-abstract "^1.19.5" get-intrinsic "^1.1.1" is-string "^1.0.7" @@ -1130,13 +742,14 @@ array-union@^2.1.0: integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== array.prototype.flat@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz#07e0975d84bbc7c48cd1879d609e682598d33e13" - integrity sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg== + version "1.3.0" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz#0b0c1567bf57b38b56b4c97b8aa72ab45e4adc7b" + integrity sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw== dependencies: call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.19.0" + es-abstract "^1.19.2" + es-shim-unscopables "^1.0.0" balanced-match@^1.0.0: version "1.0.2" @@ -1151,21 +764,7 @@ binary-extensions@^2.0.0: boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= - -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^1.1.7: version "1.1.11" @@ -1175,82 +774,32 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -braces@^3.0.1, braces@~3.0.2: +braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== dependencies: fill-range "^7.0.1" -browserslist@^4.0.0, browserslist@^4.16.6, browserslist@^4.17.5: - version "4.19.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.3.tgz#29b7caad327ecf2859485f696f9604214bedd383" - integrity sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg== +browserslist@^4.0.0, browserslist@^4.16.6, browserslist@^4.20.2, browserslist@^4.20.3: + version "4.21.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" + integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ== dependencies: - caniuse-lite "^1.0.30001312" - electron-to-chromium "^1.4.71" - escalade "^3.1.1" - node-releases "^2.0.2" - picocolors "^1.0.0" + caniuse-lite "^1.0.30001370" + electron-to-chromium "^1.4.202" + node-releases "^2.0.6" + update-browserslist-db "^1.0.5" buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -builtin-modules@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" - integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA== - -builtins@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" - integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og= - -cacache@^15.3.0: - version "15.3.0" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb" - integrity sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ== - dependencies: - "@npmcli/fs" "^1.0.0" - "@npmcli/move-file" "^1.0.1" - chownr "^2.0.0" - fs-minipass "^2.0.0" - glob "^7.1.4" - infer-owner "^1.0.4" - lru-cache "^6.0.0" - minipass "^3.1.1" - minipass-collect "^1.0.2" - minipass-flush "^1.0.5" - minipass-pipeline "^1.2.2" - mkdirp "^1.0.3" - p-map "^4.0.0" - promise-inflight "^1.0.1" - rimraf "^3.0.2" - ssri "^8.0.1" - tar "^6.0.2" - unique-filename "^1.1.1" - -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" +builtin-modules@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" @@ -1265,11 +814,6 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" @@ -1280,10 +824,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001312: - version "1.0.30001313" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001313.tgz#a380b079db91621e1b7120895874e2fd62ed2e2f" - integrity sha512-rI1UN0koZUiKINjysQDuRi2VeSCce3bYJNmDcj3PIKREiAmjakugBul1QSkg/fPrlULYl6oWfGg3PbgOSY9X4Q== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001370: + version "1.0.30001393" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001393.tgz#1aa161e24fe6af2e2ccda000fc2b94be0b0db356" + integrity sha512-N/od11RX+Gsk+1qY/jbPa0R6zJupEa0lxeBG598EbrtblxVCTJsQwbRBm6+V+rxpc5lHKdsXb9RY83cZIPLseA== chalk@^2.0.0: version "2.4.2" @@ -1294,7 +838,7 @@ chalk@^2.0.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4.0.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -1302,11 +846,6 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -charcodes@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/charcodes/-/charcodes-0.2.0.tgz#5208d327e6cc05f99eb80ffc814707572d1f14e4" - integrity sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ== - "chokidar@>=3.0.0 <4.0.0": version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -1322,45 +861,6 @@ charcodes@^0.2.0: optionalDependencies: fsevents "~2.3.2" -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== - -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - -cint@^8.2.1: - version "8.2.1" - resolved "https://registry.yarnpkg.com/cint/-/cint-8.2.1.tgz#70386b1b48e2773d0d63166a55aff94ef4456a12" - integrity sha1-cDhrG0jidz0NYxZqVa/5TvRFahI= - -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - -cli-table@^0.3.11: - version "0.3.11" - resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.11.tgz#ac69cdecbe81dccdba4889b9a18b7da312a9d3ee" - integrity sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ== - dependencies: - colors "1.0.3" - -clone-response@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" - integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= - dependencies: - mimic-response "^1.0.0" - color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1378,29 +878,19 @@ color-convert@^2.0.1: color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-support@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" - integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== - colord@^2.9.1: - version "2.9.2" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1" - integrity sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ== + version "2.9.3" + resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" + integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== -colors@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= - -commander@^2.20.0: +commander@^2.20.0, commander@^2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -1410,43 +900,26 @@ commander@^7.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== -commander@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.0.0.tgz#86d58f24ee98126568936bd1d3574e0308a99a40" - integrity sha512-JJfP2saEKbQqvW+FI93OYUB4ByV5cizMpFMiiJI8xDbBvQvSkIk0VvQdn1CZ8mqAO8Loq2h0gYTYtDFUZUeERw== - commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== + +component-emitter@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -configstore@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" - integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== - dependencies: - dot-prop "^5.2.0" - graceful-fs "^4.1.2" - make-dir "^3.0.0" - unique-string "^2.0.0" - write-file-atomic "^3.0.0" - xdg-basedir "^4.0.0" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== confusing-browser-globals@^1.0.10: version "1.0.11" resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== -console-control-strings@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - convert-source-map@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" @@ -1474,26 +947,24 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +crypto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" + integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== -css-declaration-sorter@^6.0.3: - version "6.1.4" - resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.1.4.tgz#b9bfb4ed9a41f8dcca9bf7184d849ea94a8294b4" - integrity sha512-lpfkqS0fctcmZotJGhnxkIyJWvBXgpyi2wsFd4J8VB7wzyrT6Ch/3Q+FMNJpjK4gu1+GN5khOnpU2ZVKrLbhCw== - dependencies: - timsort "^0.3.0" +css-declaration-sorter@^6.3.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz#be5e1d71b7a992433fb1c542c7a1b835e45682ec" + integrity sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w== css-select@^4.1.3: - version "4.2.1" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.2.1.tgz#9e665d6ae4c7f9d65dbe69d0316e3221fb274cdd" - integrity sha512-/aUslKhzkTNCQUB2qTX84lVmfia9NyjP3WpDGtj/WxhwBzWBYUV3DgUpurHTme8UTPcPlAD1DJ+b0nN/t50zDQ== + version "4.3.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" + integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== dependencies: boolbase "^1.0.0" - css-what "^5.1.0" - domhandler "^4.3.0" + css-what "^6.0.1" + domhandler "^4.3.1" domutils "^2.8.0" nth-check "^2.0.1" @@ -1505,50 +976,55 @@ css-tree@^1.1.2, css-tree@^1.1.3: mdn-data "2.0.14" source-map "^0.6.1" -css-what@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.1.0.tgz#3f7b707aadf633baf62c2ceb8579b545bb40f7fe" - integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw== +css-what@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssnano-preset-default@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.0.tgz#2579d38b9217746f2cf9f938954a91e00418ded6" - integrity sha512-3N5Vcptj2pqVKpHVqH6ezOJvqikR2PdLTbTrsrhF61FbLRQuujAqZ2sKN5rvcMsb7hFjrNnjZT8CGEkxoN/Pwg== +cssfilter@0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" + integrity sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw== + +cssnano-preset-default@^5.2.12: + version "5.2.12" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz#ebe6596ec7030e62c3eb2b3c09f533c0644a9a97" + integrity sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew== dependencies: - css-declaration-sorter "^6.0.3" + css-declaration-sorter "^6.3.0" cssnano-utils "^3.1.0" postcss-calc "^8.2.3" postcss-colormin "^5.3.0" - postcss-convert-values "^5.1.0" - postcss-discard-comments "^5.1.0" + postcss-convert-values "^5.1.2" + postcss-discard-comments "^5.1.2" postcss-discard-duplicates "^5.1.0" - postcss-discard-empty "^5.1.0" + postcss-discard-empty "^5.1.1" postcss-discard-overridden "^5.1.0" - postcss-merge-longhand "^5.1.0" - postcss-merge-rules "^5.1.0" + postcss-merge-longhand "^5.1.6" + postcss-merge-rules "^5.1.2" postcss-minify-font-values "^5.1.0" - postcss-minify-gradients "^5.1.0" - postcss-minify-params "^5.1.0" - postcss-minify-selectors "^5.2.0" + postcss-minify-gradients "^5.1.1" + postcss-minify-params "^5.1.3" + postcss-minify-selectors "^5.2.1" postcss-normalize-charset "^5.1.0" postcss-normalize-display-values "^5.1.0" - postcss-normalize-positions "^5.1.0" - postcss-normalize-repeat-style "^5.1.0" + postcss-normalize-positions "^5.1.1" + postcss-normalize-repeat-style "^5.1.1" postcss-normalize-string "^5.1.0" postcss-normalize-timing-functions "^5.1.0" postcss-normalize-unicode "^5.1.0" postcss-normalize-url "^5.1.0" - postcss-normalize-whitespace "^5.1.0" - postcss-ordered-values "^5.1.0" + postcss-normalize-whitespace "^5.1.1" + postcss-ordered-values "^5.1.3" postcss-reduce-initial "^5.1.0" postcss-reduce-transforms "^5.1.0" postcss-svgo "^5.1.0" - postcss-unique-selectors "^5.1.0" + postcss-unique-selectors "^5.1.1" cssnano-utils@^3.1.0: version "3.1.0" @@ -1556,11 +1032,11 @@ cssnano-utils@^3.1.0: integrity sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA== cssnano@*, cssnano@^5.0.15: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.0.tgz#cf977d660a5824d0d5542639ed1d4045afd84cbe" - integrity sha512-wWxave1wMlThGg4ueK98jFKaNqXnQd1nVZpSkQ9XvR+YymlzP1ofWqES1JkHtI250LksP9z5JH+oDcrKDJezAg== + version "5.1.13" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.13.tgz#83d0926e72955332dc4802a7070296e6258efc0a" + integrity sha512-S2SL2ekdEz6w6a2epXn4CmMKU4K3KpcyXLKfAYc9UQQqJRkD/2eLUG0vJ3Db/9OvO5GuAdgXw3pFbR6abqghDQ== dependencies: - cssnano-preset-default "^5.2.0" + cssnano-preset-default "^5.2.12" lilconfig "^2.0.3" yaml "^1.10.2" @@ -1571,7 +1047,7 @@ csso@^4.2.0: dependencies: css-tree "^1.1.2" -custom-card-helpers@^1.8.0: +custom-card-helpers@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/custom-card-helpers/-/custom-card-helpers-1.9.0.tgz#dac7bb7a531101f4c83096b26a8be8ccad70d8c0" integrity sha512-5IW4OXq3MiiCqDvqeu+MYsM1NmntKW1WfJhyJFsdP2tbzqEI4BOnqRz2qzdp08lE4QLVhYfRLwe0WAqgQVNeFg== @@ -1584,17 +1060,10 @@ custom-card-helpers@^1.8.0: superstruct "^0.15.3" typescript "^4.5.4" -dayjs@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.0.tgz#009bf7ef2e2ea2d5db2e6583d2d39a4b5061e805" - integrity sha512-JLC809s6Y948/FuCZPm5IX8rRhQwOiyMb2TfVVQEixG7P8Lm/gt5S7yoQZmC8x1UehI9Pb7sksEt4xx14m+7Ug== - -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: - version "4.3.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" - integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== - dependencies: - ms "2.1.2" +date-fns@^2.29.2: + version "2.29.2" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931" + integrity sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA== debug@^2.6.9: version "2.6.9" @@ -1610,22 +1079,17 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= - -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= - dependencies: - mimic-response "^1.0.0" - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== deep-is@^0.1.3: version "0.1.4" @@ -1637,27 +1101,13 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - -define-properties@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" - integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== +define-properties@^1.1.3, define-properties@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" + integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== dependencies: - object-keys "^1.0.12" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - -depd@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" dir-glob@^3.0.1: version "3.0.1" @@ -1681,23 +1131,23 @@ doctrine@^3.0.0: esutils "^2.0.2" dom-serializer@^1.0.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" - integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig== + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== dependencies: domelementtype "^2.0.1" domhandler "^4.2.0" entities "^2.0.0" domelementtype@^2.0.1, domelementtype@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" - integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domhandler@^4.2.0, domhandler@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.0.tgz#16c658c626cf966967e306f966b431f77d4a5626" - integrity sha512-fC0aXNQXqKSFTr2wDNZDhsEYjCiYsDWl3D01kwt25hm1YIPyDGHvvi3rw+PLqHAl/m71MaiF7d5zvBr0p5UB2g== +domhandler@^4.2.0, domhandler@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== dependencies: domelementtype "^2.2.0" @@ -1710,67 +1160,33 @@ domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" -dot-prop@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" - integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== +electron-to-chromium@^1.4.202: + version "1.4.247" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.247.tgz#cc93859bc5fc521f611656e65ce17eae26a0fd3d" + integrity sha512-FLs6R4FQE+1JHM0hh3sfdxnYjKvJpHZyhQDjc2qFq/xFvmmRt/TATNToZhrcGUFzpF2XjeiuozrA8lI0PZmYYw== + +embla-carousel-wheel-gestures@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-3.0.0.tgz#a68ad0f76c2bec876354939b7edac00245433e19" + integrity sha512-OxMyRkDxiSHJy1DCInYjpq93q8Xz311ajCEUp/pvWH/odDc+KODLAtVI06U5NiPNIUJWj+IlZvLSVZUtpdTBEQ== dependencies: - is-obj "^2.0.0" + wheel-gestures "^2.2.5" -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - -electron-to-chromium@^1.4.71: - version "1.4.76" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.76.tgz#a0494baedaf51094b1c172999919becd9975a934" - integrity sha512-3Vftv7cenJtQb+k00McEBZ2vVmZ/x+HEF7pcZONZIkOsESqAqVuACmBxMv0JhzX7u0YltU0vSqRqgBSTAhFUjA== - -embla-carousel@^6.1.1: - version "6.2.0" - resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-6.2.0.tgz#c16b18abe50e05ccd03d0b8d0b738f6a87aea1e0" - integrity sha512-dSNsiQ7nmSQJZgbYfZCLdzrnznHwpaAcdJFcMRKgm/pjH1doOgxmfsvlMy4VfO4J11hLz8jm/W8WxSSDqfuu4w== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +embla-carousel@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-7.0.2.tgz#49660d75d26604de1ca1bb0c0be13e7cae65b0ff" + integrity sha512-6SHKbLJRvcjunb0VT1Aw7YwK0Dw8ecqEa+ppT1j+kHGEg5I4WvUd8SjRkc1NAuQK0PTBD0d567UCmzEAngeAxw== emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -encoding@^0.1.13: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== -env-paths@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" - integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== - -err-code@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" - integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== - error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -1778,31 +1194,41 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.19.0, es-abstract@^1.19.1: - version "1.19.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3" - integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w== +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: + version "1.20.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.2.tgz#8495a07bc56d342a3b8ea3ab01bd986700c2ccb3" + integrity sha512-XxXQuVNrySBNlEkTYJoDNFe5+s2yIOpzq80sUHEdPdQr0S5nTLz4ZPPPswNIpKseDDUS5yghX1gfLIHQZ1iNuQ== dependencies: call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" - get-intrinsic "^1.1.1" + function.prototype.name "^1.1.5" + get-intrinsic "^1.1.2" get-symbol-description "^1.0.0" has "^1.0.3" - has-symbols "^1.0.2" + has-property-descriptors "^1.0.0" + has-symbols "^1.0.3" internal-slot "^1.0.3" is-callable "^1.2.4" - is-negative-zero "^2.0.1" + is-negative-zero "^2.0.2" is-regex "^1.1.4" - is-shared-array-buffer "^1.0.1" + is-shared-array-buffer "^1.0.2" is-string "^1.0.7" - is-weakref "^1.0.1" - object-inspect "^1.11.0" + is-weakref "^1.0.2" + object-inspect "^1.12.2" object-keys "^1.1.1" - object.assign "^4.1.2" - string.prototype.trimend "^1.0.4" - string.prototype.trimstart "^1.0.4" - unbox-primitive "^1.0.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.4.3" + string.prototype.trimend "^1.0.5" + string.prototype.trimstart "^1.0.5" + unbox-primitive "^1.0.2" + +es-shim-unscopables@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" + integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== + dependencies: + has "^1.0.3" es-to-primitive@^1.2.1: version "1.2.1" @@ -1818,15 +1244,10 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== escape-string-regexp@^4.0.0: version "4.0.0" @@ -1856,37 +1277,36 @@ eslint-import-resolver-node@^0.3.6: debug "^3.2.7" resolve "^1.20.0" -eslint-module-utils@^2.7.2: - version "2.7.3" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz#ad7e3a10552fdd0642e1e55292781bd6e34876ee" - integrity sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ== +eslint-module-utils@^2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" + integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== dependencies: debug "^3.2.7" - find-up "^2.1.0" -eslint-plugin-import@^2.24.2: - version "2.25.4" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz#322f3f916a4e9e991ac7af32032c25ce313209f1" - integrity sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA== +eslint-plugin-import@^2.25.4: + version "2.26.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b" + integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA== dependencies: array-includes "^3.1.4" array.prototype.flat "^1.2.5" debug "^2.6.9" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.6" - eslint-module-utils "^2.7.2" + eslint-module-utils "^2.7.3" has "^1.0.3" - is-core-module "^2.8.0" + is-core-module "^2.8.1" is-glob "^4.0.3" - minimatch "^3.0.4" + minimatch "^3.1.2" object.values "^1.1.5" - resolve "^1.20.0" - tsconfig-paths "^3.12.0" + resolve "^1.22.0" + tsconfig-paths "^3.14.1" -eslint-plugin-prettier@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz#8b99d1e4b8b24a762472b4567992023619cb98e0" - integrity sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ== +eslint-plugin-prettier@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" + integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ== dependencies: prettier-linter-helpers "^1.0.0" @@ -1918,18 +1338,20 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.3.0: +eslint-visitor-keys@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@^8.11.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.11.0.tgz#88b91cfba1356fc10bb9eb592958457dfe09fb37" - integrity sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA== +eslint@^8.23.0: + version "8.23.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" + integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== dependencies: - "@eslint/eslintrc" "^1.2.1" - "@humanwhocodes/config-array" "^0.9.2" + "@eslint/eslintrc" "^1.3.1" + "@humanwhocodes/config-array" "^0.10.4" + "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" + "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -1939,14 +1361,17 @@ eslint@^8.11.0: eslint-scope "^7.1.1" eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.3.1" + espree "^9.4.0" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" + find-up "^5.0.0" functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" - globals "^13.6.0" + globals "^13.15.0" + globby "^11.1.0" + grapheme-splitter "^1.0.4" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -1955,22 +1380,21 @@ eslint@^8.11.0: json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" - minimatch "^3.0.4" + minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" regexpp "^3.2.0" strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" - v8-compile-cache "^2.0.3" -espree@^9.3.1: - version "9.3.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.1.tgz#8793b4bc27ea4c778c19908e0719e7b8f4115bcd" - integrity sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ== +espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: - acorn "^8.7.0" - acorn-jsx "^5.3.1" + acorn "^8.8.0" + acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" esquery@^1.4.0: @@ -2028,9 +1452,9 @@ fast-diff@^1.1.2: integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== fast-glob@^3.2.9: - version "3.2.11" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" - integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== + version "3.2.12" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" + integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" @@ -2046,12 +1470,7 @@ fast-json-stable-stringify@^2.0.0: fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= - -fast-memoize@^2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e" - integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw== + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fastq@^1.6.0: version "1.13.0" @@ -2060,11 +1479,6 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -figgy-pudding@^3.5.1: - version "3.5.2" - resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" - integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -2082,7 +1496,7 @@ fill-range@^7.0.1: filter-obj@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" - integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= + integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== find-cache-dir@^3.3.2: version "3.3.2" @@ -2093,28 +1507,6 @@ find-cache-dir@^3.3.2: make-dir "^3.0.2" pkg-dir "^4.1.0" -find-up@5.0.0, find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - find-up@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -2123,6 +1515,14 @@ find-up@^4.0.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -2132,35 +1532,23 @@ flat-cache@^3.0.4: rimraf "^3.0.2" flatted@^3.1.0: - version "3.2.5" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3" - integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg== - -fp-and-or@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/fp-and-or/-/fp-and-or-0.1.3.tgz#e6fba83872a5853a56b3ebdf8d3167f5dfca1882" - integrity sha512-wJaE62fLaB3jCYvY2ZHjZvmKK2iiLiiehX38rz5QZxtdN8fVPJDeZUiVvJrHStdTc+23LHlyZuSEKgFc0pxi2g== + version "3.2.7" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== fs-extra@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.1.tgz#27de43b4320e833f6867cc044bfce29fdf0ef3b8" - integrity sha512-NbdoVMZso2Lsrn/QwLXOy6rm0ufY2zEOKCDzJR/0kBsb0E6qed0P3iYK+Ath3BfvXEeu4JhEtXLgILx5psUfag== + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== dependencies: graceful-fs "^4.2.0" jsonfile "^6.0.1" universalify "^2.0.0" -fs-minipass@^2.0.0, fs-minipass@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== fsevents@~2.3.2: version "2.3.2" @@ -2172,58 +1560,39 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function.prototype.name@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" + integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + functions-have-names "^1.2.2" + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== -gauge@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.2.tgz#c3777652f542b6ef62797246e8c7caddecb32cc7" - integrity sha512-aSPRm2CvA9R8QyU5eXMFPd+cYkyxLsXHd2l5/FOH2V/eml//M04G6KZOmTap07O1PvEwNcl2NndyLfK8g3QrKA== - dependencies: - ansi-regex "^5.0.1" - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.3" - console-control-strings "^1.1.0" - has-unicode "^2.0.1" - signal-exit "^3.0.7" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.5" +functions-have-names@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" - integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1, get-intrinsic@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" + integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== dependencies: function-bind "^1.1.1" has "^1.0.3" - has-symbols "^1.0.1" - -get-stdin@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53" - integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg== - -get-stream@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" - integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== - dependencies: - pump "^3.0.0" - -get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" + has-symbols "^1.0.3" get-symbol-description@^1.0.0: version "1.0.0" @@ -2247,38 +1616,31 @@ glob-parent@^6.0.1: dependencies: is-glob "^4.0.3" -glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== +glob@^7.1.3, glob@^7.1.6: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.6.0, globals@^13.9.0: - version "13.12.1" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.12.1.tgz#ec206be932e6c77236677127577aa8e50bf1c5cb" - integrity sha512-317dFlgY2pdJZ9rspXDks7073GpDmXdfbM3vYYp0HAMKGDh1FfWPleI2ljVNLQX5M5lXcAslTcPTrOrMEFOjyw== +globals@^13.15.0: + version "13.17.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.17.0.tgz#902eb1e680a41da93945adbdcb5a9f361ba69bd4" + integrity sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw== dependencies: type-fest "^0.20.2" -globby@^11.0.4: +globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -2290,44 +1652,39 @@ globby@^11.0.4: merge2 "^1.4.1" slash "^3.0.0" -got@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6: - version "4.2.9" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" - integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== +grapheme-splitter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" + integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== -has-bigints@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" - integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-symbols@^1.0.1, has-symbols@^1.0.2: +has-property-descriptors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" + integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== + dependencies: + get-intrinsic "^1.1.1" + +has-symbols@^1.0.2, has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== @@ -2339,16 +1696,6 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" -has-unicode@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -2356,75 +1703,30 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -home-assistant-js-websocket@^6.0.1, home-assistant-js-websocket@^6.1.1: +home-assistant-js-websocket@^6.0.1: version "6.1.1" resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-6.1.1.tgz#87ba846753c4fb58a2e5ace6bb15a82689fd0735" integrity sha512-TnZFzF4mn5F/v0XKUTK2GMQXrn/+eQpgaSDSELl6U0HSwSbFwRhGWLz330YT+hiKMspDflamsye//RPL+zwhDw== -hosted-git-info@^4.0.1, hosted-git-info@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" - integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== - dependencies: - lru-cache "^6.0.0" - -http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== - -http-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" - integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== - dependencies: - "@tootallnate/once" "2" - agent-base "6" - debug "4" - -https-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" - integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== - dependencies: - agent-base "6" - debug "4" - -humanize-ms@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" - integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0= - dependencies: - ms "^2.0.0" - -iconv-lite@^0.6.2: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" +home-assistant-js-websocket@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-8.0.0.tgz#7111da312ac9fafb47d9bdfbcca8ee8fca4cd64d" + integrity sha512-H/JZDnlfXW3OBZK29YhaF3U/2maFcJSvxb0jKQBiN5i4XR+7ApYKALBBh2afLeiFQfy8Ys9AzSBY0kIeBW4Kzw== icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -ignore-walk@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-4.0.1.tgz#fc840e8346cf88a3a9380c5b17933cd8f4d39fa3" - integrity sha512-rzDQLaW4jQbh2YrOFlJdCtX8qgJTehFRYiUB2r1osqTeDzV/3+Jh8fz1oAPzUThf3iku8Ds4IDqawI5d8mUiQw== - dependencies: - minimatch "^3.0.4" - -ignore@^5.1.8, ignore@^5.2.0: +ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== immutable@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23" - integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw== + version "4.1.0" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef" + integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ== import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" @@ -2434,49 +1736,24 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= - -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - -infer-owner@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" - integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== dependencies: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3: +inherits@2: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - -ini@^1.3.5, ini@~1.3.0: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -2487,24 +1764,19 @@ internal-slot@^1.0.3: side-channel "^1.0.4" intl-messageformat@^9.11.1: - version "9.11.4" - resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.11.4.tgz#0f9030bc6d10e6a48592142f88e646d88f05f1f2" - integrity sha512-77TSkNubIy/hsapz6LQpyR6OADcxhWdhSaboPb5flMaALCVkPvAIxr48AlPqaMl4r1anNcvR9rpLWVdwUY1IKg== + version "9.13.0" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.13.0.tgz#97360b73bd82212e4f6005c712a4a16053165468" + integrity sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw== dependencies: - "@formatjs/ecma402-abstract" "1.11.3" + "@formatjs/ecma402-abstract" "1.11.4" "@formatjs/fast-memoize" "1.2.1" - "@formatjs/icu-messageformat-parser" "2.0.18" + "@formatjs/icu-messageformat-parser" "2.1.0" tslib "^2.1.0" -ip@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= - is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== is-bigint@^1.0.1: version "1.0.4" @@ -2528,22 +1800,22 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-builtin-module@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0" + integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw== + dependencies: + builtin-modules "^3.3.0" + is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - -is-core-module@^2.5.0, is-core-module@^2.8.0, is-core-module@^2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" - integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== +is-core-module@^2.8.1, is-core-module@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" + integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== dependencies: has "^1.0.3" @@ -2557,12 +1829,7 @@ is-date-object@^1.0.1: is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" @@ -2571,38 +1838,20 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - -is-lambda@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" - integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= - is-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= + integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== -is-negative-zero@^2.0.1: +is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - is-number-object@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.6.tgz#6a7aaf838c7f0686a50b4553f7e54a96494e89f0" - integrity sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g== + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== dependencies: has-tostringtag "^1.0.0" @@ -2611,16 +1860,6 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" - integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-reference@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" @@ -2636,10 +1875,12 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-shared-array-buffer@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6" - integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA== +is-shared-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" + integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== + dependencies: + call-bind "^1.0.2" is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" @@ -2655,27 +1896,17 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-typedarray@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= - -is-weakref@^1.0.1: +is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== dependencies: call-bind "^1.0.2" -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== jest-worker@^26.2.1: version "26.6.2" @@ -2686,17 +1917,12 @@ jest-worker@^26.2.1: merge-stream "^2.0.0" supports-color "^7.0.0" -jju@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" - integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo= - js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^4.0.0, js-yaml@^4.1.0: +js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== @@ -2708,23 +1934,11 @@ jsesc@^2.5.1: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= - json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== -json-parse-helpfulerror@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz#13f14ce02eed4e981297b64eb9e3b932e2dd13dc" - integrity sha1-E/FM4C7tTpgSl7ZOueO5MuLdE9w= - dependencies: - jju "^1.1.0" - json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -2733,7 +1947,7 @@ json-schema-traverse@^0.4.1: json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== json5@^1.0.1: version "1.0.1" @@ -2742,12 +1956,10 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.2: - version "2.2.0" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" - integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== - dependencies: - minimist "^1.2.5" +json5@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" + integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== jsonfile@^6.0.1: version "6.1.0" @@ -2758,34 +1970,10 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsonlines@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsonlines/-/jsonlines-0.1.1.tgz#4fcd246dc5d0e38691907c44ab002f782d1d94cc" - integrity sha1-T80kbcXQ44aRkHxEqwAveC0dlMw= - -jsonparse@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= - -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" +keycharm@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/keycharm/-/keycharm-0.4.0.tgz#8d684ea9cc01379a07fbddee33ff32d97f5ae2a7" + integrity sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ== levn@^0.4.1: version "0.4.1" @@ -2795,19 +1983,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libnpmconfig@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/libnpmconfig/-/libnpmconfig-1.2.1.tgz#c0c2f793a74e67d4825e5039e7a02a0044dfcbc0" - integrity sha512-9esX8rTQAHqarx6qeZqmGQKBNZR5OIbl/Ayr0qQDy3oXja2iFVQQI81R6GZ2a02bSNZ9p3YOGX1O6HHCb1X7kA== - dependencies: - figgy-pudding "^3.5.1" - find-up "^3.0.0" - ini "^1.3.5" - lilconfig@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082" - integrity sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA== + version "2.0.6" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" + integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== lines-and-columns@^1.1.6: version "1.2.4" @@ -2815,44 +1994,28 @@ lines-and-columns@^1.1.6: integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== lit-element@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7" - integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g== + version "3.2.2" + resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b" + integrity sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ== dependencies: "@lit/reactive-element" "^1.3.0" lit-html "^2.2.0" -lit-html@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.0.tgz#99345d944611e34543cd9f153d8cc3163b8bbab8" - integrity sha512-dJnevgV8VkCuOXLWrjQopDE8nSy8CzipZ/ATfYQv7z7Dct4abblcKecf50gkIScuwCTzKvRLgvTgV0zzagW4gA== +lit-html@^2.2.0, lit-html@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.3.1.tgz#56f15104ea75c0a702904893e3409d0e89e2a2b9" + integrity sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA== dependencies: "@types/trusted-types" "^2.0.2" -lit@^2.0.0, lit@^2.1.1, lit@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.1.tgz#4b679e1d8cb6c7977b64921b1ea3eca7850ca1dd" - integrity sha512-dSe++R50JqrvNGXmI9OE13de1z5U/Y3J2dTm/9GC86vedI8ILoR8ZGnxfThFpvQ9m0lR0qRnIR4IiKj/jDCfYw== +lit@^2.1.1, lit@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48" + integrity sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw== dependencies: - "@lit/reactive-element" "^1.3.0" + "@lit/reactive-element" "^1.4.0" lit-element "^3.2.0" - lit-html "^2.2.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" + lit-html "^2.3.0" locate-path@^5.0.0: version "5.0.0" @@ -2876,7 +2039,7 @@ lodash-es@^4.17.21: lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== lodash.merge@^4.6.2: version "4.6.2" @@ -2886,22 +2049,7 @@ lodash.merge@^4.6.2: lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= - -lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== lru-cache@^6.0.0: version "6.0.0" @@ -2910,11 +2058,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.3.1, lru-cache@^7.4.0: - version "7.4.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.4.1.tgz#afe07e885ef0cd5bf99f62f4fa7545d48746d779" - integrity sha512-NCD7/WRlFmADccuHjsRUYqdluYBr//n/O0fesCb/n52FoGcgKh8o4Dpm7YIbZwVcDs8rPBQbCZLmWWsp6m+xGQ== - magic-string@^0.25.7: version "0.25.9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" @@ -2922,35 +2065,13 @@ magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.8" -make-dir@^3.0.0, make-dir@^3.0.2: +make-dir@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== dependencies: semver "^6.0.0" -make-fetch-happen@^10.0.3: - version "10.0.4" - resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.0.4.tgz#309823c7a2b4c947465274220e169112c977b94f" - integrity sha512-CiReW6usy3UXby5N46XjWfLPFPq1glugCszh18I0NYJCwr129ZAx9j3Dlv+cRsK0q3VjlVysEzhdtdw2+NhdYA== - dependencies: - agentkeepalive "^4.2.1" - cacache "^15.3.0" - http-cache-semantics "^4.1.0" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.0" - is-lambda "^1.0.1" - lru-cache "^7.4.0" - minipass "^3.1.6" - minipass-collect "^1.0.2" - minipass-fetch "^2.0.1" - minipass-flush "^1.0.5" - minipass-pipeline "^1.2.4" - negotiator "^0.6.3" - promise-retry "^2.0.1" - socks-proxy-agent "^6.1.1" - ssri "^8.0.1" - mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -2967,340 +2088,117 @@ merge2@^1.3.0, merge2@^1.4.1: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== micromatch@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" - integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== dependencies: - braces "^3.0.1" - picomatch "^2.2.3" + braces "^3.0.2" + picomatch "^2.3.1" -mime-db@1.51.0: - version "1.51.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c" - integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g== +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== mime-types@^2.1.34: - version "2.1.34" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24" - integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A== + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: - mime-db "1.51.0" + mime-db "1.52.0" mime@>=2.4.6: version "3.0.0" resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== -mimic-response@^1.0.0, mimic-response@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - mini-svg-data-uri@^1.2.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz#43177b2e93766ba338931a3e2a84a3dfd3a222b8" - integrity sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA== + version "1.4.4" + resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939" + integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg== -minimatch@^3.0.4: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" - integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== - dependencies: - brace-expansion "^2.0.1" +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== -minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== - -minipass-collect@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" - integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== - dependencies: - minipass "^3.0.0" - -minipass-fetch@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.0.2.tgz#5ea5fb9a2e24ccd3cfb489563540bb4024fc6c31" - integrity sha512-M63u5yWX0yxY1C3DcLVY1xWai0pNM3qa1xCMXFgdejY5F/NTmyzNVHGcBxKerX51lssqxwWWTjpg/ZPuD39gOQ== - dependencies: - minipass "^3.1.6" - minipass-sized "^1.0.3" - minizlib "^2.1.2" - optionalDependencies: - encoding "^0.1.13" - -minipass-flush@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" - integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== - dependencies: - minipass "^3.0.0" - -minipass-json-stream@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz#7edbb92588fbfc2ff1db2fc10397acb7b6b44aa7" - integrity sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg== - dependencies: - jsonparse "^1.3.1" - minipass "^3.0.0" - -minipass-pipeline@^1.2.2, minipass-pipeline@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" - integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== - dependencies: - minipass "^3.0.0" - -minipass-sized@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" - integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== - dependencies: - minipass "^3.0.0" - -minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee" - integrity sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ== - dependencies: - yallist "^4.0.0" - -minizlib@^2.1.1, minizlib@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mkdirp@^1.0.3, mkdirp@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment@^2.29.4: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.0.0, ms@^2.1.1: +ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" - integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== +nanoid@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -negotiator@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -node-gyp@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-9.0.0.tgz#e1da2067427f3eb5bb56820cb62bc6b1e4bd2089" - integrity sha512-Ma6p4s+XCTPxCuAMrOA/IJRmVy16R8Sdhtwl4PrCr7IBlj4cPawF0vg/l7nOT1jPbuNS7lIRJpBSvVsXwEZuzw== - dependencies: - env-paths "^2.2.0" - glob "^7.1.4" - graceful-fs "^4.2.6" - make-fetch-happen "^10.0.3" - nopt "^5.0.0" - npmlog "^6.0.0" - rimraf "^3.0.2" - semver "^7.3.5" - tar "^6.1.2" - which "^2.0.2" - -node-releases@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01" - integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg== - -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - -normalize-package-data@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz#dbcc3e2da59509a0983422884cd172eefdfa525e" - integrity sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA== - dependencies: - hosted-git-info "^4.0.1" - is-core-module "^2.5.0" - semver "^7.3.4" - validate-npm-package-license "^3.0.1" +node-releases@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" + integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - normalize-url@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== -npm-bundled@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" - integrity sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ== - dependencies: - npm-normalize-package-bin "^1.0.1" - -npm-check-updates@^12.5.3: - version "12.5.3" - resolved "https://registry.yarnpkg.com/npm-check-updates/-/npm-check-updates-12.5.3.tgz#419292a9be0478754e747d9d52dfafac3dc8a907" - integrity sha512-siFg/+Un+j4tLA6GJCnk6hz2YU4Yc5bftDnOoiSRLKL6NeAS3FScB32mtKNBtITMJf2nsoZSr8vH+wdUGeP19g== - dependencies: - chalk "^4.1.2" - cint "^8.2.1" - cli-table "^0.3.11" - commander "^9.0.0" - fast-memoize "^2.5.2" - find-up "5.0.0" - fp-and-or "^0.1.3" - get-stdin "^8.0.0" - globby "^11.0.4" - hosted-git-info "^4.1.0" - json-parse-helpfulerror "^1.0.3" - jsonlines "^0.1.1" - libnpmconfig "^1.2.1" - lodash "^4.17.21" - minimatch "^5.0.1" - p-map "^4.0.0" - pacote "^13.0.3" - parse-github-url "^1.0.2" - progress "^2.0.3" - prompts "^2.4.2" - rc-config-loader "^4.0.0" - remote-git-tags "^3.0.0" - rimraf "^3.0.2" - semver "^7.3.5" - semver-utils "^1.1.4" - source-map-support "^0.5.21" - spawn-please "^1.0.0" - update-notifier "^5.1.0" - yaml "^1.10.2" - -npm-install-checks@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-4.0.0.tgz#a37facc763a2fde0497ef2c6d0ac7c3fbe00d7b4" - integrity sha512-09OmyDkNLYwqKPOnbI8exiOZU2GVVmQp7tgez2BPi5OZC8M82elDAps7sxC4l//uSUtotWqoEIDwjRvWH4qz8w== - dependencies: - semver "^7.1.1" - -npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - -npm-package-arg@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-9.0.0.tgz#955a5e4735298fc23f71cb72da3574daa134340c" - integrity sha512-yhzXxeor+Zfhe5MGwPdDumz6HtNlj2pMekWB95IX3CC6uDNgde0oPKHDCLDPoJqQfd0HqAWt+y4Hs5m7CK1+9Q== - dependencies: - hosted-git-info "^4.1.0" - semver "^7.3.5" - validate-npm-package-name "^3.0.0" - -npm-packlist@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-3.0.0.tgz#0370df5cfc2fcc8f79b8f42b37798dd9ee32c2a9" - integrity sha512-L/cbzmutAwII5glUcf2DBRNY/d0TFd4e/FnaZigJV6JD85RHZXJFGwCndjMWiiViiWSsWt3tiOLpI3ByTnIdFQ== - dependencies: - glob "^7.1.6" - ignore-walk "^4.0.1" - npm-bundled "^1.1.1" - npm-normalize-package-bin "^1.0.1" - -npm-pick-manifest@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-7.0.0.tgz#e3b18b09678a47e894f90941bef8204ea5d96c3b" - integrity sha512-njM1AcdioFaKd0JSGtLO09YA1WRwctjGQJbnHGmKS+u+uwP8oFvtZtOQWPYdxrnY5eJud3wn8OpH4sEIx6+GEQ== - dependencies: - npm-install-checks "^4.0.0" - npm-normalize-package-bin "^1.0.1" - npm-package-arg "^9.0.0" - semver "^7.3.5" - -npm-registry-fetch@^13.0.0: - version "13.0.1" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-13.0.1.tgz#ceecbaab9f1d0d911e1c01a5be2be96d45e472f5" - integrity sha512-Ak+LXVtSrCLOdscFW/apUw67OPNph8waHsPKM9UOJosL7i59EF5XoSWQMEsXEOeifM9Bb4/2+WrQC4t/pd8DGg== - dependencies: - make-fetch-happen "^10.0.3" - minipass "^3.1.6" - minipass-fetch "^2.0.1" - minipass-json-stream "^1.0.1" - minizlib "^2.1.2" - npm-package-arg "^9.0.0" - proc-log "^2.0.0" - -npmlog@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.1.tgz#06f1344a174c06e8de9c6c70834cfba2964bba17" - integrity sha512-BTHDvY6nrRHuRfyjt1MAufLxYdVXZfd099H4+i1f0lPywNQyI4foeNXJRObB/uy+TYqUW0vAD9gbdSOXPst7Eg== - dependencies: - are-we-there-yet "^3.0.0" - console-control-strings "^1.1.0" - gauge "^4.0.0" - set-blocking "^2.0.0" - nth-check@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.1.tgz#2efe162f5c3da06a28959fbd3db75dbeea9f0fc2" - integrity sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w== + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== dependencies: boolbase "^1.0.0" -object-inspect@^1.11.0, object-inspect@^1.9.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" - integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== +object-inspect@^1.12.2, object-inspect@^1.9.0: + version "1.12.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== -object-keys@^1.0.12, object-keys@^1.1.1: +object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" - integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== +object.assign@^4.1.2, object.assign@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - has-symbols "^1.0.1" + call-bind "^1.0.2" + define-properties "^1.1.4" + has-symbols "^1.0.3" object-keys "^1.1.1" object.entries@^1.1.5: @@ -3321,10 +2219,10 @@ object.values@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" -once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" @@ -3345,24 +2243,12 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - -p-limit@^2.0.0, p-limit@^2.2.0: +p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -3376,20 +2262,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -3404,13 +2276,6 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - p-queue@^6.6.2: version "6.6.2" resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" @@ -3426,53 +2291,11 @@ p-timeout@^3.2.0: dependencies: p-finally "^1.0.0" -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - -pacote@^13.0.3: - version "13.0.3" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-13.0.3.tgz#0b9654c1aa5eb2b9af28aa259f15e556e7187422" - integrity sha512-8thQ06YoO01O1k5rvSpHS/XPJZucw2DPiiT1jI+ys8QaTN6ifAyxfyoABHBa8nIt/4wPdzly4GEPqshctHFoYA== - dependencies: - "@npmcli/git" "^3.0.0" - "@npmcli/installed-package-contents" "^1.0.7" - "@npmcli/promise-spawn" "^1.2.0" - "@npmcli/run-script" "^3.0.0" - cacache "^15.3.0" - chownr "^2.0.0" - fs-minipass "^2.1.0" - infer-owner "^1.0.4" - minipass "^3.1.6" - mkdirp "^1.0.4" - npm-package-arg "^9.0.0" - npm-packlist "^3.0.0" - npm-pick-manifest "^7.0.0" - npm-registry-fetch "^13.0.0" - proc-log "^2.0.0" - promise-retry "^2.0.1" - read-package-json "^4.1.1" - read-package-json-fast "^2.0.3" - rimraf "^3.0.2" - ssri "^8.0.1" - tar "^6.1.11" - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -3480,11 +2303,6 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-github-url@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-github-url/-/parse-github-url-1.0.2.tgz#242d3b65cbcdda14bb50439e3242acf6971db395" - integrity sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw== - parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -3495,11 +2313,6 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -3508,14 +2321,7 @@ path-exists@^4.0.0: path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-is-network-drive@^1.0.13: - version "1.0.13" - resolved "https://registry.yarnpkg.com/path-is-network-drive/-/path-is-network-drive-1.0.13.tgz#c9aa0183eb72c328aa83f43def93ddcb9d7ec4d4" - integrity sha512-Hg74mRN6mmXV+gTm3INjFK40ncAmC/Lo4qoQaSZ+GT3hZzlKdWQSqAjqyPeW0SvObP2W073WyYEBWY9d3wOm3A== - dependencies: - tslib "^2.3.1" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== path-key@^3.1.0: version "3.1.1" @@ -3527,13 +2333,6 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-strip-sep@^1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/path-strip-sep/-/path-strip-sep-1.0.10.tgz#2be4e789406b298af8709ff79af716134b733b98" - integrity sha512-JpCy+8LAJQQTO1bQsb/84s1g+/Stm3h39aOpPRBQ/paMUGVPPZChLTOTKHoaCkc/6sKuF7yVsnq5Pe1S6xQGcA== - dependencies: - tslib "^2.3.1" - path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -3544,18 +2343,11 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -"pkg-dir@< 6 >= 5": - version "5.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" - integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== - dependencies: - find-up "^5.0.0" - pkg-dir@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -3581,45 +2373,46 @@ postcss-colormin@^5.3.0: colord "^2.9.1" postcss-value-parser "^4.2.0" -postcss-convert-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.0.tgz#f8d3abe40b4ce4b1470702a0706343eac17e7c10" - integrity sha512-GkyPbZEYJiWtQB0KZ0X6qusqFHUepguBCNFi9t5JJc7I2OTXG7C0twbTLvCfaKOLl3rSXmpAwV7W5txd91V84g== +postcss-convert-values@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz#31586df4e184c2e8890e8b34a0b9355313f503ab" + integrity sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g== dependencies: + browserslist "^4.20.3" postcss-value-parser "^4.2.0" -postcss-discard-comments@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.0.tgz#87be4e0953bf599935837b940c701f8d4eca7d0b" - integrity sha512-L0IKF4jAshRyn03SkEO6ar/Ipz2oLywVbg2THf2EqqdNkBwmVMxuTR/RoAltOw4piiaLt3gCAdrbAqmTBInmhg== +postcss-discard-comments@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz#8df5e81d2925af2780075840c1526f0660e53696" + integrity sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ== postcss-discard-duplicates@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz#9eb4fe8456706a4eebd6d3b7b777d07bad03e848" integrity sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw== -postcss-discard-empty@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.0.tgz#7f51b16cd1b89f8180bbc7cee34d6cbabf2ef810" - integrity sha512-782T/buGgb3HOuHOJAHpdyKzAAKsv/BxWqsutnZ+QsiHEcDkY7v+6WWdturuBiSal6XMOO1p1aJvwXdqLD5vhA== +postcss-discard-empty@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz#e57762343ff7f503fe53fca553d18d7f0c369c6c" + integrity sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A== postcss-discard-overridden@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz#7e8c5b53325747e9d90131bb88635282fb4a276e" integrity sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw== -postcss-merge-longhand@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.0.tgz#f716bffbf0bdfbde6ea78c36088e21559f8a0a95" - integrity sha512-Gr46srN2tsLD8fudKYoHO56RG0BLQ2nsBRnSZGY04eNBPwTeWa9KeHrbL3tOLAHyB2aliikycPH2TMJG1U+W6g== +postcss-merge-longhand@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz#f378a8a7e55766b7b644f48e5d8c789ed7ed51ce" + integrity sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw== dependencies: postcss-value-parser "^4.2.0" stylehacks "^5.1.0" -postcss-merge-rules@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.0.tgz#a2d5117eba09c8686a5471d97bd9afcf30d1b41f" - integrity sha512-NecukEJovQ0mG7h7xV8wbYAkXGTO3MPKnXvuiXzOKcxoOodfTTKYjeo8TMhAswlSkjcPIBlnKbSFcTuVSDaPyQ== +postcss-merge-rules@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz#7049a14d4211045412116d79b751def4484473a5" + integrity sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ== dependencies: browserslist "^4.16.6" caniuse-api "^3.0.0" @@ -3633,28 +2426,28 @@ postcss-minify-font-values@^5.1.0: dependencies: postcss-value-parser "^4.2.0" -postcss-minify-gradients@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.0.tgz#de0260a67a13b7b321a8adc3150725f2c6612377" - integrity sha512-J/TMLklkONn3LuL8wCwfwU8zKC1hpS6VcxFkNUNjmVt53uKqrrykR3ov11mdUYyqVMEx67slMce0tE14cE4DTg== +postcss-minify-gradients@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz#f1fe1b4f498134a5068240c2f25d46fcd236ba2c" + integrity sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw== dependencies: colord "^2.9.1" cssnano-utils "^3.1.0" postcss-value-parser "^4.2.0" -postcss-minify-params@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.0.tgz#e0b1f4e05cfd396682f612856485907e4064f25e" - integrity sha512-q67dcts4Hct6x8+JmhBgctHkbvUsqGIg2IItenjE63iZXMbhjr7AlVZkNnKtIGt/1Wsv7p/7YzeSII6Q+KPXRg== +postcss-minify-params@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz#ac41a6465be2db735099bbd1798d85079a6dc1f9" + integrity sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg== dependencies: browserslist "^4.16.6" cssnano-utils "^3.1.0" postcss-value-parser "^4.2.0" -postcss-minify-selectors@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz#17c2be233e12b28ffa8a421a02fc8b839825536c" - integrity sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA== +postcss-minify-selectors@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz#d4e7e6b46147b8117ea9325a915a801d5fe656c6" + integrity sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg== dependencies: postcss-selector-parser "^6.0.5" @@ -3698,17 +2491,17 @@ postcss-normalize-display-values@^5.1.0: dependencies: postcss-value-parser "^4.2.0" -postcss-normalize-positions@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.0.tgz#902a7cb97cf0b9e8b1b654d4a43d451e48966458" - integrity sha512-8gmItgA4H5xiUxgN/3TVvXRoJxkAWLW6f/KKhdsH03atg0cB8ilXnrB5PpSshwVu/dD2ZsRFQcR1OEmSBDAgcQ== +postcss-normalize-positions@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz#ef97279d894087b59325b45c47f1e863daefbb92" + integrity sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg== dependencies: postcss-value-parser "^4.2.0" -postcss-normalize-repeat-style@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.0.tgz#f6d6fd5a54f51a741cc84a37f7459e60ef7a6398" - integrity sha512-IR3uBjc+7mcWGL6CtniKNQ4Rr5fTxwkaDHwMBDGGs1x9IVRkYIT/M4NelZWkAOBdV6v3Z9S46zqaKGlyzHSchw== +postcss-normalize-repeat-style@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz#e9eb96805204f4766df66fd09ed2e13545420fb2" + integrity sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g== dependencies: postcss-value-parser "^4.2.0" @@ -3742,17 +2535,17 @@ postcss-normalize-url@^5.1.0: normalize-url "^6.0.1" postcss-value-parser "^4.2.0" -postcss-normalize-whitespace@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.0.tgz#aed8b4580c9ad6e8eac034177291187ea16a059c" - integrity sha512-7O1FanKaJkpWFyCghFzIkLhehujV/frGkdofGLwhg5upbLyGsSfiTcZAdSzoPsSUgyPCkBkNMeWR8yVgPdQybg== +postcss-normalize-whitespace@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz#08a1a0d1ffa17a7cc6efe1e6c9da969cc4493cfa" + integrity sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA== dependencies: postcss-value-parser "^4.2.0" -postcss-ordered-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.0.tgz#04ef429e0991b0292bc918b135cd4c038f7b889f" - integrity sha512-wU4Z4D4uOIH+BUKkYid36gGDJNQtkVJT7Twv8qH6UyfttbbJWyw4/xIPuVEkkCtQLAJ0EdsNSh8dlvqkXb49TA== +postcss-ordered-values@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz#b6fd2bd10f937b23d86bc829c69e7732ce76ea38" + integrity sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ== dependencies: cssnano-utils "^3.1.0" postcss-value-parser "^4.2.0" @@ -3773,9 +2566,9 @@ postcss-reduce-transforms@^5.1.0: postcss-value-parser "^4.2.0" postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.0.9" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz#ee71c3b9ff63d9cd130838876c13a2ec1a992b2f" - integrity sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ== + version "6.0.10" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" + integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" @@ -3788,10 +2581,10 @@ postcss-svgo@^5.1.0: postcss-value-parser "^4.2.0" svgo "^2.7.0" -postcss-unique-selectors@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.0.tgz#70a945da1b0599d00f617222a44ba1d82a676694" - integrity sha512-LmUhgGobtpeVJJHuogzjLRwJlN7VH+BL5c9GKMVJSS/ejoyePZkXvNsYUtk//F6vKOGK86gfRS0xH7fXQSDtvA== +postcss-unique-selectors@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz#a9f273d1eacd09e9aa6088f4b0507b18b1b541b6" + integrity sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA== dependencies: postcss-selector-parser "^6.0.5" @@ -3801,11 +2594,11 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.4.5: - version "8.4.7" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.7.tgz#f99862069ec4541de386bf57f5660a6c7a0875a8" - integrity sha512-L9Ye3r6hkkCeOETQX6iOaWZgjp3LL6Lpqm6EtgbKrgqGGteRMNb9vzBfRL96YOSu8o7x3MfIH9Mo5cPJFGrW6A== + version "8.4.16" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.16.tgz#33a1d675fac39941f5f445db0de4db2b6e01d43c" + integrity sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ== dependencies: - nanoid "^3.3.1" + nanoid "^3.3.4" picocolors "^1.0.0" source-map-js "^1.0.2" @@ -3814,11 +2607,6 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= - prettier-linter-helpers@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" @@ -3827,61 +2615,20 @@ prettier-linter-helpers@^1.0.0: fast-diff "^1.1.2" prettier@^2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.0.tgz#12f8f504c4d8ddb76475f441337542fa799207d4" - integrity sha512-m2FgJibYrBGGgQXNzfd0PuDGShJgRavjUoRCw1mZERIWVSXF0iLzLm+aOqTAbLnC3n6JzUhAA8uZnFVghHJ86A== + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== -proc-log@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-2.0.0.tgz#25f8cb346a5d08e27f2422b3ca6ba8379bcbf8ba" - integrity sha512-I/35MfCX2H8jBUhKN8JB8nmqvQo/nKdrBodBY7L3RhDSPPyvOHwLYNmPuhwuJq7a7C3vgFKWGQM+ecPStcvOHA== - -progress@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -promise-inflight@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= - -promise-retry@^2.0.1: +propagating-hammerjs@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" - integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== - dependencies: - err-code "^2.0.2" - retry "^0.12.0" - -prompts@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" + resolved "https://registry.yarnpkg.com/propagating-hammerjs/-/propagating-hammerjs-2.0.1.tgz#2ba5d491d8734944cc0ace9ad2ae9660232cde5d" + integrity sha512-PH3zG5whbSxMocphXJzVtvKr+vWAgfkqVvtuwjSJ/apmEACUoiw6auBAT5HYXpZOR0eGcTAfYG5Yl8h91O5Elg== punycode@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - query-string@^7.1.0: version "7.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1" @@ -3898,9 +2645,9 @@ queue-microtask@^1.2.2: integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== quick-lru@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.0.tgz#2a431d0cf85f55482d314647e4e9d93b21cbc8a9" - integrity sha512-8HdyR8c0jNVWbYrhUWs9Tg/qAAHgjuJoOIX+mP3eIhgqPO9ytMRURCEFTkOxaHLLsEXo0Cm+bXO5ULuGez+45g== + version "6.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.1.tgz#f8e5bf9010376c126c80c1a62827a526c0e60adf" + integrity sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q== randombytes@^2.1.0: version "2.1.0" @@ -3909,53 +2656,6 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -rc-config-loader@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/rc-config-loader/-/rc-config-loader-4.0.0.tgz#144cf31961c9f8ebcf252bd9c263fd40d62bd387" - integrity sha512-//LRTblJEcqbmmro1GCmZ39qZXD+JqzuD8Y5/IZU3Dhp3A1Yr0Xn68ks8MQ6qKfKvYCWDveUmRDKDA40c+sCXw== - dependencies: - debug "^4.1.1" - js-yaml "^4.0.0" - json5 "^2.1.2" - require-from-string "^2.0.2" - -rc@^1.2.8: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -read-package-json-fast@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz#323ca529630da82cb34b36cc0b996693c98c2b83" - integrity sha512-W/BKtbL+dUjTuRL2vziuYhp76s5HZ9qQhd/dKfWIZveD0O40453QNyZhC0e63lqZrAQ4jiOapVoeJ7JrszenQQ== - dependencies: - json-parse-even-better-errors "^2.3.0" - npm-normalize-package-bin "^1.0.1" - -read-package-json@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-4.1.1.tgz#153be72fce801578c1c86b8ef2b21188df1b9eea" - integrity sha512-P82sbZJ3ldDrWCOSKxJT0r/CXMWR0OR3KRh55SgKo3p91GSIEEC32v3lSHAvO/UcH3/IoL7uqhOFBduAnwdldw== - dependencies: - glob "^7.1.1" - json-parse-even-better-errors "^2.3.0" - normalize-package-data "^3.0.0" - npm-normalize-package-bin "^1.0.0" - -readable-stream@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" - integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -3963,61 +2663,34 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +regexp.prototype.flags@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" + integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + functions-have-names "^1.2.2" + regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== -registry-auth-token@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" - integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw== - dependencies: - rc "^1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - -remote-git-tags@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/remote-git-tags/-/remote-git-tags-3.0.0.tgz#424f8ec2cdea00bb5af1784a49190f25e16983c3" - integrity sha512-C9hAO4eoEsX+OXA4rla66pXZQ+TLQ8T9dttgQj18yuKlPMTVkIkdYXvlMC55IuUsIkV6DpmQYi10JKFLaU+l7w== - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.21.0: - version "1.22.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" - integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== +resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.21.0, resolve@^1.22.0: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== dependencies: - is-core-module "^2.8.1" + is-core-module "^2.9.0" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= - dependencies: - lowercase-keys "^1.0.0" - -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= - reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -4030,10 +2703,17 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup-plugin-serve@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-serve/-/rollup-plugin-serve-1.1.0.tgz#0654a57021a21b903340c69940f7463706e8288d" - integrity sha512-pYkSsuA0/psKqhhictkJw1c2klya5b+LlCvipWqI9OE1aG2M97mRumZCbBlry5CMEOzYBBgSDgd1694sNbmyIw== +rollup-plugin-git-info@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-git-info/-/rollup-plugin-git-info-1.0.0.tgz#f6d9f474718d9453eeb5049772a24ca4b9ac310e" + integrity sha512-AL2jzfDvOLze+Sakz3yuQjq9wY/wIj3j49yYnhuXtr6qP4qKzD039haPFa8uaLb0rzDBj2P5S8fWvJyYeIjOVw== + dependencies: + "@rollup/plugin-json" "^4.1.0" + +rollup-plugin-serve@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-serve/-/rollup-plugin-serve-2.0.1.tgz#79d2813baeae48888ebbc608418afc68f938eab8" + integrity sha512-/9lnzylTMwbXNQUwWAO0DIxW145wl+rmikwFXgujLJ5N9bRfWcjP+qd1XM5wxSiw8ZlBAlFy/n2zRvzcG21bBw== dependencies: mime ">=2.4.6" opener "1" @@ -4072,22 +2752,20 @@ rollup-plugin-terser@^7.0.2: serialize-javascript "^4.0.0" terser "^5.0.0" -rollup-plugin-typescript2@^0.31.2: - version "0.31.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.31.2.tgz#463aa713a7e2bf85b92860094b9f7fb274c5a4d8" - integrity sha512-hRwEYR1C8xDGVVMFJQdEVnNAeWRvpaY97g5mp3IeLnzhNXzSVq78Ye/BJ9PAaUfN4DXa/uDnqerifMOaMFY54Q== +rollup-plugin-typescript2@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.33.0.tgz#2bb943f83e9d3a4c61a19aed5bee5bcff28d16a3" + integrity sha512-7ZXoZeX93kNb4/ICzOi2AlperVV6cAsNz8THqrbz+KNvpn47P2F/nFdK/BGhkoOsOwuYDuY57vccdZZrcd8/dA== dependencies: "@rollup/pluginutils" "^4.1.2" - "@yarn-tool/resolve-package" "^1.0.40" find-cache-dir "^3.3.2" fs-extra "^10.0.0" - resolve "^1.20.0" - tslib "^2.3.1" + tslib "^2.4.0" -rollup@^2.63.0, rollup@^2.70.1: - version "2.70.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.70.1.tgz#824b1f1f879ea396db30b0fc3ae8d2fead93523e" - integrity sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA== +rollup@^2.63.0, rollup@^2.79.0: + version "2.79.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.0.tgz#9177992c9f09eb58c5e56cbfa641607a12b57ce2" + integrity sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA== optionalDependencies: fsevents "~2.3.2" @@ -4098,7 +2776,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@^5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -4108,46 +2786,29 @@ safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -"safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sass@^1.49.9: - version "1.49.9" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.49.9.tgz#b15a189ecb0ca9e24634bae5d1ebc191809712f9" - integrity sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A== +sass@^1.54.9: + version "1.54.9" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.9.tgz#b05f14ed572869218d1a76961de60cd647221762" + integrity sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" source-map-js ">=0.6.2 <2.0.0" -screenfull@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-6.0.1.tgz#3b71e6f06b72d817a8d3be73c45ebe71fa8da1ce" - integrity sha512-yzQW+j4zMUBQC51xxWaoDYjxOtl8Kn+xvue3p6v/fv2pIi1jH4AldgVLU8TBfFVgH2x3VXlf3+YiA/AYIPlaew== +screenfull@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-6.0.2.tgz#3dbe4b8c4f8f49fb8e33caa8f69d0bca730ab238" + integrity sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw== -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - -semver-utils@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/semver-utils/-/semver-utils-1.1.4.tgz#cf0405e669a57488913909fc1c3f29bf2a4871e2" - integrity sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA== - -semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: +semver@^6.0.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.1, semver@^7.3.4, semver@^7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== +semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" @@ -4158,11 +2819,6 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -4184,49 +2840,22 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" -signal-exit@^3.0.2, signal-exit@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +side-drawer@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/side-drawer/-/side-drawer-3.1.0.tgz#8542c5379c1dd82cb2f59fd27de6fa8f778c55ba" + integrity sha512-muiYSSJehoKOKuYXXGUKFrUZTM4ndUzHEdWK1C9ClIMCjWtp7b5oxMACEr1DLYTr8MABlD71u2UheR4Ru8o2vQ== slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -smart-buffer@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - -socks-proxy-agent@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.1.1.tgz#e664e8f1aaf4e1fb3df945f09e3d94f911137f87" - integrity sha512-t8J0kG3csjA4g6FTbsMOWws+7R7vuRC8aQ/wy3/1OWmsgwA68zs/+cExQ0koSitUDXqhufF/YJr9wtNMZHw5Ew== - dependencies: - agent-base "^6.0.2" - debug "^4.3.1" - socks "^2.6.1" - -socks@^2.6.1: - version "2.6.2" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" - integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== - dependencies: - ip "^1.1.5" - smart-buffer "^4.2.0" - "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map-support@^0.5.21, source-map-support@~0.5.20: +source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -4234,69 +2863,21 @@ source-map-support@^0.5.21, source-map-support@~0.5.20: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.5.0: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@~0.7.2: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - sourcemap-codec@^1.4.8: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -spawn-please@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/spawn-please/-/spawn-please-1.0.0.tgz#51cf5831ba2bf418aa3ec2102d40b75cfd48b6f2" - integrity sha512-Kz33ip6NRNKuyTRo3aDWyWxeGeM0ORDO552Fs6E1nj4pLWPkl37SrRtTnq+MEopVaqgmaO6bAvVS+v64BJ5M/A== - -spdx-correct@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" - integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" - integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== - -spdx-expression-parse@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.11" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95" - integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g== - split-on-first@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== -ssri@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" - integrity sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ== - dependencies: - minipass "^3.1.1" - stable@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" @@ -4305,41 +2886,27 @@ stable@^0.1.8: strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" - integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= + integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.2, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string.prototype.trimend@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" - integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== +string.prototype.trimend@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0" + integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog== dependencies: call-bind "^1.0.2" - define-properties "^1.1.3" + define-properties "^1.1.4" + es-abstract "^1.19.5" -string.prototype.trimstart@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" - integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== +string.prototype.trimstart@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef" + integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg== dependencies: call-bind "^1.0.2" - define-properties "^1.1.3" + define-properties "^1.1.4" + es-abstract "^1.19.5" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4349,18 +2916,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - stylehacks@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.0.tgz#a40066490ca0caca04e96c6b02153ddc39913520" @@ -4370,9 +2932,9 @@ stylehacks@^5.1.0: postcss-selector-parser "^6.0.4" superstruct@^0.15.3: - version "0.15.4" - resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.15.4.tgz#e3381dd84ca07e704e19f69eda74eee1a5efb1f9" - integrity sha512-eOoMeSbP9ZJChNOm/9RYjE+F36rYR966AAqeG3xhQB02j2sfAUXDp4EQ/7bAOqnlJnuFDB8yvOu50SocvKpUEw== + version "0.15.5" + resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.15.5.tgz#0f0a8d3ce31313f0d84c6096cd4fa1bfdedc9dab" + integrity sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ== supports-color@^5.3.0: version "5.5.0" @@ -4406,47 +2968,25 @@ svgo@^2.7.0: picocolors "^1.0.0" stable "^0.1.8" -tar@^6.0.2, tar@^6.1.11, tar@^6.1.2: - version "6.1.11" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" - integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^3.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - terser@^5.0.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.12.0.tgz#728c6bff05f7d1dcb687d8eace0644802a9dae8a" - integrity sha512-R3AUhNBGWiFc77HXag+1fXpAxTAFRQTJemlJKjAgD9r8xXTpjNKqIXwHM/o7Rh+O0kUJtS3WQVdBeMKFk5sw9A== + version "5.15.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.0.tgz#e16967894eeba6e1091509ec83f0c60e179f2425" + integrity sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA== dependencies: + "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" commander "^2.20.0" - source-map "~0.7.2" source-map-support "~0.5.20" text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= - -timsort@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" - integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= - -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== to-regex-range@^5.0.1: version "5.0.1" @@ -4455,14 +2995,19 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tsconfig-paths@^3.12.0: - version "3.13.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.13.0.tgz#f3e9b8f6876698581d94470c03c95b3a48c0e3d7" - integrity sha512-nWuffZppoaYK0vQ1SQmkSsQzJoHA4s6uzdb2waRpD806x9yfq153AdVsWz4je2qZcW+pENrMQXbGQ3sMCkXuhw== +ts-toolbelt@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" + integrity sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w== + +tsconfig-paths@^3.14.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" + integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== dependencies: "@types/json5" "^0.0.29" json5 "^1.0.1" - minimist "^1.2.0" + minimist "^1.2.6" strip-bom "^3.0.0" tslib@^1.8.1: @@ -4470,10 +3015,10 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.1.0, tslib@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" - integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== tsutils@^3.21.0: version "3.21.0" @@ -4494,82 +3039,33 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -typedarray-to-buffer@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" - integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== - dependencies: - is-typedarray "^1.0.0" +typescript@^4.5.4, typescript@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.3.tgz#d59344522c4bc464a65a730ac695007fdb66dd88" + integrity sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig== -typescript@^4.5.4, typescript@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" - integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== - -unbox-primitive@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" - integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== dependencies: - function-bind "^1.1.1" - has-bigints "^1.0.1" - has-symbols "^1.0.2" + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -unique-filename@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" - integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== - dependencies: - unique-slug "^2.0.0" - -unique-slug@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" - integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== - dependencies: - imurmurhash "^0.1.4" - -unique-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -upath2@^3.1.12: - version "3.1.12" - resolved "https://registry.yarnpkg.com/upath2/-/upath2-3.1.12.tgz#441b3dfbadde21731017bd1b7beb169498efd0a9" - integrity sha512-yC3eZeCyCXFWjy7Nu4pgjLhXNYjuzuUmJiRgSSw6TJp8Emc+E4951HGPJf+bldFC5SL7oBLeNbtm1fGzXn2gxw== +update-browserslist-db@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.7.tgz#16279639cff1d0f800b14792de43d97df2d11b7d" + integrity sha512-iN/XYesmZ2RmmWAiI4Z5rq0YqSiv0brj9Ce9CfhNE4xIW2h+MFxcgkxIzZ+ShkFPUkjU3gQ+3oypadD3RAMtrg== dependencies: - path-is-network-drive "^1.0.13" - path-strip-sep "^1.0.10" - tslib "^2.3.1" - -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" + escalade "^3.1.1" + picocolors "^1.0.0" uri-js@^4.2.2: version "4.4.1" @@ -4578,37 +3074,35 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= - dependencies: - prepend-http "^2.0.0" - -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -v8-compile-cache@^2.0.3: - version "2.3.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" - integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" +vis-data@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/vis-data/-/vis-data-7.1.4.tgz#90e5e796a79e1901de14c0808fb32a1a0735c1dc" + integrity sha512-usy+ePX1XnArNvJ5BavQod7YRuGQE1pjFl+pu7IS6rCom2EBoG0o1ZzCqf3l5US6MW51kYkLR+efxRbnjxNl7w== -validate-npm-package-name@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz#5fa912d81eb7d0c74afc140de7317f0ca7df437e" - integrity sha1-X6kS2B630MdK/BQN5zF/DKffQ34= - dependencies: - builtins "^1.0.3" +vis-timeline@^7.7.0: + version "7.7.0" + resolved "https://registry.yarnpkg.com/vis-timeline/-/vis-timeline-7.7.0.tgz#50c13072b77d7b8a48029fccb3077be7119c614b" + integrity sha512-et5xobQTEp7i8lqcEjgMWoGE4s4qn+2VtEJ35uRZiL5Y3qRzi84bCTkUKAjOM/HTzVFiLTWET+DZZi1iHYriuA== + +vis-util@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/vis-util/-/vis-util-5.0.3.tgz#6a9d67c6ed92f2bfa67e50c20166bb5f59ac07bf" + integrity sha512-Wf9STUcFrDzK4/Zr7B6epW2Kvm3ORNWF+WiwEz2dpf5RdWkLUXFSbLcuB88n1W6tCdFwVN+v3V4/Xmn9PeL39g== + +wheel-gestures@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/wheel-gestures/-/wheel-gestures-2.2.5.tgz#d4a7cdcd402500de26f65aabc8553b657515fb7d" + integrity sha512-01BlXRjBVt5Gy0VH2rRqMeccHruTGq2Gd1fx0UqCPN8KDeDCmlTXsRGc/hXJk6W+wnO9CNJp2xfntMNRArBxKQ== which-boxed-primitive@^1.0.2: version "1.0.2" @@ -4621,60 +3115,30 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" -which@^2.0.1, which@^2.0.2: +which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== dependencies: isexe "^2.0.0" -wide-align@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" - integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== - dependencies: - string-width "^1.0.2 || 2 || 3 || 4" - -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - word-wrap@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -write-file-atomic@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" - integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== +xss@^1.0.14: + version "1.0.14" + resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.14.tgz#4f3efbde75ad0d82e9921cc3c95e6590dd336694" + integrity sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw== dependencies: - imurmurhash "^0.1.4" - is-typedarray "^1.0.0" - signal-exit "^3.0.2" - typedarray-to-buffer "^3.1.5" - -xdg-basedir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" - integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== + commander "^2.20.3" + cssfilter "0.0.10" yallist@^4.0.0: version "4.0.0" @@ -4691,7 +3155,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.13.4: - version "3.13.4" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.13.4.tgz#5d6fe03ef4824a637d7ef50b5441cf6ab3acede0" - integrity sha512-LZRucWt4j/ru5azOkJxCfpR87IyFDn8h2UODdqvXzZLb3K7bb9chUrUIGTy3BPsr8XnbQYfQ5Md5Hu2OYIo1mg== +zod@^3.19.0: + version "3.19.0" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.0.tgz#a9303fcaed0950a3dc57bf35cc11f6282d4f52f2" + integrity sha512-Yw0qvUsCNGBe5YacikdMt5gVYeUuaEFVDIHKMfElrGSaBhwR3CQK6vOzgfAJOjTdGIhEeoaj8GtT+NDZrepZbw==